mcp-demo/app.js.current
GoMrStone 1a7567231a feat: 录音素材管理系统 - 完整功能
- 视频分析: MiMo 2.5 大模型直接分析
- 场景检测: AI 视觉模型识别分镜头
- 语音识别: 每个场景单独 ASR
- 语音克隆: MiMo TTS 语音克隆
- 下载功能: 单文件/批量 ZIP 下载
- 系统设置: API 密钥配置
- 文件夹管理: 创建/移动/筛选
- 扁平化 UI: 简约按钮样式
2026-07-10 17:26:53 +08:00

1404 lines
52 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// 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 += `<div class="nav-item${active}" onclick="filterByCategory(this,'${cat}')">
<span class="nav-icon">[FOLDER]</span><span class="nav-label">${cat}</span>
<span class="nav-count">${s.count}</span></div>`;
}
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 = `<span class="card-tag tag-category">${f.category}</span><span class="card-tag tag-duration">${dur}</span>`;
let sTag = "";
if (s) {
const cls = { KEEP: "keep", REVIEW: "review", DELETE: "delete" }[s.decision] || "";
sTag = `<span class="card-tag tag-screener ${cls}">${s.decision}</span>`;
}
const desc = s?.ai_description ? `<div class="card-desc">${esc(s.ai_description)}</div>` : "";
const score = s ? `<span>${s.final_score}?</span>` : "";
return `<div class="card${sel ? " selected" : ""}" data-id="${f.id}">
<div class="card-thumb-wrapper">
<img class="card-thumb" src="/static/${f.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
<div class="card-play">X</div>
<div class="card-tags-top">${tags}${sTag}</div>
<div class="card-check${sel ? " checked" : ""}" onclick="event.stopPropagation();toggleSelect(${f.id},this)">X</div>
</div>
<div class="card-body">
<div class="card-title" title="${esc(f.filename)}">${esc(f.filename)}</div>
${desc}
<div class="card-meta"><span>${f.width}x${f.height}</span><span>${f.size_mb}MB</span>${score}</div>
</div></div>`;
}).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 = `<span class="page-info">${total} 个素材</span>`;
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 += `<span style="color:#ccc;padding:0 4px">...</span>`;
continue;
}
html += pgBtn(i, i, false, i === currentPage);
}
html += pgBtn(currentPage + 1, "?", currentPage >= totalPages);
el.innerHTML = html;
}
function pgBtn(page, label, disabled, active) {
return `<button class="page-btn${active ? " active" : ""}"${disabled ? " disabled" : ""} onclick="loadFootage(${page})">${label}</button>`;
}
// --- 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 = '<div class="task-empty">暂无任务</div>';
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 `<div class="task-card ${t.status}">
<div class="task-card-header">
<span class="task-type">${typeLabel}</span>
<span class="task-status ${t.status}">${statusLabel}</span>
</div>
${t.status === "running" ? `<div class="task-progress"><div class="task-progress-fill" style="width:${pct}%"></div></div>` : ''}
<div class="task-detail">${detail}</div>
<div class="task-time">${t.created_at || ''}</div>
</div>`;
}).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 = `
<div class="info-item"><span class="info-label">决策:</span><span class="card-tag tag-screener ${cls}">${s.decision}</span></div>
<div class="info-item"><span class="info-label">评分:</span><span class="info-value">${s.final_score}</span></div>`;
}
document.getElementById("modalInfo").innerHTML = `
<div class="info-item"><span class="info-label">文件:</span><span class="info-value">${esc(footage.filename)}</span></div>
<div class="info-item"><span class="info-label">时长:</span><span class="info-value">${formatDuration(footage.duration)}</span></div>
<div class="info-item"><span class="info-label">分辨率:</span><span class="info-value">${footage.width}x${footage.height}</span></div>
<div class="info-item"><span class="info-label">大小:</span><span class="info-value">${footage.size_mb}MB</span></div>
<div class="info-item"><span class="info-label">分类:</span><span class="info-value">${esc(footage.category)}</span></div>
${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 = `<div class="report-no-data">[CHART] 暂无分析数据<br><br>请先分析素材后查看报告</div>`;
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 = `
<div class="report-summary">
<div class="report-summary-item">
<span class="report-summary-num">${totalScenes}</span>
<span class="report-summary-label">总计数</span>
</div>
<div class="report-summary-item highlight">
<span class="report-summary-num">${highlightCount}</span>
<span class="report-summary-label">[CHART] 时长</span>
</div>
<div class="report-summary-item waste">
<span class="report-summary-num">${wasteCount}</span>
<span class="report-summary-label">X 有效素材</span>
</div>
</div>
${s.transcription ? `
<div class="report-transcription">
<div class="report-transcription-title">[?] 语音转文本</div>
<div class="report-transcription-content">${esc(s.transcription)}</div>
</div>
` : ''}
`;
// 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 ? `<img src="/static/${scene.thumbnail}" class="scene-thumb" loading="lazy">` : '';
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 += `<div class="scene-detail"><span class="scene-detail-label">[?] 景别</span><span class="scene-detail-value">${esc(shotType)}</span></div>`;
if (dialogue) detailsHtml += `<div class="scene-detail"><span class="scene-detail-label">[?] 对话</span><span class="scene-detail-value dialogue">${esc(dialogue)}</span></div>`;
if (soundFx) detailsHtml += `<div class="scene-detail"><span class="scene-detail-label">[?] 音效</span><span class="scene-detail-value">${esc(soundFx)}</span></div>`;
if (notes || true) detailsHtml += `
<div class="scene-detail scene-detail-notes">
<span class="scene-detail-label">[?] 备注</span>
<div class="scene-detail-value-wrapper">
<span class="scene-detail-value notes" id="notes-display-${scene.shot_index || scene.scene_index || ''}">${esc(notes || '')}</span>
<textarea class="scene-notes-input" id="notes-input-${scene.shot_index || scene.scene_index || ''}" style="display:none">${esc(notes || '')}</textarea>
<button class="btn-notes-edit" onclick="event.stopPropagation();toggleNotesEdit(${scene.shot_index || scene.scene_index || 0})" title="编辑">X</button>
<button class="btn-notes-save" onclick="event.stopPropagation();saveNotes(${scene.shot_index || scene.scene_index || 0})" style="display:none" title="保存">[?]</button>
<button class="btn-notes-cancel" onclick="event.stopPropagation();cancelNotesEdit(${scene.shot_index || scene.scene_index || 0})" style="display:none" title="取消">X</button>
</div>
</div>
`;
return `<div class="scene-card${cardCls}" data-start="${startTime}" data-end="${endTime}" style="cursor:pointer" title="场景详情">
<div style="display:flex;gap:12px">
<div class="scene-thumb-wrapper">
${frameImg}
<div class="scene-thumb-play">X</div>
</div>
<div style="flex:1;min-width:0">
<div class="scene-header">
<span class="scene-title">[?] 镜头 ${scene.shot_index || scene.scene || scene.scene_index || ""}</span>
<div class="scene-meta">
<span class="scene-tag type">${sLabel}</span>
<span class="scene-tag" style="background:#f0f0f0;color:#666">${duration.toFixed(1)}s</span>
</div>
</div>
<div class="scene-timecode">? ${fmtTime(startTime)} - ${fmtTime(endTime)}</div>
<div class="scene-desc">${esc(scene.description || "")}</div>
${detailsHtml ? '<div class="scene-details">' + detailsHtml + '</div>' : ''}
</div>
</div>
</div>`;
}).join("");
// Bind click handlers to scene cards
sceneContainer.querySelectorAll(".scene-card").forEach(card => {
card.addEventListener("click", (e) => {
e.stopPropagation();
playSceneCard(card);
});
});
} else {
sceneContainer.innerHTML = `<div style="text-align:center;padding:20px;color:#999;font-size:13px">[CHART] 暂无分析数据</div>`;
}
}
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 `<div class="upload-file"><span class="upload-file-name">[OK] ${esc(f.name)}</span><span class="upload-file-size">${sz}</span><span class="upload-file-remove" onclick="removeUploadFile(${i})>X</span></div>`;
}).join("");
}
async function submitUpload() {
if (!uploadFiles.length) return;
const btn = document.getElementById("btnUploadSubmit");
const pBar = document.getElementById("uploadProgress");
const pFill = document.getElementById("uploadProgressFill");
const pText = document.getElementById("uploadProgressText");
const res = document.getElementById("uploadResult");
btn.disabled = true;
btn.textContent = "上传中...";
pBar.style.display = "block";
pFill.style.width = "0%";
res.style.display = "none";
const fd = new FormData();
for (const f of uploadFiles) fd.append("files", f);
fd.append("category", document.getElementById("uploadCategory")?.value || "分析中");
try {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.onprogress = e => {
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 `<div class="clip-list-item" onclick="jumpToClip(${s.start}, ${s.end})" style="cursor:pointer">
<span class="clip-info">
${s.scene ? `<strong>[?]${s.scene}</strong> ` : ''}
${formatTimeFull(s.start)} ? ${formatTimeFull(s.end)} (${(s.end-s.start).toFixed(1)}s)
${s.description ? `<br><small style="color:#999">${s.description.substring(0,30)}...</small>` : ''}
</span>
<span class="scene-suggestion ${sugCls}" style="font-size:11px">${sugLabel}</span>
<span class="clip-remove" onclick="event.stopPropagation();removeClipFromList(${s.id})>X</span>
</div>`;
}).join("");
}
function jumpToClip(start, end) {
clipStart = start;
clipEnd = end;
updateClipUI();
const player = document.getElementById("videoPlayer");
if (player) {
player.currentTime = start;
player.play();
}
}
async function saveClip() {
if (modalCurrentIndex < 0) return;
const f = filteredFootage[modalCurrentIndex];
if (!f || clipEnd <= clipStart) {
alert("请先选择素材");
return;
}
const btn = event.target;
btn.disabled = true;
btn.textContent = "[REFRESH] 分析中...";
try {
const res = await fetch("/api/clip", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: f.id,
start: clipStart,
end: clipEnd,
filename: f.filename,
}),
});
const data = await res.json();
if (res.ok) {
alert(`X 已分类: ${data.output}`);
// Mark as clipped
await fetch("/api/clip-status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: f.id, status: "clipped" }),
});
loadFootage(currentPage);
loadStats();
} else {
alert("错误: " + (data.error || "Unknown error"));
}
} catch (e) {
alert("错误: " + e.message);
}
btn.disabled = false;
btn.textContent = "重新分析";
}
// --- Scene Playback ---
let sceneEndTime = 0;
let sceneTimer = null;
let activeSceneVideo = null;
let currentFootageId = 0;
function playSceneCard(card) {
// Stop any currently playing scene video
stopSceneVideo();
if (!card) return;
// Get video source
const videoSrc = `/video/${currentFootageId}`;
if (!videoSrc || !currentFootageId) return;
// Get scene data from card
const startTime = parseFloat(card.dataset.start) || 0;
const endTime = parseFloat(card.dataset.end) || 0;
// Create inline video player
const wrapper = card.querySelector(".scene-thumb-wrapper");
if (!wrapper) return;
const thumbImg = wrapper.querySelector(".scene-thumb");
if (thumbImg) thumbImg.style.display = "none";
const playBtn = wrapper.querySelector(".scene-thumb-play");
if (playBtn) playBtn.style.display = "none";
const video = document.createElement("video");
video.src = videoSrc;
video.style.cssText = "width:100%;height:100%;object-fit:contain;border-radius:6px;background:#000;";
video.muted = false;
video.controls = false;
wrapper.appendChild(video);
activeSceneVideo = video;
video.onloadeddata = function() {
video.currentTime = startTime;
video.play();
if (endTime > 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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);
}