Skip to content

fix(core): stop small previews dropping layers on heavy compositions - #5268

Merged
miguel-heygen merged 28 commits into
mainfrom
fix/player-preview-raster-size
Oct 9, 2026
Merged

miguel-heygen merged 28 commits into
mainfrom
fix/player-preview-raster-size

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

What

A small preview no longer rasters a composition's will-change: transform layers at full size. While the player shows a composition smaller than its own size, the runtime drops that hint on every layer where nothing depends on it, so those layers raster at the size they are shown. Heavy compositions stop running out of tile memory and dropping layers. Layout, paint order and pixels stay as they are in the export, and the runtime writes no inline style.

Why

Heavy compositions dropped layers in a small preview: text, labels and overlay controls vanished for single frames while WebGL canvases stayed drawn.

Chromium never rasters a layer under a will-change: transform hint below the native scale (device scale x page scale), unless the shown scale is under 25% of native (MinimumRasterContentsScaleForWillChangeTransform in cc/layers/picture_layer_impl.cc). The player fits a 1920x1080 composition into its box with transform: scale(), so in a 528px preview on a 2x screen every hinted layer rasters 3.6x larger than it is shown. A composition with many such layers exhausts the tile budget, and the compositor draws frames with missing tiles.

Earlier heads tried CSS zoom (it changed text wrapping against the render), then a stand-in property on each element (filter: opacity(1), then a far perspective). Each stand-in was visible to the editor or GSAP, so it could end up saved in a project, and went stale when classes changed. Both are gone; the transform fit stays.

How

  • setPreviewRasterScale (packages/core/src/runtime/previewRasterHints.ts) runs from the runtime's existing set-display-scale handler, which only a preview host sends (shown width / layout width). Renders never send it.
  • Below 1x it adds one rule, [data-hf-preview-raster] { will-change: auto !important }, and puts that attribute on each layer whose computed will-change holds only transform hints and whose hint changes nothing:
    • the layer is positioned, so it already contains its absolute descendants and paints in the same phase;
    • no descendant, and no ::before/::after of the layer or a descendant, is position: fixed (the hint is their containing block);
    • unless the layer has its own z-index, none of those has a z-index or a blend mode (the hint's stacking context holds them);
    • a layer that also hints opacity or filter keeps every hint, since those make boundaries of their own (for example for backdrop-filter).
  • Layers that do not qualify keep their hint and their larger raster. That is deliberate: correctness first.
  • Changes are followed while the preview is small. Any attribute change re-checks that subtree, and an added element is checked. An added stylesheet re-checks the page, again once a linked sheet loads. Inline style changes, which scripts make every frame, are handled cheaply: they give the hint back to any layer holding an element that now depends on it, and re-check a layer whose own position, z-index or inline will-change changed.
  • Not followed, so a mark can go stale until the next change above:
    • sibling (~, +) and :has() rules triggered from outside the layer;
    • CSS variables set on the layer that feed a descendant's z-index;
    • rewritten <style> text and insertRule;
    • shadow roots;
    • an opacity or filter hint that reaches an already-marked layer through a rule on an ancestor's class or attribute (the layer's own changes are followed).
  • Cost, from the review's measurements: following inline styles adds about 1 to 2 ms per animated frame on a laptop. A change on a container re-checks the layers under it (about 3 ms for 450 layers). An added stylesheet re-checks the whole page.
  • Fine detail in a swapped layer now rasters at the size shown, so a pattern a few pixels wide in the small preview blends a little instead of being downsampled from a full-size raster. That is the point of the fix; layout, shapes and positions are unchanged.
  • At 1x or more the rule and every mark are removed.
  • Studio: the preview-only attributes (data-hf-upcoming, data-hf-preview-lazy, data-hf-preview-raster) are one list, PREVIEW_ONLY_ATTRS in @hyperframes/core/studio-preview-mark. Copy (liveMarkupWithoutPreviewMarks) and text edits (buildTextField) leave them out of what is saved, and the runtime's keep-unchanged-media check ignores them, so a marked video keeps playing through an edit (this also fixes the same rebuild for data-hf-upcoming). Studio's stacking model counts a marked layer as the stacking context its hint makes, so timeline z-order edits match the render.

