// ============================================================ // Footage Screener ? Frontend // ============================================================ // --- State --- let filteredFootage = []; let selectedIds = new Set(); let modalCurrentIndex = -1; let analysisPolling = null; let currentPage = 1; let totalPages = 1; let perPage = 60; let currentScreenerFilter = ""; let currentCategory = ""; let currentStatus = "active"; let viewMode = "grid"; let uploadFiles = []; let footageAbortController = null; // --- DOM Ready --- document.addEventListener("DOMContentLoaded", () => { // Bind filter inputs document.getElementById("search").addEventListener("input", debounce(applyFilters, 300)); document.getElementById("minDur").addEventListener("input", debounce(applyFilters, 300)); document.getElementById("maxDur").addEventListener("input", debounce(applyFilters, 300)); // Bind upload dropzone const dz = document.getElementById("dropzone"); const fi = document.getElementById("fileInput"); if (dz && fi) { dz.addEventListener("dragover", e => { e.preventDefault(); dz.classList.add("dragover"); }); dz.addEventListener("dragleave", () => dz.classList.remove("dragover")); dz.addEventListener("drop", e => { e.preventDefault(); dz.classList.remove("dragover"); addUploadFiles(e.dataTransfer.files); }); fi.addEventListener("change", () => { addUploadFiles(fi.files); fi.value = ""; }); } // Bind keyboard shortcuts document.addEventListener("keydown", handleKeyboard); // Continuous playhead sync + clip range enforcement function syncPlayhead() { const vp = document.getElementById("videoPlayer"); if (vp && clipDuration > 0) { if (!vp.paused) { updatePlayhead(vp.currentTime); // Auto-pause at out-point if (clipEnd > 0 && vp.currentTime >= clipEnd) { vp.pause(); vp.currentTime = clipEnd; } } } requestAnimationFrame(syncPlayhead); } requestAnimationFrame(syncPlayhead); // Close modal on overlay click document.querySelectorAll(".modal").forEach(modal => { const overlay = modal.querySelector(".modal-overlay"); if (overlay) { overlay.addEventListener("click", () => closeModalById(modal.id)); } }); // Event delegation for scene cards - use MutationObserver const observer = new MutationObserver(() => { document.querySelectorAll(".scene-card[data-start]:not([data-bound])").forEach(card => { card.dataset.bound = "1"; card.addEventListener("click", (e) => { e.stopPropagation(); playSceneCard(card); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); // Init loadStats(); loadFootage(1); }); // --- API Calls --- async function loadFootage(page = 1) { if (footageAbortController) footageAbortController.abort(); footageAbortController = new AbortController(); currentPage = page; const params = new URLSearchParams({ page: page, per_page: perPage, status: currentStatus, }); if (currentScreenerFilter) params.set("screener", currentScreenerFilter); if (currentCategory) params.set("category", currentCategory); const searchVal = document.getElementById("search")?.value || ""; if (searchVal) params.set("search", searchVal); const minDur = document.getElementById("minDur")?.value; const maxDur = document.getElementById("maxDur")?.value; if (minDur) params.set("min_duration", minDur); if (maxDur) params.set("max_duration", maxDur); try { const res = await fetch(`/api/footage?${params}`, { signal: footageAbortController.signal }); const data = await res.json(); console.log("Footage OK:", data.total, "total,", data.items?.length, "on page"); filteredFootage = data.items || []; totalPages = data.pages || 1; renderGrid(); renderPagination(data.total || 0); const el = document.getElementById("filterResults"); if (el) el.textContent = data.total > 0 ? `[LIST] ${data.total} 个素材` : ""; } catch (e) { console.error("Load footage failed:", e); } } async function loadStats() { try { const res = await fetch("/api/stats"); const data = await res.json(); const ss = data.screener || {}; const cats = data.categories || {}; console.log("Stats:", ss); setText("countAll", cats["上传素材"]?.count || 0); setText("countUnanalyzed", ss["待拉片"] || 0); setText("countAnalyzed", ss["待拉片"] || 0); setText("countClipped", ss["待拉片"] || 0); setText("countRecycle", ss["待拉片"] || 0); // Category list const catList = document.getElementById("categoryList"); if (catList) { let html = ""; for (const [cat, s] of Object.entries(cats)) { if (cat === "上传素材") continue; const active = currentCategory === cat ? " active" : ""; html += ``; } catList.innerHTML = html; } } catch (e) {} } // --- Rendering --- function renderGrid() { const grid = document.getElementById("grid"); const empty = document.getElementById("emptyState"); if (!filteredFootage.length) { grid.innerHTML = ""; empty.style.display = "block"; return; } empty.style.display = "none"; grid.className = "grid" + (viewMode === "list" ? " list-view" : ""); grid.innerHTML = filteredFootage.map(f => { const dur = formatDuration(f.duration); const sel = selectedIds.has(f.id); const s = f.screener; let tags = `${f.category}${dur}`; let sTag = ""; if (s) { const cls = { KEEP: "keep", REVIEW: "review", DELETE: "delete" }[s.decision] || ""; sTag = `${s.decision}`; } const desc = s?.ai_description ? `
${esc(s.ai_description)}
` : ""; const score = s ? `${s.final_score}?` : ""; return `
X
${tags}${sTag}
X
${esc(f.filename)}
${desc}
${f.width}x${f.height}${f.size_mb}MB${score}
`; }).join(""); // Click to open modal grid.querySelectorAll(".card").forEach(card => { card.addEventListener("click", e => { if (e.target.closest(".card-check")) return; const f = filteredFootage.find(x => x.id === parseInt(card.dataset.id)); if (f) openModal(f); }); }); } function renderPagination(total) { const el = document.getElementById("pagination"); if (!el || totalPages <= 1) { if (el) el.innerHTML = ""; return; } let html = `${total} 个素材`; html += pgBtn(currentPage - 1, "?", currentPage <= 1); for (let i = 1; i <= totalPages; i++) { if (totalPages > 7 && i > 2 && i < totalPages - 1 && Math.abs(i - currentPage) > 1) { if (i === 3 || i === totalPages - 2) html += `...`; continue; } html += pgBtn(i, i, false, i === currentPage); } html += pgBtn(currentPage + 1, "?", currentPage >= totalPages); el.innerHTML = html; } function pgBtn(page, label, disabled, active) { return ``; } // --- Filtering --- function filterByNav(el, type) { setActiveNav(el); currentCategory = ""; currentScreenerFilter = ""; currentStatus = "active"; if (type === "analyzed") currentScreenerFilter = "待拉片"; else if (type === "clipped") currentScreenerFilter = "待拉片"; else if (type === "unanalyzed") currentScreenerFilter = "待拉片"; else if (type === "recycle") { currentStatus = "deleted"; currentScreenerFilter = ""; } setText("breadcrumbCurrent", el.querySelector(".nav-label").textContent); updateActionBar(); loadFootage(1); } function filterByCategory(el, cat) { setActiveNav(el); currentCategory = cat; currentScreenerFilter = ""; currentStatus = "active"; setText("breadcrumbCurrent", cat); updateActionBar(); loadFootage(1); } function updateActionBar() { const isRecycle = currentStatus === "deleted"; document.getElementById("normalActions").style.display = isRecycle ? "none" : "flex"; document.getElementById("recycleActions").style.display = isRecycle ? "flex" : "none"; } // --- Tasks --- let taskRefreshInterval = null; async function showTasks() { document.getElementById("taskPanel").style.display = "flex"; setActiveNav(document.querySelector('[onclick*="showTasks"]')); await loadTasks(); // Auto-refresh every 3 seconds while panel is open if (taskRefreshInterval) clearInterval(taskRefreshInterval); taskRefreshInterval = setInterval(loadTasks, 3000); } function hideTasks() { const panel = document.getElementById("taskPanel"); if (panel) panel.style.display = "none"; if (taskRefreshInterval) { clearInterval(taskRefreshInterval); taskRefreshInterval = null; } } async function loadTasks() { try { const res = await fetch("/api/tasks"); const data = await res.json(); const tasks = data.tasks || []; const running = data.running; // Update running count setText("countRunning", running ? "1" : "0"); const body = document.getElementById("taskPanelBody"); if (!tasks.length) { body.innerHTML = '
暂无任务
'; return; } body.innerHTML = tasks.map(t => { const pct = t.total_count > 0 ? Math.round(t.completed_count / t.total_count * 100) : 0; const typeLabel = { all: "全部", unanalyzed: "待分析", selected: "选中", single: "单个" }[t.type] || t.type; const statusLabel = { running: "[REFRESH] 分析中", completed: "X 完成", failed: "X 失败", pending: "X 待处理" }[t.status] || t.status; let detail = ""; if (t.status === "running") { detail = `${t.completed_count}/${t.total_count} ? ${t.current_file || ''}`; } else if (t.status === "completed") { detail = `[CHART] ${t.total_count} 个素材`; } else if (t.status === "failed") { detail = t.error_message || "分析中"; } return `
${typeLabel} ${statusLabel}
${t.status === "running" ? `
` : ''}
${detail}
${t.created_at || ''}
`; }).join(""); } catch (e) { console.error("Failed to load tasks:", e); } } function setActiveNav(el) { document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active")); el.classList.add("active"); } function applyFilters() { loadFootage(1); } // --- Selection --- function toggleSelect(id, el) { const card = el.closest(".card"); if (selectedIds.has(id)) { selectedIds.delete(id); el.classList.remove("checked"); if (card) card.classList.remove("selected"); } else { selectedIds.add(id); el.classList.add("checked"); if (card) card.classList.add("selected"); } updateSelectedBar(); } function updateSelectedBar() { const bar = document.getElementById("selectedBar"); if (selectedIds.size > 0) { bar.style.display = "flex"; setText("selectedCount", selectedIds.size); } else { bar.style.display = "none"; } } function clearSelection() { selectedIds.clear(); document.querySelectorAll(".card.selected").forEach(c => { c.classList.remove("selected"); const ch = c.querySelector(".card-check"); if (ch) ch.classList.remove("checked"); }); updateSelectedBar(); } async function deleteSelected() { if (selectedIds.size === 0) return; if (!confirm(`确定要删除 ${selectedIds.size} 个素材吗?`)) return; try { const res = await fetch("/api/batch/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: true, ids: Array.from(selectedIds) }), }); const data = await res.json(); if (res.ok) { alert(`已删除 ${data.deleted || 0} 个`); selectedIds.clear(); updateSelectedBar(); loadFootage(currentPage); loadStats(); } else { alert("错误: " + (data.error || "Unknown error")); } } catch (e) { alert("错误: " + e.message); } } async function restoreSelected() { if (selectedIds.size === 0) return; try { const res = await fetch("/api/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: Array.from(selectedIds) }), }); const data = await res.json(); if (res.ok) { alert(`已恢复 ${data.restored || 0} 个`); selectedIds.clear(); updateSelectedBar(); loadFootage(currentPage); loadStats(); } } catch (e) { alert("错误: " + e.message); } } async function permanentDeleteSelected() { if (selectedIds.size === 0) return; if (!confirm(`确定要永久删除 ${selectedIds.size} 个素材吗? 此操作不可撤销!!!`)) return; try { const res = await fetch("/api/permanent-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: Array.from(selectedIds) }), }); const data = await res.json(); if (res.ok) { alert(`已永久删除 ${data.deleted || 0} 个`); selectedIds.clear(); updateSelectedBar(); loadFootage(currentPage); loadStats(); } } catch (e) { alert("错误: " + e.message); } } function selectAllPage() { filteredFootage.forEach(f => { selectedIds.add(f.id); }); document.querySelectorAll(".card").forEach(c => { c.classList.add("selected"); const ch = c.querySelector(".card-check"); if (ch) ch.classList.add("checked"); }); updateSelectedBar(); } // --- View & Sidebar --- function setCardSize(val) { const grid = document.getElementById("grid"); if (grid) grid.style.gridTemplateColumns = `repeat(auto-fill, minmax(${val}px, 1fr))`; } function setView(mode) { viewMode = mode; document.getElementById("btnGrid")?.classList.toggle("active", mode === "grid"); document.getElementById("btnList")?.classList.toggle("active", mode === "list"); renderGrid(); } function toggleSidebar() { document.getElementById("sidebar")?.classList.toggle("collapsed"); } // --- Modals --- function openModal(footage) { modalCurrentIndex = filteredFootage.findIndex(f => f.id === footage.id); const ext = footage.filename.split(".").pop().toLowerCase(); const mimeMap = { mp4: "video/mp4", mov: "video/quicktime", avi: "video/x-msvideo", mkv: "video/x-matroska" }; const player = document.getElementById("videoPlayer"); player.src = `/video/${footage.id}`; player.type = mimeMap[ext] || "video/mp4"; // Basic info const s = footage.screener; let sHtml = ""; if (s) { const cls = { KEEP: "keep", REVIEW: "review", DELETE: "delete" }[s.decision] || ""; sHtml = `
决策:${s.decision}
评分:${s.final_score}
`; } document.getElementById("modalInfo").innerHTML = `
文件:${esc(footage.filename)}
时长:${formatDuration(footage.duration)}
分辨率:${footage.width}x${footage.height}
大小:${footage.size_mb}MB
分类:${esc(footage.category)}
${sHtml}`; // Generate report renderReport(footage); // Reset to preview tab switchModalTab("preview", document.querySelector(".modal-tab")); // Analyze button state const aBtn = document.getElementById("modalAnalyzeBtn"); const hasResult = s && s.ai_quality; aBtn.textContent = hasResult ? "重新分析" : "分析"; aBtn.disabled = false; // Initialize timeline clipDuration = footage.duration || 0; clipStart = 0; clipEnd = clipDuration; clipSegments = []; renderClipList(); document.getElementById("modal").classList.add("active"); player.play(); // Init timeline after modal is visible setTimeout(() => { initTimeline(); updateClipUI(); }, 100); } function switchModalTab(tab, el) { document.querySelectorAll(".modal-tab").forEach(t => t.classList.remove("active")); el.classList.add("active"); document.getElementById("tabPreview").style.display = tab === "preview" ? "block" : "none"; document.getElementById("tabReport").style.display = tab === "report" ? "block" : "none"; // Pause video when switching to report tab const player = document.getElementById("videoPlayer"); if (tab === "report" && player && !player.paused) { player.pause(); } // Show scene section when switching to report if (tab === "report") { document.getElementById("sceneSection").style.display = "block"; } } function renderReport(footage) { const s = footage.screener; const c = document.getElementById("reportContainer"); const sceneSection = document.getElementById("sceneSection"); // Set current footage ID for scene playback currentFootageId = footage.id; if (!s) { c.innerHTML = `
[CHART] 暂无分析数据

