Skip to content

feat(producer): write public render provenance sidecar next to outputs - #5427

Open
mvanhorn wants to merge 12 commits into
heygen-com:mainfrom
mvanhorn:feat/render-provenance-sidecar
Open

mvanhorn wants to merge 12 commits into
heygen-com:mainfrom
mvanhorn:feat/render-provenance-sidecar

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What

Every successful render now writes a portable JSON receipt next to the output artifact: out.mp4 gets out.mp4.hf-render.json. The sidecar records tool versions (producer / node / ffmpeg), the entry HTML sha256, the compiled-composition hash, @font-face families, a variables hash (count + sha256 of canonical JSON, never the raw values), format / exact-rational fps / resolution / duration / total frames, encoder facts (codec, preset, pixel format), the committed artifact's sha256 + size, per-stage wall-clock timings, worker count, HDR flag, outcome, and capture-readiness warning codes.

  • Default ON. --no-provenance (or --provenance false) disables; --provenance <path> relocates the sidecar.
  • Batch mode: each row writes its own <row output>.hf-render.json (verified: distinct variables hashes per row). A fixed custom path with --batch is rejected up front.
  • Docker mode: the disable flag is forwarded into the container; the default sidecar lands next to the output in the mounted output directory. A custom host path with --docker is rejected up front (not container-visible).
  • JSON Schema published at packages/core/schemas/hf-render-sidecar.json ($id https://hyperframes.heygen.com/schema/hf-render-sidecar.json); every emitted sidecar in testing validates against it (ajv, draft 2020-12).
  • Docs: docs/reference/render-provenance.mdx, registered under the "Rendering paths" nav group.

Why

Agents and CI need a machine-readable receipt beside the MP4 answering "what rendered this, from what inputs, and how" without re-running anything. Reproducibility checks, cache keys, and support triage all want it. Today the closest thing (runtimeEnvSnapshot, perfSummary) is internal-only, and the engine's embedded container tags carry only renderer name + version. The sidecar complements those tags: full receipt, separate file, deliberately excluding secrets (variable values are hashed, no env dump, no host paths/usernames).

How

  • packages/producer/src/services/render/provenanceSidecar.ts: sidecar builder + emitter. Wired into renderOrchestrator.ts immediately after artifactTransaction.commit() (the actual commit site; there is no stages/commitArtifact.ts in the codebase). A sidecar write failure logs a warning and never un-completes a render whose artifact already committed.

  • Reuses existing producer facts at the commit site: the compiled HTML (fonts scan), the observability composition hash, the encoder preset, perfStages, workerCount, and job warnings. Producer version prefers the bundler-injected __PRODUCER_VERSION__ define (the CLI inlines the producer via noExternal, where the package-walking reader can't resolve) with the filesystem walker as the unbundled fallback; ffmpeg version is probed once via the engine's binary resolution (honors HYPERFRAMES_FFMPEG_PATH) and cached.

  • Threading: --provenance flag → render plan (parseProvenanceArg, tri-state) → RenderOptions → createRenderRequest (validated: false or non-empty string) → RenderConfig.provenance → orchestrator. dockerRunArgs forwards --no-provenance.

  • png-sequence directory outputs skip artifact sha256/size; encoder is null for png-sequence/gif.

  • packages/studio-server/src/routes/render.ts: the Studio delete route and both Studio cancel cleanups remove <jobId>.<ext>.hf-render.json along with the output and .meta.json, from one list (renderArtifactPaths).

  • Package size: scripts/package-size-budgets.json raises the @hyperframes/producer budget to 784,260 bytes packed and 2,855,776 unpacked, this branch's measured size after merging main. The sidecar module ships in both the index.js and public-server.js bundles, which puts the package more than 1% over the previous budget.

Test plan

  • Unit tests added/updated: provenanceSidecar.test.ts (19 tests: path resolution incl. directory outputs, font extraction, canonical variables hashing, receipt shape, secrets-not-embedded, emit integration incl. write-failure and unreadable-entry tolerance) and render.provenance.test.ts (12 tests: flag parsing, plan threading, batch/docker conflicts, docker arg forwarding). Producer unit lane: green except audioPadTrim.integration.test.ts, which fails identically on unmodified main in this environment (host-ffmpeg loudness variance, pre-existing and unrelated).
  • Manual testing performed: real end-to-end renders with the branch CLI: single render (sidecar written, output.sha256 matches sha256sum of the MP4), --no-provenance (no sidecar), --provenance <path> (relocated), --batch with 2 rows (one sidecar per row, distinct variables hashes), sidecars validated against the JSON Schema with ajv. bun run build, tsc --noEmit (producer + cli), oxlint/oxfmt, and the full lefthook pre-commit suite (incl. the fallow gate) all pass.
  • Documentation updated (if applicable): docs/reference/render-provenance.mdx + docs/docs.json.

Walkthrough (HyperFrames-rendered)

HyperFrames walkthrough

The demo was storyboarded first (script, beat sheet, and seam vector ledger: render-provenance-ce-plan.md), built as a HyperFrames composition (source) that passed hyperframes lint and hyperframes check (0 errors, WCAG AA contrast), and rendered with:

node packages/cli/bin/hyperframes.mjs render /tmp/provenance-walkthrough --output walkthrough.mp4 --fps 30

Self-proof: that render exercised this PR's feature on itself. Its own receipt, render-provenance-walkthrough.mp4.hf-render.json, records

"sha256": "6af87ebf6a630a417e64db98c6d6e05f50f3fa99dcc5063e3757ae3d4e8b8de1"

which is exactly sha256sum of the rendered MP4.

Rebased on main (bb9e507). Since main now has HLS output, the sidecar treats HLS as a directory artifact (no size or sha256) and the schema lists hls as a format. After the rebase: cli tsc, producer tsc, lint, oxfmt, sync-schemas:check and the comment checks pass; the provenance/render/docker tests (188) and producer sidecar/renderRequest tests (39) pass.

This replaces #3758, which could not be reopened.

No visible change

The only Studio change is in packages/studio/vite.adapter.ts: cancelling a render now also removes its .hf-render.json sidecar from renders/. The render list only shows video files, so nothing on screen changes.

cursoragent and others added 4 commits October 10, 2026 07:18
Every successful render now writes <output>.hf-render.json after the
artifact commits: tool versions (producer/node/ffmpeg), entry sha256,
compiled-composition hash, @font-face families, variables hash (never
raw values), format/fps/resolution/duration, encoder facts, output
sha256+size, stage timings, worker count, HDR flag, and warning codes.

- Default ON; --no-provenance (or --provenance false) disables,
  --provenance <path> relocates. Batch rows each write their own
  sidecar; --docker forwards the disable flag into the container.
- JSON Schema published at packages/core/schemas/hf-render-sidecar.json.
- Docs at docs/reference/render-provenance.mdx.
- Sidecar write failures log a warning and never un-complete a render.
…+ rename

Addresses the CodeQL insecure-temporary-file finding: the receipt is now
written to a sibling .tmp file in the sidecar's own destination directory
(never the shared, world-writable os.tmpdir()), fsynced, then renamed over
the final path. Readers can only observe a complete receipt and there is
no cross-device or symlink-swap window between write and publish. Failed
renames clean up the temp file. Test asserts no .tmp residue remains.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this back up after #3758, and sorry it has waited. The sidecar module is careful: it writes to a temp file beside the destination and renames it, a write failure never fails a render that already committed, and it keeps host paths and raw variable values out.

One concrete problem, plus two things that need a decision or a refresh:

  1. Studio leaves the sidecar behind when a render is deleted. Both Studio render adapters call createRenderJob without a provenance setting (packages/cli/src/server/studioServer.ts:630, packages/studio/vite.adapter.ts:346), so with the default on, every Studio render also writes renders/<jobId>.mp4.hf-render.json. The delete route only removes <jobId> with .mp4, .webm, .mov and .meta.json (packages/studio-server/src/routes/render.ts:24-36), so the sidecar stays after the user deletes the render. The listing filters by video extension, so these files pile up where nobody can see them. Either add the sidecar suffix to the delete list or pass provenance: false from the Studio adapters. A route test that renders, deletes and checks the folder is empty would pin it.
  2. Rebase needed. The branch is far behind main, and renderOrchestrator.ts has changed a lot since the last CI run. It merges without conflicts, but it needs a fresh CI run on the current base.
  3. Default on is a maintainer call. Writing an extra file next to every output, including project renders/ folders and every batch row, is a user-visible change. Hashing the whole output also adds a full read of the file before the render reports complete, which is noticeable on long or 4K renders. I'd like a maintainer to confirm on-by-default before this merges; opt-in would avoid both concerns.

Verdict: REQUEST_CHANGES
Reasoning: Deleting a render in Studio leaves the new sidecar file behind, and the branch needs a rebase and CI run on current main; whether the sidecar is on by default is for maintainers to decide.

Studio renders now write `<jobId>.<ext>.hf-render.json` beside the output,
but DELETE /render/:jobId only removed the video files and `.meta.json`, so
the sidecar stayed behind where the render list never shows it. Add the
sidecar names for each video extension to the delete list, and add a route
test that renders, deletes and checks the renders folder is empty.
@mintlify

mintlify Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit cc13373.

Approve preview

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Good catch on the Studio delete path. 6b6f8c2 adds <jobId>.<ext>.hf-render.json for each video extension to the delete list in packages/studio-server/src/routes/render.ts, so deleting a render removes its sidecar too. I kept the Studio adapters on the default rather than passing provenance: false, so Studio renders get the same receipt as CLI renders. renderDeletion.test.ts now has a case that renders, deletes and checks the renders/ folder is empty.

The branch also merges current main (no conflicts), so CI can run on the current base. On that head, oxfmt --check, oxlint, tsc --noEmit for cli, producer and studio-server, fallow audit against main, and vitest for the studio-server routes, the CLI render tests and the producer provenance tests all pass locally.

On default-on versus opt-in, I left it as is for now since that's a maintainer call. If you'd rather ship it opt-in, I can flip the default so the sidecar and the hash only happen when asked for.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. The delete fix is right: the sidecar names are in the delete list and the new route test checks that the renders/ folder ends up empty. Thanks for merging main too.

Two checks are now red because of this branch, plus one smaller leftover:

  1. Build: producer package size. verify:packed-manifests reports @hyperframes/producer at 780,121 bytes packed against a budget of 768,210 (more than 1% over), and the same for unpacked. Build passes on the base commit, so it's the new sidecar module. Either trim what ships in the tarball or raise the budget in scripts/package-size-budgets.json and say why in the PR description, as the script's message asks.
  2. Comments: comment share. The check flags packages/studio-server/src/routes/render.ts because the new comment line above RENDER_SIDECAR_EXTENSIONS raises the file's comment share. The constant's name already says what it is, so removing the comment should clear it.
  3. Cancel can still leave the sidecar behind. Both Studio adapters clean up a cancelled render by removing only the output and .meta.json (packages/cli/src/server/studioServer.ts:647, packages/studio/vite.adapter.ts:325). The sidecar is written after the output is committed but before the job reports complete, and that step hashes the whole output, which takes a moment on a long or 4K render. If the user cancels during that hash, the adapter sees the abort, removes the video and meta, and the sidecar stays where neither the render list nor delete can reach it. Adding the sidecar suffix to both cancel cleanups, ideally from the same list the delete route uses, closes it.

The "Studio: edit accuracy gate" failure isn't yours: its shards are skipped on fork branches, so the gate has no results to report.

On default-on versus opt-in, thanks for offering to flip it. That's still for the maintainers to decide.

Verdict: REQUEST_CHANGES
Reasoning: The delete blocker is fixed, but the Build size budget and Comments checks fail because of this change, and a cancelled Studio render can still leave the sidecar behind.

Both Studio adapters cleaned up a cancelled render by removing only the
output and its .meta.json. The sidecar is written while the output is
hashed, so a cancel during that step left it behind. The adapters now
remove every path from renderArtifactPaths, built from the same
extension list the delete route uses.

Also drops the comment above RENDER_SIDECAR_EXTENSIONS and raises the
producer size budget to this branch's measured size (784260 packed,
2855776 unpacked): the sidecar module ships in both the index.js and
public-server.js bundles.

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, all three points are fixed, and the cancel fix went to the right place. renderArtifactPaths builds from the same extension list the delete route uses, so delete and both cancel paths now agree on which files a render leaves behind, and the new test pins the sidecar in that list. The budget bump is explained in the description, and Build and Comments are green.

One check is still red, and it only needs a description edit: Studio and player captures fails because the branch now touches packages/studio/vite.adapter.ts. That change is 6 lines with nothing visible, so adding a ## No visible change section to the PR description should satisfy it (the check allows that for changes under 20 lines with no .tsx, .css or .html).

The "Studio: edit accuracy gate" run is the same fork limitation as before, not this branch.

On default-on versus opt-in, that's still for the maintainers to decide. From the code side this looks good to me once the captures check passes.

Verdict: COMMENT
Reasoning: Every earlier finding is fixed and the code checks are green, but the Studio captures check is red until the description gets a "No visible change" section, and default-on still needs a maintainer decision.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

All three are fixed in 2ee6626, which also merges current main.

  1. Build: the sidecar module ends up in both index.js and public-server.js, and I couldn't trim it without dropping behavior. So the producer budget in scripts/package-size-budgets.json is now this branch's measured size after the merge (784,260 packed, 2,855,776 unpacked), and the PR description says why. verify:packed-manifests passes locally, and Build passed on 2ee6626.
  2. Comments: I dropped the comment above RENDER_SIDECAR_EXTENSIONS. Comments passes.
  3. Cancel: routes/render.ts now exports renderArtifactPaths(outputPath), built from the same RENDER_ARTIFACT_EXTENSIONS list the delete route uses. Both Studio adapters' cancel cleanups loop over it, so a cancel during the hash also removes <jobId>.<ext>.hf-render.json. A test in renderDeletion.test.ts checks the output, .meta.json and the sidecar are all in that list.

Because this now touches packages/studio, the PR captures check asked for a section. I added "No visible change", since the render list only shows video files. The studio-server route tests (651), the cli server tests, the Studio adapter tests and tsc for cli, studio and studio-server pass locally.

@jrusso1020

Copy link
Copy Markdown
Collaborator

The provenance file is useful, but we don't want a new file next to every render by default. Can you make it opt-in (a flag or config key) and drop the size budget bump if it's no longer needed? Happy to merge after that.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

@jrusso1020 f793231 merges current main, which had conflicts in the render plan, both Studio cancel cleanups and the size budgets. I kept main's delivery flag checks next to the provenance checks, kept the cancel loops on renderArtifactPaths, and added .gif to its base regex since main added .gif to the render extensions.

On the budget: I took main's numbers, so this PR no longer touches scripts/package-size-budgets.json. The producer package packs to 825,452 bytes (3,017,522 unpacked) on this head, which is under main's 828,932 / 3,081,613.

Locally on f793231: studio-server route tests (688), the CLI render and server tests, the producer provenance tests, oxfmt, oxlint and tsc for studio-server and cli pass. Two file watcher timing tests flaked once under load and pass on rerun.

Making the sidecar opt-in is the next commit.

…ckage

The sidecar schema in packages/core/schemas put @hyperframes/core's
unpacked size over its budget. Nothing imports it from core, and its
$id points at hyperframes.heygen.com/schema/, which Mintlify serves
from docs/schema. Move it there, like docs/schema/hyperframes.json,
so the $id URL resolves and core ships the same files as main.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Build was red on f793231 because of this branch: the sidecar schema in packages/core/schemas/ put @hyperframes/core at 4,066,787 bytes unpacked, just over 1% past its 4,024,585 budget.

b14e15c moves the schema to docs/schema/hf-render-sidecar.json. Nothing imports it from core, and its $id is https://hyperframes.heygen.com/schema/hf-render-sidecar.json, which Mintlify serves from docs/schema/, so the URL now resolves too. It's authored in docs like hyperframes.json, and sync-schemas.ts and the render-provenance doc point there. Core now ships the same files as main, and the budgets file is still untouched.

The producer provenance tests (20) and oxfmt and oxlint on the changed files pass locally. The other red checks on f793231 were the Windows engine-cli job hitting its 16 minute deadline (main has since split it into shards), the regression shard timing out while building its Docker image, and the edit accuracy gate that skips on forks.

Making the sidecar opt-in is still the next commit.

The sidecar and the output hash now only happen when asked for.
hyperframes render takes --provenance to write <output>.hf-render.json
and --provenance-path <path> to write it elsewhere. Render requests
default to no sidecar; provenance: true or a path turns it on.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed bf8120e. The provenance sidecar is now opt-in: no file and no output hash unless --provenance is passed, and --provenance-path sets a location and implies it. Studio passes neither, so it writes no sidecar.

…nce-sidecar

Conflicts:
  docs/docs.json

Main moved the Developers navigation under the Docs tab. Kept main's
structure and added reference/render-provenance to the same group.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

cc13373 merges current main. The only conflict was docs/docs.json: main moved the Developers pages under the Docs tab, so I kept main's layout and put reference/render-provenance back in the same group. There were no code conflicts. This PR still does not touch scripts/package-size-budgets.json.

On the merge, oxfmt --check, oxlint, bun run build, sync-schemas:check and the typecheck across all packages pass. The render route tests (105), the producer provenance tests (21) and the CLI render tests (229) pass.

Some local results were not clean:

  • verify:packed-manifests with Node 22 and pnpm 10.17.1 has core and producer within budget, but flags studio on my machine. The feat: golden baseline regression gate for composition snapshots #5308 merge on the same main flags studio the same way locally, and Build passes on it in CI, so this looks local. Build on this push is the real answer.
  • undecodableAudioTrack.test.ts, studioServer.stampOnOpen.test.ts and three chunkAudio.test.ts cases fail locally, and the same five fail on a clean checkout of main on this machine.
  • history.test.ts, fileWatcher.fs.test.ts and fileWatcher.atomic.test.ts failed once under heavy load. The first two passed when run alone. The atomic test failed once more alone, then passed 6 of 6 on both this branch and main.

I did not run the full CLI suite on this merge, only the render, server and docker args tests.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants