// Detect view — find singing segments in one video, export editor markers. import { h, clear, basename, fmtDur } from "../dom"; import { makeProgress, toast, renderSongs } from "../ui"; import { pickVideo, detectSongs, cancel, exportSegments, onDetectProgress, reveal, type Segment, } from "../api"; export interface View { el: HTMLElement; onDrop: (paths: string[]) => void; setVideo: (p: string) => void; } const FPS = ["29.97", "25", "23.976", "30", "60"]; function numField(label: string, value: number, step = "1"): [HTMLElement, HTMLInputElement] { const input = h("input", { class: "input", type: "number", value: String(value), step, min: "0" }) as HTMLInputElement; return [h("div", { class: "field" }, h("label", {}, label), input), input]; } export function buildDetect(): View { let running = false; let segments: Segment[] = []; let videoPath = ""; const videoInput = h("input", { class: "input grow", placeholder: "Select a video…", readonly: true }) as HTMLInputElement; const [gapF, gapI] = numField("Merge gap (s)", 10); const [minF, minI] = numField("Min duration (s)", 20); const [padF, padI] = numField("Padding (s)", 2); const fpsSel = h("select", { class: "input" }, ...FPS.map((f) => h("option", { value: f }, f))) as HTMLSelectElement; const detectBtn = h("button", { class: "btn primary lg", disabled: true }, "🎤 Detect Songs") as HTMLButtonElement; const stopBtn = h("button", { class: "btn danger", disabled: true }, "■ Stop") as HTMLButtonElement; const exportBtn = h("button", { class: "btn", disabled: true }, "⬇ Export EDL / CSV") as HTMLButtonElement; const revealBtn = h("button", { class: "btn ghost", style: "display:none" }, "Show in folder") as HTMLButtonElement; const progress = makeProgress(); const statsEl = h("div", { class: "pills" }); const songsEl = h("div", { class: "songs" }); let lastExportDir = ""; function setVideo(p: string) { videoPath = p; videoInput.value = basename(p); videoInput.title = p; detectBtn.disabled = running || !p; } async function detect() { if (!videoPath) return; running = true; detectBtn.disabled = true; stopBtn.disabled = false; exportBtn.disabled = true; revealBtn.style.display = "none"; clear(songsEl); clear(statsEl); progress.reset(); progress.indeterminate(true, "Starting detection…"); const un = await onDetectProgress((msg) => { progress.indeterminate(true, msg); progress.appendLog(msg); }); try { const res = await detectSongs(videoPath, +gapI.value, +minI.value, +padI.value); segments = res.segments; progress.indeterminate(false); progress.set(1, `Found ${segments.length} singing segment(s).`); renderSongs(songsEl, segments); const totalSing = segments.reduce((a, s) => a + s.duration, 0); statsEl.replaceChildren( h("span", { class: "pill" }, `${segments.length} songs`), h("span", { class: "pill" }, `singing ${fmtDur(totalSing)}`), ...Object.entries(res.stats || {}).map(([k, v]) => h("span", { class: "pill" }, `${k} ${fmtDur(v as number)}`)), ); exportBtn.disabled = segments.length === 0; if (!segments.length) toast("No singing detected — try a larger gap or smaller min duration.", ""); } catch (e) { const msg = String(e); progress.indeterminate(false); if (msg.includes("__cancelled__")) progress.set(0, "Stopped by user."); else { progress.set(0, "Detection failed."); toast(msg, "err"); } } finally { un(); running = false; stopBtn.disabled = true; detectBtn.disabled = !videoPath; } } exportBtn.addEventListener("click", async () => { if (!segments.length) return; try { const res = await exportSegments(segments, videoPath, null, +fpsSel.value); lastExportDir = res.files[0] ?? ""; toast(`Exported ${res.files.length} files`, "ok"); if (lastExportDir) revealBtn.style.display = ""; } catch (e) { toast(String(e), "err"); } }); revealBtn.addEventListener("click", () => lastExportDir && reveal(lastExportDir)); detectBtn.addEventListener("click", detect); stopBtn.addEventListener("click", () => cancel()); const el = h("div", { class: "view" }, h("div", { class: "card" }, h("h2", {}, h("span", { class: "step" }, "1"), "Source Video"), h("div", { class: "row" }, videoInput, h("button", { class: "btn", onclick: async () => { const p = await pickVideo(); if (p) setVideo(p); } }, "Browse…")), h("div", { class: "hint", style: "margin-top:8px" }, "Tip: drop a file here, or use a Concat output."), ), h("div", { class: "card" }, h("h2", {}, h("span", { class: "step" }, "2"), "Detection Settings"), h("div", { class: "row wrap" }, h("div", { class: "grow", style: "min-width:130px" }, gapF), h("div", { class: "grow", style: "min-width:130px" }, minF), h("div", { class: "grow", style: "min-width:130px" }, padF), h("div", { class: "field", style: "min-width:120px" }, h("label", {}, "FPS (export)"), fpsSel)), h("div", { class: "hint", style: "margin-top:8px" }, "Singing = \"music\". More chatting between songs → raise the gap. Short covers → lower the min duration."), ), h("div", { class: "card" }, h("h2", {}, h("span", { class: "step" }, "3"), "Run"), h("div", { class: "row", style: "margin-bottom:14px" }, detectBtn, stopBtn, exportBtn, revealBtn), progress.root, h("div", { style: "margin-top:12px" }, statsEl), h("div", { style: "margin-top:10px" }, songsEl), ), ); return { el, onDrop: (paths) => { if (paths[0]) setVideo(paths[0]); }, setVideo }; }