import { test, expect } from "@playwright/test"; import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; // The mix bench, against SYNTHESISED tracks whose true answers are known in // advance (see make-fixture.mjs): // // bg.mp4 220Hz for 3s, then 3000Hz -- same loudness either side, so only // the brightness curve can see the change. A cue at 3.00s. // song.mp4 2s of silence, then a 440Hz tone. firstSound at 2.00s. // // That pairing is the Pokemon failure in miniature. A handover placed by // envelope, or by eye off a pitch contour, lands late and the background's cue // plays through before the song arrives -- which is exactly the defect that // shipped. These assert that the tool measures both instants itself. const BG = "bg.mp4"; const SONG = "song.mp4"; // THE CORPUS'S CLIP WINDOWS, SET ASIDE FOR THIS FILE. // // The bench folds the corpus's clip windows (channels//data//clips/, // what the editor's fetch writes) in with a project's own clips-raw. The // fixture is built once per run and specs before this one fetch windows // through the editor stub (testchan/vid1 0-14, say), so which file a clip // links to, and whether c02 is fetched at all, depended on what ran first: // `:166` and `:201` failed in every full run and passed alone (FACTS, "mix.spec.ts // IS ORDER-DEPENDENT"). These tests are about the project's clips-raw, so they // start with the corpus windows moved aside, and put them back after, for the // specs that follow. const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".e2e-song"); const CHANNELS = path.join(FIXTURE, "channels"); const ASIDE = path.join(FIXTURE, "mix-spec-clips-aside"); function clipDirs(): string[] { const out: string[] = []; if (!existsSync(CHANNELS)) return out; for (const slug of readdirSync(CHANNELS)) { const data = path.join(CHANNELS, slug, "data"); if (!existsSync(data)) continue; for (const id of readdirSync(data)) { const clips = path.join(data, id, "clips"); if (existsSync(clips)) out.push(path.relative(CHANNELS, clips)); } } return out; } function restoreClips() { if (!existsSync(ASIDE)) return; for (const rel of clipDirsUnder(ASIDE)) { const back = path.join(CHANNELS, rel); if (existsSync(back)) rmSync(back, { recursive: true, force: true }); mkdirSync(path.dirname(back), { recursive: true }); renameSync(path.join(ASIDE, rel), back); } rmSync(ASIDE, { recursive: true, force: true }); } function clipDirsUnder(root: string): string[] { const out: string[] = []; for (const slug of readdirSync(root)) { const data = path.join(root, slug, "data"); if (!existsSync(data)) continue; for (const id of readdirSync(data)) out.push(path.join(slug, "data", id, "clips")); } return out; } test.beforeAll(() => { restoreClips(); // a run killed mid-file left some aside for (const rel of clipDirs()) { mkdirSync(path.dirname(path.join(ASIDE, rel)), { recursive: true }); renameSync(path.join(CHANNELS, rel), path.join(ASIDE, rel)); } }); test.afterAll(restoreClips); test("the analysis finds a cue an envelope cannot see", async ({ request }) => { const r = await request.get(`/api/mix/track?path=${encodeURIComponent(BG)}`); expect(r.ok()).toBe(true); const { analysis } = await r.json(); // Both halves are the same level, so this is NOT findable by loudness. expect(analysis.steps.length).toBeGreaterThan(0); const near = analysis.steps.filter((s: { at: number }) => Math.abs(s.at - 3.0) < 0.2); expect(near, `steps were ${JSON.stringify(analysis.steps.slice(0, 8))}`).not.toHaveLength(0); // Steps come back in TIME order, not strongest-first: ranking globally by // strength and truncating is what hid the Pokemon alarm behind eight louder // cues elsewhere in the file. const times = analysis.steps.map((s: { at: number }) => s.at); expect(times).toEqual([...times].sort((a: number, b: number) => a - b)); }); test("firstSound matches what silencedetect would report", async ({ request }) => { const r = await request.get(`/api/mix/track?path=${encodeURIComponent(SONG)}`); const { analysis, info } = await r.json(); expect(info.hasAudio).toBe(true); expect(analysis.firstSound).toBeGreaterThan(1.9); expect(analysis.firstSound).toBeLessThan(2.15); }); test("a spec resolves to the ffmpeg that will actually run", async ({ request }) => { const r = await request.post("/api/mix/render?dry=1", { data: { body: SONG, bg: BG, handover: 3, fade: 0.12, bgGain: 1, duck: 0, start: 0, end: 0, out: "out-dry.mp4" }, }); expect(r.ok()).toBe(true); const j = await r.json(); // No head trim -> the video is a stream copy, not a re-encode. expect(j.reencode).toBe(false); expect(j.cmd).toContain("-c:v copy"); // The ramp is the shape the browser preview applies, not a hard switch. expect(j.cmd).toContain("volume="); expect(j.cmd).toContain("normalize=0"); expect(j.cmd).toMatch(/if\(lt\(t,3\.0000\)/); }); test("a head trim forces a re-encode, because an output seek lands on a keyframe", async ({ request, }) => { const r = await request.post("/api/mix/render?dry=1", { data: { body: SONG, bg: null, handover: 0, fade: 0, bgGain: 1, duck: 0, start: 1.5, end: 0, out: "out-dry2.mp4" }, }); const j = await r.json(); expect(j.reencode).toBe(true); expect(j.cmd).toContain("libx264"); expect(j.cmd).toContain("-ss 1.500"); }); test("a render lands, and the end point is honoured", async ({ request }) => { const start = await request.post("/api/mix/render", { data: { body: SONG, bg: BG, handover: 3, fade: 0.1, bgGain: 1, duck: 0, start: 0, end: 4, out: "e2e-mix.mp4" }, }); expect(start.ok()).toBe(true); const { job } = await start.json(); await expect .poll( async () => (await (await request.get(`/api/mix/render?job=${job.id}`)).json()).job.state, { timeout: 60_000 }, ) .toBe("done"); // It really is on disk and really is 4 seconds, rather than merely reported. const probe = await request.get(`/api/mix/track?path=${encodeURIComponent("e2e-mix.mp4")}`); expect(probe.ok()).toBe(true); const { info } = await probe.json(); expect(info.duration).toBeGreaterThan(3.8); expect(info.duration).toBeLessThan(4.3); }); test("a render refuses to write outside the roots, or over its own input", async ({ request }) => { const outside = await request.post("/api/mix/render", { data: { body: SONG, bg: null, out: "/etc/umtool-should-not-exist.mp4" }, }); expect(outside.status()).toBe(400); const traversal = await request.post("/api/mix/render", { data: { body: SONG, bg: null, out: "../../../../tmp/escaped.mp4" }, }); expect(traversal.status()).toBe(400); // Overwriting the input would destroy the thing being judged. const onInput = await request.post("/api/mix/render", { data: { body: SONG, bg: null, out: SONG }, }); expect(onInput.status()).toBe(400); }); test("the bench loads, lists real tracks, and hides render scratch", async ({ page }) => { await page.goto("/mix"); await expect(page.getByRole("link", { name: "mix", exact: true })).toBeVisible(); const body = page.locator("select").first(); // The label exactly: `hasText` is a substring match, and a render another // spec left (`…song.mp4`) counted as a second song. await expect(body.locator("option", { hasText: /^song\.mp4$/ })).toHaveCount(1); // polytmp-*/ and poly-song-*.wav are working files, never offerable. await expect(page.locator("option").filter({ hasText: /poly-song-|polytmp-/ })).toHaveCount(0); }); test("choosing a song seeds the handover where the song starts", async ({ page }) => { await page.goto("/mix"); const selects = page.locator("select"); await selects.first().selectOption({ label: SONG }); // The readings panel reports the measured instant, and the handover follows // it -- a starting point that is the right answer in the common case. await expect(page.getByText(/2\.0\d\ds/).first()).toBeVisible({ timeout: 30_000 }); const handover = page.locator('input[type="number"]').first(); await expect.poll(async () => Number(await handover.inputValue())).toBeGreaterThan(1.9); }); // --------------------------------------------------------------------------- // Reaching the bench from a project. // // Six of seven projects used to resolve to null here: MEDIA_ROOTS was the // um-song subtree, so no report video's media was openable at all. These assert // that the way in is a link, and that a bad one is REFUSED rather than quietly // opening something else. // --------------------------------------------------------------------------- test("the picker is grouped by project, and every project's media is in it", async ({ request }) => { const j = (await (await request.get("/api/mix/files?group=project")).json()) as { groups: { project: string; label: string; files: { label: string }[] }[]; other: { label: string }[]; }; // Coverage is a property of the ENUMERATION, not of the cap. A flat // newest-first list with a 600-entry limit dropped four of six real report // deliverables off the end, and per-song cuts never appeared at all. const ids = j.groups.map((g) => g.project); // A song's cuts, three levels down, which listMedia never reached before. expect(ids).toContain("reports/videos/alpha"); // A report video's DELIVERABLE. expect(ids).toContain("reports/no-origin-fixture"); // And the intermediates stay out. out/ holds one deliverable and 40 to 60 // working files, so reaching one level deeper without excluding these by name // would put ~260 of them in a picker that is already saturated. A report // project whose only media is clips-raw correctly offers NOTHING. const all = [...j.groups.flatMap((g) => g.files), ...j.other]; expect(all.filter((f) => f.label.includes("segments/"))).toHaveLength(0); expect(all.filter((f) => f.label.includes("cards/"))).toHaveLength(0); expect(all.filter((f) => f.label.includes("clips-raw/"))).toHaveLength(0); }); test("a clip row links into the bench with the window already set", async ({ page }) => { await page.goto("/browse/reports/report-fixture"); const link = page.locator("[data-mix-link=c01]"); await expect(link).toBeVisible(); const href = (await link.getAttribute("href"))!; // The window minus the cached file's own start. c01 is 3.00-6.00 and the file // begins at 0.00, so the arithmetic is visible and exact. expect(href).toContain("start=3.00"); expect(href).toContain("end=6.00"); // ABSOLUTE, like the picker's own