mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(engine): stamp rendered files with hidden renderer provenance (#3264)
* feat(engine): stamp rendered files with hidden renderer provenance * fix(engine,producer): re-assert provenance at every container writer Review found that a no-audio MOV render still shipped untagged. The concat step is the last container write on that path (mux is skipped without audio, and applyFaststart only copies mov/webm), and the concat demuxer does not carry the chunks' container metadata through. The same hole applies to no-audio WebM, and to the in-process chunked encode in chunkEncoder, not just the distributed assemble path. mp4 was masked throughout because applyFaststart re-runs ffmpeg for that format and re-tagged the output. Tags the four remaining writers: the chunked-encode concat, and assemble's single-chunk remux, concat and cfr re-encode. Also corrects the trust claim. These are unsigned, freely writable keys, so a present tag means the file claims to be HyperFrames output, not that HyperFrames wrote it. Documented as an unauthenticated diagnostic hint rather than an authenticity or attribution boundary. Tests assert on the assembled file through the real assemble() path for both mov and webm; both fail without the concat fix. * test(engine): pin provenance through the in-process chunked concat Review noted the distributed writers are mutation-pinned but the encodeFramesChunkedConcat fix had no real-file regression of its own. Encodes 70 frames at a 30-frame chunk size so the concat step actually runs, then asserts the tags on the resulting no-audio mov. Fails without the concat fix, passes with it.
This commit is contained in:
@@ -19,8 +19,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
PROVENANCE_RENDERER_NAME,
|
||||
PROVENANCE_VERSION,
|
||||
readRenderProvenance,
|
||||
renderProvenanceArgs,
|
||||
} from "@hyperframes/engine";
|
||||
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
|
||||
import { assemble } from "./assemble.js";
|
||||
import type { DistributedFormat } from "./shared.js";
|
||||
|
||||
let runRoot: string;
|
||||
let hasFfmpeg = false;
|
||||
@@ -42,7 +49,7 @@ afterAll(() => {
|
||||
* loop.
|
||||
*/
|
||||
function buildPlanDir(
|
||||
format: "mp4" | "png-sequence",
|
||||
format: DistributedFormat,
|
||||
chunks: ChunkSliceJson[],
|
||||
totalFrames: number,
|
||||
hasAudio: boolean,
|
||||
@@ -110,6 +117,57 @@ function makeMp4Chunk(outputPath: string, frameCount: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a tiny provenance-tagged chunk in `format`, mirroring what the chunk
|
||||
* encoder writes. mov uses libx264 rather than production's ProRes: container
|
||||
* metadata handling belongs to the muxer, not the codec, and h264-in-mov keeps
|
||||
* the test fast and portable across CI ffmpeg builds.
|
||||
*/
|
||||
function makeTaggedChunk(outputPath: string, frameCount: number, format: "mov" | "webm"): void {
|
||||
const codec =
|
||||
format === "webm"
|
||||
? ["-c:v", "libvpx-vp9", "-b:v", "200k"]
|
||||
: ["-c:v", "libx264", "-preset", "ultrafast"];
|
||||
const args = [
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`testsrc=size=160x120:rate=30:duration=${frameCount / 30}`,
|
||||
...codec,
|
||||
"-g",
|
||||
String(frameCount),
|
||||
"-keyint_min",
|
||||
String(frameCount),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-vframes",
|
||||
String(frameCount),
|
||||
...renderProvenanceArgs(outputPath),
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
const result = spawnSync("ffmpeg", args, { stdio: "pipe" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffmpeg ${format} chunk failed: ${result.stderr.toString().slice(-400)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the provenance tags ffprobe actually reports for `outputPath`. */
|
||||
function probeProvenance(outputPath: string): { renderer: string; version: string } | null {
|
||||
const result = spawnSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-show_entries", "format_tags", "-of", "json", "--", outputPath],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
if (result.status !== 0) return null;
|
||||
const parsed = JSON.parse(result.stdout.toString()) as {
|
||||
format?: { tags?: Record<string, string> };
|
||||
};
|
||||
return readRenderProvenance(parsed.format?.tags ?? {});
|
||||
}
|
||||
|
||||
/** Generate an AAC audio file of `durationSeconds` of silence. */
|
||||
function makeAacAudio(outputPath: string, durationSeconds: number): void {
|
||||
const result = spawnSync("ffmpeg", [
|
||||
@@ -606,6 +664,48 @@ describe("assemble()", () => {
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Regression: a distributed render with NO audio skips the mux entirely, and
|
||||
// applyFaststart only copies mov/webm rather than re-running ffmpeg. That
|
||||
// leaves the concat step as the last container write, and the concat demuxer
|
||||
// does not carry the chunks' container metadata through — so before the
|
||||
// provenance args were added here, both formats shipped with no tags at all
|
||||
// while mp4 was silently rescued by faststart's re-mux. Asserting on the
|
||||
// assembled file rather than the argv is the point: ffmpeg accepts the
|
||||
// metadata flags either way and simply drops the keys.
|
||||
it.each(["mov", "webm"] as const)(
|
||||
"keeps render provenance on a no-audio %s render",
|
||||
async (format) => {
|
||||
if (!hasFfmpeg) {
|
||||
console.warn(`[assemble.test] skipping ${format} provenance test — ffmpeg not available`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 5 },
|
||||
{ index: 1, startFrame: 5, endFrame: 10 },
|
||||
];
|
||||
const planDir = buildPlanDir(format, chunks, 10, false);
|
||||
|
||||
const chunkAPath = join(planDir, `chunk-0.${format}`);
|
||||
const chunkBPath = join(planDir, `chunk-1.${format}`);
|
||||
makeTaggedChunk(chunkAPath, 5, format);
|
||||
makeTaggedChunk(chunkBPath, 5, format);
|
||||
// The chunks really are tagged, so a failure below is the assemble step
|
||||
// dropping them rather than the fixture never having had them.
|
||||
expect(probeProvenance(chunkAPath)).not.toBeNull();
|
||||
|
||||
const outputPath = join(planDir, `output.${format}`);
|
||||
const result = await assemble(planDir, [chunkAPath, chunkBPath], null, outputPath);
|
||||
|
||||
expect(existsSync(result.outputPath)).toBe(true);
|
||||
expect(probeProvenance(outputPath)).toEqual({
|
||||
renderer: PROVENANCE_RENDERER_NAME,
|
||||
version: PROVENANCE_VERSION,
|
||||
});
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("rejects when chunkPaths.length does not match chunks.json length", async () => {
|
||||
const chunks: ChunkSliceJson[] = [
|
||||
{ index: 0, startFrame: 0, endFrame: 5 },
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
appendRenderProvenanceArgs,
|
||||
applyFaststart,
|
||||
MIXED_AUDIO_FILENAME,
|
||||
muxVideoWithAudio,
|
||||
@@ -177,7 +178,9 @@ export async function assemble(
|
||||
// touching the encoded stream. Multi-chunk renders continue through
|
||||
// the concat demuxer where the existing `-r` input flag works.
|
||||
if (chunkPaths.length === 1) {
|
||||
const remuxArgs = ["-i", chunkPaths[0]!, "-c", "copy", "-r", fpsArg, "-y", concatOutputPath];
|
||||
const remuxArgs = ["-i", chunkPaths[0]!, "-c", "copy", "-r", fpsArg];
|
||||
appendRenderProvenanceArgs(remuxArgs, concatOutputPath);
|
||||
remuxArgs.push("-y", concatOutputPath);
|
||||
const remuxResult = await runFfmpeg(remuxArgs, { signal: abortSignal });
|
||||
if (!remuxResult.success) {
|
||||
throw new Error(
|
||||
@@ -210,9 +213,9 @@ export async function assemble(
|
||||
concatListPath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
concatOutputPath,
|
||||
];
|
||||
appendRenderProvenanceArgs(concatArgs, concatOutputPath);
|
||||
concatArgs.push("-y", concatOutputPath);
|
||||
const concatResult = await runFfmpeg(concatArgs, { signal: abortSignal });
|
||||
if (!concatResult.success) {
|
||||
throw new Error(
|
||||
@@ -280,9 +283,9 @@ export async function assemble(
|
||||
"cfr",
|
||||
"-r",
|
||||
fpsArg,
|
||||
"-y",
|
||||
cfrOutputPath,
|
||||
];
|
||||
appendRenderProvenanceArgs(cfrArgs, cfrOutputPath);
|
||||
cfrArgs.push("-y", cfrOutputPath);
|
||||
const cfrResult = await runFfmpeg(cfrArgs, { signal: abortSignal });
|
||||
if (!cfrResult.success) {
|
||||
throw new Error(
|
||||
|
||||
Reference in New Issue
Block a user