fix(producer): assert render artifact duration and frame count before commit - #3429
fix(producer): assert render artifact duration and frame count before commit#3429santhiprakash wants to merge 3 commits into
Conversation
… commit Refuse to publish an artifact that is significantly shorter or has fewer frames than the capture pipeline just reported. Adds a duration/frame-count gate on top of the existing readable-non-empty check inside ArtifactTransaction.validate(), keyed off the values the orchestrator already carries. Closes heygen-com#3395.
The frame-count gate added in heygen-com#3395 accepts an expectedFrames value from the orchestrator, but defaultArtifactDurationProbe was still returning only durationSeconds - so the wire was half-built and the assertion short-circuited on undefined for every real render. Forward meta.frames from ffprobe so the field-packet case the issue names (container duration correct, stream shorter) is actually caught by the frame-count check, not just the duration one. extractMediaMetadata now populates a new frames field from the video stream's nb_frames tag, returning undefined when the demuxer did not report one (fragmented MP4, malformed streams, muxes that require -count_packets). Callers that gate on the count must treat undefined as no answer; the assertion already does. The previous CI run (#32589981916) cancelled shard-6 at the 1h job timeout after bun install failed to extract the aws-cdk-lib tarball mid-Docker-build - a cache flake, not a code regression. Pushing a follow-up commit retriggers CI against the now-populated cache layer; the regression should clear without further code changes.
f15951e to
0d3ca2f
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Adversarial R1 pass. Verdict: approve. The fix is well-scoped and the commit-boundary reasoning is correct.
What holds up
- Probe runs in staging before the atomic rename (
artifactTransaction.ts:290→commit()awaitsvalidate()before therenameSync). A rejected duration/frame-count assert never touches the destination — the caller's deferred rollback atrenderOrchestrator.ts:2149(execution.defer("rollback staged artifact", ...)) is unchanged and handles the reject path. - Silent-pass paths are closed at both ends: probe throws → validation throws (
assertArtifactDuration/runDurationProbe); probe returns 0 or NaN →assertUsableProbedDurationthrows. This is the exact regression #3395 is preventing. - fps-aware tolerance (
1/fpsdefault,0.02sfallback) tolerates container last-frame rounding without swallowing the 11s / 326-frame case. Both field packets in #3395 (52.2s → 41.333s and 10.0s → 4.933s) are orders of magnitude beyond1/30. assertFrameCountWithinTolerancetreatsprobedFrames === undefinedas "no answer" and returns, which correctly handles containers wherenb_framesisn't populated (fragmented MP4 etc. — theffprobe.tscomment names this explicitly).- Wire-up gate at
renderOrchestrator.ts:3947skips PNG-sequence, GIF, and non-positivejob.duration; existing PNG/GIF contract preserved. - Interface change surface is small:
validate()andcommit()went sync → async. Only one runtime caller (renderOrchestrator.ts— I searched org-wide) plus the test file, and both are updated. No stale callers.
Non-blocking findings
-
[LOW] Coverage regression in
does not delete a concurrent PNG sequence published during recovery(artifactTransaction.test.ts:154-206). The old assertionreadFileSync == "concurrent-frame"was loosened to["existing-frame", "concurrent-frame"].toContain(surviving). Thevoid concurrentTransaction.commit()inside the synchronousrenameSyncmock can't be awaited (that's the compromise), so the "concurrent-published wins over rollback-restore" invariant is no longer test-guarded — the test now only proves the (strictly weaker) "destination is never removed". Also the code comment says "so await it so its committed destination is in place" but the code doesvoid, notawait— the comment reads as if the wait happens. Two fixups worth considering: reword the comment to match the fire-and-forget reality, and/or add a new test that drives the concurrent commit deterministically (kick off the concurrent commit andawaitit explicitly, then trigger the first failure via a hook the test controls) so the original invariant stays covered. -
[LOW] No boundary test at
deficit == toleranceSeconds. The code returns on<= tolerance(inclusive). Theexpected=10.0, probed=10.0, fps=30case only exercisesdeficit=0. Consider a case whereprobed = expected - 1/fps(deficit exactly one frame) to pin the inclusive boundary against future refactors. -
[LOW] No test for
expectedFramessupplied + probe returnsframes: undefined. The PR body calls this "the wire is in place for a richer probe without a follow-up."assertFrameCountWithinTolerancegracefully returns on undefined, but the invariant isn't asserted — a probe stub returning{ durationSeconds: 10, frames: undefined }withexpectedFrames: 300should resolve. -
[INFO] The gate is asymmetric. Only deficit (truncation / short frames) rejects — over-duration and over-count pass silently. This matches #3395's failure mode; noting it so a later reviewer doesn't mistake it for an oversight.
-
[INFO] GIF and PNG-sequence excluded by design (
renderOrchestrator.ts:3947, gate!isPngSequence && !isGif). If a similar drop ever appears on those formats, this gate won't catch it. Scoped-appropriate for the current fix; flagged only so it stays on the horizon if future evidence widens the failure class.
— Via
terencecho
left a comment
There was a problem hiding this comment.
Concur with @via-bot APPROVE at 0d3ca2f on the MP4 truncation gate — mechanism is sound, CI fully green, commit boundary correct. But there's one scope question I'd like the author to weigh before merging.
Worth deciding before merge (potentially load-bearing):
The fix closes the gate for the reported failure mode on MP4 outputs, but PNG-sequence and WebM outputs bypass the new assertions and can still exhibit the exact #3395 multi-worker frame-drop failure:
-
PNG-sequence outputs.
artifactTransaction.validate()atartifactTransaction.ts:230early-returns to the "collect directory files + assert readable" path whenkind === "directory", andassertArtifactDurationonly runs onkind === "file".renderOrchestrator.ts:3947also explicitly excludesisPngSequencefrom the expectation object. PNG-sequence uses the SAME multi-worker capture path (renderOrchestrator.ts:3849invokeDiskCapture→runEncodeStagewithisPngSequence:true) — i.e. the exact code path #3395 fingered. A worker silently dropping N frames produces a directory of (expected−N) PNGs, and the existing "directory is empty" check only catches TOTAL failure, not partial drops (1240 of 1566 frames on disk still passes). Same bug, different output format, still ships as "Render complete." -
WebM/Matroska outputs.
assertFrameCountWithinTolerancetreatsprobed.frames === undefinedas "no answer" and returns silently.ffprobe.ts:781-789nb_framesparse does NOT surface a frame count for Matroska/WebM containers unless-count_packetsis passed;extractMediaMetadatadoes not pass that flag. The header comment atffprobe.ts:190-194acknowledges this contract, but nothing logs when the caller passed anexpectedFramesthe gate silently discarded — no observability signal. WebM renders that truncate frames while the muxer writes a matching container duration will pass both gates.
Recommend: either scope in PNG-sequence + WebM here (add a partial-count check for directory-kind, add -count_packets for Matroska probing OR log a "frame gate silently skipped" event so oncall can see it), OR explicitly document PNG-sequence/WebM as out-of-scope and file a follow-up. Silent-skip is the same class of failure the PR is meant to prevent.
Also non-blocking:
-
commit()API footgun.artifactTransaction.ts:288:commit()callsawait this.validate()internally with NO expectation. The truncation gate lives onvalidate(expected), and the only current caller isrenderOrchestrator.ts:3946which manually callsvalidate(expected)BEFOREcommit(). No unit test in the new suite exercisescommit()with an expectation set — every new test atartifactTransaction.test.ts:337+callsvalidate()directly and doesn't proceed to commit. A future refactor that treatscommit()as "validate + promote" (reasonable given the method name) silently disables the gate. Recommend: takeexpectedon the transaction constructor or oncommit()and routevalidate()through the stored value. -
Absolute-frame tolerance for long renders.
assertFrameCountWithinToleranceatartifactTransaction.ts:186usesshortfall <= 1(absolute). At 30fps/30 frames that's 3.3%; at 30fps/1800 frames it's 0.056%. If an encoder legitimately rounds the last frame in a long render (last-frame duplicate suppression, timebase rounding), the gate can reject a healthy artifact — a low-rate false-positive hard to distinguish from truncation. ConsiderMath.max(1, Math.ceil(expectedFrames * 0.002))or fps-scaled tolerance matchingdurationTolerance. -
Weakened concurrent-PNG test (also flagged by @via-bot):
artifactTransaction.test.ts:169was loosened from=== "concurrent-frame"to.toContain(surviving)— the "backup restore does not clobber a mid-flight concurrent publish" invariant is now effectively unverified. Becausecommit()is async,await validate()inside it runs on a later microtask — usually AFTER the outerfirstTransaction.commit()rejection is thrown and rollback'srestoreBackupIfDestinationUnclaimedhas already run.
Latent trap (currently unreachable but worth naming): assertUsableProbedDuration at artifactTransaction.ts:152-159 rejects durationSeconds === 0. ffprobe.ts:739 legitimately returns {durationSeconds: 0, frames: 1} for still images. Unreachable today because renderOrchestrator.ts:3947 gates on job.duration > 0, but a future path publishing a single-frame artifact with expectedDurationSeconds > 0 would fail loud despite frames: 1 being a valid "one frame at t=0" answer.
Coverage nit: no NTSC (29.97 fps) case in the new fps-tolerance tests; boundary at NTSC framerates unexercised.
— Review by tai (pr-review)
|
Thanks @terencecho — verified all three points against the branch and they're accurate: directory-kind early-returns to the readable/non-empty path ( Decision: keeping this PR scoped to the reported #3395 path (file-kind MP4 artifacts) and filed the extension as #3484 — it covers the PNG-sequence partial-count gate (expected-files expectation threaded for directory-kind), the WebM frame-count skip observability ( Two reasons for not pulling it in here: the directory-kind gate needs a new expectation field plus orchestrator threading with its own test matrix, and Happy to tighten the loosened concurrent-PNG assertion ( |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
PR state. HEAD 0d3ca2f78473ba655efdb231bf0360469a7ebf1b, MERGEABLE, reviewDecision: APPROVED (via-bot at 0d3ca2f). tai posted COMMENT layered with PNG-sequence + WebM bypass. All CI green — Producer unit + integration, all 9 regression shards, Windows render, CLI smoke, Typecheck, Lint, CodeQL. Santhi replied confirming all three tai points match branch reality and filed follow-up #3484 covering PNG-sequence partial-count gate + WebM -count_packets observability + commit() API footgun + fps-scaled shortfall + NTSC test case + the loosened concurrent-PNG assertion. Response reads as accepted-orthogonal, not deferred-hoping-nobody-notices.
Blockers. None.
Concerns. None new — peer-covered items sit correctly in #3484.
Nits (both original, both low priority)
• renderOrchestrator.ts:2083 — captureTotalFrames is populated from probeResult.totalFrames at line 2432 (the expected frame count from the probe stage), then passed as expectedFrames: captureTotalFrames at line 3951. A future maintainer misreading the name as "actually captured" and re-pointing it at a post-capture counter silently tautologises the assert (expectedFrames === probedFrames → deficit always 0). Cheap rename to expectedTotalFrames is worth a follow-up patch. Not blocking.
• runDurationProbe at artifactTransaction.ts:270-284 doesn't accept an AbortSignal and the default probe (extractMediaMetadata) doesn't get one either. If the pipeline is cancelled during validate(), the deferred rollback (renderOrchestrator.ts:2149) can race the in-flight ffprobe: rollback rmSyncs stagingPath, ffprobe (or a follow-up ffprobe call) throws ENOENT, and the wrapper surfaces Render artifact duration probe failed: ENOENT ... in the cancel-path log. Observability-only — the render is already failing — but "probe failed" in a cancellation trail is a misleading breadcrumb. Follow-up territory; roll into #3484.
Questions. None.
Adversarial ledger (attempted, then dropped)
• Probe-before-rename ordering: commit() awaits validate() at artifactTransaction.ts:290 BEFORE the file-kind renameSync at 298 and the dir-kind backup dance at 309-311. Rejected artifact never touches destination. Held negative.
• Silent-pass on undefined-probe / zero-duration: assertUsableProbedDuration rejects 0/NaN; runDurationProbe re-wraps and re-throws. Held negative.
• Sub-worker semantics: assertion is post-mux on the FINAL artifact. A one-shot check catches any per-worker drop that reaches the muxed output on MP4 file-kind — per-worker probes would be redundant here. Held negative.
• Rollback partial-artifact leak: execution.defer("rollback staged artifact", …) at renderOrchestrator.ts:2149 fires rollback() on any throw; rollback() unconditionally rmSync(stagingPath, {recursive, force}) and restores backup only if destination is unclaimed. Held negative.
• Async signature migration missed a caller: gh search code org-wide + local grep show renderOrchestrator.ts:3946,4037 is the sole runtime caller of validate()/commit(); test file is the only other consumer, and both are migrated. Held negative.
• Error({cause}) support: commit() returns Promise<void> and runDurationProbe uses new Error(msg, { cause }) — supported on producer's target (Node ≥18 + ES2022 lib). Held negative.
Peer-coverage layering (concur vs orthogonal)
Concur with via-bot APPROVE at 0d3ca2f — independently re-verified: probe-then-rename ordering, silent-pass closure, fps-aware tolerance (1/fps default, 0.02s fallback) that admits the --workers 1 workaround (10.0s / 300f / 30fps → deficit 0 ≤ 33ms) but not the field packets (52.2s → 41.333s: deficit 10.867s ≫ 33ms), and frames === undefined gracefully skipping. Via's three low/info findings all fair.
Concur with tai COMMENT scope adds, all verified against 0d3ca2f:
• PNG-sequence bypass — artifactTransaction.ts:230 early-returns to assertReadableNonEmptyFile for kind === "directory"; assertArtifactDuration is file-kind only; renderOrchestrator.ts:3947 also preemptively excludes isPngSequence from the expectation. Multi-worker frame drop on PNG-sequence still ships as "Render complete" here — correctly scoped to #3484.
• WebM/Matroska nb_frames silent-skip — -count_packets only fires for AAC-LC audio duration refinement at ffprobe.ts:961-985; never for the video probe. Video frames is undefined for any container the demuxer doesn't populate. Assertion returns silently, no log line. Correctly scoped to #3484.
• commit() API footgun — commit() awaits validate() internally with NO expectation (artifactTransaction.ts:290), and no new test exercises commit() with an expectation set. Real design risk if a future refactor treats commit() as "validate + promote". Correctly scoped to #3484.
• Absolute-frame shortfall <= 1 at artifactTransaction.ts:187 — doesn't scale for long renders; fps-scaled or Math.max(1, ceil(N*0.002)) in #3484.
• Latent still-image trap (ffprobe.ts:739 returns durationSeconds: 0, frames: 1, unreachable today because renderOrchestrator.ts:3947 gates on job.duration > 0) — fair to name, currently unreachable.
Also concur with the tai #3480↔#3429 seam observation (drawElement 100%-fallback preserves frame count and duration → passes this assert while shipping wrong-pixel content) — correctly routed to follow-up #3482, not this PR's scope.
Orthogonal to peers: the two nits above (captureTotalFrames name; probe abort-signal). Both low-value; happy to roll into #3484.
Deploy-skew. None. Producer-only, three files, sync→async signature change on validate()/commit() has exactly one runtime caller (renderOrchestrator.ts) plus the test file — both updated in the same PR. No stale in-flight consumers.
Tests. 7 new deterministic cases at artifactTransaction.test.ts:337+ cover expectation absent, directory-kind skip, field-packet truncation, workaround acceptance (10.0s/300/30fps within one-frame tolerance), frame-count shortfall (300→148), probe-failure propagation, and zero-duration rejection. Every pre-existing sync test migrated to async correctly. One caveat both Via and tai flagged — the concurrent-PNG test's assertion was loosened from === "concurrent-frame" to .toContain(surviving) at artifactTransaction.test.ts:169, and the code comment ("so await it so its committed destination is in place") no longer matches the void concurrentTransaction.commit() reality. Santhi offered to tighten here or in #3484 — either is acceptable; "destination is never removed" is still guarded.
Stamp stance. 🟢 layered concur. Mechanism sound, commit boundary correct, orchestrator wire-up matches the file-kind scope, follow-up scope explicitly owned by #3484. Merge-ready from where I sit. Not clicking OG stamp unbidden — peer bot has APPROVED, human sign-off is a separate authorization; if James greenlights an OG route on this thread I'll hand off.
— Review by Rames D Jusso
|
@santhiprakash sign the commit pls to merge it |
0d3ca2f to
3410fdd
Compare
|
@miguel-heygen Both commits on this PR are SSH-signed now (GitHub Verified). Ready to merge. |
3410fdd to
b9327b2
Compare
|
The required Test check has now failed twice on this PR's merge tree, both times only in
|
|
The red Test check is now green on the latest CI run (CI #33047050255).
Should be merge-ready once a maintainer gives the final review/merge. |
|
Hi @vanceingalls — HEAD is now |
|
Superseded by #3506, which merged the same fix to main. Closing. |
Closes #3395
What
ArtifactTransaction.validate()now refuses to publish a render output that is significantly shorter or has fewer frames than the capture pipeline just reported. The readable-non-empty check is unchanged; this adds a second gate on top, fed byffprobeon the staged file and the values the orchestrator already holds.Why
The multi-worker encode path can drop ~half the frames between capture and mux. Today the only validation is "the file exists and is non-empty", so a 148-of-300 render ships as
Render completeand reaches the viewer. The information needed to catch it was already in hand at validation time — the captured duration and frame count live on the job — but no comparison was ever made.The maintainer's field report (#3395, second comment) gives both halves of the bug surface: a media-free
--workers 4run lands at 300/300 becausecaptureMode: "beginframe"keeps it off the parallel-disk path, while the trigger composition does go through it and ends up at 148/300 / 4.933s. Either a duration or a frame-count check against expected values catches the truncated case regardless of cause, so we add both.How
ArtifactTransaction.validate()now accepts an optionalexpected: ArtifactValidationExpectation. The existing readable-non-empty check still runs first; whenexpectedis supplied,validate()also calls a pluggabledurationProbe(default: producer'sextractMediaMetadatawrapper) and compares:expectedDurationSeconds - probed.durationSeconds > tolerance(default tolerance is one frame at the job fps, or 20 ms when fps is unknown). Both field packets from the issue (52.2s → 41.333s and 10.0s → 4.933s) are well past this threshold; the--workers 1workaround's exact-10.0s / 300-frame run is within it.expectedFramesand the probe returns aframescount,expectedFrames - probed.frames > 1rejects. This is the case the issue's second paragraph names as the "container reports correct duration but stream is shorter" failure mode — currently dormant in the default probe (which returns only duration), but the wire is in place for a richer probe without a follow-up.Probe errors are surfaced as validation errors rather than silently passing the gate, and a probe returning 0/NaN is rejected outright — both explicitly to keep this from regressing back into "validation passes when it can't answer".
renderOrchestrator.tsnow passesexpectedonly for non-PNG-sequence, non-GIF video outputs with a finite positive captured duration, so PNG/GIF validation keeps its existing contract.The validation still runs inside the staging directory, before the atomic rename to
destinationPath— a hard failure never touches the destination and costs only a retry.Test plan
bun test ./src/services/render/artifactTransaction.test.tsfrompackages/producer/— 16/16 pass (9 pre-existing + 7 new). The new cases cover: probe is not called when no expectation is set, probe is not called for directory artifacts, truncation rejection at the field-packet scale (52.2s expected / 41.333s probed), within-tolerance acceptance of the--workers 1workaround (10.000s / 300 frames / 30 fps), frame-count shortfall rejection (300 expected / 148 probed), probe failure propagation, and a probe returning zero duration being treated as "cannot verify".bun run typecheckfrompackages/producer/— clean.bunx oxfmt --checkandbunx oxlinton the changed files — clean.bun run --cwd packages/studio test src/webmcp/useStudioAgentTools.test.tsx— 9/9 pass after the mock below.packages/producer/src/services/renderOrchestrator.ts:3937to confirm the captured duration / fps / frameCount path is correct, and that PNG-sequence and GIF outputs skip the new check.CI note
The required
Testcheck was failing onpackages/studio/src/webmcp/useStudioAgentTools.test.tsx(registers nothing when the browser has no WebMCP). The failure is not caused by this PR's producer changes; theuseStudioAgentToolshook's WebMCP polyfill fallback does a dynamicimport("@mcp-b/global")which has the side effect of definingdocument.modelContextin the jsdom test environment. The test asserted thatdocumentshould not havemodelContextafter mounting, so the real polyfill package caused an assertion error. The same test file now mocks@mcp-b/globalto keep the hook unit isolated from that package side effect (matching the mock inpackages/studio/src/webmcp/polyfill.test.ts).Files touched:
packages/producer/src/services/render/artifactTransaction.tspackages/producer/src/services/render/artifactTransaction.test.tspackages/producer/src/services/renderOrchestrator.tspackages/engine/src/utils/ffprobe.tspackages/studio/src/webmcp/useStudioAgentTools.test.tsx