Measured

Synthetic composition of 48 will-change lyric layers (a fixture built for this PR), 528px player, DPR 2, 80 MB tile budget, full Chrome 154, 3 paired runs against the merge base. Playback for 8 s, then the player grown to 1100px and shrunk back to 528px.

main this PR
Largest raster / shown size, 528px 3.64 1.00
Largest raster / shown size, 1100px 1.75 1.00
will-change compositor layers 48 0
Compositor layers 51 4
Main-thread busy during playback 28.8 / 24.2 / 21.2 % 21.1 / 20.4 / 20.9 %
Compositor frames in the 2.5 s after growing to 1100px 16 / 17 / 13 186 / 253 / 186
Frames drawn with missing tiles, Before/After capture below 4 0

The layout of every element is unchanged (checked to 0.01px by the browser test). The paint is unchanged too: the browser test's frosted-glass and 3D probes compare the swap on and off at the same size.

Tests

  • packages/player/tests/browser/preview-raster-size.ts (player-perf load shard, test:browser-raster), fixture tests/perf/fixtures/preview-raster:
    • shows the composition at full size and records every element's box, a z-index paint-order probe, a flipped 3D card probe, every inline style and every computed filter;
    • shrinks the player and checks that no box moved, paint order and the 3D card are unchanged, no inline style or computed filter changed, that the layers holding a fixed child, a z-indexed child, a ::before behind them, 3D or a mixed hint keep their hint while the others lose it, and that a layer added later loses it too;
    • at that same small size, turns the swap off and on with the set-display-scale message: with it off, the compositor trace must show layers rastered above 1.5x (so the measurement can fail); with it on, three traces in a row must show none. Frosted glass (backdrop-filter inside a hinted wrapper) and a 3D-rotated child must render the same both ways;
    • gives a child of a marked layer an inline z-index, as a script would, and checks the layer gets its hint back;
    • grows it back and checks every mark is gone.
  • packages/core/src/runtime/previewRasterHints.test.ts: which layers are marked and which keep their hint, restore leaving the document byte-identical, late layers, class changes, inline z-index, position and will-change changes, added stylesheets and attribute selectors.
  • packages/studio/src/utils/authoredSource.test.ts and domEditingTextFields.test.ts: copied markup and a saved text edit carry no preview mark.
  • packages/core/src/runtime/stackingContext.test.ts: a marked layer keeps its stacking context. init.swapScenes.test.ts: a video carrying either preview mark is kept through a scene swap. previewRasterHints.test.ts also checks that nothing is injected before a set-display-scale arrives.
  • Mutations: each of these fails at least one test: no rule, no positioned check, no fixed check, no z-index check, no blend check, no pseudo-element check, accepting mixed hints, not following inline styles, not giving holders their hint back, no restore, no observer, not re-checking a layer's own inline values, no full pass on an added stylesheet, following only class, and reading hints through the mark.

Before

A 528px player on a 2x screen with an 80 MB tile budget, playing the 48-layer fixture on main (merge base). Every hinted layer rasters at 3.6x the size shown, and 4 frames are drawn with missing tiles (text cut off or gone for a frame).

flicker-preview-before.mp4

After

The same playback with this PR: layers raster at the size shown, no frame is drawn with missing tiles, and the layout is the same.

flicker-preview-after.mp4

Review

