v6: Tauri (Rust + TS) rewrite, Python as ML sidecar
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>
This commit is contained in:
129
v6_stream_studio_tauri/detect.ts
Normal file
129
v6_stream_studio_tauri/detect.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user