diff --git a/.gitignore b/.gitignore index f7da770..4d60acc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ Thumbs.db !.env.example !.env.test +# Test temp files +tmp-test-remux/ +tmp-test-*/ + # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/src/tests/fixtures/medarecorder-emu.webm b/src/tests/fixtures/medarecorder-emu.webm index 336d911..edf8d5c 100644 Binary files a/src/tests/fixtures/medarecorder-emu.webm and b/src/tests/fixtures/medarecorder-emu.webm differ diff --git a/src/tests/remux.integration.test.ts b/src/tests/remux.integration.test.ts index 23ff776..78e6288 100644 --- a/src/tests/remux.integration.test.ts +++ b/src/tests/remux.integration.test.ts @@ -6,12 +6,13 @@ import { execFileSync } from 'child_process'; /** * Integration test for webm remux using real ffmpeg. * - * FIXTURE NOTE: The fixture (fixtures/medarecorder-emu.webm) is generated - * by ffmpeg from a silent audio source, NOT captured from a real PWA - * MediaRecorder session. It may NOT reproduce the exact no-Cues / - * unset-duration characteristics of real browser output. - * This test is flagged LOWER-CONFIDENCE for the MediaRecorder regression - * aspect per the story specification. + * FIXTURE NOTE: The fixture (fixtures/medarecorder-emu.webm) is generated by + * ffmpeg -live 1 from a silent audio source (NOT a real PWA MediaRecorder + * capture). The -live 1 flag suppresses Cues and Duration in the Segment + * Info header, producing a file that emulates the "no Cues, unset/streaming + * duration" characteristic of browser MediaRecorder opus-in-webm output. + * This synthetic fixture is LOWER-CONFIDENCE than a real PWA capture but + * DOES reproduce the specific ffprobe Duration=N/A failure mode. */ describe('remux — integration (lower-confidence: synthetic fixture)', () => { const fixturePath = join(__dirname, 'fixtures', 'medarecorder-emu.webm'); @@ -21,7 +22,7 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { if (!existsSync(fixturePath)) { throw new Error( `Fixture not found at ${fixturePath}. Generate with:\n` + - ` ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 2 -c:a libopus -b:a 32k -f webm ${fixturePath}` + ` ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 2 -c:a libopus -b:a 32k -f webm -live 1 ${fixturePath}` ); } if (!existsSync(tmpDir)) { @@ -30,10 +31,27 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { }); afterAll(() => { - // Clean up temp dir try { unlinkSync(join(tmpDir, 'integration-test.webm')); } catch {} try { unlinkSync(join(tmpDir, 'integration-test.webm.out')); } catch {} try { unlinkSync(join(tmpDir, 'integration-test.webm.remuxed')); } catch {} + try { unlinkSync(join(tmpDir, 'fixture-test-remuxed.webm')); } catch {} + try { unlinkSync(join(tmpDir, 'integration-audio-chain.webm')); } catch {} + try { unlinkSync(join(tmpDir, 'integration-audio-chain.wav')); } catch {} + }); + + it('fixture input lacks duration (emulates MediaRecorder streaming output)', () => { + const probeIn = execFileSync('ffprobe', [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + fixturePath + ], { encoding: 'utf8' }); + + const info = JSON.parse(probeIn); + + // Fixture generated with -live 1: no Duration in Segment Info + expect(info.format.duration).toBeUndefined(); + expect(info.format.format_name).toMatch(/matroska|webm/i); }); it('remux produces a valid webm with finalized duration', () => { @@ -41,7 +59,7 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { const outFile = join(tmpDir, 'integration-test.webm.remuxed'); copyFileSync(fixturePath, testFile); - // Run ffmpeg remux — same command as remuxUpload() + // remux via ffmpeg (same args as remuxUpload) execFileSync('ffmpeg', [ '-y', '-i', testFile, @@ -51,12 +69,12 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { outFile ]); - // Output exists and non-empty + // output file exists and non-empty expect(existsSync(outFile)).toBe(true); const outSize = readFileSync(outFile).length; expect(outSize).toBeGreaterThan(0); - // Verify with ffprobe + // probe output streams and duration const probeOut = execFileSync('ffprobe', [ '-v', 'quiet', '-print_format', 'json', @@ -70,12 +88,12 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { expect(info.streams[0].codec_name).toBe('opus'); expect(info.format.format_name).toMatch(/matroska|webm/i); - // Duration should be present after remux + // duration is now finalized const duration = parseFloat(info.format.duration); expect(duration).toBeGreaterThan(0); - expect(duration).toBeCloseTo(2.0, 0); // ~2s as generated + expect(duration).toBeCloseTo(2.0, 0); // approx 2s source - // Remux is idempotent: re-run on output produces same duration + // idempotent: second remux pass preserves duration const outFile2 = join(tmpDir, 'integration-test.webm.remuxed2'); execFileSync('ffmpeg', [ '-y', @@ -94,14 +112,92 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { const info2 = JSON.parse(probeOut2); expect(parseFloat(info2.format.duration)).toBeCloseTo(2.0, 0); - // Cleanup + // cleanup unlinkSync(outFile); unlinkSync(outFile2); unlinkSync(testFile); }); + it('remuxUpload function accepts fixture and fixes duration', async () => { + const testFile = join(tmpDir, 'fixture-test-remuxed.webm'); + copyFileSync(fixturePath, testFile); + + // confirm input has no duration before remux + const beforeProbe = execFileSync('ffprobe', [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + testFile + ], { encoding: 'utf8' }); + expect(JSON.parse(beforeProbe).format.duration).toBeUndefined(); + + // call the actual module function via dynamic import + const { remuxUpload } = await import('$lib/server/remux.js'); + const result = await remuxUpload(testFile, 'fixture-test'); + + expect(result).toBe(testFile); + + // after remux: duration is finalized + const afterProbe = execFileSync('ffprobe', [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + testFile + ], { encoding: 'utf8' }); + const afterInfo = JSON.parse(afterProbe); + const duration = parseFloat(afterInfo.format.duration); + expect(duration).toBeGreaterThan(0); + expect(duration).toBeCloseTo(2.0, 0); + + unlinkSync(testFile); + }); + + it('remuxed output is consumable by prepareAudio-equivalent ffmpeg convert', () => { + const testFile = join(tmpDir, 'integration-audio-chain.webm'); + const wavFile = join(tmpDir, 'integration-audio-chain.wav'); + copyFileSync(fixturePath, testFile); + + // remux in-place (same as pipeline does) + execFileSync('ffmpeg', [ + '-y', + '-i', testFile, + '-c', 'copy', + '-fflags', '+genpts', + '-f', 'webm', + testFile + '.fixed' + ]); + copyFileSync(testFile + '.fixed', testFile); + unlinkSync(testFile + '.fixed'); + + // prepareAudio's core: ffmpeg -> 16kHz mono WAV + execFileSync('ffmpeg', [ + '-y', + '-i', testFile, + '-ar', '16000', '-ac', '1', + '-c:a', 'pcm_s16le', + wavFile + ]); + + expect(existsSync(wavFile)).toBe(true); + const wavStat = readFileSync(wavFile); + expect(wavStat.length).toBeGreaterThan(1000); + + const probe = execFileSync('ffprobe', [ + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + wavFile + ], { encoding: 'utf8' }); + const info = JSON.parse(probe); + expect(info.format.format_name).toMatch(/wav/i); + expect(parseFloat(info.format.duration)).toBeGreaterThan(0); + + unlinkSync(testFile); + unlinkSync(wavFile); + }); + it('non-webm files are unaffected by remux command', () => { - // Generate a minimal valid mp4 with ffmpeg + // generate a non-webm-like file using webm container const testFile = join(tmpDir, 'test-non.webm'); execFileSync('ffmpeg', [ '-y', @@ -112,10 +208,7 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => { testFile ]); - // Rename to .mp4 (wrong extension, but same format) - const mp4File = testFile + '.mp4'; - // Actually just use original extension - this test proves non-webm pass-through - // by running the same ffmpeg command on it + // remux command doesn't choke on it const outFile = join(tmpDir, 'test-non-out.webm'); execFileSync('ffmpeg', [ '-y',