An independent adversarial review ran on every head of this rework. What it found and what changed:

  • An identity filter stand-in leaked into saved projects through Studio's blur slider and broke backdrop-filter; a far perspective stand-in could still be saved through perspective keyframes and the copy fallback. Both are gone: the runtime writes no inline style.
  • Pseudo-elements, mixed hints (transform, opacity) and inline style changes could make a dropped hint matter. They are now checked and followed, with tests.
  • Re-reading every marked layer on a container change cost about 6 ms and a re-raster per event. Now only the changed element is re-read, and a full pass decides every mark before writing any.
  • The browser check's swap-off half was being turned back on by the player's own scale messages, so it compared the swapped page with itself. The test now holds those messages on the host page, asserts the swap is off, and drops the layers that keep their hint before measuring. 20 of 20 runs pass, and mutants for mask, clip-path, blend mode, a clipped 3D child, an identity filter, a near perspective and a missing hold each fail it.
  • Accepted and listed under How: the restyle paths that are not followed, and the per-frame cost of following inline styles.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2061 (base branch 2061), smooth 1506 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • crop-none-pct-r0-root-z50: tracking 0.05, pressJump 0, drop 40.07, reload 40.07, render 40.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.11, reload 0.09, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0.11, reload 0.09, render 0.03, renderKey -, undo true, teleport true

GSAP infers a -50% CSS translate from the rendered pixel matrix. At a
fractional preview zoom that matrix is snapped, so a centered layer lost
its percentage and shifted by half its height after reload, render and
redo. The runtime now restores the authored percentage before GSAP's
first transform parse. Also restores the original thumbnail ceilings.
resize-scale-px-r30-{root,nested}-z50-after measured 0.53 px tracking on
main and 0.49 px here. Also drops a comment line the runtime name covers.
The centering repair now also reads percentages from a stylesheet
transform (the common translate(-50%, -50%) pattern), runs for every
property that makes GSAP parse a transform (transformOrigin first, too),
resolves against the border box, and leaves elements without a layout
box alone instead of throwing. The browser check now runs zoomed.
GSAP folds every other CSS percentage translate into pixels, and
compositions rely on that (a translateX(-100%) title tweened to x: 0
slides in). The repair now gives back only a -50% that zoom snapping
lost, and treats a size that is not in px (a hidden element sized in
percent) as no box.
A pixel part in the translate means GSAP never infers -50%, so the
repair leaves it alone, and a layer without a layout box is left to
GSAP whatever size its computed style reports (that differs by Chrome
build for hidden percent-sized layers).
The zoom was applied on the first resize callback, after the source was
assigned, so a fast-loading composition could run its scripts and lay
out unzoomed and then reflow. Fitting on connect, before any source,
makes the first layout use the preview zoom.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 9, 2026 02:50

@jrusso1020 jrusso1020 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.

Request changes at b44a30e3. The fix does make layers raster at the size they're shown. But the GSAP adapter changes the final render, which the body says it doesn't. And fitting with zoom makes the preview wrap text differently from the export.

Blocker 1: the GSAP adapter changes renders at native scale (core/src/runtime/init.ts:346).

  • installFlatGsapTransforms runs from runtime/entry.ts:44. That is the runtime the producer injects into every render page, and the producer's early GSAP stub can be chained, so the adapter installs during renders.
  • The body says "At native scale GSAP already infers centering, so nothing changes." GSAP's own rule (CSSPlugin.js ~1003) compares Math.round(offsetWidth/2) with Math.round(-x), so it doesn't infer centering for widths between about 2k+0.5 and 2k+1 px. The adapter forces -50% there.
  • Repro: Chrome, DPR 1, no zoom, producer early stub + runtime + GSAP 3.15.
    • Setup: left:50%; transform:translate(-50%,-50%); width:226.6px, then gsap.from(el,{x:-400}).
    • Without the adapter, frame 0 is at left=560. With it, frame 0 is at left=446.7, a jump of half the element's width.
    • Widths of 227px and 226.4px don't change.
    • Removing init.ts:346 and rebuilding the runtime brings back 560.
  • No producer golden covers this, so the regression shards stay green.
  • Fix: apply GSAP's native rule with the unzoomed size, so the render is unchanged. Or keep the change on purpose and own it with a body note, a release note and a producer fixture.

