D1: webm remux integration test with no-Cues fixture + idempotency/chain coverage
- Regenerated fixture medarecorder-emu.webm with ffmpeg -live 1 to suppress Cues and Duration, emulating browser MediaRecorder output - Integration test now asserts fixture lacked duration before remux - Added remuxUpload function call test (real module, not inline ffmpeg) - Added prepareAudio-chain test: remux -> 16kHz WAV conversion succeeds - Added idempotency assertion: second remux pass preserves duration - Added tmp-test-remux to .gitignore
This commit is contained in:
@@ -18,6 +18,10 @@ Thumbs.db
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Test temp files
|
||||||
|
tmp-test-remux/
|
||||||
|
tmp-test-*/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -6,12 +6,13 @@ import { execFileSync } from 'child_process';
|
|||||||
/**
|
/**
|
||||||
* Integration test for webm remux using real ffmpeg.
|
* Integration test for webm remux using real ffmpeg.
|
||||||
*
|
*
|
||||||
* FIXTURE NOTE: The fixture (fixtures/medarecorder-emu.webm) is generated
|
* FIXTURE NOTE: The fixture (fixtures/medarecorder-emu.webm) is generated by
|
||||||
* by ffmpeg from a silent audio source, NOT captured from a real PWA
|
* ffmpeg -live 1 from a silent audio source (NOT a real PWA MediaRecorder
|
||||||
* MediaRecorder session. It may NOT reproduce the exact no-Cues /
|
* capture). The -live 1 flag suppresses Cues and Duration in the Segment
|
||||||
* unset-duration characteristics of real browser output.
|
* Info header, producing a file that emulates the "no Cues, unset/streaming
|
||||||
* This test is flagged LOWER-CONFIDENCE for the MediaRecorder regression
|
* duration" characteristic of browser MediaRecorder opus-in-webm output.
|
||||||
* aspect per the story specification.
|
* 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)', () => {
|
describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
||||||
const fixturePath = join(__dirname, 'fixtures', 'medarecorder-emu.webm');
|
const fixturePath = join(__dirname, 'fixtures', 'medarecorder-emu.webm');
|
||||||
@@ -21,7 +22,7 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
|||||||
if (!existsSync(fixturePath)) {
|
if (!existsSync(fixturePath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Fixture not found at ${fixturePath}. Generate with:\n` +
|
`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)) {
|
if (!existsSync(tmpDir)) {
|
||||||
@@ -30,10 +31,27 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
// Clean up temp dir
|
|
||||||
try { unlinkSync(join(tmpDir, 'integration-test.webm')); } catch {}
|
try { unlinkSync(join(tmpDir, 'integration-test.webm')); } catch {}
|
||||||
try { unlinkSync(join(tmpDir, 'integration-test.webm.out')); } catch {}
|
try { unlinkSync(join(tmpDir, 'integration-test.webm.out')); } catch {}
|
||||||
try { unlinkSync(join(tmpDir, 'integration-test.webm.remuxed')); } 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', () => {
|
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');
|
const outFile = join(tmpDir, 'integration-test.webm.remuxed');
|
||||||
copyFileSync(fixturePath, testFile);
|
copyFileSync(fixturePath, testFile);
|
||||||
|
|
||||||
// Run ffmpeg remux — same command as remuxUpload()
|
// remux via ffmpeg (same args as remuxUpload)
|
||||||
execFileSync('ffmpeg', [
|
execFileSync('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', testFile,
|
'-i', testFile,
|
||||||
@@ -51,12 +69,12 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
|||||||
outFile
|
outFile
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Output exists and non-empty
|
// output file exists and non-empty
|
||||||
expect(existsSync(outFile)).toBe(true);
|
expect(existsSync(outFile)).toBe(true);
|
||||||
const outSize = readFileSync(outFile).length;
|
const outSize = readFileSync(outFile).length;
|
||||||
expect(outSize).toBeGreaterThan(0);
|
expect(outSize).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Verify with ffprobe
|
// probe output streams and duration
|
||||||
const probeOut = execFileSync('ffprobe', [
|
const probeOut = execFileSync('ffprobe', [
|
||||||
'-v', 'quiet',
|
'-v', 'quiet',
|
||||||
'-print_format', 'json',
|
'-print_format', 'json',
|
||||||
@@ -70,12 +88,12 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
|||||||
expect(info.streams[0].codec_name).toBe('opus');
|
expect(info.streams[0].codec_name).toBe('opus');
|
||||||
expect(info.format.format_name).toMatch(/matroska|webm/i);
|
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);
|
const duration = parseFloat(info.format.duration);
|
||||||
expect(duration).toBeGreaterThan(0);
|
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');
|
const outFile2 = join(tmpDir, 'integration-test.webm.remuxed2');
|
||||||
execFileSync('ffmpeg', [
|
execFileSync('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
@@ -94,14 +112,92 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
|||||||
const info2 = JSON.parse(probeOut2);
|
const info2 = JSON.parse(probeOut2);
|
||||||
expect(parseFloat(info2.format.duration)).toBeCloseTo(2.0, 0);
|
expect(parseFloat(info2.format.duration)).toBeCloseTo(2.0, 0);
|
||||||
|
|
||||||
// Cleanup
|
// cleanup
|
||||||
unlinkSync(outFile);
|
unlinkSync(outFile);
|
||||||
unlinkSync(outFile2);
|
unlinkSync(outFile2);
|
||||||
unlinkSync(testFile);
|
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', () => {
|
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');
|
const testFile = join(tmpDir, 'test-non.webm');
|
||||||
execFileSync('ffmpeg', [
|
execFileSync('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
@@ -112,10 +208,7 @@ describe('remux — integration (lower-confidence: synthetic fixture)', () => {
|
|||||||
testFile
|
testFile
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Rename to .mp4 (wrong extension, but same format)
|
// remux command doesn't choke on it
|
||||||
const mp4File = testFile + '.mp4';
|
|
||||||
// Actually just use original extension - this test proves non-webm pass-through
|
|
||||||
// by running the same ffmpeg command on it
|
|
||||||
const outFile = join(tmpDir, 'test-non-out.webm');
|
const outFile = join(tmpDir, 'test-non-out.webm');
|
||||||
execFileSync('ffmpeg', [
|
execFileSync('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
|
|||||||
Reference in New Issue
Block a user