const statusBar = document.getElementById("statusBar"); const statusLabel = document.getElementById("statusLabel"); const searchForm = document.getElementById("searchForm"); const searchQuery = document.getElementById("searchQuery"); const searchResults = document.getElementById("searchResults"); const searchWarning = document.getElementById("searchWarning"); const queryVariants = document.getElementById("queryVariants"); const searchModeTitle = document.getElementById("searchModeTitle"); const searchModeHint = document.getElementById("searchModeHint"); const searchSubmitButton = document.getElementById("searchSubmitButton"); const searchResultsViewport = document.getElementById("searchResultsViewport"); const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-toggle]")); const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]")); const imageSearchSandbox = document.getElementById("imageSearchSandbox"); const imagePromptChips = Array.from(document.querySelectorAll("[data-image-prompt]")); const giphyMetaPanel = document.getElementById("giphyMetaPanel"); const giphyOriginalQuery = document.getElementById("giphyOriginalQuery"); const giphyResultCount = document.getElementById("giphyResultCount"); const giphyExpandedQueries = document.getElementById("giphyExpandedQueries"); const dropzone = document.getElementById("dropzone"); const fileInput = document.getElementById("fileInput"); const uploadResult = document.getElementById("uploadResult"); const downloadForm = document.getElementById("downloadForm"); const downloadUrl = document.getElementById("downloadUrl"); const downloadResult = document.getElementById("downloadResult"); const cardTemplate = document.getElementById("searchCardTemplate"); const imageCardTemplate = document.getElementById("imageCardTemplate"); const previewModal = document.getElementById("previewModal"); const previewMediaFrame = document.getElementById("previewMediaFrame"); const previewTitle = document.getElementById("previewTitle"); const previewVideo = document.getElementById("previewVideo"); const previewThumbnail = document.getElementById("previewThumbnail"); const previewDuration = document.getElementById("previewDuration"); const qualitySelect = document.getElementById("qualitySelect"); const confirmDownload = document.getElementById("confirmDownload"); const closePreviewModal = document.getElementById("closePreviewModal"); const rangeSummary = document.getElementById("rangeSummary"); const rangeFill = document.getElementById("rangeFill"); const startThumb = document.getElementById("startThumb"); const endThumb = document.getElementById("endThumb"); const startLabel = document.getElementById("startLabel"); const endLabel = document.getElementById("endLabel"); const setStartFromPreview = document.getElementById("setStartFromPreview"); const setEndFromPreview = document.getElementById("setEndFromPreview"); const debugToggle = document.getElementById("debugToggle"); const debugPanel = document.getElementById("debugPanel"); const closeDebugPanel = document.getElementById("closeDebugPanel"); const clearLogs = document.getElementById("clearLogs"); const downloadLogs = document.getElementById("downloadLogs"); const debugLogList = document.getElementById("debugLogList"); const debugSummary = document.getElementById("debugSummary"); const resultModal = document.getElementById("resultModal"); const resultModalTitle = document.getElementById("resultModalTitle"); const resultModalSource = document.getElementById("resultModalSource"); const resultModalSnippet = document.getElementById("resultModalSnippet"); const resultModalReason = document.getElementById("resultModalReason"); const resultModalFrame = document.getElementById("resultModalFrame"); const resultModalMediaFrame = document.getElementById("resultModalMediaFrame"); const resultModalVideo = document.getElementById("resultModalVideo"); const resultModalThumbnail = document.getElementById("resultModalThumbnail"); const resultModalGooglePanel = document.getElementById("resultModalGooglePanel"); const resultModalGoogleImage = document.getElementById("resultModalGoogleImage"); const resultModalGoogleText = document.getElementById("resultModalGoogleText"); const resultModalFallbackLabel = document.getElementById("resultModalFallbackLabel"); const resultModalOpenExternal = document.getElementById("resultModalOpenExternal"); const resultModalDownload = document.getElementById("resultModalDownload"); const resultModalSecondaryAction = document.getElementById("resultModalSecondaryAction"); const closeResultModal = document.getElementById("closeResultModal"); const resultModalReady = Boolean( resultModal && resultModalTitle && resultModalSource && resultModalSnippet && resultModalReason && resultModalFrame && resultModalMediaFrame && resultModalVideo && resultModalThumbnail && resultModalGooglePanel && resultModalGoogleImage && resultModalGoogleText && resultModalFallbackLabel && resultModalOpenExternal && resultModalDownload && resultModalSecondaryAction && closeResultModal, ); let pendingDownload = null; let cropStart = 0; let cropEnd = 0; let cropMax = 0; let activeThumb = null; let activeResultItem = null; let activeResultModalSummaryRequest = 0; const activePlatforms = new Set(["envato", "artgrid", "google video"]); const hlsInstances = new WeakMap(); const debugEntries = []; const summaryTranslationCache = new Map(); const summaryTranslationInflight = new Map(); const resultPreviewCache = new Map(); const resultPreviewInflight = new Map(); let cardSummaryObserver = null; let activeMediaType = "video"; const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; let activeImageSearchResponse = null; function proxiedPreviewURL(src) { if (!src) { return ""; } return `/api/preview/stream?url=${encodeURIComponent(src)}`; } function transcodedPreviewURL(src) { if (!src) { return ""; } return `/api/preview/transcode?url=${encodeURIComponent(src)}`; } function buildPlayablePreviewURL(src, source = "") { const trimmed = String(src || "").trim(); if (!trimmed) { return ""; } const lower = trimmed.toLowerCase(); if (lower.includes(".m3u8") && (String(source || "").toLowerCase() === "artgrid" || lower.includes("artgrid") || lower.includes("artlist"))) { return transcodedPreviewURL(trimmed); } return proxiedPreviewURL(trimmed); } function isLowValueThumbnailURL(src) { const lower = String(src || "").toLowerCase(); if (!lower) { return true; } return [ "favicon", "apple-touch-icon", "/logo", "/icon", "icon.", "logo.", "placehold.co", ].some((token) => lower.includes(token)) || ((lower.includes("googleusercontent.com") || lower.includes("gstatic.com") || lower.includes("bing.com") || lower.includes("duckduckgo.com")) && !lower.includes("ytimg.com")); } function hasUsableThumbnail(src) { return Boolean(src) && !isLowValueThumbnailURL(src); } function summarizeReason(reason) { const text = String(reason || "").trim(); if (!text) { return ""; } if (text === "Fallback due to missing provider preview.") { return "Provider preview missing"; } return text; } function setStatus(label, progress) { statusLabel.textContent = label; statusBar.style.width = `${Math.max(0, Math.min(100, progress))}%`; logEvent("status", { label, progress }); } function setHidden(element, hidden, visibleDisplayClass = "flex") { element.classList.toggle("hidden", hidden); if (visibleDisplayClass) { element.classList.toggle(visibleDisplayClass, !hidden); } } function showWarning(message) { searchWarning.textContent = message; setHidden(searchWarning, !message, ""); } function logEvent(type, payload) { const entry = { ts: new Date().toISOString(), type, payload, }; debugEntries.push(entry); if (debugEntries.length > 400) { debugEntries.shift(); } renderLogs(); } function safeStringify(value) { try { return JSON.stringify(compactPayload(value), null, 2); } catch { return String(value); } } function compactPayload(value, depth = 0) { if (depth > 3) { return "[truncated]"; } if (Array.isArray(value)) { if (value.length > 8) { return { type: "array", length: value.length, sample: value.slice(0, 5).map((item) => compactPayload(item, depth + 1)), }; } return value.map((item) => compactPayload(item, depth + 1)); } if (value && typeof value === "object") { const entries = Object.entries(value); return Object.fromEntries(entries.map(([key, item]) => [key, compactPayload(item, depth + 1)])); } if (typeof value === "string" && value.length > 500) { return `${value.slice(0, 500)}...`; } return value; } function renderLogs() { debugSummary.textContent = `${debugEntries.length} events captured`; debugLogList.innerHTML = ""; for (const entry of debugEntries.slice().reverse()) { const node = document.createElement("div"); node.className = "debug-entry"; node.innerHTML = `
${entry.type} ${entry.ts}
${safeStringify(entry.payload)}
`; debugLogList.appendChild(node); } } function toClock(totalSeconds) { const seconds = Math.max(0, Math.floor(Number(totalSeconds) || 0)); const hours = String(Math.floor(seconds / 3600)).padStart(2, "0"); const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0"); const secs = String(seconds % 60).padStart(2, "0"); return `${hours}:${minutes}:${secs}`; } function extractYouTubeID(link) { if (!link) { return ""; } const patterns = [ /(?:v=|\/shorts\/|\/embed\/)([A-Za-z0-9_-]{11})/, /youtu\.be\/([A-Za-z0-9_-]{11})/, ]; for (const pattern of patterns) { const match = link.match(pattern); if (match?.[1]) { return match[1]; } } return ""; } function syncRanges() { let start = cropStart; let end = cropEnd; if (start > end) { if (activeThumb === "start") { end = start; } else { start = end; } } cropStart = start; cropEnd = end; const startPct = cropMax > 0 ? (cropStart / cropMax) * 100 : 0; const endPct = cropMax > 0 ? (cropEnd / cropMax) * 100 : 0; startThumb.style.left = `calc(${startPct}% - 10px)`; endThumb.style.left = `calc(${endPct}% - 10px)`; rangeFill.style.left = `${startPct}%`; rangeFill.style.width = `${Math.max(0, endPct - startPct)}%`; rangeSummary.textContent = `${toClock(start)} - ${toClock(end)}`; startLabel.textContent = `Start ${toClock(start)}`; endLabel.textContent = `End ${toClock(end)}`; } function renderQueryVariants(queries = []) { queryVariants.classList.add("hidden"); } function syncMediaTypeButtons() { for (const button of mediaTypeToggles) { const type = button.dataset.mediaTypeToggle; const active = type === activeMediaType; button.classList.toggle("bg-white", active); button.classList.toggle("text-black", active); button.classList.toggle("text-zinc-300", !active); } } function renderImageEmptyState(message) { searchResults.innerHTML = ""; searchResults.innerHTML = `
${message}
`; } function renderExpandedQueries(queries = []) { giphyExpandedQueries.innerHTML = ""; for (const item of queries) { const chip = document.createElement("span"); chip.className = "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-zinc-300"; chip.textContent = item; giphyExpandedQueries.appendChild(chip); } } function updateImageSearchMeta(data = null) { activeImageSearchResponse = data; const visible = Boolean(data); setHidden(giphyMetaPanel, !visible, "block"); if (!visible) { giphyOriginalQuery.textContent = "Original query: -"; giphyResultCount.textContent = "0 results"; giphyExpandedQueries.innerHTML = ""; return; } giphyOriginalQuery.textContent = `Original query: ${data.originalQuery || "-"}`; giphyResultCount.textContent = `${Number(data.total || 0)} results`; renderExpandedQueries(data.expandedQueries || []); } function renderImageResults(items = []) { searchResults.innerHTML = ""; searchResults.classList.remove("xl:grid-cols-3"); searchResults.classList.add("xl:grid-cols-4"); if (!items.length) { renderImageEmptyState("GIPHY에서 표시할 이미지/GIF를 찾지 못했습니다."); return; } for (const item of items) { const node = imageCardTemplate.content.firstElementChild.cloneNode(true); const image = node.querySelector("img"); image.loading = "lazy"; image.src = item.previewStillUrl || item.previewUrl || item.fullUrl || PREVIEW_PLACEHOLDER; image.alt = item.title; node.querySelector(".image-card-tag").textContent = `GIPHY / ${item.searchQuery || "query"}`; node.querySelector(".image-card-title").textContent = item.title; node.querySelector(".image-card-caption").textContent = item.title || "Untitled GIPHY result"; node.querySelector(".image-card-meta").textContent = `${item.rating || "unrated"} / ${item.width || "?"}x${item.height || "?"}`; node.addEventListener("click", () => openResultModal(item)); searchResults.appendChild(node); } } function applyMediaTypeUI() { const isImageMode = activeMediaType === "image"; syncMediaTypeButtons(); setHidden(imageSearchSandbox, !isImageMode, "block"); setHidden(giphyMetaPanel, true, "block"); setHidden(queryVariants, true, ""); showWarning(""); searchResultsViewport.classList.toggle("image-results-scroll", isImageMode); searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery"; searchModeHint.textContent = isImageMode ? "GIPHY 이미지/GIF 검색 모드입니다. Gemini가 영어 검색어 5개로 확장한 뒤 최대 100개 결과를 보여줍니다." : "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다."; searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요"; searchSubmitButton.textContent = isImageMode ? "Search GIPHY" : "AI Search"; for (const button of platformToggles) { button.classList.toggle("hidden", isImageMode); } if (isImageMode) { updateImageSearchMeta(null); setStatus("giphy image mode", 0); renderImageEmptyState("GIPHY 검색어를 입력하면 여기에 최대 100개의 이미지/GIF 결과가 표시됩니다."); } else { searchResults.classList.add("xl:grid-cols-3"); searchResults.classList.remove("xl:grid-cols-4"); searchResults.innerHTML = ""; setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0); } } function syncPlatformButtons() { for (const button of platformToggles) { const platform = button.dataset.platformToggle; const active = activePlatforms.has(platform); button.classList.toggle("bg-white", active); button.classList.toggle("text-black", active); button.classList.toggle("border-white", active); button.classList.toggle("bg-transparent", !active); button.classList.toggle("text-zinc-300", !active); button.classList.toggle("border-white/20", !active); } logEvent("platforms:update", { active: Array.from(activePlatforms) }); } function connectWS() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const socket = new WebSocket(`${protocol}://${window.location.host}/ws`); socket.addEventListener("message", (event) => { const payload = JSON.parse(event.data); if (payload.event !== "progress") { logEvent("ws:message:ignored", payload); return; } logEvent("ws:message", payload); const data = payload.data; const label = data.status || `${data.type || "task"} in progress`; setStatus(label, Number(data.progress ?? 0)); if (data.type === "upload" && data.status === "completed") { uploadResult.textContent = `${data.filename} saved successfully`; } if (data.type === "download" && data.status === "completed") { downloadResult.textContent = data.output || "download completed"; } if (data.status === "error") { downloadResult.textContent = data.message || "task failed"; } }); socket.addEventListener("close", () => { logEvent("ws:close", { reason: "socket closed" }); setTimeout(connectWS, 1000); }); socket.addEventListener("open", () => { logEvent("ws:open", { url: socket.url }); }); socket.addEventListener("error", () => { logEvent("ws:error", { url: socket.url }); }); } async function api(path, options = {}) { logEvent("api:request", { path, method: options.method || "GET", hasBody: Boolean(options.body), bodyPreview: typeof options.body === "string" ? options.body.slice(0, 800) : "[non-string body]", }); const response = await fetch(path, options); const rawText = await response.text(); let data = {}; if (rawText) { try { data = JSON.parse(rawText); } catch { data = { rawText }; } } logEvent("api:response", { path, status: response.status, ok: response.ok, body: compactPayload(data), }); if (!response.ok) { const message = data.error || data.rawText || `request failed (${response.status})`; const error = new Error(message); error.status = response.status; error.data = data; throw error; } return data; } function attachVideoSource(video, src) { detachVideoSource(video); if (!src) { logEvent("preview:attach:skipped", { reason: "empty src" }); return; } if (src.endsWith(".m3u8")) { if (window.Hls && window.Hls.isSupported()) { const hls = new window.Hls({ enableWorker: false }); hls.loadSource(src); hls.attachMedia(video); hlsInstances.set(video, hls); logEvent("preview:attach:hls", { src, mode: "hls.js" }); return; } if (video.canPlayType("application/vnd.apple.mpegurl")) { video.src = src; logEvent("preview:attach:hls", { src, mode: "native" }); return; } logEvent("preview:attach:skipped", { reason: "hls unsupported", src }); return; } video.src = src; logEvent("preview:attach:file", { src }); } function startHoverPreview(video, src) { if (!src) { return; } attachVideoSource(video, src); video.classList.remove("hidden"); const attemptPlay = () => { video.play().catch((error) => { logEvent("preview:hover:play:error", { src, message: String(error) }); }); }; if (video.readyState >= 2) { attemptPlay(); return; } const onReady = () => { video.removeEventListener("loadeddata", onReady); video.removeEventListener("canplay", onReady); attemptPlay(); }; video.addEventListener("loadeddata", onReady, { once: true }); video.addEventListener("canplay", onReady, { once: true }); if (video.load) { video.load(); } } function detachVideoSource(video) { const existing = hlsInstances.get(video); if (existing) { existing.destroy(); hlsInstances.delete(video); logEvent("preview:detach:hls", { ok: true }); } video.removeAttribute("src"); video.load(); } function resetPreviewPlayer() { previewVideo.pause(); detachVideoSource(previewVideo); previewMediaFrame.style.aspectRatio = ""; } function showModal(element) { setHidden(element, false); } function hideModal(element) { setHidden(element, true); } function buildResultModalEmbedURL(item) { if (item?.embedUrl) { if (item.source === "Google Video" && item.embedUrl.includes("youtube-nocookie.com/embed/")) { return `${item.embedUrl}&origin=${encodeURIComponent(window.location.origin)}`; } return item.embedUrl; } if (!item?.link) { return "about:blank"; } if (item.source === "Google Video") { const videoId = extractYouTubeID(item.link); if (videoId) { return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&playsinline=1&modestbranding=1&enablejsapi=1&origin=${encodeURIComponent(window.location.origin)}`; } } return item.link; } function resetResultModalMedia() { if (!resultModalReady) { return; } resultModalFrame.onload = null; resultModalFrame.src = "about:blank"; resultModalVideo.pause(); detachVideoSource(resultModalVideo); resultModalThumbnail.removeAttribute("src"); resultModalGoogleImage.removeAttribute("src"); resultModalGoogleText.textContent = ""; resultModalFallbackLabel.textContent = "Preview Fallback"; resultModalMediaFrame.style.aspectRatio = ""; setHidden(resultModalFrame, true, ""); setHidden(resultModalVideo, true, ""); setHidden(resultModalThumbnail, true, ""); setHidden(resultModalGooglePanel, true, "flex"); } function isGiphyResult(item) { return String(item?.provider || "").toLowerCase() === "giphy" || String(item?.source || "").toLowerCase() === "giphy"; } function showResultModalFrame(src) { if (!src) { return; } resultModalFrame.src = src; setHidden(resultModalFrame, false, ""); } function showResultModalVideo(src) { if (!src) { return; } attachVideoSource(resultModalVideo, src); setHidden(resultModalVideo, false, ""); } function showResultModalThumbnail(src, alt) { resultModalThumbnail.src = src || PREVIEW_PLACEHOLDER; resultModalThumbnail.alt = alt || ""; setHidden(resultModalThumbnail, false, ""); } function showResultModalGooglePanel(item, message = "") { resultModalFallbackLabel.textContent = item.source || "Preview Fallback"; resultModalGoogleImage.src = hasUsableThumbnail(item.thumbnailUrl) ? item.thumbnailUrl : PREVIEW_PLACEHOLDER; resultModalGoogleImage.alt = item.title || ""; resultModalGoogleText.textContent = summarizeReason(message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action."); setHidden(resultModalGooglePanel, false, "flex"); } async function translateSummaryForModal(item, originalText, requestId) { const translated = await translateSummaryText(originalText); if (!translated) { return; } if (activeResultItem?.link === item.link && activeResultModalSummaryRequest === requestId) { resultModalSnippet.textContent = translated; logEvent("result:modal:summary_translated", { title: item.title, source: item.source }); } } async function translateSummaryText(originalText) { const trimmed = String(originalText || "").trim(); if (!trimmed) { return ""; } if (summaryTranslationCache.has(trimmed)) { return summaryTranslationCache.get(trimmed); } if (summaryTranslationInflight.has(trimmed)) { return summaryTranslationInflight.get(trimmed); } const request = (async () => { try { const data = await api("/api/translate/summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: trimmed }), }); const translated = String(data.translatedText || "").trim(); if (translated) { summaryTranslationCache.set(trimmed, translated); } return translated; } catch { return ""; } finally { summaryTranslationInflight.delete(trimmed); } })(); summaryTranslationInflight.set(trimmed, request); try { return await request; } catch { return ""; } } async function translateCardSummary(node) { if (!node || node.dataset.summaryTranslated === "true") { return; } node.dataset.summaryTranslated = "true"; const originalText = node.dataset.summaryOriginal || ""; const translated = await translateSummaryText(originalText); if (!translated) { return; } const summaryNode = node.querySelector(".result-reason"); if (summaryNode) { summaryNode.textContent = translated; } } async function fetchResultPreview(item) { const key = String(item?.link || "").trim(); if (!key) { return null; } if (resultPreviewCache.has(key)) { return resultPreviewCache.get(key); } if (resultPreviewInflight.has(key)) { return resultPreviewInflight.get(key); } const request = (async () => { try { const preview = await api("/api/download/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: key }), }); resultPreviewCache.set(key, preview); return preview; } catch (error) { logEvent("result:preview:fetch_failed", { link: key, source: item?.source || "", message: error.message }); return null; } finally { resultPreviewInflight.delete(key); } })(); resultPreviewInflight.set(key, request); return request; } function ensureCardSummaryObserver() { if (cardSummaryObserver || typeof IntersectionObserver === "undefined") { return; } cardSummaryObserver = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) { continue; } cardSummaryObserver.unobserve(entry.target); void translateCardSummary(entry.target); } }, { rootMargin: "160px 0px" }); } function fallbackResultModalMedia(item, reason) { logEvent("result:modal:fallback", { title: item.title, source: item.source, reason, mediaMode: item.mediaMode }); if (item.previewVideoUrl) { showResultModalVideo(item.previewVideoUrl); return; } if (hasUsableThumbnail(item.thumbnailUrl)) { showResultModalThumbnail(item.thumbnailUrl, item.title || ""); return; } showResultModalGooglePanel(item, reason || "Preview fallback is being shown."); } function renderResults(results) { searchResults.innerHTML = ""; ensureCardSummaryObserver(); if (!results.length) { searchResults.innerHTML = `
No results matched the current search sources.
`; return; } for (const item of results) { const node = cardTemplate.content.firstElementChild.cloneNode(true); const image = node.querySelector("img"); const previewVideo = node.querySelector(".preview-hover"); const mediaFallback = node.querySelector(".media-fallback"); const overlays = node.querySelectorAll(".preview-overlay"); const usableThumbnail = hasUsableThumbnail(item.thumbnailUrl); if (usableThumbnail) { image.src = item.thumbnailUrl; image.alt = item.title; image.classList.remove("hidden"); mediaFallback.classList.add("hidden"); } else { image.removeAttribute("src"); image.alt = ""; image.classList.add("hidden"); mediaFallback.classList.remove("hidden"); mediaFallback.classList.add("flex"); mediaFallback.textContent = item.source === "Envato" || item.source === "Artgrid" ? `${item.source} preview unavailable` : "Preview unavailable"; } node.querySelector("h3").textContent = item.title; node.querySelector(".result-snippet").textContent = summarizeReason(item.reason) || item.source || ""; node.querySelector(".result-reason").textContent = item.snippet || item.previewBlockedReason || ""; node.querySelector(".source-badge").textContent = item.source; node.dataset.summaryOriginal = item.snippet || ""; node.dataset.summaryTranslated = "false"; node.addEventListener("click", () => openResultModal(item)); previewVideo.poster = usableThumbnail ? item.thumbnailUrl : ""; const mediaArea = node.querySelector(".relative"); if (item.previewVideoUrl || item.source === "Google Video") { mediaArea.addEventListener("mouseenter", async () => { let previewURL = item.previewVideoUrl || ""; if (!previewURL && item.source === "Google Video") { const preview = await fetchResultPreview(item); previewURL = preview?.previewStreamUrl || ""; } logEvent("preview:hover:start", { title: item.title, source: item.source, previewVideoUrl: previewURL }); if (!previewURL) { return; } overlays.forEach((overlay) => overlay.classList.add("hidden")); startHoverPreview(previewVideo, buildPlayablePreviewURL(previewURL, item.source)); }); mediaArea.addEventListener("mouseleave", () => { logEvent("preview:hover:end", { title: item.title, source: item.source }); previewVideo.pause(); previewVideo.currentTime = 0; previewVideo.classList.add("hidden"); detachVideoSource(previewVideo); overlays.forEach((overlay) => overlay.classList.remove("hidden")); }); } if (cardSummaryObserver && item.snippet) { cardSummaryObserver.observe(node); } else if (item.snippet) { void translateCardSummary(node); } searchResults.appendChild(node); } } async function prepareDirectDownload(targetUrl) { downloadUrl.value = targetUrl; downloadResult.textContent = "checking duplicate history..."; const dup = await api(`/api/history/check?url=${encodeURIComponent(targetUrl)}`); let force = false; if (dup.exists) { force = window.confirm("동일 URL 다운로드 이력이 있습니다. 계속 진행할까요?"); if (!force) { downloadResult.textContent = "cancelled"; return; } } pendingDownload = { url: targetUrl, force }; downloadResult.textContent = "loading preview..."; const preview = await api("/api/download/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: targetUrl }), }); openPreviewModal(preview); downloadResult.textContent = "preview loaded"; } async function openResultModal(item) { if (!resultModalReady) { logEvent("result:modal:error", { message: "result modal is not fully initialized" }); return; } const giphyItem = isGiphyResult(item); activeResultItem = item; activeResultModalSummaryRequest += 1; const summaryRequestId = activeResultModalSummaryRequest; resultModalTitle.textContent = item.title || "Untitled"; resultModalSource.textContent = item.source || ""; resultModalReason.textContent = giphyItem ? [ `Original Query: ${item.originalQuery || "-"}`, `Expanded Query: ${item.searchQuery || "-"}`, `Rating: ${item.rating || "unrated"}`, ].join("\n") : (summarizeReason(item.reason) || "AI 노트가 없습니다."); const originalSummary = giphyItem ? `Powered by GIPHY\n${item.width || "?"} x ${item.height || "?"}\n${item.sourcePageUrl || item.openUrl || item.link || ""}`.trim() : (item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."); resultModalSnippet.textContent = originalSummary; resultModalOpenExternal.href = item.openUrl || item.sourcePageUrl || item.link || "#"; resultModalDownload.classList.toggle("hidden", !item.actionType); resultModalDownload.textContent = item.actionLabel || "Open Source"; const showSecondary = Boolean(item.secondaryActionLabel && (item.openUrl || item.link)); resultModalSecondaryAction.classList.toggle("hidden", !showSecondary); resultModalSecondaryAction.textContent = item.secondaryActionLabel || "Open Source"; resetResultModalMedia(); if (giphyItem) { showResultModalThumbnail(item.fullUrl || item.previewUrl || item.previewStillUrl, item.title || ""); showModal(resultModal); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link, provider: item.provider }); return; } const embedURL = buildResultModalEmbedURL(item); const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview."; let resolvedPreviewURL = item.previewVideoUrl || ""; if (!resolvedPreviewURL && item.source === "Google Video") { const preview = await fetchResultPreview(item); resolvedPreviewURL = preview?.previewStreamUrl || ""; } if (resolvedPreviewURL) { showResultModalVideo(buildPlayablePreviewURL(resolvedPreviewURL, item.source)); } else if (item.source === "Google Video" && item.mediaMode === "thumbnail") { showResultModalGooglePanel(item, item.snippet || "Open source page or download directly."); } else if (item.mediaMode === "embed" && embedURL && embedURL !== "about:blank") { showResultModalFrame(embedURL); const timeout = window.setTimeout(() => { logEvent("result:modal:iframe_timeout", { title: item.title, source: item.source, embedURL }); if (activeResultItem?.link === item.link) { resetResultModalMedia(); fallbackResultModalMedia(item, fallbackReason); } }, item.source === "Google Video" ? 5000 : 2000); resultModalFrame.onload = () => { window.clearTimeout(timeout); }; } else if (item.mediaMode === "preview_video" && item.previewVideoUrl) { showResultModalVideo(buildPlayablePreviewURL(item.previewVideoUrl, item.source)); } else if (item.mediaMode === "thumbnail" && hasUsableThumbnail(item.thumbnailUrl)) { showResultModalThumbnail(item.thumbnailUrl, item.title || ""); } else { fallbackResultModalMedia(item, fallbackReason); } showModal(resultModal); void translateSummaryForModal(item, item.snippet, summaryRequestId); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); } function closeResultViewer() { if (!resultModalReady) { return; } if (!resultModal.classList.contains("hidden")) { logEvent("result:modal:close", { title: activeResultItem?.title || "" }); } activeResultModalSummaryRequest += 1; activeResultItem = null; resetResultModalMedia(); hideModal(resultModal); } searchForm.addEventListener("submit", async (event) => { event.preventDefault(); if (activeMediaType === "image") { setStatus("searching GIPHY", 10); showWarning(""); try { const data = await api("/api/giphy/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: searchQuery.value, maxResults: 100 }), }); updateImageSearchMeta(data); renderImageResults(data.items || []); showWarning(data.warning || ""); logEvent("image-search:completed", { query: data.originalQuery || searchQuery.value, total: data.total || 0, expandedQueries: data.expandedQueries || [] }); setStatus("giphy search complete", 100); } catch (error) { updateImageSearchMeta(null); renderImageEmptyState("GIPHY 검색 결과를 불러오지 못했습니다."); showWarning(error.message); setStatus("giphy search failed", 100); } return; } setStatus("preparing search", 5); showWarning(""); try { const data = await api("/api/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: searchQuery.value, platforms: Array.from(activePlatforms) }), }); renderResults(data.results || []); renderQueryVariants(data.queries || []); showWarning(data.warning || ""); logEvent("search:completed", { results: data.results?.length || 0, queries: data.queries || [] }); setStatus("search complete", 100); } catch (error) { showWarning(error.message); renderQueryVariants([]); setStatus("search failed", 100); } }); for (const button of mediaTypeToggles) { button.addEventListener("click", () => { activeMediaType = button.dataset.mediaTypeToggle || "video"; applyMediaTypeUI(); logEvent("media-type:update", { active: activeMediaType }); }); } for (const chip of imagePromptChips) { chip.addEventListener("click", () => { searchQuery.value = chip.dataset.imagePrompt || ""; if (activeMediaType === "image") { setStatus("image prompt applied", 0); } }); } async function uploadFile(file) { const formData = new FormData(); formData.append("file", file); uploadResult.textContent = "uploading..."; try { await api("/api/upload", { method: "POST", body: formData }); logEvent("upload:completed", { fileName: file.name, size: file.size }); } catch (error) { uploadResult.textContent = error.message; } } function openPreviewModal(preview) { logEvent("preview:modal:open", preview); previewTitle.textContent = preview.title; previewThumbnail.src = preview.thumbnail || PREVIEW_PLACEHOLDER; previewThumbnail.alt = preview.title; resetPreviewPlayer(); if (preview.previewStreamUrl) { attachVideoSource(previewVideo, proxiedPreviewURL(preview.previewStreamUrl)); setHidden(previewVideo, false, ""); setHidden(previewThumbnail, true, ""); } else { setHidden(previewVideo, true, ""); setHidden(previewThumbnail, false, ""); } previewDuration.textContent = preview.duration; qualitySelect.innerHTML = ""; for (const item of preview.qualities || []) { const option = document.createElement("option"); option.value = item.value; option.textContent = item.label; qualitySelect.appendChild(option); } cropMax = Number(preview.durationSeconds || 0); cropStart = 0; cropEnd = cropMax; syncRanges(); showModal(previewModal); } function closeModal() { logEvent("preview:modal:close", { title: previewTitle.textContent }); resetPreviewPlayer(); hideModal(previewModal); cropStart = 0; cropEnd = 0; cropMax = 0; syncRanges(); pendingDownload = null; } async function downloadGiphyItem(item) { resultModalDownload.disabled = true; const originalLabel = resultModalDownload.textContent; resultModalDownload.textContent = "Downloading..."; try { const data = await api("/api/giphy/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providerId: item.providerId, title: item.title, downloadUrl: item.downloadUrl, originalQuery: item.originalQuery, selectedExpansionQuery: item.searchQuery, }), }); downloadResult.textContent = `${data.fileName} saved to ${data.savedPath}`; setStatus("giphy download complete", 100); logEvent("giphy:download:completed", { providerId: item.providerId, fileName: data.fileName, savedPath: data.savedPath }); } catch (error) { downloadResult.textContent = error.message; setStatus("giphy download failed", 100); logEvent("giphy:download:error", { providerId: item.providerId, message: error.message, data: error.data || null }); } finally { resultModalDownload.disabled = false; resultModalDownload.textContent = originalLabel; } } dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("border-white/60", "bg-white/[0.08]"); }); dropzone.addEventListener("dragleave", () => { dropzone.classList.remove("border-white/60", "bg-white/[0.08]"); }); dropzone.addEventListener("drop", async (event) => { event.preventDefault(); dropzone.classList.remove("border-white/60", "bg-white/[0.08]"); const file = event.dataTransfer.files[0]; if (file) { await uploadFile(file); } }); fileInput.addEventListener("change", async () => { const [file] = fileInput.files; if (file) { await uploadFile(file); } }); downloadForm.addEventListener("submit", async (event) => { event.preventDefault(); try { await prepareDirectDownload(downloadUrl.value); } catch (error) { downloadResult.textContent = error.message; logEvent("download:preview:error", { message: error.message, data: error.data || null }); } }); confirmDownload.addEventListener("click", async () => { if (!pendingDownload) { return; } try { const data = await api("/api/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: pendingDownload.url, start: toClock(cropStart), end: toClock(cropEnd), quality: qualitySelect.value, force: pendingDownload.force, }), }); closeModal(); downloadResult.textContent = data.message; } catch (error) { downloadResult.textContent = error.message; logEvent("download:start:error", { message: error.message, data: error.data || null }); } }); closePreviewModal.addEventListener("click", closeModal); if (resultModalReady) { closeResultModal.addEventListener("click", closeResultViewer); resultModal.addEventListener("click", (event) => { if (event.target === resultModal) { closeResultViewer(); } }); resultModalDownload.addEventListener("click", async () => { if (!activeResultItem) { return; } const currentItem = activeResultItem; if (currentItem.actionType === "giphy_download") { await downloadGiphyItem(currentItem); return; } if (currentItem.actionType === "download") { try { closeResultViewer(); await prepareDirectDownload(currentItem.link); } catch (error) { downloadResult.textContent = error.message; logEvent("download:preview:error", { message: error.message, data: error.data || null, source: currentItem?.source || "" }); } return; } window.open(currentItem.openUrl || currentItem.sourcePageUrl || currentItem.link, "_blank", "noopener,noreferrer"); }); resultModalSecondaryAction.addEventListener("click", () => { if (!activeResultItem) { return; } window.open(activeResultItem.openUrl || activeResultItem.sourcePageUrl || activeResultItem.link, "_blank", "noopener,noreferrer"); }); } previewModal.addEventListener("click", (event) => { if (event.target === previewModal) { closeModal(); } }); setStartFromPreview.addEventListener("click", () => { cropStart = Math.floor(previewVideo.currentTime || 0); activeThumb = "start"; syncRanges(); }); setEndFromPreview.addEventListener("click", () => { cropEnd = Math.floor(previewVideo.currentTime || 0); activeThumb = "end"; syncRanges(); }); for (const [thumb, name] of [[startThumb, "start"], [endThumb, "end"]]) { thumb.addEventListener("pointerdown", (event) => { event.preventDefault(); activeThumb = name; thumb.setPointerCapture(event.pointerId); }); thumb.addEventListener("pointermove", (event) => { if (activeThumb !== name || cropMax <= 0) { return; } const track = thumb.parentElement.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (event.clientX - track.left) / track.width)); const value = Math.round(ratio * cropMax); if (name === "start") { cropStart = value; } else { cropEnd = value; } syncRanges(); }); thumb.addEventListener("pointerup", () => { activeThumb = null; }); } previewVideo.addEventListener("loadedmetadata", () => { logEvent("preview:modal:loadedmetadata", { width: previewVideo.videoWidth, height: previewVideo.videoHeight, src: previewVideo.currentSrc || previewVideo.src, }); if (previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) { previewMediaFrame.style.aspectRatio = `${previewVideo.videoWidth} / ${previewVideo.videoHeight}`; } }); previewThumbnail.addEventListener("load", () => { logEvent("preview:thumbnail:loaded", { width: previewThumbnail.naturalWidth, height: previewThumbnail.naturalHeight, src: previewThumbnail.currentSrc || previewThumbnail.src, }); if (!previewVideo.src && previewThumbnail.naturalWidth > 0 && previewThumbnail.naturalHeight > 0) { previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`; } }); if (resultModalReady) { resultModalVideo.addEventListener("loadedmetadata", () => { if (resultModalVideo.videoWidth > 0 && resultModalVideo.videoHeight > 0) { resultModalMediaFrame.style.aspectRatio = `${resultModalVideo.videoWidth} / ${resultModalVideo.videoHeight}`; } }); resultModalThumbnail.addEventListener("load", () => { if (resultModalThumbnail.naturalWidth > 0 && resultModalThumbnail.naturalHeight > 0) { resultModalMediaFrame.style.aspectRatio = `${resultModalThumbnail.naturalWidth} / ${resultModalThumbnail.naturalHeight}`; } }); } for (const button of platformToggles) { button.addEventListener("click", () => { const platform = button.dataset.platformToggle; if (activePlatforms.has(platform) && activePlatforms.size > 1) { activePlatforms.delete(platform); } else { activePlatforms.add(platform); } syncPlatformButtons(); setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0); }); } debugToggle.addEventListener("click", () => { debugPanel.classList.remove("hidden"); logEvent("debug:panel:open", {}); }); closeDebugPanel.addEventListener("click", () => { debugPanel.classList.add("hidden"); }); clearLogs.addEventListener("click", () => { debugEntries.length = 0; renderLogs(); }); downloadLogs.addEventListener("click", () => { const blob = new Blob( [debugEntries.map((entry) => `[${entry.ts}] ${entry.type}\n${safeStringify(entry.payload)}\n`).join("\n")], { type: "text/plain;charset=utf-8" }, ); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `ai-media-hub-${new Date().toISOString().replace(/[:.]/g, "-")}.log`; anchor.click(); URL.revokeObjectURL(url); }); window.addEventListener("error", (event) => { logEvent("window:error", { message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno }); }); window.addEventListener("unhandledrejection", (event) => { logEvent("window:unhandledrejection", { reason: String(event.reason) }); }); window.addEventListener("keydown", (event) => { if (event.key !== "Escape") { return; } closeModal(); closeResultViewer(); }); connectWS(); syncPlatformButtons(); applyMediaTypeUI(); logEvent("app:ready", { activePlatforms: Array.from(activePlatforms) });