Blocker 2: preview text no longer wraps like the render.

  • I laid out 120 paragraphs three ways:
    • the old transform fit;
    • the new zoom fit;
    • a native 1920px page at DPR 1, matching render conditions.
  • Transform fit vs native: 0/120 differ in line count.
  • Zoom fit vs native:
    • 1–2/120 wrap to a different number of lines. For example, a 23.1px sans paragraph in a 295px box is 6 lines in the render and 5 in the preview.
    • 106–113/120 differ in height, by up to a full line.
  • It held at DPR 1 and 2, for 240, 528 and 1100px players, with font hinting on and off.
  • Zoom shapes text at the zoomed font size, so this follows from the approach itself. The GSAP adapter patches one symptom of the changed layout, and text wrapping is another.

Should-fix

  • Resizing re-lays-out the whole composition.
    • The ResizeObserver (hyperframes-player.ts:222) calls _rescale, which writes iframe.style.zoom (iframe-dom.ts:76) on every tick with no debounce.

    • 40 resize steps on a 3000-node composition:

      Fit Layout Style recalc
      Transform 1.9 ms 2.6 ms
      Zoom 794 ms 418 ms
    • That's about 30 ms per step, so dragging a panel janks on a heavy composition.

    • Option: keep the transform during the drag and apply the zoom once resizing settles.

  • Canvases sized from devicePixelRatio stay at fit resolution when the editor zooms in.
    • The editor zooms the stage with a transform (up to 400%, NLEPreview.tsx:246), which doesn't change the frame's DPR.
    • DOM text re-rasters crisp, but VFX canvases size from DPR with no floor (core/src/runtime/vfx.ts:709) and go soft.
    • At 400% on a 2x screen, the share of blurry edge pixels was 0.064 with zoom against 0.017 before.
    • The existing set-display-scale message could size those canvases.
  • One hidden player can switch the fix off for the whole page.
    • zoomsFramesWhole (iframe-dom.ts:84-101) is decided once, by the first player that connects.
    • If that player sits in a display:none container, the probe records "no zoom". The next, visible player then gets transform: … scale(0.275) with no zoom.
    • Fix: treat a 0×0 frame as unknown and probe again later.

Checked and fine

  • Player code is preview-only. The producer and engine don't import the player, and the CLI play/present commands are previews.
  • Geometry is right at high DPR, for small and large players:
    • the frame's DPR equals host DPR × zoom;
    • the inner viewport stays 1920×1080;
    • the iframe sits at its expected rect, so picking math is unaffected (edit-accuracy 20/20 green).
  • Body claims that check out:
    • frameDisplayScale divides out the zoom (protocol.ts:76); a mutant removing it is caught.
    • The probe reuses the initial frame: one frame, one navigation.
    • Renders don't use the player.
  • Tests: core 1920/1920, player 540/540, teleport.test 21/21, and test:browser-raster passes on Chrome.

Nits

  • baseline.json is fully re-recorded, not two cases. All 2061 cases moved, each by at most 0.13px against a 0.5px limit. That still shifts what the gate compares against, so the body should say so.
  • Mutants that survive unit tests:
    • Caught only by the browser test: removing the adapter install, dropping initializeIframeScaling from connectedCallback, and reverting the _connected guard.
    • Survive everything:
      • dropping the probe's DPR check (iframe-dom.ts:100);
      • applying the -50% rule per axis, since mixed centering like translate(-50%, -100%) is untested.
  • Simpler shape, which I haven't tested: keep the transform fit and cap oversized will-change rasters only in the preview. Layout would stay identical to the render, with no GSAP patch.

CI: 77 pass and 8 skipped across 85 distinct checks, 0 failures.

— Rames

@miguel-heygen
miguel-heygen disabled auto-merge October 9, 2026 03:19
@miguel-heygen
miguel-heygen marked this pull request as draft October 9, 2026 03:19
…ayout

