- 视频分析: MiMo 2.5 大模型直接分析 - 场景检测: AI 视觉模型识别分镜头 - 语音识别: 每个场景单独 ASR - 语音克隆: MiMo TTS 语音克隆 - 下载功能: 单文件/批量 ZIP 下载 - 系统设置: API 密钥配置 - 文件夹管理: 创建/移动/筛选 - 扁平化 UI: 简约按钮样式
1404 lines
52 KiB
Plaintext
1404 lines
52 KiB
Plaintext
// ============================================================
|
||
// 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, "&").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);
|
||
}
|
||
|
||
|