test(producer): add stream duration parity check to regression harness (#1652)

Probes the rendered output for video and audio stream durations after
render and fails the test if they differ by more than 0.5s. Catches
mux-level truncation regressions like the ffmpeg -shortest bug (#1648)
where one stream gets silently cut short.

Runs on all non-png-sequence fixtures with audio — no new meta.json
field needed since this is a universal invariant, not a per-fixture
threshold.
This commit is contained in:
Miguel Ángel
2026-06-22 18:32:59 -04:00
committed by GitHub
parent 4976b5e036
commit 60d3eeb1f7
9 changed files with 383 additions and 3 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
- shard: shard-7
args: "sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector"
- shard: shard-8
args: "style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat"
args: "style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity"
steps:
- name: Checkout (with LFS)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+6
View File
@@ -81,6 +81,10 @@ export interface VideoMetadata {
export interface AudioMetadata {
durationSeconds: number;
/** Audio stream's own duration (from `stream.duration`), falling back to
* container duration when the stream field is absent. Prefer this over
* `durationSeconds` for stream-level parity checks. */
streamDurationSeconds?: number;
sampleRate: number;
channels: number;
audioCodec: string;
@@ -363,9 +367,11 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
const durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
return {
durationSeconds,
streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
channels: audioStream.channels || 2,
audioCodec: audioStream.codec_name || "unknown",
+70 -2
View File
@@ -17,7 +17,7 @@ import process from "node:process";
import { createRenderJob, executeRenderJob } from "./services/renderOrchestrator.js";
import { compileForRender } from "./services/htmlCompiler.js";
import { validateCompilation } from "./services/compilationTester.js";
import { extractMediaMetadata } from "./utils/ffprobe.js";
import { extractMediaMetadata, extractAudioMetadata } from "./utils/ffprobe.js";
import {
buildRmsEnvelope,
compareAudioEnvelopes,
@@ -193,6 +193,12 @@ type TestResult = {
residualRmsDb?: number;
residualError?: string;
};
streamDurationParity?: {
passed: boolean;
videoDurationSeconds: number;
audioDurationSeconds: number;
driftSeconds: number;
};
renderedOutputPath?: string;
};
@@ -785,6 +791,49 @@ function saveFailureDetails(
logPretty(`Saved audio failure details to ${failuresDir}/`, "💾");
}
// Save stream duration parity failures
if (result.streamDurationParity && !result.streamDurationParity.passed) {
writeFileSync(
join(failuresDir, "stream-parity-failure.json"),
JSON.stringify(result.streamDurationParity, null, 2),
"utf-8",
);
logPretty(`Saved stream duration parity failure to ${failuresDir}/`, "💾");
}
}
// ── Stream Duration Parity ──────────────────────────────────────────────────
export const MAX_STREAM_DRIFT_SECONDS = 0.5;
export type StreamDurationParity = {
passed: boolean;
videoDurationSeconds: number;
audioDurationSeconds: number;
driftSeconds: number;
};
export async function checkStreamDurationParity(
videoPath: string,
): Promise<StreamDurationParity | null> {
const meta = await extractMediaMetadata(videoPath);
if (!meta.hasAudio) return null;
// Read the audio stream's own duration rather than the container's
// format.duration. extractAudioMetadata returns format.duration which
// collapses to the same value as videoStreamDurationSeconds when the
// fallback fires — making the check a tautology on broken muxes where
// both streams are truncated in sync.
const audioMeta = await extractAudioMetadata(videoPath);
const videoDur = meta.videoStreamDurationSeconds;
const audioDur = audioMeta.streamDurationSeconds ?? audioMeta.durationSeconds;
const drift = Math.abs(videoDur - audioDur);
return {
passed: drift <= MAX_STREAM_DRIFT_SECONDS,
videoDurationSeconds: videoDur,
audioDurationSeconds: audioDur,
driftSeconds: drift,
};
}
// ── Test Execution ───────────────────────────────────────────────────────────
@@ -1017,6 +1066,24 @@ async function runTestSuite(
throw new Error(`Snapshot not found: ${snapshotVideoPath}. Run with --update to create it.`);
}
if (!isPngSequence) {
const parity = await checkStreamDurationParity(renderedOutputPath);
if (parity) {
result.streamDurationParity = parity;
if (parity.passed) {
logPretty(
`Stream duration parity: PASSED (video: ${parity.videoDurationSeconds.toFixed(2)}s, audio: ${parity.audioDurationSeconds.toFixed(2)}s, drift: ${parity.driftSeconds.toFixed(3)}s)`,
"✓",
);
} else {
logPretty(
`Stream duration parity: FAILED (video: ${parity.videoDurationSeconds.toFixed(2)}s, audio: ${parity.audioDurationSeconds.toFixed(2)}s, drift: ${parity.driftSeconds.toFixed(3)}s > ${MAX_STREAM_DRIFT_SECONDS}s)`,
"✗",
);
}
}
}
let visualPassed: boolean;
let failedFrames: number;
const visualCheckpoints: Array<{ time: number; psnr: number; passed: boolean }> = [];
@@ -1221,7 +1288,8 @@ async function runTestSuite(
}
// Overall test passes if all checks passed
result.passed = result.compilation!.passed && visualPassed && audioPassed;
const parityPassed = result.streamDurationParity?.passed ?? true;
result.passed = result.compilation!.passed && visualPassed && audioPassed && parityPassed;
result.renderedOutputPath = options.keepTemp ? renderedOutputPath : undefined;
if (result.passed) {
@@ -0,0 +1,157 @@
import { mkdtempSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getFfmpegBinary } from "@hyperframes/engine";
import { checkStreamDurationParity, MAX_STREAM_DRIFT_SECONDS } from "../regression-harness.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function mktmp(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-parity-"));
tempDirs.push(dir);
return dir;
}
function ffmpeg(args: string[]): void {
execFileSync(getFfmpegBinary(), ["-y", "-hide_banner", "-loglevel", "error", ...args], {
timeout: 30_000,
});
}
function muxWithMatchingDurations(dir: string): string {
const out = join(dir, "matched.mp4");
const video = join(dir, "v.mp4");
const audio = join(dir, "a.aac");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=blue:s=64x64:d=5:r=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
video,
]);
ffmpeg([
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=5",
"-c:a",
"aac",
"-b:a",
"64k",
audio,
]);
ffmpeg([
"-i",
video,
"-i",
audio,
"-c:v",
"copy",
"-c:a",
"copy",
"-movflags",
"+faststart",
out,
]);
return out;
}
function muxWithTruncatedVideo(dir: string): string {
const out = join(dir, "truncated.mp4");
const video = join(dir, "v-short.mp4");
const audio = join(dir, "a-long.aac");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=red:s=64x64:d=2:r=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
video,
]);
ffmpeg([
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=10",
"-c:a",
"aac",
"-b:a",
"64k",
audio,
]);
ffmpeg([
"-i",
video,
"-i",
audio,
"-c:v",
"copy",
"-c:a",
"copy",
"-movflags",
"+faststart",
out,
]);
return out;
}
describe("checkStreamDurationParity", () => {
it("passes when video and audio durations match", async () => {
const dir = mktmp();
const video = muxWithMatchingDurations(dir);
const result = await checkStreamDurationParity(video);
expect(result).not.toBeNull();
expect(result!.passed).toBe(true);
expect(result!.driftSeconds).toBeLessThanOrEqual(MAX_STREAM_DRIFT_SECONDS);
});
it("fails when video is truncated relative to audio (regression #1648)", async () => {
const dir = mktmp();
const video = muxWithTruncatedVideo(dir);
const result = await checkStreamDurationParity(video);
expect(result).not.toBeNull();
expect(result!.passed).toBe(false);
expect(result!.videoDurationSeconds).toBeLessThan(3);
expect(result!.audioDurationSeconds).toBeGreaterThan(9);
expect(result!.driftSeconds).toBeGreaterThan(MAX_STREAM_DRIFT_SECONDS);
});
it("returns null for video-only files (no audio stream)", async () => {
const dir = mktmp();
const video = join(dir, "silent.mp4");
ffmpeg([
"-f",
"lavfi",
"-i",
"color=c=green:s=64x64:d=3:r=30",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
video,
]);
const result = await checkStreamDurationParity(video);
expect(result).toBeNull();
});
});
@@ -0,0 +1,15 @@
{
"name": "Audio Mux Stream Parity",
"description": "Verifies video and audio stream durations match after mux. Catches truncation regressions like the ffmpeg -shortest bug (#1648) where one stream gets silently cut short.",
"tags": ["audio", "mux", "parity", "regression"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0.9,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 30
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c7b88b0f40c29dcfa13faff6dc16bea06170d8b6b8a686fa1c2b2e53d82498a
size 321645
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Audio mux parity</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #111; }
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: system-ui, sans-serif;
color: #fff;
}
.scene h1 { font-size: 80px; opacity: 0; }
#scene-1 { background: #1a1a2e; }
#scene-2 { background: #16213e; }
#scene-3 { background: #0f3460; }
</style>
</head>
<body>
<div
id="root"
data-composition-id="audio-mux-parity"
data-start="0"
data-duration="10"
data-width="1920"
data-height="1080"
data-fps="30"
>
<!-- Three audio tracks covering different time windows -->
<audio id="vo-1" src="assets/tone.wav" data-start="0" data-duration="3" data-volume="1"></audio>
<audio id="vo-2" src="assets/tone.wav" data-start="3.5" data-duration="3" data-volume="1"></audio>
<audio id="vo-3" src="assets/tone.wav" data-start="7" data-duration="3" data-volume="1"></audio>
<section id="scene-1" class="scene clip" data-start="0" data-duration="3.5" data-track-index="1">
<h1 id="t1">One</h1>
</section>
<section id="scene-2" class="scene clip" data-start="3.5" data-duration="3.5" data-track-index="1">
<h1 id="t2">Two</h1>
</section>
<section id="scene-3" class="scene clip" data-start="7" data-duration="3" data-track-index="1">
<h1 id="t3">Three</h1>
</section>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#t1", { opacity: 1, duration: 0.5 }, 0.2);
tl.to("#t1", { opacity: 0, duration: 0.3 }, 3);
tl.to("#t2", { opacity: 1, duration: 0.5 }, 3.7);
tl.to("#t2", { opacity: 0, duration: 0.3 }, 6.5);
tl.to("#t3", { opacity: 1, duration: 0.5 }, 7.2);
tl.to("#t3", { opacity: 0, duration: 0.3 }, 9.5);
window.__timelines = window.__timelines || {};
window.__timelines["audio-mux-parity"] = tl;
</script>
</body>
</html>