CSS zoom shaped text at the zoomed size, so paragraph heights in the
preview differed from the render by up to a line. The transform fit
stays; the centering adapter and zoom-only bench changes go with it.
Chromium never rasters a will-change: transform layer below device
scale, so a preview shown at a quarter size rastered every hinted layer
at full size and could run out of tile memory, dropping layers. While
the player shows the composition below 1x, the runtime swaps the hint
for an identity filter, which keeps its stacking context and
containing block; preserve-3d containers keep the hint.
…n save

The identity filter that replaced will-change: transform showed up in computed filter, so
Studio's blur slider saved it, GSAP filter tweens and clearProps picked it up, and it cut
backdrop-filter off from what is behind it. A far perspective keeps the same stacking context
and containing block, is never composited for flat content, and nothing reads or writes it.

Also: the runtime guard lint (realm-safe element checks), a class change that drops the hint
undoes the swap, and the browser check measures raster scale with the swap off and on at one
shown size and compares frosted glass and 3D pixels.
The perspective that stood in for will-change: transform could still reach a saved project:
Studio records perspective keyframes from GSAP's computed value, and copying a generated
element serialised its inline styles. It also went stale when a parent class or a later
stylesheet changed the element.

Now the runtime writes no inline style. It marks a hinted layer with a preview attribute
and one injected rule sets its will-change to auto. Only layers whose hint changes nothing
are marked: positioned, with no fixed descendants, and no z-indexed or blending descendants
unless the layer has its own z-index. Every class or tree change re-checks the whole page.
Studio strips the mark from copied markup, as it does the other preview marks.
…ine styles need

A layer's ::before or ::after can sit behind it, blend with it or be fixed to it, so those count
like children. A layer that also hints opacity or filter keeps every hint, since those make their
own boundaries. Inline style changes, which scripts make every frame, are watched cheaply: they
only give the hint back to the layers holding an element that now depends on it, or to a layer
that turned static or changed its z-index. Text changes no longer trigger a full pass; a new
stylesheet still does.
…e or late sheet

A marked layer now has its mark lifted while its hints are read, so a class or inline value
that adds opacity or filter gives every hint back. Any attribute change re-checks its subtree,
so attribute selectors are followed, and a linked stylesheet re-runs the pass once it loads.
Lifting a mark to read a layer's own hints repaints it, and a change on a scene container lifted
every marked layer under it (about 6 ms and a re-raster per event). Now only the changed element
is lifted, and a full pass clears all marks at once so the page restyles a single time. Tests
cover leaving marked layers alone, ignoring our own marks, and the linked-sheet load pass.
A full pass re-marked layers one at a time, so each read restyled the page again. Every mark is
now decided first and written after. The browser check holds off the player's own scale
messages while it turns the swap off and on, since a late resize tick could turn it back on.
…s the swap off

Turning the swap off restyles the page, and the player answers by resending its real scale,
which turned the swap back on within milliseconds, so the off half compared the swapped page
with itself. The check now holds the player's sends on the host page, asserts the swap is off
before the native shots, and removes the layers that keep their hint before either trace.
Stripes a few pixels wide alias differently at each raster scale, so the probe measured texture
resampling rather than the 3D shape it is there for. A solid fill keeps the shape and position.
@miguel-heygen miguel-heygen changed the title fix(player): render the preview at its shown size so heavy compositions stop dropping layers fix(core): stop small previews dropping layers on heavy compositions Oct 9, 2026
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks, Rames. Reworked at aca81c35 on the route you sketched under "Simpler shape": the transform fit stays, and only the preview stops oversized will-change rasters. Zoom, the GSAP adapter and the probe are gone. Point by point:

Blocker 1 (GSAP adapter changes renders). The adapter is deleted; nothing in this PR touches GSAP. The new code runs only after a set-display-scale message below 1, which only a preview host sends. The producer, engine and CLI never send it, so a render page never swaps anything.

Blocker 2 (zoom changes text wrapping). Zoom is gone; the preview fits with transform: scale() as on main, so text is shaped at the authored size. The browser check records every element's box at full size and requires each to stay within 0.01px after shrinking.

