"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import MixLanes, { type Analysis, type Marker } from "./MixLanes"; type FileRow = { path: string; label: string; size: number; mtimeMs: number }; type FileGroup = { project: string; label: string; kind: string; files: FileRow[] }; type Info = { path: string; label: string; duration: number; hasVideo: boolean; hasAudio: boolean }; type Track = { info: Info; analysis: Analysis | null }; type Job = { id: string; state: "running" | "done" | "failed"; startedAt: number; endedAt: number | null; label: string; cmd: string; error: string | null; duration: number; }; type Spec = { body: string; bg: string | null; handover: number; fade: number; bgGain: number; duck: number; start: number; end: number; out: string; }; const BLANK: Spec = { body: "", bg: null, handover: 0, fade: 0.12, bgGain: 1, duck: 0, start: 0, end: 0, out: "", }; const mediaUrl = (p: string) => `/api/mix/media?path=${encodeURIComponent(p)}`; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); const fmt = (t: number) => `${t.toFixed(3)}s`; /** * The gain the background should have at time t, in the SAME shape * `bgVolumeExpr` renders. This function existing twice would defeat the whole * purpose of the bench -- what you hear has to be what gets written -- so it is * written once here and mirrored by one expression builder on the server, and * the spec e2e asserts the two agree at the interesting instants. */ export function bgGainAt(t: number, s: Pick): number { if (t < s.handover) return s.bgGain; if (s.fade <= 0.0005 || t >= s.handover + s.fade) return s.duck; return s.bgGain + (s.duck - s.bgGain) * ((t - s.handover) / s.fade); } export type MixPresetProp = { body: string; label: string; start: number; end: number; duration: number; from: string | null; clip: string | null; fileFrom: number; fileTo: number; } | null; export default function MixBench({ preset = null }: { preset?: MixPresetProp }) { const [groups, setGroups] = useState([]); const [other, setOther] = useState([]); // The only client state the picker adds. A twelve-project tree makes the // select long enough that scrolling it is worse than typing three letters. const [filter, setFilter] = useState(""); const [spec, setSpec] = useState(BLANK); const [body, setBody] = useState(null); const [bg, setBg] = useState(null); const [loading, setLoading] = useState(""); const [note, setNote] = useState(""); const [job, setJob] = useState(null); const [playing, setPlaying] = useState(false); const [playhead, setPlayhead] = useState(null); const [view, setView] = useState({ from: 0, to: 30 }); const vidRef = useRef(null); const bgRef = useRef(null); const ctxRef = useRef(null); const songSrc = useRef(null); const bgSrc = useRef(null); const bgGainNode = useRef(null); const rafRef = useRef(null); const duration = body?.info.duration ?? 0; const end = spec.end > 0 ? Math.min(spec.end, duration) : duration; // ---- loading ------------------------------------------------------------- useEffect(() => { void (async () => { const [f, s] = await Promise.all([ fetch("/api/mix/files?group=project", { cache: "no-store" }).then((r) => r.json()), fetch("/api/mix/session", { cache: "no-store" }).then((r) => r.json()), ]); setGroups(f.groups ?? []); setOther(f.other ?? []); // PRECEDENCE: preset > the saved pair > BLANK. `last` is used only when // there is no preset -- a link that named a file and a window must not // lose to whatever this bench was last pointed at. if (preset) { // The per-pair knobs are still worth having: handover, fade, gain and // duck are things learned about THIS pair, while body/start/end came // from the link. const pair = s?.byPair?.[`${preset.body}|`] ?? {}; setSpec({ ...BLANK, ...pair, body: preset.body, bg: null, start: preset.start, end: preset.end, }); return; } const last = s?.last ? s.byPair?.[s.last] : null; if (last?.body) setSpec({ ...BLANK, ...last }); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [preset?.body, preset?.start, preset?.end]); const loadTrack = useCallback(async (which: "body" | "bg", path: string | null) => { if (!path) { if (which === "bg") setBg(null); else setBody(null); return; } setLoading(`analysing ${path.split("/").pop()}…`); try { const r = await fetch(`/api/mix/track?path=${encodeURIComponent(path)}`, { cache: "no-store" }); const j = (await r.json()) as Track & { error?: string }; if (!r.ok || j.error) { setNote(`could not read ${path}: ${j.error ?? r.status}`); return; } if (which === "body") setBody(j); else setBg(j); setNote(""); } finally { setLoading(""); } }, []); useEffect(() => { void loadTrack("body", spec.body || null); }, [spec.body, loadTrack]); useEffect(() => { void loadTrack("bg", spec.bg); }, [spec.bg, loadTrack]); // A freshly-loaded body sets the view and, if nothing has been chosen yet, // seeds the handover at the moment the song starts -- which is the answer in // the common case and a visible starting point in the rest. useEffect(() => { if (!body) return; setView({ from: 0, to: Math.max(4, body.info.duration) }); setSpec((s) => { const next = { ...s }; if (!next.out) { const base = body.info.label.replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "-"); next.out = `${base}-mix.mp4`; } if (!next.handover && body.analysis?.firstSound != null) next.handover = body.analysis.firstSound; return next; }); }, [body]); // ---- audio graph --------------------------------------------------------- const ctx = () => { if (!ctxRef.current) ctxRef.current = new AudioContext(); return ctxRef.current; }; const wire = useCallback(() => { const c = ctx(); if (vidRef.current && !songSrc.current) { songSrc.current = c.createMediaElementSource(vidRef.current); songSrc.current.connect(c.destination); } if (bgRef.current && !bgSrc.current) { bgSrc.current = c.createMediaElementSource(bgRef.current); const g = c.createGain(); bgSrc.current.connect(g); g.connect(c.destination); bgGainNode.current = g; } }, []); const scheduleBg = useCallback( (from: number) => { const g = bgGainNode.current; const c = ctxRef.current; if (!g || !c) return; const now = c.currentTime; g.gain.cancelScheduledValues(now); const { handover: H, fade: F, duck } = spec; g.gain.setValueAtTime(bgGainAt(from, spec), now); if (from < H) { g.gain.setValueAtTime(spec.bgGain, now + (H - from)); if (F > 0.0005) g.gain.linearRampToValueAtTime(duck, now + (H - from) + F); else g.gain.setValueAtTime(duck, now + (H - from)); } else if (from < H + F) { g.gain.linearRampToValueAtTime(duck, now + (H + F - from)); } }, [spec], ); const stop = useCallback(() => { vidRef.current?.pause(); bgRef.current?.pause(); if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = null; setPlaying(false); }, []); const play = useCallback( async (from?: number) => { const v = vidRef.current; if (!v || !body) return; wire(); const c = ctx(); if (c.state === "suspended") await c.resume(); const t0 = clamp(from ?? playhead ?? spec.start, spec.start, Math.max(spec.start, end - 0.05)); v.currentTime = t0; if (bgRef.current) bgRef.current.currentTime = t0; scheduleBg(t0); await v.play().catch(() => {}); if (bgRef.current) await bgRef.current.play().catch(() => {}); setPlaying(true); const tick = () => { const t = vidRef.current?.currentTime ?? 0; setPlayhead(t); if (t >= end - 0.01) { stop(); return; } rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); }, [body, playhead, spec.start, end, scheduleBg, stop, wire], ); // A parameter changed mid-play: re-arm the ramp from where we are, so the // preview reflects the edit immediately instead of on the next play. useEffect(() => { if (playing) scheduleBg(vidRef.current?.currentTime ?? 0); }, [playing, scheduleBg]); useEffect(() => stop, [stop]); // ---- editing ------------------------------------------------------------- const set = useCallback((patch: Partial) => setSpec((s) => ({ ...s, ...patch })), []); const onMove = useCallback( (which: Marker, t: number, _dragging: boolean) => { const d = duration || t; if (which === "handover") set({ handover: +clamp(t, 0, d).toFixed(3) }); else if (which === "start") set({ start: +clamp(t, 0, Math.max(0, end - 0.1)).toFixed(3) }); else set({ end: +clamp(t, spec.start + 0.1, d).toFixed(3) }); }, [duration, end, spec.start, set], ); const seek = useCallback( (t: number) => { const to = clamp(t, 0, duration || t); setPlayhead(to); if (vidRef.current) vidRef.current.currentTime = to; if (bgRef.current) bgRef.current.currentTime = to; if (playing) scheduleBg(to); }, [duration, playing, scheduleBg], ); // ---- render -------------------------------------------------------------- const submit = useCallback( async (dry: boolean) => { setNote(""); const r = await fetch(`/api/mix/render${dry ? "?dry=1" : ""}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...spec, end: spec.end || 0 }), }); const j = await r.json(); if (!r.ok || j.error) { setNote(`render refused: ${j.error ?? r.status}`); return; } if (dry) { setNote(`${j.reencode ? "re-encode" : "stream copy"} · ${j.duration}s → ${j.out}`); return; } setJob(j.job); void fetch("/api/mix/session", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(spec), }); }, [spec], ); useEffect(() => { if (!job || job.state !== "running") return; const id = setInterval(async () => { const r = await fetch(`/api/mix/render?job=${job.id}`, { cache: "no-store" }); const j = await r.json(); if (j.job) setJob(j.job); }, 1000); return () => clearInterval(id); }, [job]); // ---- keyboard ------------------------------------------------------------ useEffect(() => { const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA")) return; const nudge = e.shiftKey ? 0.01 : 0.1; if (e.key === " ") { e.preventDefault(); if (playing) stop(); else void play(); } else if (e.key === "[") { e.preventDefault(); set({ handover: +Math.max(0, spec.handover - nudge).toFixed(3) }); } else if (e.key === "]") { e.preventDefault(); set({ handover: +(spec.handover + nudge).toFixed(3) }); } else if (e.key === "h") { e.preventDefault(); void play(Math.max(0, spec.handover - 2)); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [playing, play, stop, set, spec.handover]); // The cues worth offering are the ones NEAR where you are working, not the // loudest in the file. Prefer what is inside the current view, then order by // distance from the handover. const steps = (() => { const all = bg?.analysis?.steps ?? []; if (!all.length) return []; const inView = all.filter((s) => s.at >= view.from && s.at <= view.to); return (inView.length ? inView : all) .slice() .sort((a, b) => Math.abs(a.at - spec.handover) - Math.abs(b.at - spec.handover)); })(); const zoomHandover = useCallback(() => { setView({ from: Math.max(0, spec.handover - 3), to: Math.min(duration || spec.handover + 3, spec.handover + 3) }); }, [spec.handover, duration]); // ---- the picker ---------------------------------------------------------- // // Grouped by project, because a flat newest-first list with a cap could not // show them all: four of six report deliverables fell off the end of a // 600-entry list, and per-song cuts never appeared at all. // // The