fix(producer): give inlined media a document-unique render id - #3342
Conversation
Element ids are unique per composition file, but the render document is
the inlined union of every file. The producer merged the per-file media
lists and deduplicated by id, so clips that shared an id collapsed into a
single entry, and every id-keyed stage (extract, inject, visibility,
bounds) resolved to whichever element came first in the document. The
surviving clip's frames landed on the wrong element and the visible scene
rendered without footage.
Two shapes hit this, and neither is author error:
- Two scenes that each declare `<video id="clip">`. Legal per file, and
unavoidable when a scene is duplicated into a copy with inner ids
kept, or when one file is mounted twice.
- Two scenes that each declare a bare `<video>`. The timing compiler
numbers auto-ids per file, so both arrive as `hf-video-0` with no
authored id involved at all.
Stamp a document-unique `data-hf-render-id` while inlining, and read the
media list off the inlined document instead of merging per-file lists.
The render id equals the element id whenever that id is already unique,
so documents without a collision keep identical pipeline keys.
Author `id` attributes are left alone: 158 of the 161 registry blocks
reference their own ids from `#id` CSS or getElementById, so renaming
would trade broken footage for broken styling. The engine resolves media
elements through the render id instead, falling back to getElementById
for documents the producer never compiled.
Collecting from the inlined document also retires the per-file media
extraction in parseSubCompositions along with its offset bookkeeping;
host offsets are recovered from the composition hosts the clip sits in.
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at 096f9624. The diagnosis is right and the fix is in the right place — stamping while the inliner still holds the merged document is the only point that can tell two mounts of one file apart. All four items you flagged check out at source, with one exception: item 3 closes on the creation side and leaves four in-page readers on the old derivation. No blockers.
Audited end-to-end: core/compiler/mediaRenderIds.ts, producer/services/renderMediaCollector.ts, engine/services/mediaRenderIdBridge.ts, the changed regions of screenshotService.ts / videoFrameInjector.ts / videoFrameExtractor.ts / audioMixer.ts / frameCapture.ts, plus the untouched readers the change implicates — core/src/runtime/colorGrading.ts, media.ts, mediaProxy.ts, adapters/video-texture-compat.ts, and main's retired parseSubCompositions for offset parity. Read for context: both new test files, compilationRunner.ts / compilationTester.ts. Not executed: I did not run the suites locally; everything below is a source read.
CI is incomplete, so nothing here rests on it. 7 of 8 required contexts green at this head; the required Test context has not reported — the CI run is still in_progress, with Producer: integration tests and Preview parity also running. Worth a re-check before this merges rather than reading the current rollup as green.
The four you flagged
-
Byte-equivalence — holds, and it is correctly scoped.
uniqueRenderIdreturnsbaseIduntouched when it is unclaimed (mediaRenderIds.ts:51-52), and all three parsers prefer the stamp then fall back to the plain id (videoFrameExtractor.ts:547and:621,audioMixer.ts:494). So on a collision-free document every pipeline key is the plain id exactly as before. Note the claim is about keys and log output, not the compiled HTML — that does gain an attribute on every media element — and the body says keys, so it is accurate as written.Because that looked like it should move the committed goldens, I checked: the 29
packages/producer/tests/*/output/compiled.htmlfiles that contain<video>do not break.compilationRunner.ts:54compares throughvalidateCompilation(compilationTester.ts:245), which extracts timed elements and matches them by authorid— and author ids are untouched. Structural, not byte-for-byte. -
applyDomLayerMask— safe, and the code is stricter than the body describes.screenshotService.ts:476-483is an if/else, not an unconditional render-id-only path: a stamped element is addressed by[data-hf-render-id="…"]alone (right —#idwould unhide the colliding scene's element), while an unstamped document still falls back to#${CSS.escape(id)}at:481-482. The element resolution a few lines up (:470) goes through the bridge, which itself falls back togetElementById(mediaRenderIdBridge.ts:39). And both callers are producer HDR-composite paths (hdrCompositor.ts:683,captureHdrFrameShared.ts:244), never snapshot or check — so the asymmetry cannot reach a document the producer did not compile. The audit comes out clean in both directions. -
__render_frame_*— see below. It closes in the engine and not incore/src/runtime. -
recompileWithResolutions— the test pins exactly what you said, and it is genuinely pre-existing.htmlCompiler.test.ts:1139-1166is present onmainat the same line. It resolvesscene-hostto duration 2 and asserts the clip stays{start: 2, end: 6}, i.e. the freshly stampeddata-enddoes not newly clamp it. Claim verified rather than taken.
important — item 3 closes on the creation side; four in-page readers still key on video.id
The engine half is right: the sibling is created from the render id (screenshotService.ts:615, __hfMediaId(video) ?? video.id) and read back by render id (videoFrameInjector.ts:288, screenshotService.ts:484,510). But four readers of that same id live in packages/core/src/runtime and still build it from the plain el.id. None is in the diff:
colorGrading.ts:2472-2476— the sharp one.findRenderFrameImageisdocument.getElementById(\render_frame${video.id}_`)with no sibling or class check to save it. For the second colliding video (plainclip, render idclip__hf2) that resolvesrender_frame_clip— the *first* video's frame — sohasInjectedRenderFrame(:2478-2484`) answers using another element's image and the grading source is the wrong clip's pixels.adapters/video-texture-compat.ts:36-40— mostly mitigated. The primary path is the class-checked immediate sibling (:27-35) and the engine inserts the<img>atvideo.nextSibling, so the id lookup is only the documented "in case a node was inserted between them" fallback.media.ts:413-414(skipForInjectedVideo) andmediaProxy.ts:63-69(isRenderMode) — both are really "are we mid-render" proxies, so finding any render-frame sibling gives the right answer. Correct today for a reason that has nothing to do with identity, which makes them latent rather than broken.
Grading this honestly, because it cuts against calling it a blocker: on a collision-free document the render id is the plain id, so all four behave exactly as before, and on a colliding document they were already resolving to the wrong element before this PR. Nothing that worked stops working — this is incomplete propagation of the new identity, not a regression, and the render is strictly better than main either way. It earns the flag because it sits in the one place the body says the sideways collision is closed, and colorGrading turns it back into wrong pixels for the second clip.
Cheap to close: the bridge is installed via evaluateOnNewDocument before any page script runs (frameCapture.ts:1300), so window.__hfMediaId?.(el) ?? el.id — the same one-liner the engine uses — is available in core runtime too, and mediaProxy.ts:64 already reads an optional global two lines above the site.
nit — a unique authored id can still be renamed by an unrelated collision
uniqueRenderId draws suffixes from the same taken set, so for plain ids clip, clip, clip__hf2 in document order the third element — whose authored id is unique — becomes clip__hf2__hf2, because the second claimed clip__hf2. Only pipeline keys move (author ids are untouched), so nothing user-visible breaks, and it is outside the body's "document without a collision" claim. Worth noting that the stamped form of this is tested ("does not claim an id that a later element already holds as its render id") while the unstamped form is not.
notes
- Selector agreement checked, since a miss here fails quietly rather than loudly:
MEDIA_SELECTOR(video[src], audio[src], img[src]) covers every element the three parsers select (video[src],img[src],audio[id][src]), so no parsed element arrives unstamped. That matters because an unstamped element would silently takeROOT_WINDOWatrenderMediaCollector.ts:126— losing its host offset rather than erroring — and the-audiostrip at:144lines up withaudioMixer.ts:507building the track id off the render id. - Host-offset parity against the retired walk:
parseNumericrejects a relativedata-startand contributes 0, andmain's retired code did the same (parseFloat(el.getAttribute("data-start") || "0")), so a composition host with a referenced start behaves identically on both sides. Publishing the negative result so the next reader does not re-derive it as a finding. - Your "Not covered" note has
distributed/png-sequenceat PSNR 0 pending confirmation as pre-existing. The reasoning is consistent — that fixture has no media elements, so nothing in this change reaches it — but the same-image comparison againstmainis still owed before it can be treated as settled.
Verdict: COMMENT
Reasoning: No blockers — the identity fix is sound, correctly placed, and every load-bearing claim in the body holds at source. Not an approval because a required context has not reported yet and the item-3 gap is worth closing first; neither is a reason to hold the design.
— Rames Jusso
The injector creates each `__render_frame_<id>__` sibling from the media element's render id, but four runtime readers still built that id from the plain `el.id`. On a document where two compositions share a media id, all of them resolved the first collider's frame. colorGrading is the one that changes pixels: findRenderFrameImage returns the image the grading pass samples, with no class check to catch the mismatch, so the second video was graded from the first one's frame. media, mediaProxy and video-texture-compat use it as a render-mode or substitute-source signal, where both colliders happen to agree during render, but none of them should rest on that. Add renderFrameSibling as the single owner of "which frame belongs to this element" and route all four through it. It reads the stamped render id and falls back to the author id, so a collision-free document resolves exactly as before and an uncompiled one (preview, snapshot, check) is unchanged. The engine's in-page bridge keeps its own copy of the rule because code serialized into page.evaluate cannot import; it now names core as the definition, and a test pins the sibling-id format both sides build so they cannot drift apart silently.
|
Thanks, that's a good catch and the grading one is real. Pushed Item 3: all four readers now route through one ownerYou were right that it's the half the diff doesn't show. Confirmed all four at source before touching them:
The bottom three happen to agree on a colliding document, because during render both videos are injected so the boolean lands right either way. That is agreement by accident, so they got the same treatment rather than a "safe enough" pass. New I took your suggestion's intent but not the mechanism: rather than png-sequence: my reasoning was wrong, and the result is better than I claimedIt is not a Chrome bump. I quoted the Checking it properly turned out to be stronger evidence than the same-image comparison you asked for. The pointer records the baseline's Byte-identical on all four frames I sampled (0.00s, 0.13s, 0.23s, 0.30s). So this branch reproduces that baseline byte for byte, which is the strictest gate in the suite passing, not failing. No I've corrected the PR body rather than leaving the wrong explanation in it. Still owedAgreed on not reading the rollup as green — I won't. The full Docker regression suite is still running on this branch (it was mid-flight when I pushed, so it is testing Ready for re-review whenever you are. |
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at c19ea923 (my earlier COMMENT was at 096f9624). Both things I named as holding back an approval are resolved, so this is an approval — see the CI note and the follow-up below.
The item-3 gap is closed, and closed better than I asked for
I suggested routing the untouched readers through the new identity; renderFrameSibling.ts makes it a single owner with the derivation documented, which is the version that survives the next person touching it. Verified each reader at source rather than trusting the commit message:
colorGrading.ts:2473-2475—findRenderFrameImagenow resolves viafindInjectedRenderFrame, keepingisDrawableSourceas the filter. This was the one that changed pixels, so it is the one that mattered.adapters/video-texture-compat.ts:38-40— id-lookup fallback replaced; the sibling fast path keeps its__render_frame__class check.media.ts:414—skipForInjectedVideono longer needs theel.id &&guard.mediaProxy.ts:66—isRenderModelikewise.
Two details worth calling out because they are easy to get wrong and this got them right. readMediaRenderId uses ||, not ?? — getAttribute returns "" for a valueless attribute, and ?? would have kept that empty string instead of falling through to the author id. And it matches the in-page bridge exactly (mediaRenderIdBridge.ts:46, el.getAttribute(attr) || el.id), which is the whole point of having one definition. The author-id fallback keeps uncompiled documents — preview, snapshot, check — resolving as before, and renderFrameSibling.test.ts covers all three states including the collision case that resolves each video to its own frame.
important — the drift guard names both sides but pins one
mediaRenderIdBridge.ts:16-19 says renderFrameSibling.test.ts "pins the sibling-id format both sides build." It pins core's side: renderFrameElementId asserted against the literals __render_frame_hero__ and __render_frame_c__hf2__ (renderFrameSibling.test.ts:36-43). The engine builds that same id at six independent template sites — screenshotService.ts:484, :510, :615, :732, and videoFrameInjector.ts:290, :669 — and none of them is compared against renderFrameElementId by anything. Change the template at :615 and the test stays green while every core reader silently stops finding its frame, which is this PR's own failure mode one level up.
The mechanism to fix it is already in the file: installMediaRenderIdBridge imports from core and passes MEDIA_RENDER_ID_ATTR into evaluateOnNewDocument as an argument. Installing the sibling-id rule the same way — a window.__hfRenderFrameId built from renderFrameElementId's definition, with the six engine sites calling it — leaves one definition, and then the existing test really does span both sides.
nit
screenshotService.ts:615 is window.__hfMediaId?.(video) ?? video.id where core uses ||. __hfMediaId is declared returning string and evaluates el.getAttribute(attr) || el.id, so for an element with neither it yields "" rather than undefined, and "" ?? video.id keeps "" — creating an <img id="__render_frame___"> that no core reader looks for, since readMediaRenderId returns null there. Inert today: compileForRender assigns positional ids to id-less timed media (the video-hfid-no-id fixture exists for exactly that), and the behaviour is identical to pre-PR. || would just make the two sides agree in the one case they currently don't.
notes
findInjectedRenderFramedoes not check the__render_frame__class even though the test fixtures set it. That matches whatcolorGradingdid before —isDrawableSourceis the real filter — andvideo-texture-compatstill class-checks its sibling path, so nothing loosened here.- The engine-side id space is internally consistent, which I checked rather than assumed: every site that creates a sibling and every site that looks one up uses the same string it resolved the media element with (
screenshotService.ts:705-706and:732both onitem.videoId;videoFrameInjector.ts:286-290on the sameidit passed to__hfMediaEl;:656derivesidfrom__hfMediaIdbefore:669uses it).
CI
My earlier note was that the required Test context had not been created at all. It has now reported at c19ea923, and all 8 required contexts are green — including Test, Tests on windows-latest, regression, and all nine regression-shards plus Preview parity. That resolves the caveat I attached to the previous review.
Verdict: APPROVE
Reasoning: The identity fix is correct and complete across the readers, the two conditions I named last time — the item-3 propagation gap and the missing required context — are both resolved, and what's left is a drift guard weaker than its comment claims plus one inert operator mismatch, neither of which affects the fix. This approval doesn't merge it; the merge is yours.
— Rames Jusso
The drift guard named both sides but pinned one. renderFrameSibling.test asserts core's format, while the engine rebuilt the same id from a literal template at six independent sites. Changing the format on either side left the test green and every runtime reader silently unable to find its frame — this PR's own failure mode, one level up. Export the affixes and renderFrameIdForRenderId from core, and take the id from there at all six. Four sites resolve it on the Node side, where the engine can import; the two that iterate the DOM in-page receive the affixes as evaluate arguments, which avoids depending on bridge install order. Also switch two `__hfMediaId?.(el) ?? el.id` reads to `||`. The bridge returns "" for an element with neither id, so `??` kept the empty string and built `__render_frame___`, which no reader looks for. Inert today because the compiler assigns positional ids to id-less timed media, but it made the two sides disagree in the one case they could.
|
Both follow-ups closed in The drift guard now spans both sidesRather than the Two shapes, picked per site rather than uniformly:
I used arguments rather than a global on purpose. The nit, both instancesFixed at Note on the
|
|
Correction on the regression run I said I'd report: it produced no usable signal, so treat it as absent rather than as evidence. It finished 72/72 failed, and the reason is the same one behind the The container was also killed partway through ( I am not re-running it. CI already runs this suite properly via the So the regression evidence for this PR is CI's, not mine. It is re-running on |
What
Media elements in a compiled render document now carry a document-unique
data-hf-render-id, and the producer reads its media list off the inlined document instead of merging the per-file lists and deduplicating by element id.Fixes #3340.
Why
Element ids are unique within one composition file. The render document is the inlined union of every file, so ids collide there. The producer merged the per-file media lists and deduplicated by id, which collapsed colliding clips into a single entry, and every id-keyed stage (extract, inject, visibility, bounds) then resolved through
document.getElementByIdto whichever element came first in the document. The surviving clip's frames were injected onto the wrong element, usually one that is hidden during the survivor's window, so the visible scene rendered scene chrome with no footage.Two shapes hit this, and neither is author error:
<video id="clip">. Legal per file. Unavoidable when a scene is duplicated into a copy with its inner ids kept, and impossible to avoid when one file is mounted twice: it is a single file with a single id.<video>. The timing compiler numbers auto-ids per file, so both arrive ashf-video-0. No authored id is involved at all, which makes this the more common of the two.On
main, the issue's reproduction compiles tovideoCount: 1and renders both halves as flat scene background.How
assignMediaRenderIds(core) stamps everyvideo[src] / audio[src] / img[src]with a document-unique key while the inliner still holds the merged document. That is the point where ids become ambiguous, and the only point that can tell repeated mounts of one file apart. The render id equals the element's own id whenever that id is already unique, so a document without a collision keeps byte-identical pipeline keys and log output; only later duplicates get a__hf2suffix.collectRenderMedia(producer) then reads the media list from the inlined document and recovers each clip's timeline window from the composition hosts it is nested inside. This replaces the per-file extraction plus id-dedupe merge, and retiresparseSubCompositions's media extraction and offset bookkeeping along with it, so "what media exists in this render" has one owner.Author
idattributes are deliberately left untouched. Renaming the duplicate would need no engine changes at all, but 158 of the 161 registry blocks reference their own element ids from#idCSS orgetElementById, so renaming would trade broken footage for broken styling in exactly the compositions being fixed. The engine instead resolves media through the render id, via one shared in-page bridge installed withevaluateOnNewDocument. Every call site keeps agetElementByIdfallback for documents the producer never compiled (snapshot, check, direct engine callers), where the authored id already is the identity.Two details worth a reviewer's eye:
__render_frame_*sibling<img>s are derived from the render id now. They were derived fromvideo.id, so duplicate videos produced duplicate sibling ids, moving the collision one element sideways. The four runtime readers of that sibling inpackages/core/src/runtimewere derived from the plainel.idtoo and now route through one shared owner,renderFrameSibling.ts.colorGrading's is the one that changed pixels: it returns the image the grading pass samples, so the second collider was graded from the first one's frame.applyDomLayerMaskaddresses a stamped element only by its render id, with no#idfallback alongside. An id is duplicated exactly when two compositions share it, so keeping#idas an extra selector would unhide the other scene's element, which is the collision this is meant to resolve.recompileWithResolutionskeeps the first-pass media list rather than re-collecting. Resolving a composition's duration stamps adata-endon its host, and re-collecting would newly clamp clips to it. That is a retiming, not an identity fix, and there is an existing test pinning the current behaviour.Test plan
New tests
packages/core/src/compiler/mediaRenderIds.test.ts(8 tests): unique ids pass through unchanged, authored and auto-id collisions disambiguate, repeated collisions keep counting, the pass is idempotent, and re-running over a partially stamped document does not hand out an id a later element already holds.packages/producer/src/services/htmlCompiler.test.ts(6 tests): all three collision shapes end-to-end throughcompileForRender(two scenes sharing an authored id, one scene file mounted twice, two unnamed videos), plus each id addressing exactly one element in the compiled HTML, author ids surviving intact, and the same collision for<audio>.Reproduction, from the issue, rendered end to end:
mainvideoCount061907(scene bg)180a28(scene bg)Both halves also show the correct slices: the rendered frame at t=0.5s matches the source at 5.5s and the frame at t=3.5s matches the source at 50.5s, which are the two scenes' distinct
data-media-startvalues.Suites: core 2421 passed, engine 1601 passed (3 skipped), producer unit lane 40 files with zero failures.
Not covered
distributed/png-sequencereported PSNR 0 on all 60 frames in that run. That was my harness setup, not the code: the worktree was created withoutgit lfs pull, so every baseline frame was a 129-byte LFS pointer being compared against a real frame. The pointers record the baselines'sha256, and the rendered frames hash identically to them on every frame sampled, so this branch reproduces that baseline byte for byte.data-hf-render-key="<composition-chain>.<id>"naming was not adopted. A chain of authored composition ids is identical across two mounts of one file, so it does not disambiguate that case.