Should-fix: resizing re-lays-out the composition. Gone with zoom. Resizing only changes the transform. The swap runs once when the shown scale first drops below 1, and later messages below 1 return early. Coming back to 1x restores once.

Should-fix: canvases stay at fit resolution when the editor zooms in. That came from zoom changing the frame's device pixel ratio. With the transform fit the frame's DPR is the host's again, as on main, so this PR no longer changes canvas sizing.

Should-fix: one hidden player switches the fix off. The zoom probe is deleted. Each document swaps from its own set-display-scale, so players are independent.

Nits. baseline.json and the edit-accuracy changes are reverted to main. The adapter mutants no longer apply. The new tests are listed in the body; each check was shown to fail with its own fix removed.

How it keeps layout: the runtime writes no inline style. Below 1x it adds one rule ([data-hf-preview-raster] { will-change: auto !important }) and marks only the layers whose hint changes nothing. A marked layer is positioned, has no fixed descendants, has no z-indexed or blending descendants unless it carries its own z-index (pseudo-elements included), and hints nothing but transforms. Every other layer keeps its hint and its larger raster. Studio strips the mark from copied markup, the same way it strips its other preview marks. Two earlier versions of this head used stand-in properties (filter: opacity(1), then a far perspective). Review showed the editor or GSAP could read them and save them, so they are gone.

Numbers on the 48-layer fixture (528px, DPR 2, 80 MB tiles, full Chrome 154, 3 paired runs vs the merge base): largest raster over shown size 3.64 to 1.00, will-change layers 48 to 0, and frames with missing tiles in the capture 4 to 0. After growing to 1100px, main drew 13 to 17 frames in 2.5 s; this PR drew 186 to 253. The table, captures and test list are in the body.

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 9, 2026 20:52

@jrusso1020 jrusso1020 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.

Request changes at aca81c35, narrowly. The rework fixes every finding from my last review. Renders are byte-identical to main, and layout doesn't move. Two small Studio-side defects remain, each a few lines plus a test. I'll approve a head that fixes both.

My earlier findings

Finding at b44a30e3 Now
GSAP adapter changed renders (init.ts:346) Resolved. init.ts differs from main only by the import and the onSetDisplayScale wrapper. A 2614-node composition with GSAP-animated will-change layers rendered to PNG through the CLI gives 20/20 frames byte-identical, head against merge-base.
Zoom changed text wrap Resolved. Zoom is gone. In Chrome 153, toggling the swap moved 0 of 2614 boxes and changed the line count of 0 of 120 paragraphs. The browser test asserts boxes within 0.01 px.
794 ms resize layout No longer applies. A resize only changes the transform. The swap runs when the shown scale crosses 1x: 9.6 ms the first time, then about 7–8 ms to re-mark and 2 ms to restore.
VFX soft under editor zoom No longer applies: no zoom, so the frame's DPR is the host's again.
One hidden player switched the fix off No longer applies: the probe is gone. Each document swaps on its own set-display-scale.

Preview-only, as claimed: setPreviewRasterScale is reachable only through the bridge's set-display-scale message. Only the player (hyperframes-player.ts:1013) and Studio (runtimeProtocol.ts:40) send it. The producer, engine and CLI render never do, and the module has no load-time side effects. A mark that ends up in a saved file does nothing in a render, because the CSS rule that reads it is injected only in a preview.

Should-fix 1: Studio's stacking model reads the hint the swap drops (core/src/runtime/stackingContext.ts:59-65)

  • The cause: stackingContext.ts decides stacking contexts from computed will-change, and a marked layer now reads auto. That feeds stackingContextId in runtime/timeline.ts:483/594/651 and studio/.../timelineSyncHydration.ts:106, and computeStackingPatches (timelineStackingSync.ts:178-180) compares only clips with the same id.
  • Scenario: clip A sits inside <div style="position:absolute; will-change:transform"> and clip C sits at the root. Once the preview is small, A's id changes from css:1.0.0 to css:root. Dragging A's row above C then returns [{a, zIndex: 1}] instead of [].
  • The effect: Studio writes a z-index into the source that does nothing in the export, because A is still inside the wrapper's stacking context. The timeline order no longer matches the render.
  • Fix: treat [data-hf-preview-raster] as still hinted in stackingContext.ts, or read the authored hint.

