commit 1a7567231a4fd4b049acd54349120d081fd37bdb Author: GoMrStone Date: Fri Jul 10 17:26:53 2026 +0800 feat: 录音素材管理系统 - 完整功能 - 视频分析: MiMo 2.5 大模型直接分析 - 场景检测: AI 视觉模型识别分镜头 - 语音识别: 每个场景单独 ASR - 语音克隆: MiMo TTS 语音克隆 - 下载功能: 单文件/批量 ZIP 下载 - 系统设置: API 密钥配置 - 文件夹管理: 创建/移动/筛选 - 扁平化 UI: 简约按钮样式 diff --git a/app.js.backup b/app.js.backup new file mode 100644 index 0000000..648a6eb --- /dev/null +++ b/app.js.backup @@ -0,0 +1,1403 @@ +// ============================================================ +// 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); +} + + diff --git a/app.js.current b/app.js.current new file mode 100644 index 0000000..4dec09d --- /dev/null +++ b/app.js.current @@ -0,0 +1,1403 @@ +// ============================================================ +// 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); +} + + diff --git a/check切割.py b/check切割.py new file mode 100644 index 0000000..372cc5f --- /dev/null +++ b/check切割.py @@ -0,0 +1,53 @@ +import json +import urllib.request + +resp = urllib.request.urlopen("http://localhost:5001/api/footage?per_page=60") +data = json.loads(resp.read().decode()) +items = data.get("items", []) + +# Find the target video +for item in items: + if "763456798450254550912" in item.get("filename", ""): + screener = item.get("screener", {}) + scenes = screener.get("scenes", []) + transcription = screener.get("transcription", "") + + print(f"Video: {item['filename']}") + print(f"Duration: {item.get('duration', 'N/A')}s") + print(f"Total scenes: {len(scenes)}") + print() + + # Check scene timing + print("=== Scene Timing Analysis ===") + total_scene_duration = 0 + for i, scene in enumerate(scenes): + start = scene.get("start_time", 0) + end = scene.get("end_time", 0) + duration = scene.get("duration", 0) + total_scene_duration += duration + + # Check if duration matches end - start + calculated_duration = end - start + match = abs(duration - calculated_duration) < 0.1 + + print(f"Scene {scene.get('shot_index')}: {start}s - {end}s ({duration}s) {'✅' if match else '❌ MISMATCH'}") + + print(f"\nTotal scene duration: {total_scene_duration}s") + print(f"Video duration: {item.get('duration', 'N/A')}s") + + # Check speech alignment + print("\n=== Speech Alignment ===") + all_dialogue = " ".join([s.get("dialogue", "") for s in scenes if s.get("dialogue")]) + print(f"Total dialogue length: {len(all_dialogue)} chars") + print(f"Transcription length: {len(transcription)} chars") + + # Check if dialogue matches transcription + if transcription: + # Simple check: see if dialogue words appear in transcription + dialogue_words = set(all_dialogue.replace(",", "").replace("。", "").replace("!", "")) + trans_words = set(transcription.replace(",", "").replace("。", "").replace("!", "")) + + overlap = dialogue_words & trans_words + print(f"Dialogue words overlap with transcription: {len(overlap)} chars") + + break diff --git a/index.html.current b/index.html.current new file mode 100644 index 0000000..fe69a90 --- /dev/null +++ b/index.html.current @@ -0,0 +1,279 @@ + + + + + + 拍摄素材管理 + + + +
+ + + + +
+ +
+
+ + +
+
+
+ + + +
+
+
+
+ + +
+ + +
+
+ + +
+
+ + + - + + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mimo_result.txt b/mimo_result.txt new file mode 100644 index 0000000..4c70422 --- /dev/null +++ b/mimo_result.txt @@ -0,0 +1,2 @@ +### 1) 顶部区域 +这是表格的表头栏,背景为浅灰色,文字为黑色加粗字体,从左到右依次排列信息列标题,最右侧还有一个**文档复制/分享类图标**,为两个重叠的矩形样式,整体横向分割出表格 \ No newline at end of file diff --git a/mimo_result2.txt b/mimo_result2.txt new file mode 100644 index 0000000..e9944f1 --- /dev/null +++ b/mimo_result2.txt @@ -0,0 +1,62 @@ +### 表头文字 +镜号、时间码、景别、画面描述、台词/旁白、音效/配乐、备注 + +--- + +### 镜头1(镜号1) +- 时间码:00:00-00:03 +- 景别:中景 +- 画面描述:女主白衣短袖,手持玻璃密封罐装绿豆。背景:红色复古小冰箱、熊猫磁贴、“EVERYTHING WILL BE FINE”装饰画。桌面红搪瓷牡丹花盘 +- 台词/旁白:天热可别去超市买绿豆了,贵不说吧,还不容易出沙。 +- 音效/配乐:轻快BGM渐入;器皿声 +- 备注:痛点切入,悬念感 + +--- + +### 镜头2(镜号2) +- 时间码:00:03-00:06 +- 景别:特写俯拍 +- 画面描述:红盘正上方,绿豆倾泻而下堆满盘子,红绿对比鲜明,颗粒饱满 +- 台词/旁白:你看我找到这个,到手足足三斤才啥价呀,比超市散装都便宜了。 +- 音效/配乐:BGM;绿豆倾倒“哗啦啦”ASMR +- 备注:量大+便宜,视听双刺激 + +--- + +### 镜头3(镜号3) +- 时间码:00:06-00:11 +- 景别:特写俯拍 +- 画面描述:白皙手指伸入绿豆堆,轻轻拨动捧起,展示干净饱满无杂质 +- 台词/旁白:主要它没有杂质和坏的,都是精挑细选的绿豆,品质特别好。 +- 音效/配乐:BGM;手拨绿豆沙沙声 +- 备注:信任感构建 + +--- + +### 镜头4(镜号4) +- 时间码:00:11-00:16 +- 景别:特写斜俯拍 +- 画面描述:黄色电炖锅内,绿豆汤绵密起沙,白色瓷勺轻搅带起浓稠绿豆沙 +- 台词/旁白:这夏天用它熬个绿豆沙沙的,这绵绵沙沙的,特别清凉好喝。 +- 音效/配乐:舒缓治愈BGM;勺搅“咕嘟”声 +- 备注:成品诱惑,激发食欲 + +--- + +### 镜头5(镜号5) +- 时间码:00:16-00:21 +- 景别:中景 +- 画面描述:切回女主正面,双手端满盘绿豆,笑容诚恳直视镜头 +- 台词/旁白:关键这价格是真合适,给你买上一次的话,一夏天都够了。 +- 音效/配乐:BGM渐强至高点 +- 备注:价格锚定+周期暗示 + +--- + +### 镜头6(镜号6) +- 时间码:00:21-00:23 +- 景别:图文定格 +- 画面描述:产品/成品定格,出现购买链接或“点击左下角”引导 +- 台词/旁白:(无台词) +- 音效/配乐:音乐收尾;提示音“叮” +- 备注:CTA转化引导 \ No newline at end of file diff --git a/server_index.html b/server_index.html new file mode 100644 index 0000000..32c23ba --- /dev/null +++ b/server_index.html @@ -0,0 +1,419 @@ + + + + + + 拍摄素材管理 + + + + +
+ + + + +
+ +
+
+ + +
+
+
+ + + +
+
+
+
+ + +
+ + +
+
+ + +
+
+ + + - + + +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+ + +
+
+
+

🎤 语音克隆

+

上传音频/视频样本,使用 AI 克隆声音并合成语音