请先分析素材后查看报告
`; sceneSection.style.display = "none"; return; } // Show scene section sceneSection.style.display = "block"; // Render scene summary header const scenes = s.scenes || []; const totalScenes = scenes.length; const highlightCount = scenes.filter(x => x.is_highlight).length; const wasteCount = scenes.filter(x => x.is_waste).length; console.log("renderReport - s:", s); console.log("renderReport - s.transcription:", s.transcription); c.innerHTML = `
${totalScenes} 总计数
${highlightCount} [CHART] 时长
${wasteCount} X 有效素材
${s.transcription ? `
[?]️ 语音转文本
${esc(s.transcription)}
` : ''} `; // Render scenes const sceneContainer = document.getElementById("sceneResults"); if (scenes.length > 0) { sceneContainer.innerHTML = scenes.map(scene => { const isWaste = scene.is_waste; const isHighlight = scene.is_highlight; const cardCls = isWaste ? " waste" : isHighlight ? " highlight" : ""; const sLabel = isHighlight ? "X 亮点" : isWaste ? "[?]️ 废料" : "X 有效"; const startTime = scene.start_time || 0; const endTime = scene.end_time || scene.start_time || 0; const duration = scene.duration || (endTime - startTime); const frameImg = scene.thumbnail ? `` : ''; const shotType = scene.shot_type || ""; const dialogue = scene.dialogue || ""; const soundFx = scene.sound_effects || ""; const notes = scene.notes || ""; // 解析时长为 MM:SS const fmtTime = (s) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); const ms = Math.round((s % 1) * 100); return String(m).padStart(2, "0") + ":" + String(sec).padStart(2, "0") + "." + String(ms).padStart(2, "0"); }; // 解析备注 let detailsHtml = ""; if (shotType) detailsHtml += `
[?] 景别${esc(shotType)}
`; if (dialogue) detailsHtml += `
[?]️ 对话${esc(dialogue)}
`; if (soundFx) detailsHtml += `
[?] 音效${esc(soundFx)}
`; if (notes || true) detailsHtml += `
[?] 备注
${esc(notes || '')}
`; return `
${frameImg}
X
[?] 镜头 ${scene.shot_index || scene.scene || scene.scene_index || ""}
${sLabel} ${duration.toFixed(1)}s
? ${fmtTime(startTime)} - ${fmtTime(endTime)}
${esc(scene.description || "")}
${detailsHtml ? '
' + detailsHtml + '
' : ''}
`; }).join(""); // Bind click handlers to scene cards sceneContainer.querySelectorAll(".scene-card").forEach(card => { card.addEventListener("click", (e) => { e.stopPropagation(); playSceneCard(card); }); }); } else { sceneContainer.innerHTML = `
[CHART] 暂无分析数据
`; } } function closeModal() { document.getElementById("modal").classList.remove("active"); const p = document.getElementById("videoPlayer"); p.pause(); p.src = ""; modalCurrentIndex = -1; if (sceneTimer) { clearInterval(sceneTimer); sceneTimer = null; } stopSceneVideo(); } function closeModalById(id) { if (id === "modal") closeModal(); else if (id === "uploadModal") closeUploadModal(); } function navigateModal(d) { if (modalCurrentIndex < 0 || !filteredFootage.length) return; let i = modalCurrentIndex + d; if (i < 0) i = filteredFootage.length - 1; if (i >= filteredFootage.length) i = 0; openModal(filteredFootage[i]); } async function analyzeFromModal() { if (modalCurrentIndex < 0) return; const f = filteredFootage[modalCurrentIndex]; if (!f) return; const btn = document.getElementById("modalAnalyzeBtn"); btn.disabled = true; btn.textContent = "[REFRESH] 分析中..."; try { const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "selected", selected_ids: [f.id] }), }); const data = await res.json(); if (res.ok) { // Poll for completion const poll = setInterval(async () => { try { const sr = await fetch("/api/analyze/status"); const sd = await sr.json(); if (sd.status === "done") { clearInterval(poll); // Reload footage data await loadFootage(currentPage); // Re-fetch the updated footage const updated = filteredFootage.find(x => x.id === f.id); if (updated) { renderReport(updated); const hasResult = updated.screener && updated.screener.ai_quality; btn.textContent = hasResult ? "重新分析" : "分析"; } btn.disabled = false; loadStats(); if (document.getElementById("taskPanel").style.display !== "none") loadTasks(); } } catch (e) {} }, 1000); } else { alert(data.error || "分析中"); btn.textContent = "分析"; btn.disabled = false; } } catch (e) { alert("错误: " + e.message); btn.textContent = "分析"; btn.disabled = false; } } // --- Upload --- function openUploadModal() { uploadFiles = []; document.getElementById("uploadFilelist").innerHTML = ""; document.getElementById("uploadProgress").style.display = "none"; document.getElementById("uploadResult").style.display = "none"; document.getElementById("btnUploadSubmit").disabled = true; document.getElementById("btnUploadSubmit").textContent = "上传中"; document.getElementById("uploadModal").classList.add("active"); } function closeUploadModal() { document.getElementById("uploadModal").classList.remove("active"); uploadFiles = []; } function addUploadFiles(files) { const allowed = ["mp4","mov","avi","mkv","mts","mxf","wmv","flv","mpg","mpeg","3gp","webm","ts","m4v"]; for (const f of files) { const ext = f.name.split(".").pop().toLowerCase(); if (!allowed.includes(ext)) { alert(`?分类: ${f.name}`); continue; } if (!uploadFiles.find(u => u.name === f.name && u.size === f.size)) uploadFiles.push(f); } renderUploadList(); document.getElementById("btnUploadSubmit").disabled = uploadFiles.length === 0; } function removeUploadFile(i) { uploadFiles.splice(i, 1); renderUploadList(); document.getElementById("btnUploadSubmit").disabled = uploadFiles.length === 0; } function renderUploadList() { document.getElementById("uploadFilelist").innerHTML = uploadFiles.map((f, i) => { const sz = f.size > 1073741824 ? (f.size / 1073741824).toFixed(1) + " GB" : (f.size / 1048576).toFixed(1) + " MB"; return `
[OK] ${esc(f.name)}${sz} { if (e.lengthComputable) { const pct = Math.round(e.loaded / e.total * 100); pFill.style.width = pct + "%"; const loaded = e.loaded > 1073741824 ? (e.loaded / 1073741824).toFixed(1) + " GB" : (e.loaded / 1048576).toFixed(0) + " MB"; pText.textContent = `上传中 ${loaded} (${pct}%)`; } }; xhr.onload = () => { pBar.style.display = "none"; if (xhr.status === 200) { const d = JSON.parse(xhr.responseText); res.className = "upload-result success"; res.innerHTML = `X 上传成功 ${d.uploaded} 个文件`; res.style.display = "block"; uploadFiles = []; document.getElementById("uploadFilelist").innerHTML = ""; loadFootage(1); loadStats(); } else { res.className = "upload-result error"; res.textContent = "? 上传中"; res.style.display = "block"; } btn.textContent = "上传中"; btn.disabled = uploadFiles.length === 0; }; xhr.onerror = () => { pBar.style.display = "none"; res.className = "upload-result error"; res.textContent = "? 上传中"; res.style.display = "block"; btn.textContent = "上传中"; btn.disabled = false; }; xhr.send(fd); } catch (e) { pBar.style.display = "none"; res.className = "upload-result error"; res.textContent = "? " + e.message; res.style.display = "block"; btn.textContent = "上传中"; btn.disabled = false; } } // --- Analysis --- async function startAnalysis(mode = "all") { const body = { mode }; if (mode === "selected") body.selected_ids = Array.from(selectedIds); const btn = document.getElementById("btnAnalyze"); btn.disabled = true; btn.textContent = "[REFRESH] 分析中..."; try { const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (res.ok) startPolling(); else { alert(data.error || "Failed"); resetBtn(btn); } } catch (e) { alert("Failed to start analysis"); resetBtn(btn); } } function analyzeSelected() { if (selectedIds.size > 0) startAnalysis("selected"); } function resetBtn(btn) { btn.disabled = false; btn.textContent = "分析"; } function startPolling() { const pBar = document.getElementById("progressBar"); const pFill = document.getElementById("progressFill"); const pText = document.getElementById("progressText"); pBar.style.display = "block"; if (analysisPolling) clearInterval(analysisPolling); analysisPolling = setInterval(async () => { try { const res = await fetch("/api/analyze/status"); const d = await res.json(); const pct = d.total > 0 ? (d.current / d.total * 100) : 0; pFill.style.width = pct + "%"; pText.textContent = `[REFRESH] 分析中 ${d.current}/${d.total}: ${d.file}`; if (d.status === "done") { clearInterval(analysisPolling); pBar.style.display = "none"; resetBtn(document.getElementById("btnAnalyze")); loadFootage(currentPage); loadStats(); if (document.getElementById("taskPanel").style.display !== "none") loadTasks(); } } catch (e) {} }, 1000); } // --- Export --- function exportSelected() { const items = filteredFootage.filter(f => selectedIds.has(f.id)); const data = { time: new Date().toISOString(), total: items.length, clips: items.map(f => ({ filename: f.filename, category: f.category, duration: f.duration, resolution: `${f.width}x${f.height}`, size_mb: f.size_mb, screener: f.screener, })), }; const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" })); a.download = `footage_report_${Date.now()}.json`; a.click(); } // --- Keyboard --- function handleKeyboard(e) { if (e.key === "Escape") { if (document.getElementById("uploadModal").classList.contains("active")) closeUploadModal(); else closeModal(); return; } if (!document.getElementById("modal").classList.contains("active")) return; if (e.key === "ArrowLeft") navigateModal(-1); else if (e.key === "ArrowRight") navigateModal(1); } // --- Clip Functions --- let clipStart = 0; let clipEnd = 0; let clipDuration = 0; let clipSegments = []; let isDraggingMarker = null; let timelineEl = null; function initTimeline() { timelineEl = document.getElementById("clipTimeline"); if (!timelineEl) return; // Click on timeline to set playhead timelineEl.addEventListener("click", (e) => { if (isDraggingMarker) return; const rect = timelineEl.getBoundingClientRect(); const pct = (e.clientX - rect.left) / rect.width; const time = pct * clipDuration; document.getElementById("videoPlayer").currentTime = time; updatePlayhead(time); }); // Drag markers document.addEventListener("mousemove", (e) => { if (!isDraggingMarker || !timelineEl) return; const rect = timelineEl.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const time = pct * clipDuration; if (isDraggingMarker === "in") { clipStart = Math.min(time, clipEnd - 0.1); updateClipUI(); } else if (isDraggingMarker === "out") { clipEnd = Math.max(time, clipStart + 0.1); updateClipUI(); } }); document.addEventListener("mouseup", () => { isDraggingMarker = null; }); // Marker drag start document.getElementById("clipMarkerIn").addEventListener("mousedown", (e) => { e.stopPropagation(); isDraggingMarker = "in"; }); document.getElementById("clipMarkerOut").addEventListener("mousedown", (e) => { e.stopPropagation(); isDraggingMarker = "out"; }); } function playClipRange() { const player = document.getElementById("videoPlayer"); if (!player || clipStart >= clipEnd) return; player.currentTime = clipStart; player.play(); } function setClipStart() { const player = document.getElementById("videoPlayer"); clipStart = player.currentTime; if (clipEnd <= clipStart) clipEnd = Math.min(clipStart + 1, clipDuration); updateClipUI(); // Seek to in-point and play player.currentTime = clipStart; player.play(); } function setClipEnd() { const player = document.getElementById("videoPlayer"); clipEnd = player.currentTime; if (clipStart >= clipEnd) clipStart = Math.max(clipEnd - 1, 0); updateClipUI(); // Pause at out-point player.pause(); } function updateClipUI() { document.getElementById("clipStart").textContent = formatTimeFull(clipStart); document.getElementById("clipEnd").textContent = formatTimeFull(clipEnd); const dur = Math.max(0, clipEnd - clipStart); document.getElementById("clipDuration").textContent = `分类: ${dur.toFixed(1)}s`; updateTimelineMarkers(); } function updateTimelineMarkers() { if (!timelineEl || clipDuration <= 0) return; const inPct = (clipStart / clipDuration) * 100; const outPct = (clipEnd / clipDuration) * 100; document.getElementById("clipMarkerIn").style.left = `${inPct}%`; document.getElementById("clipMarkerOut").style.left = `calc(${outPct}% - 12px)`; document.getElementById("clipSelection").style.left = `${inPct}%`; document.getElementById("clipSelection").style.width = `${outPct - inPct}%`; } function updatePlayhead(time) { if (!timelineEl || clipDuration <= 0) return; const pct = Math.min(100, (time / clipDuration) * 100); document.getElementById("clipPlayhead").style.left = `${pct}%`; document.getElementById("clipPlayed").style.width = `${pct}%`; } function formatTimeFull(sec) { const m = Math.floor(sec / 60); const s = sec % 60; return `${String(m).padStart(2, "0")}:${s.toFixed(3).padStart(6, "0")}`; } function addClipToList() { if (clipEnd <= clipStart) return; const seg = { start: clipStart, end: clipEnd, id: Date.now() }; clipSegments.push(seg); renderClipList(); // Reset for next clip clipStart = clipEnd; clipEnd = Math.min(clipStart + 1, clipDuration); updateClipUI(); } function parseSceneTime(timeStr, endTime) { // Handle numeric start_time / end_time from AI analysis if (typeof timeStr === "number" && typeof endTime === "number" && endTime > timeStr) { return { start: timeStr, end: endTime }; } // Handle string format "0.00-0.33" or "0:00-0:03" if (!timeStr) return { start: 0, end: 0 }; if (typeof timeStr === "number") return { start: timeStr, end: timeStr }; const parts = String(timeStr).split("-"); if (parts.length === 2) { return { start: parseFloat(parts[0]) || 0, end: parseFloat(parts[1]) || 0, }; } return { start: parseFloat(timeStr) || 0, end: parseFloat(timeStr) || 0 }; } function splitClip() { if (modalCurrentIndex < 0) return; const footage = filteredFootage[modalCurrentIndex]; if (!footage || !footage.screener || !footage.screener.scenes || !footage.screener.scenes.length) { alert("请先选择要分析的素材"); return; } const scenes = footage.screener.scenes; clipSegments = []; scenes.forEach((scene, i) => { const t = parseSceneTime(scene.start_time || scene.time, scene.end_time); let start = t.start; let end = t.end; if (end > start) { clipSegments.push({ start: start, end: end, id: Date.now() + i, scene: scene.scene || i + 1, description: scene.description || "", suggestion: scene.suggestion || "keep", }); } }); renderClipList(); if (clipSegments.length > 0) { clipStart = clipSegments[0].start; clipEnd = clipSegments[0].end; updateClipUI(); } alert(`已保存 ${clipSegments.length} 个分镜`); } function removeClipFromList(id) { clipSegments = clipSegments.filter(s => s.id !== id); renderClipList(); } function renderClipList() { const el = document.getElementById("clipList"); if (!clipSegments.length) { el.innerHTML = ""; return; } el.innerHTML = clipSegments.map((s, i) => { const sugCls = s.suggestion === "keep" ? "keep" : s.suggestion === "highlight" ? "highlight" : s.suggestion === "waste" ? "waste" : ""; const sugLabel = { keep: "X", highlight: "X", waste: "[?]️", trim: "X️" }[s.suggestion] || ""; return `
${s.scene ? `[?]${s.scene} ` : ''} ${formatTimeFull(s.start)} ? ${formatTimeFull(s.end)} (${(s.end-s.start).toFixed(1)}s) ${s.description ? `
${s.description.substring(0,30)}...` : ''}
${sugLabel} startTime) { const checkEnd = setInterval(() => { if (video.currentTime >= endTime) { video.pause(); clearInterval(checkEnd); } }, 100); } }; } function stopSceneVideo() { if (activeSceneVideo) { activeSceneVideo.pause(); activeSceneVideo.src = ""; const wrapper = activeSceneVideo.parentElement; activeSceneVideo.remove(); activeSceneVideo = null; if (wrapper) { const thumb = wrapper.querySelector(".scene-thumb"); if (thumb) thumb.style.display = ""; const playBtn = wrapper.querySelector(".scene-thumb-play"); if (playBtn) playBtn.style.display = ""; } } } // --- Helpers --- function setText(id, val) { const el = document.getElementById(id); if (el) el.textContent = val; } function esc(s) { if (s === null || s === undefined) return ""; return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function formatDuration(s) { if (!s) return "0:00"; return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`; } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } // Updated: 2026-07-08 16:05:21 // Updated: 2026-07-08 16:21:44 // Updated: 2026-07-08 16:25:57 // ===== 备注编辑 ===== function toggleNotesEdit(sceneIndex) { const display = document.getElementById(`notes-display-${sceneIndex}`); const input = document.getElementById(`notes-input-${sceneIndex}`); const editBtn = document.querySelector(`#notes-display-${sceneIndex}`)?.parentElement?.querySelector('.btn-notes-edit'); const saveBtn = document.querySelector(`#notes-display-${sceneIndex}`)?.parentElement?.querySelector('.btn-notes-save'); const cancelBtn = document.querySelector(`#notes-display-${sceneIndex}`)?.parentElement?.querySelector('.btn-notes-cancel'); if (display && input) { display.style.display = 'none'; input.style.display = 'block'; input.focus(); if (editBtn) editBtn.style.display = 'none'; if (saveBtn) saveBtn.style.display = 'inline-block'; if (cancelBtn) cancelBtn.style.display = 'inline-block'; } } function cancelNotesEdit(sceneIndex) { const display = document.getElementById(`notes-display-${sceneIndex}`); const input = document.getElementById(`notes-input-${sceneIndex}`); const editBtn = document.querySelector(`#notes-display-${sceneIndex}`)?.parentElement?.querySelector('.btn-notes-edit'); const saveBtn = document.querySelector(`#notes-display-${sceneIndex}`)?.parentElement?.querySelector('.btn-notes-save'); const cancelBtn = document.querySelector(`#notes-display-${sceneIndex}`)?.parentElement?.querySelector('.btn-notes-cancel'); if (display && input) { input.value = display.textContent; display.style.display = 'inline'; input.style.display = 'none'; if (editBtn) editBtn.style.display = 'inline-block'; if (saveBtn) saveBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; } } async function saveNotes(sceneIndex) { const input = document.getElementById(`notes-input-${sceneIndex}`); const display = document.getElementById(`notes-display-${sceneIndex}`); if (!input || !display) return; const notes = input.value.trim(); const footageId = modalCurrentIndex >= 0 ? filteredFootage[modalCurrentIndex]?.id : null; if (!footageId) { alert('请先选择素材 ID'); return; } try { const response = await fetch('/api/scene-notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ footage_id: footageId, scene_index: sceneIndex, notes: notes }) }); const result = await response.json(); if (result.success) { display.textContent = notes || '(无备注)'; display.style.display = 'inline'; input.style.display = 'none'; const editBtn = display.parentElement.querySelector('.btn-notes-edit'); const saveBtn = display.parentElement.querySelector('.btn-notes-save'); const cancelBtn = display.parentElement.querySelector('.btn-notes-cancel'); if (editBtn) editBtn.style.display = 'inline-block'; if (saveBtn) saveBtn.style.display = 'none'; if (cancelBtn) cancelBtn.style.display = 'none'; // 解析备注 if (filteredFootage[modalCurrentIndex]?.screener?.scenes) { const scenes = filteredFootage[modalCurrentIndex].screener.scenes; for (let scene of scenes) { if (scene.shot_index === sceneIndex || scene.scene_index === sceneIndex) { scene.notes = notes; break; } } } // 保存备注成功 showNotification('已保存', 'success'); } else { alert('保存失败: ' + (result.error || '未知错误')); } } catch (error) { console.error('保存备注失败:', error); alert('保存失败'); } } function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.className = `notification notification-${type}`; notification.textContent = message; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; color: white; font-weight: 500; z-index: 10000; animation: slideIn 0.3s ease; background: ${type === 'success' ? '#34a853' : type === 'error' ? '#ea4335' : '#4285f4'}; `; document.body.appendChild(notification); setTimeout(() => { notification.style.animation = 'slideOut 0.3s ease'; setTimeout(() => notification.remove(), 300); }, 2000); }