Should-fix 2: a text edit saves the mark into the source (domEditingLayers.ts:72-77, :144-160)

  • buildTextField copies every live attribute except style, and the serializer writes them out.
  • Repro: editing "Hello" in <h1>Hello <span class="word" data-hf-preview-raster>world</span></h1> produces the patch Hi<span class="word" data-hf-preview-raster="" data-hf-text-key="child:1:span">world</span>.
  • data-hf-upcoming already has the same gap. But positioned per-word will-change spans are exactly what this PR marks, so this path will be hit far more often.
  • The body's "Studio strips the mark from copied markup" holds only for copy and ask-agent.
  • Fix: keep one shared list of preview marks, used by both liveMarkupWithoutPreviewMarks and buildTextField.

Nits

  • Nothing pins "no message, page untouched". Turning the swap on at module load with no message still passes the browser test, and fails one unit test only through a leftover timer. A unit test asserting that the runtime injects no style element and no marks without a set-display-scale would pin the render guarantee.
  • Per-frame cost is a bit above the body's 1–2 ms. With 450 GSAP-animated words in 30 marked lines (Chrome 153), script time per frame is 3.90 ms with the swap against 1.11 ms without, and adding a stylesheet costs 15.3 ms against 0.6 ms. Toggling a class on a container is cheaper with the swap (5.3 against 11.5 ms). None of this blocks.
  • "Pixels unchanged" is slightly strong. Paint order is identical, and static text and overlays match. The GSAP-scaled words re-raster sharper, which the body does mention.

Tests:

  • Core 4408 pass (runtime 1942/1942, previewRasterHints 10/10), Studio 7814 pass (authoredSource 1/1), Player 553/553.
  • test:browser-raster passes 3/3 on chrome-headless-shell 152.
  • Typecheck, oxlint and oxfmt are clean.
  • Mutants:
    • restore only above 1x: 6 unit tests fail;
    • dropping the layer's own ::before/::after check: the browser test fails;
    • removing the authoredSource strip: the studio test fails;
    • dropping unmarkHolders: 1 test fails;
    • turning the swap on with no message survives (nit above).

CI: regression on the latest run failed because shard 9 hit a Docker Hub 429 while pulling the node image, and the other shards were cancelled. That's infrastructure. The earlier run at this same head had regression and all 10 shards green. A rerun should clear it.

— Rames

…ext edits

Studio's stacking model read a marked layer's will-change as auto, so a timeline drag could
write a z-index that does nothing in the render; a marked element now still counts as a stacking
context. A text edit copied every live attribute but style, so it could save the mark; the
preview-only attributes are now one list, used by both the copy and the text-edit paths.
The runtime keeps a video across a scene swap only when its live attributes match the fresh
import, and the preview raster mark made them differ, so a hinted video in a small preview was
rebuilt on every edit. The comparison now ignores every preview-only attribute, which also
fixes the same rebuild for data-hf-upcoming. The shared list is renamed PREVIEW_ONLY_ATTRS, and
the lazy-image strip in copied markup gets a test.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks, Rames. Both should-fixes and the nit are in at 46e70977:

Should-fix 1 (stacking model reads the dropped hint). stackingContext.ts now treats an element carrying data-hf-preview-raster as a stacking context, since the mark only ever stands in for a dropped transform hint. Your scenario keeps css:1.0.0 for clip A in a small preview, so computeStackingPatches returns [] and no inert z-index is written. Test: core/src/runtime/stackingContext.test.ts (fails with the line removed).

