mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -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>
|
||||
Reference in New Issue
Block a user