diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index 68716faaeb..66da8cc742 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -147,6 +147,40 @@ For several variable-driven versions, use batch rendering. For remote infrastruc Those workflows involve output naming, credentials, concurrency, and infrastructure choices. Start in the [CLI guide](/developers/cli) and use the complete [CLI reference](/packages/cli) when you need every flag. +## Render provenance + +Rendered video carries two container metadata tags that say which tool wrote the file: + +```bash +ffprobe -v error -show_entries format_tags -of json out.mp4 +``` + +```json +{ "hyperframes_renderer": "hyperframes", "hyperframes_version": "0.7.107" } +``` + +That is the whole of it. The tags name the renderer and its version, and nothing else: no file +paths, usernames, machine names, project names, or anything about the composition. They are +container metadata, not a visible watermark, so no pixel of your video changes. Matroska +uppercases tag names on read, so a `.webm` reports `HYPERFRAMES_RENDERER`. + +Strip them whenever you like: + +```bash +ffmpeg -i out.mp4 -map_metadata -1 -c copy clean.mp4 +``` + + + These tags are an unauthenticated diagnostic hint, not proof of origin. They are ordinary unsigned + container keys, so anything can write the same two values with a single `ffmpeg -metadata` + command: a tag that is present means the file *claims* to be HyperFrames output, not that + HyperFrames wrote it. A tag that is absent means just as little, because re-encoding, remuxing, or + any tool that drops unknown keys strips it, and files rendered by older versions never carried it. + Treat it as a "what probably produced this file?" hint for support and debugging, never as an + authenticity, attribution, or licensing check. Verifiable provenance needs signed claims such as + C2PA. + + ## If rendering fails Run: diff --git a/packages/cli/src/background-removal/pipeline.ts b/packages/cli/src/background-removal/pipeline.ts index ff19979263..c50233ee47 100644 --- a/packages/cli/src/background-removal/pipeline.ts +++ b/packages/cli/src/background-removal/pipeline.ts @@ -18,7 +18,7 @@ import { extname } from "node:path"; import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { createSession, type Session } from "./inference.js"; import { type Device, type ModelId } from "./manager.js"; -import { DEFAULT_VP9_CPU_USED } from "@hyperframes/engine"; +import { DEFAULT_VP9_CPU_USED, renderProvenanceArgs } from "@hyperframes/engine"; export type OutputFormat = "webm" | "mov" | "png"; @@ -182,6 +182,7 @@ export function buildEncoderArgs( "-metadata:s:v:0", "alpha_mode=1", "-an", + ...renderProvenanceArgs(outputPath), outputPath, ]; } @@ -197,6 +198,7 @@ export function buildEncoderArgs( "-pix_fmt", "yuva444p10le", "-an", + ...renderProvenanceArgs(outputPath), outputPath, ]; } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a10070ce1f..b8f8177f3b 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -393,3 +393,13 @@ export { type HdrMasteringMetadata, } from "./utils/hdr.js"; export type { VideoColorSpace } from "./utils/ffprobe.js"; +export { + renderProvenanceArgs, + appendRenderProvenanceArgs, + readRenderProvenance, + PROVENANCE_RENDERER_TAG, + PROVENANCE_VERSION_TAG, + PROVENANCE_RENDERER_NAME, + PROVENANCE_VERSION, + type RenderProvenance, +} from "./utils/renderProvenance.js"; diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 80491e966a..b9471d2746 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, it, expect, vi } from "vitest"; import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js"; +import { renderProvenanceArgs } from "../utils/renderProvenance.js"; const TINY_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAEElEQVR4nGP8wwACLGCSAQANBAECv1AVswAAAABJRU5ErkJggg==", @@ -403,6 +404,7 @@ describe("muxVideoWithAudio audio codec handling", () => { "+faststart", "-avoid_negative_ts", "make_zero", + ...renderProvenanceArgs("/tmp/output.mp4"), "-r", "30", "-y", @@ -410,6 +412,10 @@ describe("muxVideoWithAudio audio codec handling", () => { ]); expect(calls[0]!.args).not.toContain("-shortest"); expect(calls[0]!.args).not.toContain("-use_editlist"); + // The faststart flag set above must survive the provenance flag: ffmpeg + // takes the last -movflags occurrence, and a non-additive one would drop it. + expect(calls[0]!.args.filter((a) => a === "-movflags")).toHaveLength(2); + expect(calls[0]!.args).toContain("+faststart"); emitClose(calls[0]!.proc, 0); await expect(muxPromise).resolves.toMatchObject({ diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 709d89ac49..dbad9fc716 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -22,6 +22,7 @@ import { extractAudioMetadata } from "../utils/ffprobe.js"; import { type Fps, fpsToFfmpegArg } from "@hyperframes/core"; import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; import { appendVp9CpuUsedArg } from "./vp9Options.js"; +import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js"; export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js"; @@ -361,6 +362,7 @@ export function buildEncoderArgs( } else if (codec === "prores") { args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0"); args.push("-pix_fmt", pixelFormat); + appendRenderProvenanceArgs(args, outputPath); return [...args, "-y", outputPath]; } @@ -445,6 +447,8 @@ export function buildEncoderArgs( args.push("-avoid_negative_ts", "make_zero"); + appendRenderProvenanceArgs(args, outputPath); + args.push("-y", outputPath); return args; } @@ -611,18 +615,14 @@ export async function encodeFramesChunkedConcat( const concatInput = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n"); writeFileSync(concatListPath, concatInput, "utf-8"); - const concatArgs = [ - "-f", - "concat", - "-safe", - "0", - "-i", - concatListPath, - "-c", - "copy", - "-y", - outputPath, - ]; + const concatArgs = ["-f", "concat", "-safe", "0", "-i", concatListPath, "-c", "copy"]; + // The concat demuxer does not carry per-chunk container metadata into the + // output, so the chunks' provenance is dropped here even though every chunk + // carries it. Re-assert on the concatenated file: for a no-audio mov/webm + // this is the last container write, since mux is skipped and applyFaststart + // only copies those two formats. + appendRenderProvenanceArgs(concatArgs, outputPath); + concatArgs.push("-y", outputPath); const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout; const concatProcessResult = await runFfmpeg(concatArgs, { signal, timeout: encodeTimeout }); const concatResult = { @@ -701,6 +701,10 @@ export async function muxVideoWithAudio( // AAC priming packet. `make_zero` discards that edit and shifts copied video // forward by one AAC frame (~21ms), creating a visible first-frame offset. if (!copiesContainerizedAac) args.push("-avoid_negative_ts", "make_zero"); + // Re-assert provenance here: this stage re-muxes into the delivered + // container, and the mp4 muxer drops the encode stage's tags without the + // use_metadata_tags flag that appendRenderProvenanceArgs adds. + appendRenderProvenanceArgs(args, outputPath); if (fps !== undefined) { // Set the exact output framerate so the muxer doesn't PTS-average a // fractional rational like `360000/12001` instead of `30/1` into the @@ -742,6 +746,7 @@ export async function applyFaststart( return { success: true, outputPath, durationMs: 0 }; } const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart"]; + appendRenderProvenanceArgs(args, outputPath); if (fps !== undefined) { // Set the exact output framerate so the final remux doesn't PTS-average // a fractional rational like `360000/12001` instead of `30/1` into the diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 7e896f9853..545c56024f 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -36,6 +36,7 @@ import { withEvenDimensionPad } from "../utils/evenDimensions.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { fpsToFfmpegArg, type Fps } from "@hyperframes/core"; import { appendVp9CpuUsedArg } from "./vp9Options.js"; +import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js"; // Re-export EncoderOptions so callers can reference the type via this module. export type { EncoderOptions } from "./chunkEncoder.types.js"; @@ -350,6 +351,7 @@ export function buildStreamingArgs( } else if (codec === "prores") { args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0"); args.push("-pix_fmt", pixelFormat); + appendRenderProvenanceArgs(args, outputPath); return [...args, "-y", outputPath]; } @@ -428,6 +430,8 @@ export function buildStreamingArgs( // for the full explanation; same playback compatibility class. args.push("-avoid_negative_ts", "make_zero"); + appendRenderProvenanceArgs(args, outputPath); + args.push("-y", outputPath); return args; } diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index 006d6ef084..f4f6ccc3f9 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -608,7 +608,10 @@ function extractStillImageMetadata(filePath: string): StillImageMetadata | null * in newer ones; HDR tags vary similarly. Use this for any sidecar tag where * you want to be resilient across muxer versions. */ -function readTagCI(tags: Record | undefined, name: string): string { +export function readTagCI( + tags: Record | undefined, + name: string, +): string { if (!tags) return ""; const target = name.toLowerCase(); for (const [key, value] of Object.entries(tags)) { diff --git a/packages/engine/src/utils/renderProvenance.test.ts b/packages/engine/src/utils/renderProvenance.test.ts new file mode 100644 index 0000000000..7c5a75b0a0 --- /dev/null +++ b/packages/engine/src/utils/renderProvenance.test.ts @@ -0,0 +1,267 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getFfmpegBinary, getFfprobeBinary } from "./ffmpegBinaries.js"; +import { + PROVENANCE_RENDERER_NAME, + PROVENANCE_RENDERER_TAG, + PROVENANCE_VERSION, + PROVENANCE_VERSION_TAG, + readRenderProvenance, + renderProvenanceArgs, +} from "./renderProvenance.js"; +import { + applyFaststart, + buildEncoderArgs, + encodeFramesChunkedConcat, + muxVideoWithAudio, +} from "../services/chunkEncoder.js"; + +/** Same probe the ffmpeg-dependent suites use: ask the binary, don't assume it. */ +const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0; + +describe("renderProvenanceArgs", () => { + it("tags the renderer and version", () => { + const args = renderProvenanceArgs("out.mp4"); + expect(args).toContain(`${PROVENANCE_RENDERER_TAG}=${PROVENANCE_RENDERER_NAME}`); + expect(args).toContain(`${PROVENANCE_VERSION_TAG}=${PROVENANCE_VERSION}`); + }); + + it("carries nothing that identifies the machine or the project", () => { + // Provenance is renderer identity only. A path, username or composition + // name here would travel with every distributed file. + const values = renderProvenanceArgs("/home/someone/projects/secret-launch/out.mp4") + .filter((a) => a.includes("=")) + .join(" "); + expect(values).not.toMatch(/secret-launch|someone|\/home\//); + }); + + it("adds use_metadata_tags for the mov-family containers only", () => { + for (const ext of ["mp4", "mov", "m4v"]) { + expect(renderProvenanceArgs(`out.${ext}`)).toContain("-movflags"); + } + expect(renderProvenanceArgs("out.webm")).not.toContain("-movflags"); + }); + + it("matches the container case-insensitively", () => { + expect(renderProvenanceArgs("OUT.MP4")).toContain("-movflags"); + }); + + it("uses the additive + form so it cannot clobber an earlier -movflags", () => { + // Regression guard. A bare `use_metadata_tags` resets the flag field and + // silently drops `+faststart` set earlier in the same command: tags still + // probe correctly, but the moov atom moves to the end of the file. The + // real-encode case below proves the behaviour; this pins the argument. + const args = renderProvenanceArgs("out.mp4"); + expect(args[args.indexOf("-movflags") + 1]).toBe("+use_metadata_tags"); + }); +}); + +describe("readRenderProvenance", () => { + it("reads mp4-cased tags", () => { + expect( + readRenderProvenance({ hyperframes_renderer: "hyperframes", hyperframes_version: "1.2.3" }), + ).toEqual({ renderer: "hyperframes", version: "1.2.3" }); + }); + + it("reads matroska-uppercased tags", () => { + // Matroska uppercases keys on read; a case-sensitive lookup would work on + // mp4 and miss every webm. + expect( + readRenderProvenance({ HYPERFRAMES_RENDERER: "hyperframes", HYPERFRAMES_VERSION: "1.2.3" }), + ).toEqual({ renderer: "hyperframes", version: "1.2.3" }); + }); + + it("returns null when there is no provenance", () => { + expect(readRenderProvenance({ major_brand: "isom" })).toBeNull(); + expect(readRenderProvenance(undefined)).toBeNull(); + }); +}); + +describe.skipIf(!HAS_FFMPEG)("provenance survives a real encode", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-provenance-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + const run = (args: string[]): void => { + const res = spawnSync(getFfmpegBinary(), args, { encoding: "utf-8" }); + if (res.status !== 0) throw new Error(`ffmpeg failed: ${res.stderr ?? ""}`); + }; + + /** Format tags as ffprobe reports them, which is the only thing that counts. */ + const formatTags = (file: string): Record => { + const res = spawnSync( + getFfprobeBinary(), + ["-v", "error", "-show_entries", "format_tags", "-of", "json", "--", file], + { encoding: "utf-8" }, + ); + if (res.status !== 0) throw new Error(`ffprobe failed: ${res.stderr ?? ""}`); + const parsed = JSON.parse(res.stdout) as { format?: { tags?: Record } }; + return parsed.format?.tags ?? {}; + }; + + const source = (): string => { + const src = join(dir, "src.mp4"); + run([ + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc=size=160x120:rate=15:duration=1", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-y", + src, + ]); + return src; + }; + + // The whole point of the feature: assert against the encoded file, not the + // argument array. mp4 accepts `-metadata` for an unknown key and then + // silently discards it, so an args-only test passes while every shipped mp4 + // carries no provenance at all. + it.each(["mp4", "mov", "webm"])("round-trips through a real %s encode", (ext) => { + const out = join(dir, `out.${ext}`); + const codec = ext === "webm" ? ["-c:v", "libvpx-vp9", "-b:v", "200k"] : ["-c:v", "libx264"]; + run([ + "-v", + "error", + "-i", + source(), + ...codec, + "-pix_fmt", + "yuv420p", + ...renderProvenanceArgs(out), + "-y", + out, + ]); + + expect(readRenderProvenance(formatTags(out))).toEqual({ + renderer: PROVENANCE_RENDERER_NAME, + version: PROVENANCE_VERSION, + }); + }); + + it("keeps +faststart working alongside the provenance flag", () => { + // Guards the clobber directly: with a non-additive `use_metadata_tags` the + // tags below still pass while the moov atom silently moves to the end. + const out = join(dir, "faststart.mp4"); + run([ + "-v", + "error", + "-i", + source(), + "-c", + "copy", + "-movflags", + "+faststart", + ...renderProvenanceArgs(out), + "-y", + out, + ]); + + expect(readRenderProvenance(formatTags(out))).not.toBeNull(); + + // faststart means the moov atom precedes mdat. Read the whole file and + // compare offsets: both atoms are always present, so a missing one would + // mean the file is malformed rather than merely unoptimised. + const bytes = readFileSync(out); + const moov = bytes.indexOf("moov", 0, "latin1"); + const mdat = bytes.indexOf("mdat", 0, "latin1"); + expect(moov).toBeGreaterThan(-1); + expect(mdat).toBeGreaterThan(-1); + expect(moov).toBeLessThan(mdat); + }); + + // The in-process sibling of the distributed-assemble regression. This path + // does its own concat-copy straight to the deliverable, and the concat + // demuxer does not carry the chunks' container metadata through — so for a + // no-audio mov (mux skipped, faststart only copies mov) the concat is the + // last container write and the tags have to be re-asserted there. mp4 would + // hide this: faststart re-muxes it and puts them back. + it("survives the in-process chunked-encode concat for a no-audio mov", async () => { + const framesDir = join(dir, "frames"); + mkdirSync(framesDir, { recursive: true }); + // 70 frames against a 30-frame chunk size gives 3 chunks, so the concat + // step actually runs. A single chunk would skip it entirely. + run([ + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc=size=160x120:rate=30:duration=2.34", + "-frames:v", + "70", + "-start_number", + "0", + "-y", + join(framesDir, "frame_%06d.png"), + ]); + + const out = join(dir, "chunked.mov"); + const result = await encodeFramesChunkedConcat( + framesDir, + "frame_%06d.png", + out, + { fps: { num: 30, den: 1 }, width: 160, height: 120, preset: "ultrafast" }, + 30, + ); + + expect(result.success).toBe(true); + expect(readRenderProvenance(formatTags(out))).toEqual({ + renderer: PROVENANCE_RENDERER_NAME, + version: PROVENANCE_VERSION, + }); + }, 60_000); + + it("survives the encode -> mux -> faststart chain that produces a delivered mp4", async () => { + // The stage that actually bites: `muxVideoWithAudio` and `applyFaststart` + // each run their own ffmpeg over the encoder's output. Tagging only at the + // encode stage passes an args test and still ships an mp4 with no + // provenance, because the mux drops unknown keys on the way through. + const encoded = join(dir, "encoded.mp4"); + run([ + "-v", + "error", + "-i", + source(), + ...buildEncoderArgs({ fps: { num: 15, den: 1 }, width: 160, height: 120 }, [], encoded), + ]); + expect(readRenderProvenance(formatTags(encoded))).not.toBeNull(); + + const audio = join(dir, "audio.m4a"); + run([ + "-v", + "error", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + "aac", + "-y", + audio, + ]); + + const muxed = join(dir, "muxed.mp4"); + expect((await muxVideoWithAudio(encoded, audio, muxed)).success).toBe(true); + expect(readRenderProvenance(formatTags(muxed))).not.toBeNull(); + + const delivered = join(dir, "delivered.mp4"); + expect((await applyFaststart(muxed, delivered)).success).toBe(true); + expect(readRenderProvenance(formatTags(delivered))).toEqual({ + renderer: PROVENANCE_RENDERER_NAME, + version: PROVENANCE_VERSION, + }); + }); +}); diff --git a/packages/engine/src/utils/renderProvenance.ts b/packages/engine/src/utils/renderProvenance.ts new file mode 100644 index 0000000000..63087137c0 --- /dev/null +++ b/packages/engine/src/utils/renderProvenance.ts @@ -0,0 +1,107 @@ +import { createRequire } from "node:module"; +import { readTagCI } from "./ffprobe.js"; + +/** + * Hidden render provenance. + * + * HyperFrames stamps the *container* — never the picture — with the renderer + * name and version, so a rendered file carries a machine-readable note about + * what produced it, with no visible watermark burned into the frames. + * + * What goes in is deliberately boring: renderer name and version. No file + * paths, usernames, machine names, project names or composition content. Once + * a file is distributed the metadata travels with it, and metadata leaks are + * hard to walk back. + * + * **An unauthenticated hint — not an authenticity or attribution boundary.** + * These are ordinary unsigned container keys that any tool can write, so a + * present tag means the file *claims* to be HyperFrames output, not that + * HyperFrames produced it: one `ffmpeg -metadata hyperframes_renderer=...` + * forges it. Absence proves just as little, since re-encoding, remuxing, or + * any tool that drops unknown keys strips them, and files rendered before this + * feature never had them. Good for diagnostics and support ("what wrote this + * file?"); never a basis for trust, attribution or licensing decisions in + * either direction. Verifiable provenance needs signed claims (C2PA), which + * this deliberately is not. + */ + +export const PROVENANCE_RENDERER_TAG = "hyperframes_renderer"; +export const PROVENANCE_VERSION_TAG = "hyperframes_version"; +export const PROVENANCE_RENDERER_NAME = "hyperframes"; + +const UNKNOWN_VERSION = "0.0.0-dev"; + +function readEngineVersion(): string { + try { + // The engine ships as raw TS and exports "./package.json", so this + // resolves without a build-time define. A failed read must never break a + // render, hence the fallback rather than a throw. + const version = (createRequire(import.meta.url)("../../package.json") as { version?: string }) + .version; + return typeof version === "string" && version.length > 0 ? version : UNKNOWN_VERSION; + } catch { + return UNKNOWN_VERSION; + } +} + +export const PROVENANCE_VERSION = readEngineVersion(); + +/** + * MP4/MOV (the mov muxer family) writes only tags it recognises from a fixed + * map and silently discards everything else. WebM/Matroska keeps arbitrary + * keys as-is. + */ +function isMovFamilyContainer(outputPath: string): boolean { + const lower = outputPath.toLowerCase(); + return lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".m4v"); +} + +/** + * The provenance ffmpeg arguments for a given output container. Place them + * before the output path. + * + * Must be applied on *every* stage that writes an mp4 — encode, mux and the + * faststart remux each run their own ffmpeg, and a stage without the flag + * drops the tags written by the stage before it. + */ +export function renderProvenanceArgs(outputPath: string): string[] { + const args = [ + "-metadata", + `${PROVENANCE_RENDERER_TAG}=${PROVENANCE_RENDERER_NAME}`, + "-metadata", + `${PROVENANCE_VERSION_TAG}=${PROVENANCE_VERSION}`, + ]; + + if (isMovFamilyContainer(outputPath)) { + // The additive `+` form is mandatory. A bare `-movflags use_metadata_tags` + // *resets* the flag field, silently discarding a `+faststart` set earlier + // in the same command — the file still probes fine and keeps its tags, + // but the moov atom lands at the end and progressive playback regresses. + args.push("-movflags", "+use_metadata_tags"); + } + return args; +} + +/** Mutating form of {@link renderProvenanceArgs} for push-built arg lists. */ +export function appendRenderProvenanceArgs(args: string[], outputPath: string): void { + args.push(...renderProvenanceArgs(outputPath)); +} + +export interface RenderProvenance { + renderer: string; + /** Empty string when the renderer tag is present but the version is not. */ + version: string; +} + +/** + * Read provenance back from ffprobe format tags. Matroska uppercases keys on + * read while mp4 preserves the case written, so the lookup is + * case-insensitive — a case-sensitive read works on mp4 and misses every webm. + */ +export function readRenderProvenance( + tags: Record | undefined, +): RenderProvenance | null { + const renderer = readTagCI(tags, PROVENANCE_RENDERER_TAG); + if (renderer === "") return null; + return { renderer, version: readTagCI(tags, PROVENANCE_VERSION_TAG) }; +} diff --git a/packages/producer/src/services/distributed/assemble.test.ts b/packages/producer/src/services/distributed/assemble.test.ts index 6a6e69c028..5eaa5aaa38 100644 --- a/packages/producer/src/services/distributed/assemble.test.ts +++ b/packages/producer/src/services/distributed/assemble.test.ts @@ -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 }; + }; + 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 }, diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index e7478c41aa..309c26db88 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -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(