// ============================================================
// 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 += `
[FOLDER]${cat}
${s.count}
`;
}
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 `
${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 `
? ${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);
}