Cross-platform Stream Studio rewrite. UI/ffmpeg/exports move to Rust+TypeScript; Python shrinks to a thin sidecar that runs inaSpeechSegmenter and emits line-delimited JSON. Detection slice extracted from stream-studio. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
5.6 KiB
TypeScript
130 lines
5.6 KiB
TypeScript
// 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 };
|
|
}
|