Should-fix 2 (text edit saves the mark). The preview-only attributes are now one list, PREVIEW_ONLY_ATTRS in @hyperframes/core/studio-preview-mark (data-hf-upcoming, data-hf-preview-lazy, data-hf-preview-raster). buildTextField skips them along with style, and liveMarkupWithoutPreviewMarks loops the same list. Your repro now serializes Hi<span class="word" data-hf-text-key="child:1:span">world</span>. Test in domEditingTextFields.test.ts (fails with the filter removed).

Also from a review of that delta: the runtime's keep-unchanged-media check compared live attributes, so a marked video was rebuilt on every scene swap in a small preview. RUNTIME_MEDIA_ATTRS now spreads the same list, which also fixes the same rebuild for data-hf-upcoming on main. There are two new rows in init.swapScenes.test.ts.

Nit (nothing pins "no message, page untouched"). previewRasterHints.test.ts now asserts no style element and no marks before any set-display-scale, and a hinted layer still reads transform.

Nit (per-frame cost). Thanks for the numbers; the body now cites the review's measurements rather than a single figure. "Pixels unchanged": the body says fine detail in a swapped layer rasters at the size shown and can look a little different, while layout, shapes and positions stay.

CI at aca81c35: all five regression-shard failures were Docker Hub (one 429 on the node image, three token timeouts pulling buildkit) before any test ran; regression is their aggregate. 46e70977 runs fresh.

Checks at 46e70977: core (scene swap, raster hints, stacking, timeline) 225/225 x3, studio set 58/58 x3, test:browser-raster 3/3 in full Chrome 154, guard lint and comment ratchet clean.

@jrusso1020 jrusso1020 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.

Approve at 46e70977. Both should-fixes from my aca81c35 review are fixed, and each fix has a test that fails without it. The nit about the untouched page is pinned too.

Should-fix 1, stacking: fixed (stackingContext.ts:72)

  • A marked element now counts as a stacking context.
  • That's exact, not just safe. The runtime marks a layer only when every will-change hint is transform, translate, rotate or scale (previewRasterHints.ts:15-18). All four are in STACKING_CONTEXT_WILL_CHANGE_PROPERTIES, so the authored layer was always a stacking context. The id is the same path-based css:… either way, so computeStackingPatches sees the same id with or without the mark.
  • stackingContext.test.ts covers it. Deleting the line fails that test.

Should-fix 2, saved text edits: fixed (domEditingLayers.ts:59, :76)

  • buildTextField now skips style plus every entry of the new shared PREVIEW_ONLY_ATTRS. liveMarkupWithoutPreviewMarks uses the same list, so the two can't drift.
  • The test round-trips <span class="word" data-hf-preview-raster data-hf-upcoming> and checks the saved patch carries neither mark. Reverting the filter fails it.
  • The lazy mark's loading="lazy" isn't stripped on this path. That can't happen here, because isEditableTextLeaf only takes text tags with no children, so an <img> never becomes a text field.

Also in this head

  • Scene swaps (init.ts:295): RUNTIME_MEDIA_ATTRS now includes the preview marks. A marked video is no longer read as an authored change during a scene swap. Removing the spread fails 2 of the new swap tests.
  • authoredSource.ts: the strip loop is rewritten around the shared list and behaves the same as before. The new lazy-image test fails if loading is no longer dropped.
  • Untouched page: previewRasterHints.test.ts now asserts no style element and no marks before any set-display-scale. That covers my nit.

Tests

  • Core runtime 1946/1946 (76 files), Studio editor + utils 2801 pass (16 todo).
  • Mutants, 4 of 4 caught:
    • dropping the stacking line;
    • reverting the text-field filter;
    • removing the marks from RUNTIME_MEDIA_ATTRS;
    • keeping loading on a lazily previewed image.
  • This approval waits for the required checks at this head.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 5562c28 Oct 9, 2026
165 checks passed
@miguel-heygen
miguel-heygen deleted the fix/player-preview-raster-size branch October 9, 2026 23:36
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.

2 participants