+
+ + +
+
📁
+
拖拽音频/视频文件到这里,或点击选择
+
支持 WAV, MP3, M4A, AAC, FLAC, MP4, MOV, AVI, MKV
+ +
+ + + +
+
语音预设
+
+ + + + + + +
+
+ + +
+
合成文本
+ +
+ + +
+
+ ⚙️ 高级设置 + +
+ +
+ + +
+ +
+ + + + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/server_style.css b/server_style.css new file mode 100644 index 0000000..211f442 --- /dev/null +++ b/server_style.css @@ -0,0 +1,1643 @@ +/* === TRAE Work Design Tokens === */ +:root { + /* radius */ + --radius-0: 0px; + --radius-2: 2px; + --radius-4: 4px; + --radius-6: 6px; + --radius-8: 8px; + --radius-10: 10px; + --radius-12: 12px; + --radius-16: 16px; + --radius-20: 20px; + --radius-24: 24px; + --radius-32: 32px; + --radius-full: 999px; + --radius-xs: var(--radius-2); + --radius-sm: var(--radius-4); + --radius-md: var(--radius-6); + --radius-lg: var(--radius-8); + --radius-xl: var(--radius-10); + --border-width-default: 1px; + + /* spacers */ + --spacer-0: 0px; + --spacer-2: 2px; + --spacer-3: 3px; + --spacer-4: 4px; + --spacer-6: 6px; + --spacer-8: 8px; + --spacer-10: 10px; + --spacer-12: 12px; + --spacer-16: 16px; + --spacer-20: 20px; + --spacer-24: 24px; + --spacer-32: 32px; + --spacer-40: 40px; + --spacer-48: 48px; + --spacer-64: 64px; + + /* icon sizes */ + --icon-size-12: 12px; + --icon-size-14: 14px; + --icon-size-16: 16px; + --icon-size-20: 20px; + --icon-size-24: 24px; + + /* font */ + --font-family-default: "SF Pro Text", "PingFang SC", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-family-heading: "SF Pro", "PingFang SC", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-family-metric: "Inter", "SF Pro Text", "PingFang SC", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-family-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --font-weight-default: 400; + --font-weight-code: 450; + --font-weight-medium: 500; + --font-weight-strong: 600; + --font-family-base: var(--font-family-default); + --font-family-heading-alias: var(--font-family-heading); + --font-family-mono-alias: var(--font-family-mono); + + /* body */ + --body-xs-font-family: var(--font-family-default); + --body-xs-font-size: 10px; + --body-xs-font-weight: 400; + --body-xs-line-height: 14px; + --body-xs-letter-spacing: 0; + --body-xs-strong-font-family: var(--font-family-default); + --body-xs-strong-font-size: 10px; + --body-xs-strong-font-weight: 500; + --body-xs-strong-line-height: 14px; + --body-xs-strong-letter-spacing: 0; + --body-sm-font-family: var(--font-family-default); + --body-sm-font-size: 11px; + --body-sm-font-weight: 400; + --body-sm-line-height: 16px; + --body-sm-letter-spacing: 0; + --body-sm-strong-font-family: var(--font-family-default); + --body-sm-strong-font-size: 11px; + --body-sm-strong-font-weight: 500; + --body-sm-strong-line-height: 16px; + --body-sm-strong-letter-spacing: 0; + --body-md-font-family: var(--font-family-default); + --body-md-font-size: 12px; + --body-md-font-weight: 400; + --body-md-line-height: 18px; + --body-md-letter-spacing: 0; + --body-md-strong-font-family: var(--font-family-default); + --body-md-strong-font-size: 12px; + --body-md-strong-font-weight: 500; + --body-md-strong-line-height: 18px; + --body-md-strong-letter-spacing: 0; + --body-base-font-family: var(--font-family-default); + --body-base-font-size: 14px; + --body-base-font-weight: 400; + --body-base-line-height: 20px; + --body-base-letter-spacing: -0.02em; + --body-base-strong-font-family: var(--font-family-default); + --body-base-strong-font-size: 14px; + --body-base-strong-font-weight: 500; + --body-base-strong-line-height: 20px; + --body-base-strong-letter-spacing: -0.02em; + --body-lg-font-family: var(--font-family-default); + --body-lg-font-size: 18px; + --body-lg-font-weight: 400; + --body-lg-line-height: 28px; + --body-lg-letter-spacing: -0.02em; + + /* heading */ + --heading-3xs-font-family: var(--font-family-default); + --heading-3xs-font-size: 11px; + --heading-3xs-font-weight: 600; + --heading-3xs-line-height: 16px; + --heading-3xs-letter-spacing: 0; + --heading-2xs-font-family: var(--font-family-default); + --heading-2xs-font-size: 12px; + --heading-2xs-font-weight: 600; + --heading-2xs-line-height: 18px; + --heading-2xs-letter-spacing: 0; + --heading-xs-font-family: var(--font-family-default); + --heading-xs-font-size: 13px; + --heading-xs-font-weight: 600; + --heading-xs-line-height: 20px; + --heading-xs-letter-spacing: 0; + --heading-sm-font-family: var(--font-family-default); + --heading-sm-font-size: 16px; + --heading-sm-font-weight: 600; + --heading-sm-line-height: 24px; + --heading-sm-letter-spacing: 0; + --heading-md-font-family: var(--font-family-heading); + --heading-md-font-size: 20px; + --heading-md-font-weight: 600; + --heading-md-line-height: 28px; + --heading-md-letter-spacing: 0; + --heading-lg-font-family: var(--font-family-heading); + --heading-lg-font-size: 22px; + --heading-lg-font-weight: 600; + --heading-lg-line-height: 30px; + --heading-lg-letter-spacing: 0; + --heading-xl-font-family: var(--font-family-heading); + --heading-xl-font-size: 24px; + --heading-xl-font-weight: 600; + --heading-xl-line-height: 32px; + --heading-xl-letter-spacing: 0; + --heading-2xl-font-family: var(--font-family-heading); + --heading-2xl-font-size: 28px; + --heading-2xl-font-weight: 600; + --heading-2xl-line-height: 36px; + --heading-2xl-letter-spacing: 0; + --heading-3xl-font-family: var(--font-family-heading); + --heading-3xl-font-size: 32px; + --heading-3xl-font-weight: 600; + --heading-3xl-line-height: 40px; + --heading-3xl-letter-spacing: 0; + --heading-display-font-family: var(--font-family-heading); + --heading-display-font-size: 52px; + --heading-display-font-weight: 600; + --heading-display-line-height: 60px; + --heading-display-letter-spacing: -0.03em; + --heading-display-sm-font-family: var(--font-family-heading); + --heading-display-sm-font-size: 40px; + --heading-display-sm-font-weight: 600; + --heading-display-sm-line-height: 48px; + --heading-display-sm-letter-spacing: -0.02em; + + /* code */ + --code-editor-font-family: var(--font-family-mono); + --code-editor-font-size: 13px; + --code-editor-font-weight: 450; + --code-editor-line-height: 20px; + --code-terminal-font-family: var(--font-family-mono); + --code-terminal-font-size: 12px; + --code-terminal-font-weight: 450; + --code-terminal-line-height: 18px; + --code-text: #17181A; + --code-doc: #8393A3; + --code-link: #2F74FF; + --code-number: #E54595; + --code-action: #5F36B2; + --code-instruction: #B15EF2; + --code-function: #FF5D4D; + --code-constant: #175CE6; + --code-parameter: #4DA621; + --code-attribute: #C99100; + --code-tag: #E24864; + + /* bg */ + --bg-base-default: #FFFFFF; + --bg-base-secondary: #F5F5F5; + --bg-base-tertiary: #E5E5E5; + --bg-overlay-l1: rgba(115, 115, 115, 0.08); + --bg-overlay-l2: rgba(115, 115, 115, 0.12); + --bg-overlay-l3: rgba(115, 115, 115, 0.16); + --bg-overlay-l4: rgba(115, 115, 115, 0.20); + --bg-white: #FFFFFF; + --bg-menu: #FFFFFF; + --bg-tooltip: #FFFFFF; + --bg-invert: #262626; + --bg-invert-hover: #404040; + --bg-invert-active: #171717; + --bg-invert-disabled: rgba(115, 115, 115, 0.20); + --color-background: var(--bg-base-default); + --color-surface: var(--bg-base-secondary); + --color-surface-variant: var(--bg-base-tertiary); + --color-overlay-1: var(--bg-overlay-l1); + --color-overlay-2: var(--bg-overlay-l2); + --color-overlay-3: var(--bg-overlay-l3); + --color-overlay-4: var(--bg-overlay-l4); + --color-card: var(--bg-base-secondary); + --color-tooltip: var(--bg-tooltip); + --color-menu: var(--bg-menu); + + /* bg-brand */ + --bg-brand: #4B3FE3; + --bg-brand-hover: #6A6FFF; + --bg-brand-active: #3F31C6; + --bg-brand-disabled: rgba(75, 63, 227, 0.22); + --bg-brand-popup: rgba(170, 183, 255, 0.36); + --color-primary: var(--bg-brand); + --color-primary-hover: var(--bg-brand-hover); + --color-primary-disabled: var(--bg-brand-disabled); + --color-primary-soft: var(--bg-brand-popup); + --color-accent: var(--bg-brand); + + /* text */ + --text-default: #171717; + --text-default-hover: #262626; + --text-default-active: #262626; + --text-secondary: #404040; + --text-secondary-hover: #171717; + --text-secondary-active: #171717; + --text-tertiary: #737373; + --text-disabled: #A1A1A1; + --text-onbrand: #FFFFFF; + --text-onaccent: #FFFFFF; + --text-white: #FFFFFF; + --color-foreground: var(--text-default); + --color-foreground-hover: var(--text-default-hover); + --color-on-surface: var(--text-default); + --color-on-surface-variant: var(--text-secondary); + --color-muted-foreground: var(--text-secondary); + --color-disabled-foreground: var(--text-disabled); + --color-on-primary: var(--text-onbrand); + --text-brand: #4B3FE3; + --text-brand-hover: #6A6FFF; + + /* icon */ + --icon-default: #262626; + --icon-default-hover: #171717; + --icon-default-active: #171717; + --icon-secondary: #404040; + --icon-secondary-hover: #262626; + --icon-secondary-active: #262626; + --icon-tertiary: #737373; + --icon-disabled: #A1A1A1; + --icon-onbrand: #FFFFFF; + --icon-onaccent: #FFFFFF; + --icon-white: #FFFFFF; + --icon-brand: #4B3FE3; + --icon-brand-hover: #6A6FFF; + + /* border */ + --border-neutral-l1: rgba(115, 115, 115, 0.12); + --border-neutral-l2: rgba(115, 115, 115, 0.18); + --border-neutral-l3: rgba(115, 115, 115, 0.36); + --border-contrast: #000000; + --border-brand: #4B3FE3; + --color-border: var(--border-neutral-l1); + --color-border-strong: var(--border-neutral-l2); + --color-border-stronger: var(--border-neutral-l3); + --color-border-contrast: var(--border-contrast); + --color-outline: var(--border-neutral-l1); + + /* accent */ + --accent-teal: #00B983; + --accent-coral: #FF6B45; + --accent-amber: #F2A90C; + --accent-lime: #5FC000; + --accent-cyan: #00B6F5; + --accent-blue: #3F85FF; + --accent-magenta: #F53CFF; + --accent-violet: #9570FF; + --accent-slate: #747E94; + + /* status */ + --status-primary-default: #2F74FF; + --status-primary-hover: #4C88FF; + --status-primary-active: #1759DD; + --status-primary-surface-l1: rgba(47, 116, 255, 0.12); + --status-primary-surface-l2: rgba(47, 116, 255, 0.18); + --status-primary-surface-l3: rgba(47, 116, 255, 0.36); + --status-success-default: #15A877; + --status-success-hover: #2FB287; + --status-success-active: #1E8B56; + --status-success-surface-l1: rgba(64, 176, 139, 0.12); + --status-success-surface-l2: rgba(64, 176, 139, 0.18); + --status-success-surface-l3: rgba(64, 176, 139, 0.36); + --status-alert-default: #FEA900; + --status-alert-hover: #FFB700; + --status-alert-active: #E59100; + --status-alert-surface-l1: rgba(254, 169, 0, 0.14); + --status-alert-surface-l2: rgba(254, 169, 0, 0.20); + --status-alert-surface-l3: rgba(254, 169, 0, 0.36); + --status-warning-default: #E27900; + --status-warning-hover: #F39A35; + --status-warning-active: #C46F11; + --status-warning-surface-l1: rgba(226, 121, 0, 0.12); + --status-warning-surface-l2: rgba(226, 121, 0, 0.16); + --status-warning-surface-l3: rgba(226, 121, 0, 0.36); + --status-error-default: #E8463A; + --status-error-hover: #EA574C; + --status-error-active: #C9382F; + --status-error-surface-l1: rgba(232, 70, 58, 0.12); + --status-error-surface-l2: rgba(232, 70, 58, 0.16); + --status-error-surface-l3: rgba(232, 70, 58, 0.36); + --color-info: var(--status-primary-default); + --color-success: var(--status-success-default); + --color-warning: var(--status-warning-default); + --color-error: var(--status-error-default); + --color-error-soft: var(--status-error-surface-l1); + + /* brand */ + --brand-50: #F2F7FF; + --brand-100: #E5EAFF; + --brand-200: #CFD8FF; + --brand-300: #AAB7FF; + --brand-400: #8894FF; + --brand-500: #6A6FFF; + --brand-600: #4B3FE3; + --brand-700: #3F31C6; + --brand-800: #2C2290; + --brand-900: #1A1759; + --brand-950: #010109; + --brand-grey-50: #FAFAFA; + --brand-grey-100: #F5F5F5; + --brand-grey-200: #E5E5E5; + --brand-grey-300: #D4D4D4; + --brand-grey-400: #A1A1A1; + --brand-grey-500: #737373; + --brand-grey-600: #525252; + --brand-grey-700: #404040; + --brand-grey-800: #262626; + --brand-grey-900: #171717; + --brand-grey-950: #0A0A0A; + --brand-green-100: var(--brand-50); + --brand-green-200: var(--brand-100); + --brand-green-300: var(--brand-200); + --brand-green-400: var(--brand-300); + --brand-green-500: var(--brand-600); + --brand-green-600: var(--brand-500); + --brand-green-700: var(--brand-700); + --brand-green-800: var(--brand-800); + --brand-green-900: var(--brand-900); + --brand-green-1000: var(--brand-950); + --brand-red-100: #FFE5E5; + --brand-red-200: #FFCCCC; + --brand-red-300: #FFB2B2; + --brand-red-400: #FF9999; + --brand-red-500: #FF8080; + --brand-red-600: #FF6464; + --brand-red-700: #E63737; + --brand-red-800: #CB1010; + --brand-red-900: #8F0505; + --brand-red-1000: #4D0000; + --brand-yellow-100: #FFF6E5; + --brand-yellow-200: #FFECCC; + --brand-yellow-300: #FFE3B2; + --brand-yellow-400: #FFDA99; + --brand-yellow-500: #FFD080; + --brand-yellow-600: #FFC864; + --brand-yellow-700: #E6A637; + --brand-yellow-800: #CB8710; + --brand-yellow-900: #8F5C05; + --brand-yellow-1000: #4D3000; + --brand-blue-100: #E5F3FF; + --brand-blue-200: #CCE6FF; + --brand-blue-300: #B2DAFF; + --brand-blue-400: #99CEFF; + --brand-blue-500: #80C1FF; + --brand-blue-600: #64B4FF; + --brand-blue-700: #3792E6; + --brand-blue-800: #1071CB; + --brand-blue-900: #054C8F; + --brand-blue-1000: #00284D; + --brand-purple-100: #E8E5FF; + --brand-purple-200: #D2CCFF; + --brand-purple-300: #BBB2FF; + --brand-purple-400: #A599FF; + --brand-purple-500: #8E80FF; + --brand-purple-600: #7864FF; + --brand-purple-700: #4C37E6; + --brand-purple-800: #2610CB; + --brand-purple-900: #15058F; + --brand-purple-1000: #09004D; + --brand-neutral-grey-100: #F2F2F2; + --brand-neutral-grey-200: #E3E3E3; + --brand-neutral-grey-300: #D6D6D6; + --brand-neutral-grey-400: #C7C7C7; + --brand-neutral-grey-500: #BABABA; + --brand-neutral-grey-600: #ABABAB; + --brand-neutral-grey-700: #8A8A8A; + --brand-neutral-grey-800: #686868; + --brand-neutral-grey-900: #474747; + --brand-neutral-grey-1000: #262626; + --brand-blue-grey-100: #F1EFF4; + --brand-blue-grey-200: #E0E1E6; + --brand-blue-grey-300: #CFD3D8; + --brand-blue-grey-400: #BFC5CA; + --brand-blue-grey-500: #B0B5BA; + --brand-blue-grey-600: #A0A6AB; + --brand-blue-grey-700: #80898E; + --brand-blue-grey-800: #5E696E; + --brand-blue-grey-900: #404A4F; + --brand-blue-grey-1000: #21262B; + --brand-green-grey-100: #F1F4F4; + --brand-green-grey-200: #E0E6E6; + --brand-green-grey-300: #CFD8D8; + --brand-green-grey-400: #BFCACA; + --brand-green-grey-500: #B0BABA; + --brand-green-grey-600: #A0ABAB; + --brand-green-grey-700: #808E8E; + --brand-green-grey-800: #5E6E6E; + --brand-green-grey-900: #404F4F; + --brand-green-grey-1000: #212B2B; + + /* generic fallback */ + --font-family: var(--font-family-default); + --font-size: var(--body-xs-font-size); + --font-weight: var(--body-xs-font-weight); + --line-height: var(--body-xs-line-height); + + /* viz */ + --viz-series-brand: var(--bg-brand); + --viz-series-brand-soft: var(--brand-300); + --viz-series-brand-mid: var(--brand-500); + --viz-series-brand-deep: var(--brand-700); + --viz-series-neutral: var(--brand-grey-500); + --viz-series-coral: #F87454; + --viz-series-amber: #EDAA45; + --viz-series-mint: #1DC981; + --viz-series-teal: #59E8E8; + --viz-series-sky: #22A5F7; + --viz-series-violet: #B655FC; + --viz-series-magenta: #FB9DD7; + --viz-series-indigo: #B6A3FF; + --viz-series-lime: #A6EA39; + --viz-series-slate: #859EAD; + --viz-ui-bg-chart: var(--bg-menu); + --viz-ui-bg-tooltip: var(--bg-tooltip); + --viz-ui-chart-title: var(--text-default); + --viz-ui-chart-subtitle: var(--text-secondary); + --viz-ui-chart-axis: var(--border-neutral-l2); + --viz-ui-chart-tick: var(--text-secondary); + --viz-ui-legend-label: var(--text-secondary); + --viz-ui-legend-value: var(--text-default); + + /* special */ + --special-white: #FFFFFF; + --special-black: #000000; + --special-bgtabsoverlay: rgba(255, 255, 255, 0.12); + --special-bggoldoverlay: rgba(210, 181, 19, 0.12); + + /* layout aliases */ + --brand-1: var(--brand-600); + --brand-2: var(--brand-500); + --brand-3: var(--brand-300); + --brand-4: var(--brand-100); + --site-canvas: var(--bg-base-default); + --bg-layout-1: var(--bg-base-secondary); + --bg-layout-2: var(--bg-overlay-l1); + --border-1: var(--border-neutral-l1); +} + +/* === Reset === */ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: var(--font-family-default); + background: var(--bg-base-secondary); + color: var(--text-default); + height: 100vh; + overflow: hidden; +} + +/* === App Layout === */ +.app-layout { + display: flex; + height: 100vh; +} + +/* === Sidebar === */ +.sidebar { + width: 220px; + background: #fff; + border-right: 1px solid var(--border-neutral-l1); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.2s; + overflow: hidden; +} +.sidebar.collapsed { width: 0; border-right: none; } + +.sidebar-header { + padding: 20px 16px 12px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--bg-overlay-l1); +} +.sidebar-logo { font-size: 24px; } +.sidebar-title { font-size: var(--heading-sm-font-size); font-weight: var(--heading-sm-font-weight); color: var(--text-default); line-height: var(--heading-sm-line-height); } + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} +.nav-section { padding: 0 8px; } +.nav-section-title { + font-size: var(--body-md-font-size); + color: var(--text-tertiary); + padding: 8px 12px 4px; + font-weight: 500; +} +.nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--radius-6); + cursor: pointer; + font-size: var(--body-base-font-size); + color: var(--text-secondary); + transition: all 0.15s; +} +.nav-item:hover { background: var(--bg-base-secondary); color: var(--text-default); } +.nav-item.active { background: var(--bg-overlay-l2); color: var(--text-default); font-weight: 500; } +.nav-icon { font-size: 16px; width: 20px; text-align: center; } +.nav-label { flex: 1; } +.nav-count { + font-size: var(--body-md-font-size); + color: var(--text-tertiary); + background: var(--bg-overlay-l1); + padding: 1px 6px; + border-radius: var(--radius-8); +} +.nav-item.active .nav-count { background: var(--bg-overlay-l3); color: var(--text-secondary); } +.nav-divider { height: 1px; background: var(--bg-overlay-l1); margin: 8px 16px; } + +.sidebar-footer { + padding: 12px; + border-top: 1px solid var(--bg-overlay-l1); +} + +/* === Main Area === */ +.main-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +/* === Top Bar === */ +.topbar { + height: 56px; + background: #fff; + border-bottom: 1px solid var(--border-neutral-l1); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + flex-shrink: 0; +} +.topbar-left { display: flex; align-items: center; gap: 12px; } +.topbar-center { display: flex; align-items: center; } +.topbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; } + +.btn-icon { + width: 32px; height: 32px; + border: none; + background: none; + font-size: 18px; + cursor: pointer; + border-radius: var(--radius-6); + display: flex; + align-items: center; + justify-content: center; +} +.btn-icon:hover { background: var(--bg-overlay-l1); } + +.breadcrumb { display: flex; align-items: center; gap: 6px; font-size: var(--body-base-font-size); } +.breadcrumb-item { color: var(--text-secondary); cursor: pointer; } +.breadcrumb-item:hover { color: var(--text-default); } +.breadcrumb-item.active { color: var(--text-default); font-weight: 500; } +.breadcrumb-sep { color: var(--text-tertiary); } + +/* Slider */ +.slider-group { + display: flex; + align-items: center; + gap: 8px; +} +.slider-label { font-size: var(--body-md-font-size); color: var(--text-tertiary); } +.slider-group input[type="range"] { + width: 120px; + accent-color: var(--bg-brand); +} + +/* View Toggle */ +.view-toggle { display: flex; border: 1px solid var(--border-neutral-l1); border-radius: var(--radius-6); overflow: hidden; } +.view-btn { + width: 32px; height: 30px; + border: none; + background: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.view-btn.active { background: var(--bg-overlay-l3); color: var(--text-default); } +.view-btn:hover:not(.active) { background: var(--bg-base-secondary); } + +.search-box input { + padding: 6px 12px; + border: 1px solid var(--border-neutral-l1); + border-radius: var(--radius-6); + background: var(--bg-base-default); + font-size: var(--body-md-font-size); + width: 220px; +} +.search-box input:focus { outline: none; border-color: var(--border-contrast); background: #fff; } + +/* Buttons */ +.btn { + padding: 6px 14px; + border: none; + border-radius: var(--radius-6); + cursor: pointer; + font-size: var(--body-md-font-size); + font-weight: var(--font-weight-medium); + transition: all 0.15s; + white-space: nowrap; +} +.btn-upload { background: var(--bg-brand); color: var(--text-onbrand); } +.btn-upload:hover { background: var(--bg-brand-active); } +.btn-block { width: 100%; text-align: center; padding: 10px; font-size: var(--body-base-font-size); } +.btn-analyze { background: var(--bg-overlay-l1); color: var(--text-default); border: 1px solid var(--border-neutral-l1); } +.btn-analyze:hover { background: var(--border-neutral-l1); } +.btn-analyze:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-export { background: var(--status-success-default); color: var(--text-onaccent); } +.btn-export:hover { background: var(--status-success-active); } +.btn-analyze-selected { background: var(--bg-overlay-l1); color: var(--text-default); border: 1px solid var(--border-neutral-l1); } +.btn-delete-selected { background: var(--status-error-default); color: var(--text-onaccent); } +.btn-delete-selected:hover { background: var(--status-error-active); } +.btn-restore { background: var(--status-success-default); color: var(--text-onaccent); } +.btn-restore:hover { background: var(--status-success-active); } +.btn-permanent-delete { background: var(--status-error-default); color: var(--text-onaccent); } +.btn-permanent-delete:hover { background: var(--status-error-active); } +.btn-clear { background: var(--bg-overlay-l1); color: var(--text-default); } +.btn-clear:hover { background: var(--border-neutral-l1); } + +/* === Filter Bar === */ +.filterbar { + height: 40px; + background: #fff; + border-bottom: 1px solid var(--bg-overlay-l1); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + font-size: var(--body-md-font-size); + color: var(--text-secondary); + flex-shrink: 0; +} +.filter-group { + display: flex; + align-items: center; + gap: 4px; +} +.filter-group input, .filter-group select { + padding: 4px 8px; + border: 1px solid var(--border-neutral-l1); + border-radius: var(--radius-4); + font-size: var(--body-md-font-size); + background: #fff; +} +.filter-group input:focus, .filter-group select:focus { outline: none; border-color: var(--border-contrast); } +.filter-results { margin-left: auto; color: var(--text-tertiary); font-size: var(--body-md-font-size); } +.btn-select-all { background: var(--bg-overlay-l1); border: 1px solid var(--border-neutral-l1); color: var(--text-default); font-size: var(--body-md-font-size); } +.btn-select-all:hover { border-color: var(--bg-brand); color: var(--bg-brand); } + +/* === Content Area === */ +.content-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.grid-container { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* === Grid === */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} +.empty-state { + text-align: center; + padding: 80px; + color: var(--text-tertiary); + font-size: 16px; +} + +/* === Card === */ +.card { + background: #fff; + border-radius: var(--radius-8); + overflow: hidden; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.15s; + position: relative; +} +.card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.08); } +.card.selected { border-color: var(--border-brand); box-shadow: 0 0 0 2px var(--bg-brand-popup); } + +/* Thumbnail */ +.card-thumb-wrapper { + position: relative; + aspect-ratio: 9/16; + background: var(--bg-overlay-l1); +} +.card-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.card-play { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 44px; height: 44px; + background: rgba(0,0,0,0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #fff; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +.card:hover .card-play { opacity: 1; } + +/* Tags on thumbnail */ +.card-tags-top { + position: absolute; + top: 6px; + left: 6px; + right: 6px; + display: flex; + justify-content: space-between; + pointer-events: none; +} +.card-tag { + padding: 2px 6px; + border-radius: var(--radius-4); + font-size: var(--body-sm-font-size); + font-weight: 500; + backdrop-filter: blur(4px); +} +.tag-category { + background: var(--bg-brand-popup); + color: var(--text-brand); +} +.tag-duration { + background: rgba(0,0,0,0.5); + color: #fff; +} +.tag-screener { + background: rgba(0,0,0,0.5); + color: #fff; +} +.tag-screener.keep { background: var(--status-success-surface-l1); color: var(--status-success-default); } +.tag-screener.review { background: var(--status-alert-surface-l1); color: var(--status-alert-default); } +.tag-screener.delete { background: var(--status-error-surface-l1); color: var(--status-error-default); } + +/* Checkbox */ +.card-check { + position: absolute; + bottom: 6px; + right: 6px; + width: 22px; height: 22px; + background: rgba(255,255,255,0.85); + border: 2px solid var(--text-tertiary); + border-radius: var(--radius-4); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--body-md-font-size); + z-index: 3; + cursor: pointer; + color: transparent; + transition: all 0.15s; + pointer-events: auto; +} +.card-check:hover { border-color: var(--bg-brand); } +.card-check.checked { background: var(--bg-brand); border-color: var(--bg-brand); color: var(--text-onbrand); } + +/* Card Body */ +.card-body { padding: 8px 10px; } +.card-title { + font-size: var(--body-md-font-size); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-default); + margin-bottom: 2px; +} +.card-desc { + font-size: var(--body-sm-font-size); + color: var(--text-tertiary); + line-height: var(--body-sm-line-height); + max-height: 2.8em; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.card-meta { + display: flex; + gap: 8px; + font-size: var(--body-sm-font-size); + color: var(--text-tertiary); + margin-top: 4px; +} + +/* === List View === */ +.grid.list-view { + grid-template-columns: 1fr; + gap: 4px; +} +.grid.list-view .card { + display: flex; + flex-direction: row; + border-radius: var(--radius-6); +} +.grid.list-view .card-thumb-wrapper { + width: 80px; + aspect-ratio: 9/16; + flex-shrink: 0; +} +.grid.list-view .card-body { + flex: 1; + display: flex; + align-items: center; + gap: 16px; +} +.grid.list-view .card-title { flex: 1; margin-bottom: 0; } +.grid.list-view .card-meta { flex-shrink: 0; } + +/* === Pagination === */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 12px; + border-top: 1px solid var(--bg-overlay-l1); + background: #fff; + flex-shrink: 0; +} +.page-btn { + width: 32px; height: 32px; + border: 1px solid var(--border-neutral-l1); + border-radius: var(--radius-4); + background: #fff; + color: var(--text-default); + cursor: pointer; + font-size: var(--body-md-font-size); + display: flex; + align-items: center; + justify-content: center; +} +.page-btn:hover:not(:disabled) { border-color: var(--bg-brand); color: var(--bg-brand); } +.page-btn.active { background: var(--bg-overlay-l3); color: var(--text-default); border-color: var(--border-neutral-l2); } +.page-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.page-info { font-size: var(--body-md-font-size); color: var(--text-tertiary); margin-right: 8px; } + +/* === Selected Bar === */ +.selected-bar { + position: fixed; + bottom: 0; left: 220px; right: 0; + background: #fff; + padding: 12px 20px; + display: flex; + align-items: center; + gap: 16px; + border-top: 2px solid var(--border-brand); + box-shadow: 0 -2px 8px rgba(0,0,0,0.06); + z-index: 100; + font-size: var(--body-base-font-size); +} + +/* === Modal === */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 200; + display: none; + align-items: center; + justify-content: center; +} +.modal.active { display: flex; } +.modal-overlay { + position: absolute; + inset: 0; + cursor: pointer; +} +.modal-content { + background: #fff; + border-radius: var(--radius-12); + box-shadow: 0 24px 64px color-mix(in srgb, var(--text-default) 14%, transparent); + overflow: hidden; + max-width: 900px; + width: 90%; + position: relative; + z-index: 10; +} +.modal-close { + position: absolute; + top: 10px; right: 14px; + background: none; + border: none; + color: var(--text-secondary); + font-size: 24px; + cursor: pointer; + z-index: 10; + width: 32px; height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.modal-close:hover { background: var(--bg-overlay-l1); } +.modal-content video { + width: auto; + max-height: 75vh; + max-width: 45vh; + margin: 0 auto; + display: block; + background: #000; + aspect-ratio: 9/16; +} + +/* Modal Tabs */ +.modal-tabs { + display: flex; + border-bottom: 1px solid var(--border-neutral-l1); +} +.modal-tab { + flex: 1; + padding: 12px; + border: none; + background: none; + font-size: var(--body-base-font-size); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + border-bottom: 2px solid transparent; +} +.modal-tab:hover { color: var(--text-default); background: var(--bg-base-default); } +.modal-tab.active { color: var(--text-default); border-bottom-color: var(--icon-default); font-weight: 500; } +.modal-tab-content { min-height: 200px; max-height: 70vh; overflow-y: auto; } +.modal-tab-content video { max-height: 60vh; } + +/* Clip Toolbar */ +.clip-toolbar { + background: var(--bg-base-default); + border-top: 1px solid var(--border-neutral-l1); + padding: 10px 16px; +} +.clip-timeline-wrapper { + margin-bottom: 10px; +} +.clip-timeline { + position: relative; + height: 32px; + background: var(--bg-overlay-l2); + border-radius: var(--radius-4); + cursor: pointer; + overflow: hidden; +} +.clip-selection { + position: absolute; + top: 0; + height: 100%; + background: var(--bg-brand-popup); + border-left: 2px solid var(--bg-brand); + border-right: 2px solid var(--bg-brand); + pointer-events: none; +} +.clip-marker { + position: absolute; + top: 0; + width: 12px; + height: 100%; + cursor: ew-resize; + z-index: 5; +} +.clip-marker-in { + left: 0; + background: var(--bg-brand); + border-radius: var(--radius-4) 0 0 var(--radius-4); +} +.clip-marker-out { + right: 0; + background: var(--bg-brand); + border-radius: 0 var(--radius-4) var(--radius-4) 0; +} +.clip-playhead { + position: absolute; + top: 0; + width: 3px; + height: 100%; + background: var(--status-error-default); + z-index: 10; + pointer-events: none; +} +.clip-played { + position: absolute; + top: 0; + height: 100%; + background: rgba(0,0,0,0.15); + pointer-events: none; +} +.clip-timeaxis { + display: flex; + justify-content: space-between; + font-size: var(--body-xs-font-size); + color: var(--text-tertiary); + margin-top: 2px; + padding: 0 2px; +} +.clip-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.clip-sep { color: var(--text-tertiary); font-size: 16px; } +.clip-time { + font-family: monospace; + font-size: var(--body-md-font-size); + color: var(--text-secondary); + background: #fff; + padding: 4px 8px; + border-radius: var(--radius-4); + border: 1px solid var(--border-neutral-l1); + min-width: 80px; +} +.clip-duration { font-size: var(--body-md-font-size); color: var(--text-tertiary); } +.btn-clip-set { + background: #fff; + border: 1px solid var(--border-neutral-l1); + color: var(--text-default); + padding: 5px 12px; + font-size: var(--body-md-font-size); +} +.btn-clip-set:hover { border-color: var(--bg-brand); color: var(--bg-brand); } +.btn-clip-split { + background: var(--status-alert-default); + color: var(--text-default); + padding: 5px 14px; + font-size: var(--body-md-font-size); + font-weight: 500; +} +.btn-clip-split:hover { background: var(--status-alert-active); } +.btn-clip-play { + background: var(--bg-brand); + color: var(--text-onbrand); + padding: 5px 14px; + font-size: var(--body-md-font-size); + font-weight: 500; +} +.btn-clip-play:hover { background: var(--bg-brand-active); } +.btn-clip-save { + background: var(--status-success-default); + color: var(--text-onaccent); + padding: 5px 14px; + font-size: var(--body-md-font-size); + font-weight: 500; +} +.btn-clip-save:hover { background: var(--status-success-active); } +.btn-clip-add { + background: #fff; + border: 1px solid var(--border-neutral-l1); + color: var(--text-default); + padding: 5px 14px; + font-size: var(--body-md-font-size); +} +.btn-clip-add:hover { border-color: var(--bg-brand); color: var(--bg-brand); } +.clip-list { + margin-top: 8px; + max-height: 120px; + overflow-y: auto; +} +.clip-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: #fff; + border: 1px solid var(--border-neutral-l1); + border-radius: var(--radius-4); + margin-bottom: 4px; + font-size: var(--body-md-font-size); +} +.clip-list-item .clip-info { color: var(--text-secondary); } +.clip-list-item .clip-remove { color: var(--status-error-default); cursor: pointer; } + +/* Report Container */ +.report-container { padding: 20px 24px; } +.report-section { margin-bottom: 20px; } +.report-section-title { + font-size: var(--body-md-font-size); + font-weight: 600; + color: var(--text-brand); + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-neutral-l1); +} +.report-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.report-item { + background: var(--bg-base-default); + border-radius: var(--radius-8); + padding: 12px; +} +.report-label { font-size: var(--body-md-font-size); color: var(--text-tertiary); margin-bottom: 4px; } +.report-value { font-size: 16px; font-weight: 600; color: var(--text-default); } +.report-value.good { color: var(--status-success-default); } +.report-value.warn { color: var(--status-alert-default); } +.report-value.bad { color: var(--status-error-default); } +.report-bar { + height: 6px; + background: var(--border-neutral-l1); + border-radius: 3px; + margin-top: 6px; + overflow: hidden; +} +.report-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} +.report-decision { + text-align: center; + padding: 20px; + background: var(--bg-base-default); + border-radius: var(--radius-10); + margin-bottom: 16px; +} +.report-decision-badge { + display: inline-block; + padding: 8px 24px; + border-radius: 20px; + font-size: 18px; + font-weight: 600; + color: var(--text-onaccent); +} +.report-decision-badge.keep { background: var(--status-success-default); } +.report-decision-badge.review { background: var(--status-alert-default); color: var(--text-default); } +.report-decision-badge.delete { background: var(--status-error-default); } +.report-decision-score { + font-size: 36px; + font-weight: 700; + font-family: var(--font-family-metric); + margin: 8px 0 4px; +} +.report-decision-reason { font-size: var(--body-md-font-size); color: var(--text-secondary); } +.report-desc { + background: var(--bg-base-default); + border-radius: var(--radius-8); + padding: 12px 16px; + font-size: var(--body-base-font-size); + color: var(--text-default); + line-height: var(--body-base-line-height); +} +.report-issues { + list-style: none; + padding: 0; +} +.report-issues li { + padding: 6px 0; + font-size: var(--body-md-font-size); + color: var(--text-secondary); + border-bottom: 1px solid var(--bg-overlay-l1); +} +.report-issues li:last-child { border-bottom: none; } +.report-issues li::before { content: "• "; color: var(--status-error-default); } +.report-no-data { + text-align: center; + padding: 40px; + color: var(--text-tertiary); + font-size: var(--body-base-font-size); +} + +/* Scene Analysis */ +.scene-section { margin-top: 16px; } +.waste-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 8px; +} +.waste-stat-item { + background: var(--bg-base-default); + border-radius: var(--radius-8); + padding: 12px; + text-align: center; +} +.waste-stat-num { font-size: 24px; font-weight: 700; } +.waste-stat-label { font-size: var(--body-md-font-size); color: var(--text-tertiary); margin-top: 2px; } +.waste-stat-num.total { color: var(--text-default); } +.waste-stat-num.keep { color: var(--status-success-default); } +.waste-stat-num.trim { color: var(--status-alert-default); } +.waste-stat-num.waste { color: var(--status-error-default); } +.scene-card.waste { border-left-color: var(--status-error-default); background: var(--status-error-surface-l1); } +.scene-card.trim { border-left-color: var(--status-alert-default); } +.scene-card.highlight { border-left-color: var(--status-success-default); background: var(--status-success-surface-l1); } +.scene-suggestion { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-4); + font-size: var(--body-sm-font-size); + font-weight: 600; +} +.scene-suggestion.keep { background: var(--status-success-surface-l1); color: var(--status-success-default); } +.scene-suggestion.highlight { background: var(--status-success-surface-l1); color: var(--status-success-default); } +.scene-suggestion.waste { background: var(--status-error-surface-l1); color: var(--status-error-default); } +.scene-suggestion.trim { background: var(--status-alert-surface-l1); color: var(--status-warning-default); } +.scene-card { + background: var(--bg-base-default); + border-radius: var(--radius-8); + padding: 14px; + margin-bottom: 10px; + border-left: 4px solid var(--bg-brand); +} +.scene-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.scene-title { font-weight: 600; font-size: var(--body-base-font-size); color: var(--text-default); } +.scene-meta { display: flex; gap: 8px; font-size: var(--body-md-font-size); color: var(--text-tertiary); } +.scene-tag { + padding: 2px 8px; + border-radius: var(--radius-4); + font-size: var(--body-sm-font-size); + font-weight: 500; +} +.scene-tag.type { background: var(--bg-brand-popup); color: var(--bg-brand); } +.scene-tag.usage { background: var(--status-alert-surface-l1); color: var(--status-warning-default); } +.scene-tag.quality { background: var(--status-success-surface-l1); color: var(--status-success-default); } +.scene-tag.quality.low { background: var(--status-error-surface-l1); color: var(--status-error-default); } +.scene-desc { font-size: var(--body-md-font-size); color: var(--text-secondary); line-height: 1.5; } +.scene-thumb-wrapper { + position: relative; + width: 80px; + aspect-ratio: 9/16; + flex-shrink: 0; + border-radius: var(--radius-6); + overflow: hidden; + background: var(--bg-overlay-l1); +} +.scene-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.scene-thumb-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28px; + height: 28px; + background: rgba(0,0,0,0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--body-md-font-size); + color: #fff; + opacity: 0; + transition: opacity 0.2s; +} +.scene-card:hover .scene-thumb-play { opacity: 1; } +.scene-loading { + text-align: center; + padding: 20px; + color: var(--text-tertiary); + font-size: var(--body-md-font-size); +} +.scene-loading::after { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border-neutral-l1); + border-top-color: var(--bg-brand); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 8px; + vertical-align: middle; +} +@keyframes spin { to { transform: rotate(360deg); } } +.modal-info { + padding: 16px 20px; + font-size: var(--body-base-font-size); + color: var(--text-secondary); + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.modal-info .info-item { display: flex; align-items: center; gap: 4px; } +.modal-info .info-label { color: var(--text-tertiary); } +.modal-info .info-value { color: var(--text-default); font-weight: 500; } +.modal-info .info-full { flex-basis: 100%; margin-top: 4px; } +.modal-actions { + padding: 12px 20px; + display: flex; + gap: 10px; + border-top: 1px solid var(--bg-overlay-l1); +} +.btn-analyze-modal { background: var(--bg-brand); color: var(--text-onbrand); padding: 8px 20px; font-size: var(--body-base-font-size); } +.btn-analyze-modal:hover { background: var(--bg-brand-active); } +.btn-analyze-modal:disabled { background: var(--text-tertiary); cursor: not-allowed; } +.btn-prev, .btn-next { background: var(--bg-overlay-l1); color: var(--text-default); padding: 8px 16px; font-size: var(--body-base-font-size); } +.btn-prev:hover, .btn-next:hover { background: var(--border-neutral-l1); } + +/* === Upload Modal === */ +.upload-header { padding: 20px 24px 0; } +.upload-header h2 { font-size: var(--heading-sm-font-size); margin-bottom: 4px; } +.upload-header p { font-size: var(--body-md-font-size); color: var(--text-tertiary); } +.upload-body { padding: 16px 24px 24px; } +.upload-dropzone { + border: 2px dashed var(--border-neutral-l2); + border-radius: var(--radius-10); + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 16px; + position: relative; +} +.upload-dropzone:hover, .upload-dropzone.dragover { + border-color: var(--bg-brand); +} +.dropzone-icon { font-size: 40px; margin-bottom: 8px; } +.upload-dropzone p { color: var(--text-tertiary); font-size: var(--body-base-font-size); } +.upload-link { color: var(--bg-brand); cursor: pointer; text-decoration: underline; } +.upload-category { + display: flex; align-items: center; gap: 8px; + margin-bottom: 16px; font-size: var(--body-base-font-size); +} +.upload-category input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-neutral-l1); + border-radius: var(--radius-6); + font-size: var(--body-base-font-size); +} +.upload-category input:focus { outline: none; border-color: var(--bg-brand); } +.upload-filelist { max-height: 200px; overflow-y: auto; margin-bottom: 16px; } +.upload-file { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 12px; background: var(--bg-base-default); border-radius: var(--radius-6); + margin-bottom: 4px; font-size: var(--body-md-font-size); +} +.upload-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.upload-file-size { color: var(--text-tertiary); margin-left: 12px; white-space: nowrap; } +.upload-file-remove { color: var(--status-error-default); cursor: pointer; margin-left: 12px; } +.upload-progress { margin-bottom: 16px; } +.upload-result { padding: 12px; border-radius: var(--radius-6); margin-bottom: 16px; font-size: var(--body-base-font-size); } +.upload-result.success { background: var(--status-success-surface-l1); color: var(--status-success-default); } +.upload-result.error { background: var(--status-error-surface-l1); color: var(--status-error-default); } +.btn-upload-submit { + width: 100%; padding: 12px; background: var(--bg-brand); color: var(--text-onbrand); + font-size: 15px; font-weight: 500; +} +.btn-upload-submit:hover:not(:disabled) { background: var(--bg-brand-active); } +.btn-upload-submit:disabled { background: var(--border-neutral-l1); color: var(--text-tertiary); cursor: not-allowed; } + +/* === Task Panel === */ +.task-panel { + position: fixed; + top: 0; + left: 220px; + width: 360px; + height: 100vh; + background: #fff; + border-right: 1px solid var(--border-neutral-l1); + box-shadow: 4px 0 16px rgba(0,0,0,0.08); + z-index: 280; + display: flex; + flex-direction: column; +} +.task-panel-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-neutral-l1); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 16px; + font-weight: 600; +} +.task-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; +} +.task-card { + background: var(--bg-base-default); + border-radius: var(--radius-8); + padding: 14px; + margin-bottom: 10px; + border-left: 4px solid var(--border-neutral-l1); +} +.task-card.running { border-left-color: var(--bg-brand); background: var(--bg-brand-popup); } +.task-card.completed { border-left-color: var(--status-success-default); } +.task-card.failed { border-left-color: var(--status-error-default); } +.task-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} +.task-type { font-size: var(--body-md-font-size); font-weight: 500; color: var(--text-default); } +.task-status { + font-size: var(--body-sm-font-size); + padding: 2px 8px; + border-radius: var(--radius-10); + font-weight: 500; +} +.task-status.running { background: var(--bg-brand-popup); color: var(--text-brand); } +.task-status.completed { background: var(--status-success-surface-l1); color: var(--status-success-default); } +.task-status.failed { background: var(--status-error-surface-l1); color: var(--status-error-default); } +.task-progress { + height: 4px; + background: var(--bg-overlay-l2); + border-radius: 2px; + margin: 8px 0; + overflow: hidden; +} +.task-progress-fill { + height: 100%; + background: var(--bg-brand); + border-radius: 2px; + transition: width 0.3s; +} +.task-detail { font-size: var(--body-md-font-size); color: var(--text-tertiary); } +.task-time { font-size: var(--body-sm-font-size); color: var(--text-tertiary); margin-top: 4px; } +.task-empty { text-align: center; padding: 40px; color: var(--text-tertiary); font-size: var(--body-base-font-size); } + +/* === Progress Overlay === */ +.progress-overlay { + position: fixed; + top: 0; left: 220px; right: 0; + background: #fff; + padding: 12px 20px; + z-index: 290; + border-bottom: 1px solid var(--border-neutral-l1); +} +.progress-bar { + height: 4px; + background: var(--bg-overlay-l2); + border-radius: 2px; + overflow: hidden; + margin-bottom: 6px; +} +.progress-fill { + height: 100%; + background: var(--bg-brand); + width: 0%; + transition: width 0.3s; +} +.progress-text { font-size: var(--body-md-font-size); color: var(--text-secondary); } + +/* === Voice Clone - Class Toggle (零侵入) === */ +body.vc-active .topbar, +body.vc-active .filterbar, +body.vc-active .content-area { display: none !important; } +body.vc-active #voiceClonePanel { display: block !important; } +body.vc-active .selected-bar { display: none !important; } +#voiceClonePanel { display: none; } + +/* === Voice Clone Panel === */ +.vc-container { max-width: 720px; margin: 0 auto; padding: 32px 24px; } +.vc-header h2 { margin: 0 0 4px; font-size: 24px; } +.vc-subtitle { margin: 0; color: var(--text-secondary); font-size: 14px; } +.vc-section { margin-top: 24px; } +.vc-section-title { font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 10px; } + +/* Upload Zone */ +.vc-upload-zone { + position: relative; border: 2px dashed var(--border-default); border-radius: 12px; + padding: 40px 20px; text-align: center; cursor: pointer; transition: all 0.2s; + background: var(--bg-base-secondary); +} +.vc-upload-zone:hover, .vc-upload-zone.dragover { + border-color: var(--accent-blue); background: rgba(var(--accent-blue-rgb, 56,132,244), 0.06); +} +.vc-upload-icon { font-size: 40px; margin-bottom: 8px; } +.vc-upload-text { font-size: 15px; color: var(--text-default); } +.vc-upload-hint { font-size: 12px; color: var(--text-tertiary); margin-top: 6px; } +.vc-file-info { + display: flex; align-items: center; gap: 10px; margin-top: 12px; padding: 10px 14px; + background: var(--bg-base-secondary); border-radius: 8px; font-size: 14px; +} +.vc-file-icon { font-size: 20px; } +.vc-file-name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.vc-file-type { color: var(--text-tertiary); font-size: 12px; } + +/* Presets */ +.vc-preset-group { display: flex; gap: 8px; flex-wrap: wrap; } +.vc-preset-btn { + padding: 8px 16px; border-radius: 20px; border: 1px solid var(--border-default); + background: var(--bg-base); color: var(--text-secondary); cursor: pointer; + font-size: 13px; transition: all 0.15s; +} +.vc-preset-btn:hover { border-color: var(--accent-blue); color: var(--text-default); } +.vc-preset-btn.active { + background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); +} + +/* Text Input */ +.vc-textarea { + width: 100%; padding: 12px 14px; border-radius: 8px; border: 1px solid var(--border-default); + background: var(--bg-base); color: var(--text-default); font-size: 14px; + font-family: inherit; resize: vertical; box-sizing: border-box; +} +.vc-textarea:focus { outline: none; border-color: var(--accent-blue); } + +/* Advanced Settings */ +.vc-section-toggle { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 14px; background: var(--bg-base-secondary); border-radius: 8px; + cursor: pointer; font-size: 14px; color: var(--text-secondary); +} +.vc-section-toggle:hover { background: var(--bg-overlay-l1); } +.vc-advanced { margin-top: 10px; padding: 16px; background: var(--bg-base-secondary); border-radius: 8px; } +.vc-slider-group { + display: flex; align-items: center; gap: 12px; margin-bottom: 14px; +} +.vc-slider-group:last-child { margin-bottom: 0; } +.vc-slider-group label { width: 70px; font-size: 13px; color: var(--text-secondary); flex-shrink: 0; } +.vc-slider-group input[type="range"] { flex: 1; accent-color: var(--accent-blue); } +.vc-slider-value { width: 36px; text-align: right; font-size: 13px; color: var(--text-default); font-variant-numeric: tabular-nums; } + +/* Switch Toggle */ +.vc-switch { position: relative; display: inline-block; width: 40px; height: 22px; cursor: pointer; } +.vc-switch input { opacity: 0; width: 0; height: 0; } +.vc-switch-slider { + position: absolute; top: 0; left: 0; right: 0; bottom: 0; + background: var(--border-default); border-radius: 22px; transition: 0.2s; +} +.vc-switch-slider:before { + content: ""; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; + background: #fff; border-radius: 50%; transition: 0.2s; +} +.vc-switch input:checked + .vc-switch-slider { background: var(--accent-blue); } +.vc-switch input:checked + .vc-switch-slider:before { transform: translateX(18px); } + +/* Generate Button */ +.btn-vc-generate { + width: 100%; padding: 14px; border-radius: 10px; border: none; + background: var(--accent-blue); color: #fff; font-size: 16px; font-weight: 600; + cursor: pointer; transition: all 0.2s; +} +.btn-vc-generate:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); } +.btn-vc-generate:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Loading */ +.vc-loading { + display: flex; align-items: center; gap: 12px; padding: 16px; + background: var(--bg-base-secondary); border-radius: 8px; margin-top: 16px; +} +.vc-spinner { + width: 20px; height: 20px; border: 2px solid var(--border-default); + border-top-color: var(--accent-blue); border-radius: 50%; + animation: vc-spin 0.8s linear infinite; +} +@keyframes vc-spin { to { transform: rotate(360deg); } } + +/* Error */ +.vc-error { + padding: 12px 16px; background: rgba(239,68,68,0.1); color: #ef4444; + border-radius: 8px; margin-top: 12px; font-size: 14px; +} + +/* Player */ +.vc-player { + margin-top: 20px; padding: 20px; background: var(--bg-base-secondary); + border-radius: 12px; +} +.vc-player-header { font-weight: 600; margin-bottom: 14px; font-size: 15px; } +.vc-player-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.btn-vc-play, .btn-vc-stop, .btn-vc-save { + padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-default); + background: var(--bg-base); color: var(--text-default); cursor: pointer; + font-size: 13px; transition: all 0.15s; white-space: nowrap; +} +.btn-vc-play:hover, .btn-vc-stop:hover, .btn-vc-save:hover { border-color: var(--accent-blue); } +.btn-vc-play.playing { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } +.vc-progress-wrapper { + display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px; +} +.vc-progress { flex: 1; accent-color: var(--accent-blue); } +.vc-time { font-size: 12px; color: var(--text-tertiary); font-variant-numeric: tabular-nums; min-width: 36px; } +.vc-speed-group { display: flex; gap: 4px; } +.vc-speed-btn { + padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border-default); + background: var(--bg-base); color: var(--text-secondary); cursor: pointer; + font-size: 12px; transition: all 0.15s; +} +.vc-speed-btn:hover { border-color: var(--accent-blue); } +.vc-speed-btn.active { background: var(--accent-blue); color: #fff; border-color: var(--accent-blue); } diff --git a/style.css.current b/style.css.current new file mode 100644 index 0000000..262757a --- /dev/null +++ b/style.css.current @@ -0,0 +1,1303 @@ +/* === Reset === */ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; + background: #f5f5f5; + color: #333; + height: 100vh; + overflow: hidden; +} + +/* === App Layout === */ +.app-layout { + display: flex; + height: 100vh; +} + +/* === Sidebar === */ +.sidebar { + width: 220px; + background: #fff; + border-right: 1px solid #e8e8e8; + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.2s; + overflow: hidden; +} +.sidebar.collapsed { width: 0; border-right: none; } + +.sidebar-header { + padding: 20px 16px 12px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid #f0f0f0; +} +.sidebar-logo { font-size: 24px; } +.sidebar-title { font-size: 16px; font-weight: 600; color: #222; } + +.sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} +.nav-section { padding: 0 8px; } +.nav-section-title { + font-size: 12px; + color: #999; + padding: 8px 12px 4px; + font-weight: 500; +} +.nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + color: #555; + transition: all 0.15s; +} +.nav-item:hover { background: #f5f5f5; color: #222; } +.nav-item.active { background: #e8f4fd; color: #1a73e8; font-weight: 500; } +.nav-icon { font-size: 16px; width: 20px; text-align: center; } +.nav-label { flex: 1; } +.nav-count { + font-size: 12px; + color: #999; + background: #f0f0f0; + padding: 1px 6px; + border-radius: 8px; +} +.nav-item.active .nav-count { background: #c8e0f7; color: #1a73e8; } +.nav-divider { height: 1px; background: #f0f0f0; margin: 8px 16px; } + +.sidebar-footer { + padding: 12px; + border-top: 1px solid #f0f0f0; +} + +/* === Main Area === */ +.main-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +/* === Top Bar === */ +.topbar { + height: 56px; + background: #fff; + border-bottom: 1px solid #e8e8e8; + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + flex-shrink: 0; +} +.topbar-left { display: flex; align-items: center; gap: 12px; } +.topbar-center { display: flex; align-items: center; } +.topbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; } + +.btn-icon { + width: 32px; height: 32px; + border: none; + background: none; + font-size: 18px; + cursor: pointer; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} +.btn-icon:hover { background: #f0f0f0; } + +.breadcrumb { display: flex; align-items: center; gap: 6px; font-size: 14px; } +.breadcrumb-item { color: #666; cursor: pointer; } +.breadcrumb-item:hover { color: #222; } +.breadcrumb-item.active { color: #222; font-weight: 500; } +.breadcrumb-sep { color: #ccc; } + +/* Slider */ +.slider-group { + display: flex; + align-items: center; + gap: 8px; +} +.slider-label { font-size: 12px; color: #999; } +.slider-group input[type="range"] { + width: 120px; + accent-color: #1a73e8; +} + +/* View Toggle */ +.view-toggle { display: flex; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; } +.view-btn { + width: 32px; height: 30px; + border: none; + background: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.view-btn.active { background: #1a73e8; color: #fff; } +.view-btn:hover:not(.active) { background: #f5f5f5; } + +.search-box input { + padding: 6px 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: #f8f8f8; + font-size: 13px; + width: 220px; +} +.search-box input:focus { outline: none; border-color: #1a73e8; background: #fff; } + +/* Buttons */ +.btn { + padding: 6px 14px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s; + white-space: nowrap; +} +.btn-upload { background: #1a73e8; color: #fff; } +.btn-upload:hover { background: #1557b0; } +.btn-block { width: 100%; text-align: center; padding: 10px; font-size: 14px; } +.btn-analyze { background: #f0f0f0; color: #333; border: 1px solid #ddd; } +.btn-analyze:hover { background: #e8e8e8; } +.btn-analyze:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-export { background: #34a853; color: #fff; } +.btn-export:hover { background: #2d8e47; } +.btn-analyze-selected { background: #f0f0f0; color: #333; border: 1px solid #ddd; } +.btn-delete-selected { background: #ea4335; color: #fff; } +.btn-delete-selected:hover { background: #d33426; } +.btn-restore { background: #34a853; color: #fff; } +.btn-restore:hover { background: #2d8e47; } +.btn-permanent-delete { background: #c62828; color: #fff; } +.btn-permanent-delete:hover { background: #a31f1f; } +.btn-clear { background: #f0f0f0; color: #333; } +.btn-clear:hover { background: #e8e8e8; } + +/* === Filter Bar === */ +.filterbar { + height: 40px; + background: #fff; + border-bottom: 1px solid #f0f0f0; + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + font-size: 13px; + color: #666; + flex-shrink: 0; +} +.filter-group { + display: flex; + align-items: center; + gap: 4px; +} +.filter-group input, .filter-group select { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + background: #fff; +} +.filter-group input:focus, .filter-group select:focus { outline: none; border-color: #1a73e8; } +.filter-results { margin-left: auto; color: #999; font-size: 13px; } +.btn-select-all { background: #fff; border: 1px solid #ddd; color: #333; font-size: 13px; } +.btn-select-all:hover { border-color: #1a73e8; color: #1a73e8; } + +/* === Content Area === */ +.content-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.grid-container { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* === Grid === */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} +.empty-state { + text-align: center; + padding: 80px; + color: #999; + font-size: 16px; +} + +/* === Card === */ +.card { + background: #fff; + border-radius: 8px; + overflow: hidden; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.15s; + position: relative; +} +.card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.08); } +.card.selected { border-color: #1a73e8; box-shadow: 0 0 0 2px rgba(26,115,232,0.2); } + +/* Thumbnail */ +.card-thumb-wrapper { + position: relative; + aspect-ratio: 9/16; + background: #f0f0f0; +} +.card-thumb { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: #000; +} +.card-play { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 44px; height: 44px; + background: rgba(0,0,0,0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #fff; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +.card:hover .card-play { opacity: 1; } + +/* Tags on thumbnail */ +.card-tags-top { + position: absolute; + top: 6px; + left: 6px; + right: 6px; + display: flex; + justify-content: space-between; + pointer-events: none; +} +.card-tag { + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + backdrop-filter: blur(4px); +} +.tag-category { + background: rgba(26,115,232,0.85); + color: #fff; +} +.tag-duration { + background: rgba(0,0,0,0.5); + color: #fff; +} +.tag-screener { + background: rgba(0,0,0,0.5); + color: #fff; +} +.tag-screener.keep { background: rgba(52,168,83,0.85); } +.tag-screener.review { background: rgba(251,188,4,0.85); } +.tag-screener.delete { background: rgba(234,67,53,0.85); } + +/* Checkbox */ +.card-check { + position: absolute; + bottom: 6px; + right: 6px; + width: 22px; height: 22px; + background: rgba(255,255,255,0.85); + border: 2px solid #ccc; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + z-index: 3; + cursor: pointer; + color: transparent; + transition: all 0.15s; + pointer-events: auto; +} +.card-check:hover { border-color: #1a73e8; } +.card-check.checked { background: #1a73e8; border-color: #1a73e8; color: #fff; } + +/* Card Body */ +.card-body { padding: 8px 10px; } +.card-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #222; + margin-bottom: 2px; +} +.card-desc { + font-size: 11px; + color: #999; + line-height: 1.4; + max-height: 2.8em; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.card-meta { + display: flex; + gap: 8px; + font-size: 11px; + color: #aaa; + margin-top: 4px; +} + +/* === List View === */ +.grid.list-view { + grid-template-columns: 1fr; + gap: 4px; +} +.grid.list-view .card { + display: flex; + flex-direction: row; + border-radius: 6px; +} +.grid.list-view .card-thumb-wrapper { + width: 80px; + aspect-ratio: 9/16; + flex-shrink: 0; +} +.grid.list-view .card-body { + flex: 1; + display: flex; + align-items: center; + gap: 16px; +} +.grid.list-view .card-title { flex: 1; margin-bottom: 0; } +.grid.list-view .card-meta { flex-shrink: 0; } + +/* === Pagination === */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 12px; + border-top: 1px solid #f0f0f0; + background: #fff; + flex-shrink: 0; +} +.page-btn { + width: 32px; height: 32px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + color: #333; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; +} +.page-btn:hover:not(:disabled) { border-color: #1a73e8; color: #1a73e8; } +.page-btn.active { background: #1a73e8; border-color: #1a73e8; color: #fff; } +.page-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.page-info { font-size: 13px; color: #999; margin-right: 8px; } + +/* === Selected Bar === */ +.selected-bar { + position: fixed; + bottom: 0; left: 220px; right: 0; + background: #fff; + padding: 12px 20px; + display: flex; + align-items: center; + gap: 16px; + border-top: 2px solid #1a73e8; + box-shadow: 0 -2px 8px rgba(0,0,0,0.06); + z-index: 100; + font-size: 14px; +} + +/* === Modal === */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 200; + display: none; + align-items: center; + justify-content: center; +} +.modal.active { display: flex; } +.modal-overlay { + position: absolute; + inset: 0; + cursor: pointer; +} +.modal-content { + background: #fff; + border-radius: 12px; + overflow: hidden; + max-width: 900px; + width: 90%; + position: relative; + z-index: 10; +} +.modal-close { + position: absolute; + top: 10px; right: 14px; + background: none; + border: none; + color: #666; + font-size: 24px; + cursor: pointer; + z-index: 10; + width: 32px; height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.modal-close:hover { background: #f0f0f0; } +.modal-content video { + width: auto; + max-height: 75vh; + max-width: 45vh; + margin: 0 auto; + display: block; + background: #000; + aspect-ratio: 9/16; +} + +/* Modal Tabs */ +.modal-tabs { + display: flex; + border-bottom: 1px solid #e8e8e8; +} +.modal-tab { + flex: 1; + padding: 12px; + border: none; + background: none; + font-size: 14px; + color: #666; + cursor: pointer; + transition: all 0.15s; + border-bottom: 2px solid transparent; +} +.modal-tab:hover { color: #333; background: #f8f8f8; } +.modal-tab.active { color: #1a73e8; border-bottom-color: #1a73e8; font-weight: 500; } +.modal-tab-content { min-height: 200px; max-height: 70vh; overflow-y: auto; } +.modal-tab-content video { max-height: 60vh; } + +/* Clip Toolbar */ +.clip-toolbar { + background: #f8f9fa; + border-top: 1px solid #e8e8e8; + padding: 10px 16px; +} +.clip-timeline-wrapper { + margin-bottom: 10px; +} +.clip-timeline { + position: relative; + height: 32px; + background: #e0e0e0; + border-radius: 4px; + cursor: pointer; + overflow: hidden; +} +.clip-selection { + position: absolute; + top: 0; + height: 100%; + background: rgba(26, 115, 232, 0.3); + border-left: 2px solid #1a73e8; + border-right: 2px solid #1a73e8; + pointer-events: none; +} +.clip-marker { + position: absolute; + top: 0; + width: 12px; + height: 100%; + cursor: ew-resize; + z-index: 5; +} +.clip-marker-in { + left: 0; + background: #1a73e8; + border-radius: 4px 0 0 4px; +} +.clip-marker-out { + right: 0; + background: #1a73e8; + border-radius: 0 4px 4px 0; +} +.clip-playhead { + position: absolute; + top: 0; + width: 3px; + height: 100%; + background: #ea4335; + z-index: 10; + pointer-events: none; +} +.clip-played { + position: absolute; + top: 0; + height: 100%; + background: rgba(0,0,0,0.15); + pointer-events: none; +} +.clip-timeaxis { + display: flex; + justify-content: space-between; + font-size: 10px; + color: #999; + margin-top: 2px; + padding: 0 2px; +} +.clip-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.clip-sep { color: #ccc; font-size: 16px; } +.clip-time { + font-family: monospace; + font-size: 13px; + color: #555; + background: #fff; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid #ddd; + min-width: 80px; +} +.clip-duration { font-size: 12px; color: #888; } +.btn-clip-set { + background: #fff; + border: 1px solid #ddd; + color: #333; + padding: 5px 12px; + font-size: 12px; +} +.btn-clip-set:hover { border-color: #1a73e8; color: #1a73e8; } +.btn-clip-split { + background: #fbbc04; + color: #333; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; +} +.btn-clip-split:hover { background: #e6a800; } +.btn-clip-play { + background: #1a73e8; + color: #fff; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; +} +.btn-clip-play:hover { background: #1557b0; } +.btn-clip-save { + background: #34a853; + color: #fff; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; +} +.btn-clip-save:hover { background: #2d8e47; } +.btn-clip-add { + background: #fff; + border: 1px solid #ddd; + color: #333; + padding: 5px 14px; + font-size: 12px; +} +.btn-clip-add:hover { border-color: #1a73e8; color: #1a73e8; } +.clip-list { + margin-top: 8px; + max-height: 120px; + overflow-y: auto; +} +.clip-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: #fff; + border: 1px solid #e8e8e8; + border-radius: 4px; + margin-bottom: 4px; + font-size: 12px; +} +.clip-list-item .clip-info { color: #555; } +.clip-list-item .clip-remove { color: #ea4335; cursor: pointer; } + +/* Report Container */ +.report-container { padding: 20px 24px; } +.report-section { margin-bottom: 20px; } +.report-section-title { + font-size: 13px; + font-weight: 600; + color: #1a73e8; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid #e8e8e8; +} +.report-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.report-item { + background: #f8f9fa; + border-radius: 8px; + padding: 12px; +} +.report-label { font-size: 12px; color: #999; margin-bottom: 4px; } +.report-value { font-size: 16px; font-weight: 600; color: #333; } +.report-value.good { color: #34a853; } +.report-value.warn { color: #fbbc04; } +.report-value.bad { color: #ea4335; } +.report-bar { + height: 6px; + background: #e8e8e8; + border-radius: 3px; + margin-top: 6px; + overflow: hidden; +} +.report-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} +.report-decision { + text-align: center; + padding: 20px; + background: #f8f9fa; + border-radius: 10px; + margin-bottom: 16px; +} +.report-decision-badge { + display: inline-block; + padding: 8px 24px; + border-radius: 20px; + font-size: 18px; + font-weight: 600; + color: #fff; +} +.report-decision-badge.keep { background: #34a853; } +.report-decision-badge.review { background: #fbbc04; color: #333; } +.report-decision-badge.delete { background: #ea4335; } +.report-decision-score { + font-size: 36px; + font-weight: 700; + margin: 8px 0 4px; +} +.report-decision-reason { font-size: 13px; color: #666; } +.report-desc { + background: #f8f9fa; + border-radius: 8px; + padding: 12px 16px; + font-size: 14px; + color: #333; + line-height: 1.6; +} +.report-issues { + list-style: none; + padding: 0; +} +.report-issues li { + padding: 6px 0; + font-size: 13px; + color: #666; + border-bottom: 1px solid #f0f0f0; +} +.report-issues li:last-child { border-bottom: none; } +.report-issues li::before { content: "• "; color: #ea4335; } +.report-no-data { + text-align: center; + padding: 40px; + color: #999; + font-size: 14px; +} + +/* Scene Analysis */ +.scene-section { margin-top: 16px; } +.waste-stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 8px; +} +.waste-stat-item { + background: #f8f9fa; + border-radius: 8px; + padding: 12px; + text-align: center; +} +.waste-stat-num { font-size: 24px; font-weight: 700; } +.waste-stat-label { font-size: 12px; color: #888; margin-top: 2px; } +.waste-stat-num.total { color: #333; } +.waste-stat-num.keep { color: #34a853; } +.waste-stat-num.trim { color: #fbbc04; } +.waste-stat-num.waste { color: #ea4335; } +.scene-card.waste { border-left-color: #ea4335; background: #fef7f6; } +.scene-card.trim { border-left-color: #fbbc04; } +.scene-card.highlight { border-left-color: #34a853; background: #f0faf3; } +.scene-suggestion { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} +.scene-suggestion.keep { background: #e6f4ea; color: #34a853; } +.scene-suggestion.highlight { background: #e6f4ea; color: #34a853; } +.scene-suggestion.waste { background: #fce8e6; color: #ea4335; } +.scene-suggestion.trim { background: #fef7e0; color: #b06000; } +.scene-card { + background: #f8f9fa; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + border-left: 4px solid #1a73e8; + display: flex; + gap: 16px; + align-items: flex-start; +} +.scene-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.scene-title { font-weight: 600; font-size: 14px; color: #333; } +.scene-meta { display: flex; gap: 8px; font-size: 12px; color: #888; } +.scene-tag { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} +.scene-tag.type { background: #e8f0fe; color: #1a73e8; } +.scene-tag.usage { background: #fef7e0; color: #b06000; } +.scene-tag.quality { background: #e6f4ea; color: #34a853; } +.scene-tag.quality.low { background: #fce8e6; color: #ea4335; } +.scene-desc { font-size: 13px; color: #555; line-height: 1.5; } +.scene-thumb-wrapper { + position: relative; + width: 90px; + height: 160px; + flex-shrink: 0; + border-radius: 6px; + overflow: hidden; + background: #f0f0f0; +} +.scene-thumb { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + background: #000; +} +.scene-thumb-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 28px; + height: 28px; + background: rgba(0,0,0,0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #fff; + opacity: 0; + transition: opacity 0.2s; +} +.scene-card:hover .scene-thumb-play { opacity: 1; } +.scene-loading { + text-align: center; + padding: 20px; + color: #999; + font-size: 13px; +} +.scene-loading::after { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid #ddd; + border-top-color: #1a73e8; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 8px; + vertical-align: middle; +} +@keyframes spin { to { transform: rotate(360deg); } } +.modal-info { + padding: 16px 20px; + font-size: 14px; + color: #666; + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.modal-info .info-item { display: flex; align-items: center; gap: 4px; } +.modal-info .info-label { color: #999; } +.modal-info .info-value { color: #333; font-weight: 500; } +.modal-info .info-full { flex-basis: 100%; margin-top: 4px; } +.modal-actions { + padding: 12px 20px; + display: flex; + gap: 10px; + border-top: 1px solid #f0f0f0; +} +.btn-analyze-modal { background: #1a73e8; color: #fff; padding: 8px 20px; font-size: 14px; } +.btn-analyze-modal:hover { background: #1557b0; } +.btn-analyze-modal:disabled { background: #999; cursor: not-allowed; } +.btn-prev, .btn-next { background: #f0f0f0; color: #333; padding: 8px 16px; font-size: 14px; } +.btn-prev:hover, .btn-next:hover { background: #e8e8e8; } + +/* === Upload Modal === */ +.upload-header { padding: 20px 24px 0; } +.upload-header h2 { font-size: 18px; margin-bottom: 4px; } +.upload-header p { font-size: 13px; color: #999; } +.upload-body { padding: 16px 24px 24px; } +.upload-dropzone { + border: 2px dashed #ddd; + border-radius: 10px; + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 16px; + position: relative; +} +.upload-dropzone:hover, .upload-dropzone.dragover { + border-color: #1a73e8; + background: rgba(26,115,232,0.03); +} +.dropzone-icon { font-size: 40px; margin-bottom: 8px; } +.upload-dropzone p { color: #999; font-size: 14px; } +.upload-link { color: #1a73e8; cursor: pointer; text-decoration: underline; } +.upload-category { + display: flex; align-items: center; gap: 8px; + margin-bottom: 16px; font-size: 14px; +} +.upload-category input { + flex: 1; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; +} +.upload-category input:focus { outline: none; border-color: #1a73e8; } +.upload-filelist { max-height: 200px; overflow-y: auto; margin-bottom: 16px; } +.upload-file { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 12px; background: #f8f8f8; border-radius: 6px; + margin-bottom: 4px; font-size: 13px; +} +.upload-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.upload-file-size { color: #999; margin-left: 12px; white-space: nowrap; } +.upload-file-remove { color: #ea4335; cursor: pointer; margin-left: 12px; } +.upload-progress { margin-bottom: 16px; } +.upload-result { padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px; } +.upload-result.success { background: #e6f4ea; color: #34a853; } +.upload-result.error { background: #fce8e6; color: #ea4335; } +.btn-upload-submit { + width: 100%; padding: 12px; background: #1a73e8; color: #fff; + font-size: 15px; font-weight: 500; +} +.btn-upload-submit:hover:not(:disabled) { background: #1557b0; } +.btn-upload-submit:disabled { background: #ddd; color: #999; cursor: not-allowed; } + +/* === Task Panel === */ +.task-panel { + position: fixed; + top: 0; + left: 220px; + width: 360px; + height: 100vh; + background: #fff; + border-right: 1px solid #e8e8e8; + box-shadow: 4px 0 16px rgba(0,0,0,0.08); + z-index: 280; + display: flex; + flex-direction: column; +} +.task-panel-header { + padding: 16px 20px; + border-bottom: 1px solid #e8e8e8; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 16px; + font-weight: 600; +} +.task-panel-body { + flex: 1; + overflow-y: auto; + padding: 12px; +} +.task-card { + background: #f8f9fa; + border-radius: 8px; + padding: 14px; + margin-bottom: 10px; + border-left: 4px solid #ddd; +} +.task-card.running { border-left-color: #1a73e8; background: #e8f0fe; } +.task-card.completed { border-left-color: #34a853; } +.task-card.failed { border-left-color: #ea4335; } +.task-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} +.task-type { font-size: 13px; font-weight: 500; color: #333; } +.task-status { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; +} +.task-status.running { background: #e8f0fe; color: #1a73e8; } +.task-status.completed { background: #e6f4ea; color: #34a853; } +.task-status.failed { background: #fce8e6; color: #ea4335; } +.task-progress { + height: 4px; + background: #e0e0e0; + border-radius: 2px; + margin: 8px 0; + overflow: hidden; +} +.task-progress-fill { + height: 100%; + background: #1a73e8; + border-radius: 2px; + transition: width 0.3s; +} +.task-detail { font-size: 12px; color: #888; } +.task-time { font-size: 11px; color: #bbb; margin-top: 4px; } +.task-empty { text-align: center; padding: 40px; color: #999; font-size: 14px; } + +/* === Progress Overlay === */ +.progress-overlay { + position: fixed; + top: 0; left: 220px; right: 0; + background: #fff; + padding: 12px 20px; + z-index: 290; + border-bottom: 1px solid #e8e8e8; +} +.progress-bar { + height: 4px; + background: #e8e8e8; + border-radius: 2px; + overflow: hidden; + margin-bottom: 6px; +} +.progress-fill { + height: 100%; + background: #1a73e8; + width: 0%; + transition: width 0.3s; +} +.progress-text { font-size: 13px; color: #666; } +/* Report Summary */ +.report-summary { + display: flex; + gap: 16px; + padding: 16px; + background: #f8f9fa; + border-radius: 12px; + margin-bottom: 16px; +} + +.report-summary-item { + flex: 1; + text-align: center; + padding: 12px 8px; + background: white; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +.report-summary-item.highlight { + border-left: 3px solid #fbbc04; +} + +.report-summary-item.waste { + border-left: 3px solid #ea4335; +} + +.report-summary-num { + display: block; + font-size: 24px; + font-weight: 700; + color: #333; + line-height: 1.2; +} + +.report-summary-item.highlight .report-summary-num { + color: #fbbc04; +} + +.report-summary-item.waste .report-summary-num { + color: #ea4335; +} + +.report-summary-label { + display: block; + font-size: 12px; + color: #666; + margin-top: 4px; +} +/* Scene Details */ +.scene-details { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #f0f0f0; +} + +.scene-detail { + display: flex; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + line-height: 1.5; +} + +.scene-detail-label { + color: #999; + min-width: 36px; + flex-shrink: 0; + font-weight: 500; +} + +.scene-detail-value { + color: #555; + flex: 1; +} + +.scene-detail-value.dialogue { + color: #333; + font-style: italic; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 4px; + border-left: 2px solid #4285f4; +} + +.scene-detail-value.notes { + color: #e67e22; + font-weight: 500; +} + +.scene-timecode { + font-size: 12px; + color: #999; + margin-bottom: 4px; + font-family: 'SF Mono', 'Consolas', monospace; +} + +/* 备注编辑样式 */ +.scene-detail-notes { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; + line-height: 1.5; +} + +.scene-detail-value-wrapper { + display: flex; + align-items: flex-start; + gap: 4px; + flex: 1; +} + +.scene-notes-input { + width: 100%; + min-height: 60px; + padding: 6px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + line-height: 1.5; + resize: vertical; + font-family: inherit; +} + +.scene-notes-input:focus { + border-color: #4285f4; + outline: none; + box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2); +} + +.btn-notes-edit, +.btn-notes-save, +.btn-notes-cancel { + padding: 2px 6px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + line-height: 1; + transition: background-color 0.2s; +} + +.btn-notes-edit { + background: #f0f0f0; + color: #666; +} + +.btn-notes-edit:hover { + background: #e0e0e0; +} + +.btn-notes-save { + background: #34a853; + color: white; +} + +.btn-notes-save:hover { + background: #2d9249; +} + +.btn-notes-cancel { + background: #ea4335; + color: white; +} + +.btn-notes-cancel:hover { + background: #d33426; +} + +/* 通知样式 */ +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +.notification { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* 文件夹管理样式 */ +.move-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + margin-bottom: 8px; + max-height: 300px; + overflow-y: auto; +} + +.move-menu-header { + padding: 12px 16px; + font-weight: 600; + font-size: 13px; + color: #666; + border-bottom: 1px solid #eee; +} + +.move-menu-item { + padding: 10px 16px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} + +.move-menu-item:hover { + background: #f5f5f5; +} + +.move-menu-divider { + height: 1px; + background: #eee; + margin: 4px 0; +} + +.btn-move { + background: #4285f4; + color: white; +} + +.btn-move:hover { + background: #3367d6; +} + +/* === Report Transcription === */ +.report-transcription { + margin: 16px 0; + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + border-left: 4px solid #1a73e8; +} +.report-transcription-title { + font-weight: 600; + font-size: 14px; + color: #333; + margin-bottom: 8px; +} +.report-transcription-content { + font-size: 13px; + color: #555; + line-height: 1.6; + white-space: pre-wrap; +} diff --git a/web.py.current b/web.py.current new file mode 100644 index 0000000..4489831 --- /dev/null +++ b/web.py.current @@ -0,0 +1,709 @@ +""" +Footage Screener 鈥?Web UI (Database-backed) +""" + +import json +import os +import sys +import threading +import time +from pathlib import Path +from werkzeug.utils import secure_filename + +from flask import Flask, render_template, jsonify, send_file, request, Response + +from db import ( + init_db, insert_footage, list_footage, get_footage_by_id, get_stats, + upsert_screener, delete_footage as db_delete_footage, + soft_delete_footage, restore_footage, permanent_delete_footage, + set_clip_status, create_task, update_task, complete_task, + list_tasks, get_running_task, migrate_from_json, + get_db, list_all_categories, create_category as db_create_category, + move_footage_to_category, +) +from analyzer import analyze_video as video_analyze + +sys.stdout.reconfigure(encoding="utf-8") + +app = Flask(__name__, + template_folder=os.path.join(os.path.dirname(__file__), "templates"), + static_folder=os.path.join(os.path.dirname(__file__), "static")) +app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600 # Cache static files 1 hour +app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 * 1024 # 4GB max upload + +# Unified video storage directory +VIDEO_STORAGE_DIR = os.environ.get("VIDEO_STORAGE_DIR") or os.path.join(os.path.dirname(__file__), "videos") +UPLOAD_DIR = os.path.join(VIDEO_STORAGE_DIR, "uploads") +THUMB_DIR = os.path.join(os.path.dirname(__file__), "static", "thumbs") +ALLOWED_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".mts", ".mxf", ".wmv", ".flv", ".mpg", ".mpeg", ".3gp", ".webm", ".ts", ".m4v"} + +# Global state +_analysis_running = False +_analysis_progress = {"current": 0, "total": 0, "file": "", "status": "idle"} + +# === Routes === + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/footage") +def api_footage(): + category = request.args.get("category", "") + search = request.args.get("search", "") + ai_search = request.args.get("ai_search", "") + min_dur = request.args.get("min_duration", type=float, default=0) + max_dur = request.args.get("max_duration", type=float, default=99999) + screener_filter = request.args.get("screener", "") + page = request.args.get("page", type=int, default=1) + per_page = request.args.get("per_page", type=int, default=60) + + items, total = list_footage( + category=category, search=search, ai_search=ai_search, + min_dur=min_dur, max_dur=max_dur, + screener_filter=screener_filter, + page=page, per_page=per_page, + status=request.args.get("status", "active"), + ) + + return jsonify({ + "items": items, + "total": total, + "page": page, + "per_page": per_page, + "pages": max(1, (total + per_page - 1) // per_page), + }) + + +@app.route("/api/stats") +def api_stats(): + return jsonify(get_stats()) + + +@app.route("/video/") +def serve_video(clip_id): + """Stream video file with range request support.""" + clip = get_footage_by_id(clip_id) + if not clip: + return "Clip not found", 404 + + full_path = clip.get("filepath", "") + if not os.path.exists(full_path): + return "File not found", 404 + + ext = clip["filename"].lower().rsplit(".", 1)[-1] + mime_map = {"mp4": "video/mp4", "mov": "video/quicktime", "avi": "video/x-msvideo", + "mkv": "video/x-matroska", "mts": "video/mp2t"} + mime = mime_map.get(ext, "video/mp4") + + file_size = os.path.getsize(full_path) + range_header = request.headers.get("Range") + + if range_header: + byte_start = int(range_header.replace("bytes=", "").split("-")[0]) + byte_end = min(byte_start + 5 * 1024 * 1024, file_size - 1) + length = byte_end - byte_start + 1 + + def generate_range(): + with open(full_path, "rb") as f: + f.seek(byte_start) + remaining = length + while remaining > 0: + chunk = f.read(min(65536, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + return Response( + generate_range(), status=206, mimetype=mime, + headers={ + "Content-Range": f"bytes {byte_start}-{byte_end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(length), + }, + ) + + return send_file(full_path, mimetype=mime) + + +# === Analysis API === + +@app.route("/api/analyze", methods=["POST"]) +def api_analyze(): + global _analysis_running + if _analysis_running: + return jsonify({"error": "Analysis already running"}), 409 + + data = request.json or {} + mode = data.get("mode", "all") + selected_ids = set(data.get("selected_ids", [])) + cv_only = data.get("cv_only", False) + + # Get targets from DB + if mode == "selected" and selected_ids: + targets = [get_footage_by_id(i) for i in selected_ids] + targets = [t for t in targets if t] + elif mode == "unanalyzed": + items, _ = list_footage(screener_filter="未分析", per_page=9999) + targets = items + else: + items, _ = list_footage(per_page=9999) + targets = items + + if not targets: + return jsonify({"error": "No videos to analyze"}), 400 + + _analysis_running = True + task_id = create_task(mode, len(targets)) + thread = threading.Thread(target=_run_analysis, args=(targets, cv_only, task_id), daemon=True) + thread.start() + + return jsonify({"message": f"Analyzing {len(targets)} videos...", "total": len(targets), "task_id": task_id}) + + +def _analyze_single(clip: dict, cv_only: bool, task_id: int, progress: dict) -> bool: + """Analyze a single video using MiMo 2.5.""" + filepath = clip.get("filepath", "") + footage_id = clip.get("id", 0) + if not filepath or not os.path.exists(filepath) or not footage_id: + return False + + try: + # Use new AI-only analyzer + result = video_analyze(filepath) + + if result.error: + print(f"Analysis error for {clip.get('filename')}: {result.error}") + + # Calculate decision score + final_score = result.final_score + + # Determine decision based on quality + if result.quality_score >= 7: + decision = "KEEP" + elif result.quality_score >= 4: + decision = "REVIEW" + else: + decision = "DELETE" + + decision_reason = result.decision_reason or result.quality_reason + + upsert_screener(footage_id, { + "cv_score": 0, + "ai_score": round(final_score, 1), + "final_score": round(final_score, 1), + "decision": decision, + "decision_reason": decision_reason, + "blur_score": 0, + "shake_score": 0, + "black_frame_ratio": 0, + "overexposed_ratio": 0, + "underexposed_ratio": 0, + "is_silent": 0, + "ai_valid": 1 if decision == "KEEP" else 0, + "ai_description": result.description, + "ai_quality": result.quality_score, + "ai_reason": result.quality_reason, + "ai_mode": result.analysis_mode, + "issues": [], + "transcription": result.transcription, + "scenes": result.shots, + }) + return True + except Exception as e: + print(f"Error analyzing {clip.get('filename')}: {e}") + return False + + +def _safe_scene_analysis(filepath: str) -> dict: + """Scene analysis with error protection.""" + try: + from scene_analyzer import analyze_scenes + return analyze_scenes(filepath) + except Exception as e: + print(f"Scene analysis error: {e}") + return {"transcription": "", "scenes": [], "summary": str(e)} + + +def _run_analysis(targets: list[dict], cv_only: bool = False, task_id: int = 0): + global _analysis_running, _analysis_progress + + _analysis_progress = {"current": 0, "total": len(targets), "file": "", "status": "running"} + + error_msg = "" + import concurrent.futures + # Process videos in batches of 3 for parallel execution + batch_size = 3 + for batch_start in range(0, len(targets), batch_size): + batch = targets[batch_start:batch_start + batch_size] + + with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size) as executor: + futures = {} + for clip in batch: + future = executor.submit(_analyze_single, clip, cv_only, task_id, _analysis_progress) + futures[future] = clip + + for future in concurrent.futures.as_completed(futures): + clip = futures[future] + try: + success = future.result(timeout=600) + _analysis_progress["current"] += 1 + _analysis_progress["file"] = clip.get("filename", "") + if task_id: + update_task(task_id, completed_count=_analysis_progress["current"], + current_file=clip.get("filename", "")) + except Exception as e: + error_msg = str(e) + _analysis_progress["current"] += 1 # count errors too + print(f"Error analyzing {clip.get('filename')}: {e}") + + if task_id: + update_task(task_id, completed_count=_analysis_progress["current"], + current_file=clip.get("filename", "")) + + if task_id: + complete_task(task_id, error_msg) + + _analysis_progress["status"] = "done" + _analysis_running = False + + +@app.route("/api/clip", methods=["POST"]) +def api_clip(): + """Clip a segment from a video.""" + data = request.json or {} + footage_id = data.get("id") + start = data.get("start", 0) + end = data.get("end", 0) + filename = data.get("filename", "clip") + + if not footage_id or end <= start: + return jsonify({"error": "Invalid parameters"}), 400 + + clip = get_footage_by_id(footage_id) + if not clip: + return jsonify({"error": "Clip not found"}), 404 + + filepath = clip.get("filepath", "") + if not os.path.exists(filepath): + return jsonify({"error": "File not found"}), 404 + + # Create output filename + clip_dir = os.path.join(os.path.dirname(__file__), "clips") + os.makedirs(clip_dir, exist_ok=True) + stem = Path(filename).stem + ext = Path(filename).suffix + output_name = f"{stem}_clip_{int(start)}-{int(end)}{ext}" + output_path = os.path.join(clip_dir, output_name) + + # FFmpeg clip + cmd = [ + "ffmpeg", "-y", "-v", "quiet", + "-ss", str(start), + "-i", filepath, + "-t", str(end - start), + "-c", "copy", + output_path, + ] + + try: + proc = subprocess.run(cmd, capture_output=True, timeout=60, + encoding="utf-8", errors="replace") + if proc.returncode == 0 and os.path.exists(output_path): + return jsonify({"ok": True, "output": output_name, "path": output_path}) + else: + return jsonify({"error": "FFmpeg failed"}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/clip-status", methods=["POST"]) +def api_clip_status(): + """Mark footage as clipped or unclipped.""" + data = request.json or {} + footage_id = data.get("id") + status = data.get("status", "clipped") # "clipped" or "" + if not footage_id: + return jsonify({"error": "No ID"}), 400 + set_clip_status(footage_id, status) + return jsonify({"ok": True}) + + +@app.route("/api/analyze-scenes", methods=["POST"]) +def api_analyze_scenes(): + """Analyze all scenes/shots in a video.""" + data = request.json or {} + footage_id = data.get("id") + if not footage_id: + return jsonify({"error": "No footage ID"}), 400 + + clip = get_footage_by_id(footage_id) + if not clip: + return jsonify({"error": "Clip not found"}), 404 + + filepath = clip.get("filepath", "") + if not os.path.exists(filepath): + return jsonify({"error": "File not found"}), 404 + + from scene_analyzer import analyze_scenes + result = analyze_scenes(filepath) + return jsonify(result) + + +@app.route("/api/tasks") +def api_tasks(): + """List analysis tasks.""" + tasks = list_tasks(limit=20) + running = get_running_task() + return jsonify({"tasks": tasks, "running": running}) + + +@app.route("/api/analyze/status") +def api_analyze_status(): + return jsonify(_analysis_progress) + + +@app.route("/api/scan", methods=["POST"]) +def api_scan(): + data = request.json or {} + base_dir = data.get("directory", "") + if not base_dir or not os.path.exists(base_dir): + return jsonify({"error": "Invalid directory"}), 400 + + from scanner import scan_directory + footage = scan_directory(base_dir) + return jsonify({"message": f"Scanned {len(footage)} clips", "count": len(footage)}) + + +@app.route("/api/batch/delete", methods=["POST"]) +def api_batch_delete(): + data = request.json or {} + confirm = data.get("confirm", False) + ids = data.get("ids", []) + + if ids: + # Soft delete specific items by ID (move to recycle bin) + if not confirm: + items = [get_footage_by_id(i) for i in ids] + items = [i for i in items if i] + return jsonify({"count": len(items), "files": [f["filename"] for f in items]}) + + moved = [] + for fid in ids: + clip = get_footage_by_id(fid) + if not clip: + continue + soft_delete_footage(fid) + moved.append(clip["filename"]) + return jsonify({"deleted": len(moved), "files": moved}) + else: + # Soft delete all DELETE-category items + items, _ = list_footage(screener_filter="DELETE", per_page=9999) + if not confirm: + return jsonify({"count": len(items), "files": [f["filename"] for f in items]}) + + moved = [] + for f in items: + soft_delete_footage(f["id"]) + moved.append(f["filename"]) + + return jsonify({"deleted": len(moved), "files": moved}) + + +@app.route("/api/restore", methods=["POST"]) +def api_restore(): + """Restore items from recycle bin.""" + data = request.json or {} + ids = data.get("ids", []) + if not ids: + return jsonify({"error": "No IDs provided"}), 400 + + restored = [] + for fid in ids: + clip = get_footage_by_id(fid) + if clip: + restore_footage(fid) + restored.append(clip["filename"]) + return jsonify({"restored": len(restored), "files": restored}) + + +@app.route("/api/permanent-delete", methods=["POST"]) +def api_permanent_delete(): + """Permanently delete items from recycle bin.""" + data = request.json or {} + ids = data.get("ids", []) + if not ids: + return jsonify({"error": "No IDs provided"}), 400 + + deleted = [] + for fid in ids: + filepath = permanent_delete_footage(fid) + if filepath and os.path.exists(filepath): + try: + os.remove(filepath) + except Exception: + pass + clip = get_footage_by_id(fid) + if clip: + deleted.append(clip["filename"]) + return jsonify({"deleted": len(deleted), "files": deleted}) + + +@app.route("/api/export") +def api_export(): + selected_ids = request.args.get("ids", "") + if selected_ids: + ids = [int(x) for x in selected_ids.split(",") if x.strip()] + items = [get_footage_by_id(i) for i in ids] + items = [i for i in items if i] + else: + items, _ = list_footage(per_page=9999) + + report = { + "total": len(items), + "keep": len([f for f in items if f.get("screener", {}).get("decision") == "KEEP"]), + "review": len([f for f in items if f.get("screener", {}).get("decision") == "REVIEW"]), + "delete": len([f for f in items if f.get("screener", {}).get("decision") == "DELETE"]), + "clips": items, + } + return jsonify(report) + + +# === Upload API === + +@app.route("/api/upload", methods=["POST"]) +def api_upload(): + """Upload video files. Saves to uploads/ dir, extracts metadata, writes to DB.""" + if "files" not in request.files: + return jsonify({"error": "No files provided"}), 400 + + files = request.files.getlist("files") + category = request.form.get("category", "涓婁紶绱犳潗") + + os.makedirs(UPLOAD_DIR, exist_ok=True) + from scanner import generate_thumbnail + + uploaded = [] + errors = [] + + for f in files: + if not f.filename: + continue + + ext = os.path.splitext(f.filename)[1].lower() + if ext not in ALLOWED_EXTENSIONS: + errors.append({"filename": f.filename, "error": f"Unsupported format: {ext}"}) + continue + + # Save file (handle duplicates) + filename = secure_filename(f.filename) + if not filename: + filename = f"upload_{int(time.time())}{ext}" + dest_path = os.path.join(UPLOAD_DIR, filename) + if os.path.exists(dest_path): + name, ext2 = os.path.splitext(filename) + filename = f"{name}_{int(time.time())}{ext2}" + dest_path = os.path.join(UPLOAD_DIR, filename) + + try: + f.save(dest_path) + except Exception as e: + errors.append({"filename": f.filename, "error": str(e)}) + continue + + # Extract metadata + from cv_analyzer import _get_video_info + info = _get_video_info(dest_path) + + # Generate thumbnail + thumb_name = f"{category}_{filename}.jpg" + thumb_path = os.path.join(THUMB_DIR, thumb_name) + thumb_rel = f"thumbs/{thumb_name}" + generate_thumbnail(dest_path, thumb_path) + + # Insert to DB + clip = { + "filename": filename, + "filepath": dest_path, + "category": category, + "thumbnail": thumb_rel, + "duration": round(info["duration"], 2), + "width": info["width"], + "height": info["height"], + "fps": round(info["fps"], 2), + "total_frames": info["total_frames"], + "size_mb": round(os.path.getsize(dest_path) / 1024 / 1024, 1), + } + clip_id = insert_footage(clip) + clip["id"] = clip_id + uploaded.append(clip) + + return jsonify({ + "uploaded": len(uploaded), + "errors": len(errors), + "files": uploaded, + "error_details": errors, + }) + + +@app.route("/api/upload/status") +def api_upload_status(): + """Check upload directory.""" + if not os.path.exists(UPLOAD_DIR): + return jsonify({"count": 0, "total_size_mb": 0}) + files = [f for f in os.listdir(UPLOAD_DIR) if os.path.isfile(os.path.join(UPLOAD_DIR, f))] + total_size = sum(os.path.getsize(os.path.join(UPLOAD_DIR, f)) for f in files) + return jsonify({"count": len(files), "total_size_mb": round(total_size / 1024 / 1024, 1)}) + + +@app.route("/api/scene-notes/batch", methods=["POST"]) +def update_scene_notes_batch(): + """Update notes for multiple scenes at once.""" + data = request.json or {} + footage_id = data.get("footage_id") + updates = data.get("updates", []) + + if not footage_id or not updates: + return jsonify({"error": "footage_id and updates required"}), 400 + + try: + with get_db() as conn: + # Get current scenes + row = conn.execute("SELECT scenes FROM screener WHERE footage_id = ?", (footage_id,)).fetchone() + if not row: + return jsonify({"error": "No analysis found for this footage"}), 404 + + scenes = json.loads(row["scenes"]) if row["scenes"] else [] + + # Apply updates + for update in updates: + scene_index = update.get("scene_index") + notes = update.get("notes", "") + + for scene in scenes: + if scene.get("scene_index") == scene_index or scene.get("shot_index") == scene_index: + scene["notes"] = notes + break + + # Save back to database + conn.execute("UPDATE screener SET scenes = ? WHERE footage_id = ?", + (json.dumps(scenes, ensure_ascii=False), footage_id)) + conn.commit() + + return jsonify({"success": True, "updated": len(updates)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +# === Scene Notes API === + +@app.route("/api/scene-notes", methods=["POST"]) +def update_scene_notes(): + data = request.json or {} + footage_id = data.get("footage_id") + scene_index = data.get("scene_index") + notes = data.get("notes", "") + + if not footage_id or scene_index is None: + return jsonify({"error": "footage_id and scene_index required"}), 400 + + try: + with get_db() as conn: + # Get current scenes + row = conn.execute("SELECT scenes FROM screener WHERE footage_id = ?", (footage_id,)).fetchone() + if not row: + return jsonify({"error": "No analysis found for this footage"}), 404 + + scenes = json.loads(row["scenes"]) if row["scenes"] else [] + + # Find and update the scene + updated = False + for scene in scenes: + if scene.get("scene_index") == scene_index or scene.get("shot_index") == scene_index: + scene["notes"] = notes + updated = True + break + + if not updated: + return jsonify({"error": "Scene not found"}), 404 + + # Save back to database + conn.execute("UPDATE screener SET scenes = ? WHERE footage_id = ?", + (json.dumps(scenes, ensure_ascii=False), footage_id)) + conn.commit() + + return jsonify({"success": True, "notes": notes}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +# === Category Management API === + +@app.route("/api/categories", methods=["GET"]) +def list_categories(): + """获取所有分类列表""" + categories = list_all_categories() + return jsonify({"categories": categories}) + + +@app.route("/api/category/create", methods=["POST"]) +def create_category(): + """创建新分类(文件夹)""" + data = request.json or {} + name = data.get("name", "").strip() + + if not name: + return jsonify({"error": "分类名称不能为空"}), 400 + + if len(name) > 50: + return jsonify({"error": "分类名称不能超过50个字符"}), 400 + + if db_create_category(name): + return jsonify({"success": True, "category": name}) + else: + return jsonify({"error": "分类已存在"}), 400 + + +@app.route("/api/category/move", methods=["POST"]) +def move_to_category(): + """将选中的视频移动到指定分类""" + data = request.json or {} + ids = data.get("ids", []) + category = data.get("category", "").strip() + + if not ids: + return jsonify({"error": "请选择要移动的视频"}), 400 + + if not category: + return jsonify({"error": "请选择目标分类"}), 400 + + moved = move_footage_to_category(ids, category) + return jsonify({"success": True, "moved": moved}) + + + + +if __name__ == "__main__": + # Auto-migrate from JSON if DB is empty + init_db() + json_cache = os.path.join(os.path.dirname(__file__), "data", "footage_cache.json") + stats = get_stats() + if stats["categories"]["全部"]["count"] == 0 and os.path.exists(json_cache): + print("Migrating from JSON cache...") + count = migrate_from_json(json_cache) + print(f"Migrated {count} items.") + + # Fix any stuck running tasks from previous session + from db import get_db as _get_db + with _get_db() as _conn: + stuck = _conn.execute("SELECT COUNT(*) FROM tasks WHERE status='running'").fetchone()[0] + if stuck > 0: + _conn.execute("UPDATE tasks SET status='completed', completed_at=CURRENT_TIMESTAMP WHERE status='running'") + print(f"Fixed {stuck} stuck task(s)") + + port = int(os.environ.get("PORT", 5000)) + host = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 = 灞€鍩熺綉鍙闂? print(f"\n 馃幀 Footage Screener Web UI") + print(f" 鏈満璁块棶: http://localhost:{port}") + print(f" 灞€鍩熺綉璁块棶: http://<浣犵殑IP>:{port}") + print(f" 鎸?Ctrl+C 鍋滄鏈嶅姟\n") + app.run(host=host, port=port, debug=False)