* fix(cli): check's dense geometry seeks stop waiting for video frames they never read
* test(cli): race the dense seek against the barrier ask instead of a timer
* fix(cli): keep the geometry seek's comment share at its base
* fix(cli): dense geometry seeks still wait for frame sources, only video frames are skipped
* perf(core): probe media volume with one timeline walk per pass, not one per element
Each bind, rebind and scene-swap pass probes every media element for volume
automation, and each probe flattened the whole captured timeline
(getChildren(true, true, false)) to ask whether any tween could move that one
element's volume. A pass cost elements x tweens. Elements with no automation are
never cached, so the cost came back on every rebind pass.
On a two-hour podcast episode (2,354 media clips, ~100k caption tweens) that was
several seconds of preview main thread per pass (CPU profile of the project's
open in Desktop: IE/PE -> getChildren, ~6 s).
volumeMoversOf() now walks the tweens once and lists the elements a tween names
volume on, plus whether any non-DOM setter is tweened. Each pass builds it lazily
and shares it with every probe; a probe without one walks as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): find the master bus from the first composition root
The top-level root is the first [data-composition-id] in document order, so
one match replaces a scan of every composition node, read once per clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): lay out a clip again only when its layout inputs change
applyClipLayout ran on every timeline post and, for every root clip, lifted
display:none, read computed style and restored it. A clip whose inline
layout, class and data-width/height are as last laid out gets the same
answer, so it is skipped. On a 2,354-clip episode that was 4 of every 10 s
of playback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(core): a clip is laid out again only when its layout inputs change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): play a long film without re-walking it every frame
- The visibility pass skips root-level media far from the playhead during
steady playback: hidden last pass, not loading or armed, and its window
(widened by every look-ahead) clear of the span played and the loop start.
Seeks, forced syncs and renders visit every node.
- The GSAP re-render saves and restores only the landing marks near the
playhead, placed once per timeline structure; the zero-duration-callback
check is cached the same way. A caption track of 38k sets was walked twice
a frame.
- Registered timeline hosts, the root composition and the per-frame
whole-film queries ([data-start], audio groups, timed audio) are kept for
one timing revision; a render reads fresh.
- The runtime's own media release (src reset) and load events of clips with
an authored length no longer count as timing changes, which re-posted the
timeline about twice a second.
- The timeline payload resolves a stacking context once per parent.
2,354-clip episode in Desktop: playback 15 -> 67-89 fps (unminified runtime).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(core): check each element once per batch of raster-hint records, and cache the root from init
- A script building a large tree (a caption track) made each attribute record
re-walk its subtree with getComputedStyle; a batch now checks each element
once, an element whose own attributes changed still reading its own hints.
- The root composition cache installs the timing observer itself: a root
with a declared length never reads the duration floors that installed it,
so init looked the root up once per media clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): type the playback visibility skip for any element, and cache clip layout only once loaded
The skip's predicate took an HTMLElement where the pass hands it a
StylableElement (tsconfig.runtime.json). The layout cache records a clip only
once the document has loaded, so a pass before stylesheets apply is redone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): realm-safe element checks in the timing caches; cut added comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): exported scenes keep cards that fly in, instead of one frame of each
Under the producer's GSAP stub every timeline is a stand-in. GSAP's bundle sets
window.gsap twice, so the stub wrapped twice; and the runtime compared those
stand-ins against GSAP's real children. Nesting a scene into the root never
read back as nested, so the scene ended up parked under an abandoned composite
at time 0, and each render seek's play() snapped it back to 0. Tweens whose
from-values set opacity lost them after their first frame.
The stub now wraps a GSAP instance once, and the runtime compares the timeline
behind a stand-in.
* fix(core): keep the stand-in note to one line
* fix(core): a caption line shows again after the playhead jumps back over it
The runtime hides an off-screen clip with an inline visibility:hidden that its
children inherit. A seek that jumps over a clip renders that clip's GSAP tweens
for the first time while it is hidden, so a set or autoAlpha tween records the
inherited "hidden" as the value to restore. Seeking back then writes it back,
and the element stays hidden for the rest of the session.
Interactive seeks now render GSAP with hidden clips shown, so first reads see
the authored style. Play ticks and render captures move forward and are
unchanged.
* fix(core): keep the files' comment share where it was
* fix(core): show hidden clips for any seek that jumps past what has rendered
Clicks and scrubs were covered, but render seeks jump too (hover frames,
thumbnails, stills at several times). The decision now lives in the seek
itself: a seek more than a frame and a half from the last one, to a time the
timelines have not rendered through yet, renders GSAP with hidden clips shown.
Frame-by-frame renders, play ticks and scrubs over already-rendered time pay
nothing.
* fix(core): timed svg clips hide outside their window
* fix(core): type the clip visibility path for SVG as well as HTML elements
* chore(core): regenerate the position edits render script
* fix(core): a faint SVG grain is not the click target, and a page reset is not an element's CSS
A filter rect under an svg at opacity 0.07 was still picked. The veil check
required that element's own opacity and a background-image. A `*` rule matched
every element, so a rect was given the page reset as its CSS.
fails without the fix: clicking the grain selects the rect, and inspection attaches `*`.
* fix(core): keep picker comments within the source ratchet
* fix(core): preserve selector literals and SVG veil boundaries
* fix(core): a hidden scene's contents stay hidden when a child is set visible
* fix(core): keep the hidden-clip rule's file within its comment share
* fix(core): hide a hidden scene's contents by the visibility pass's own mark
* fix(core): keep a scene's video across a swap when the hidden mark is on it
* test(core): pin that a mask-hidden container's contents are left to the mask
* test(producer): a nested scene stays hidden before its host starts in the static-volume baseline
* fix(core): a scene copy a shader transition forces visible shows its contents
* fix(core): show a scene's starting state on a render's first frame
A seek to the time a GSAP timeline already sits at renders nothing, so a
sub-composition timeline at 0 never applied its t=0 steps before frame 0
was captured. Every runtime seek now goes through seekGsapTimeline, which
redraws the timeline silently when the seek did not move it.
* test(producer): share the frame-level reader across render tests
* fix(core): name the in-place render skip instead of commenting it
* perf(core): redraw a parked scene timeline once instead of every frame
* fix(core): redraw only a scene timeline GSAP has never drawn
* fix(inspector): report nested hosts' authored window, one location per source line
The runtime strips data-duration/data-end from nested composition hosts after
boot and keeps them as data-hf-authored-*. Timeline inspection built its
resolver without authored attrs, so a host with data-duration="4" reported
its GSAP timeline length (3.72s) instead.
Timeline text and events[].sources listed the same file:line once per mapped
snippet (creation, caller, dependency), so a one-line script printed one
location up to nine times.
* refactor(inspector): carry the dedupe reason in the helper's name
* fix(inspector): end continuation on a page that fits no snippet; fill budget with candidates
* fix(inspector): timeline groups by component, names attr values, eases, film end
* fix(studio-server): stamp hf ids into a file without reformatting it
* fix(inspector): find an element's tag before its served hf id reaches the source
* fix(inspector): live element map shows authored classes and text, not the playhead's
* fix(inspector): collapse script-built text so the element map ignores the playhead
Class names stay in source order, a runtime-stripped id is shown as id="…"
rather than a selector, and a grouped row names which children it covers.
* fix(inspector): keep paging, stamping, and the live map still on the reported edges
A snippet larger than the page ended the walk, so later snippets could not
be reached. Raw text ended at a longer tag name, which could stamp an id
inside a script. An empty or mixed source element showed live text, and an
unknown duration read as a film that ends at 0s.
* fix(inspector): mint ids for an empty hf id and keep an unquoted class
An empty data-hf-id was treated as already assigned, so stamping left it
blank while the older stamper minted one. An unquoted class was read as no
class, so the live map dropped it.
* fix(inspector): narrow markup before reading a child tag
skipMarkup returns a boolean, so the build still sees Markup where a tag
is required. Narrow on the kind first so the inspector typechecks.
* fix: quiet check false positives and name the ancestor of an id-less target
Endpoint contact, disjoint fromTo pairs, and a negative z-index under a
stacking-context parent were warnings with bad advice. Layout and contrast
blamed a dot grid, a circular avatar's panel, and a visibility override.
A faint texture veil is no longer the click target.
* fix(lint): drop a history sentence and six gsap comments
The comments check treats "load-bearing" as history, and gsap.ts added six
comment lines. The names already carry what those lines said.
* fix(parsers): ignore an empty id written inside another attribute
The empty-id scan matched the first data-hf-id="" in the tag, including one
inside a quoted title. It now skips quoted text, so the real attribute is minted.
* fix(lint): keep real covers and a negative z-index under a card
A gradient scale-down treated every fill like a radial dot, so a panel
that starts transparent stopped counting as a cover. A stacking context
on any ancestor cleared a negative z-index, and a faint wrapper made
images inside it unpickable.
* fix: keep an emoji from shifting a stamped id
The quote mask replaced one emoji with one space, so the id landed before
the closing quote. A radial cover that turns opaque after a clear stop
stays fully painted.
* fix(lint): score a letter-only dot as a dot, not a color name
namesAColor saw #fff and rgba as words, so those dot grids counted as a
full cover. Hex and rgb colors are stripped before that word check.
* fix(core): keep the colour grade on a graded video's first rendered frame
A graded clip rendered ungraded for one frame wherever a capture session
first drew it: the clip's own first frame, a parallel worker's first frame
inside it, or a capture segment's first frame inside it. The grade dropped
the clip while its injected frame was still decoding, so the redraw after
decode had nothing to draw.
Part of #5548
* chore: pin the producer test-classification hash and drop an added comment
* fix(core): a render keeps a fromTo's from-values when a frame lands just after its start
The frame redraw steps to 1 ms below the frame and back. When a tween starts
inside that 1 ms, the step down reverts its from-values and the step up does
not reapply them, so a wipe bar whose opacity comes only from its from-vars was
never drawn. Reapply them for every tween whose start the step crossed, as
#5125 does for a tween starting exactly on the frame.
Adds the gsap-redraw-start-and-overlap producer fixture, which also pins that a
slow drift resumes after a short move on the same property, as in playback.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(core): keep the redraw's comment share and schedule the new fixture
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(inspector): element map, live inspection engine and Node runtime
* fix(inspector): reject preview reload races
Return the live document generation from the same page invocation as each
query and reject mismatches. Rearm browser cleanup for failures before a
page operation starts, and cover reloads across all three live operations.
* feat(inspector): query timelines and inspect the live preview
* fix(inspector): align live timeline contracts
Keep realm-safe target detection through the restack and align
floating-point interval boundaries. Update the live group fixture and
make the private runtime entry build itself before use.
* fix(inspector): declare generated build inputs
The WAAPI and CSS adapters wrote currentTime and then called pause().
On a running animation pause() is pending until the next animation
frame, and Chromium then sets the hold time to the time at that frame,
16.7 ms past the sought time. Pause first: writing currentTime while a
pause is pending completes it with the sought time.
An animation created after the runtime's discover seek is still running
when a render worker makes its first seek, so the first frame of each
worker chunk after the first was drawn 16.7 ms late.
* feat(audio): composition master bus (data-fx-chain on the root)
A clip can belong to only one audio group, so there was no way to put one
effect, such as a limiter, across the whole mix. The data-fx-chain on the
top-level composition root now runs once over the sum of every clip and every
group, after each group's own chain and fader. No new attribute: a sub-composition
root's data-fx-chain was never applied to anything and still is not.
Render: when a master chain exists, mixAudioTracks writes a float WAV instead
of AAC, applyAudioFxChain runs the chain over it, and ffmpeg encodes AAC last,
so the chain reads unclipped samples. An unreadable chain is a mix failure, as
for a group. Preview: the transport splices the chain between the clips' sum and
the monitor, re-anchored per play generation and watched for edits.
Only the top-level root is read. No master automation: the root's data-automation
is not read as the bus's. No lint rule: data-fx-chain is valid on clips, groups
and roots, and the one place it does nothing (a sub-composition root) has always
been silent.
— Jerrai
* fix(core): preview routes every audible video through the master bus
A root data-fx-chain is attached only inside the Web Audio graph, but a plainly
audible <video> joined that graph only for its own chain, group, automation or a
volume over 1. So preview limited the music and left the talking head dry, and a
composition whose only audio is video got no master chain at all, while export
sums every audible element into the master. A master chain on the top-level
composition root is now a reason to join, the same as a video's own chain.
Elements under a root with no chain keep native output. Sub-composition roots
are still ignored.
— Jerrai
* fix(audio): master bus review round 2 (temp dir, tests, docs, Studio meter)
- audioMixer: a fatal FX error in the master pass or a group bus left the
work directory (the full float sum, about 1.4 GB an hour) on disk. The
final mix now runs in try/finally and the group rethrow removes it first.
- Tests: the "once over the sum" case now uses a limiter then a gain, so a
per-clip chain (-10.4) and a doubled one (-18) both fail it; a new case
proves the sum stays float until the chain reads it; the fader case uses
a group volume of 0.5 so the fader's position is exercised; the work-dir
removal is covered for both paths.
- Docs: "Three rules exist" (the master-bus lint rule is gone), a bad master
chain stops the render with audio_processing_failed, a sub-composition
file opened on its own has its root chain as the master bus, and a seek
clears a group or master limiter.
- Studio: TimelineElement.masterChain carries the root's data-fx-chain, so
the program meter shows for a video-only composition whose only audio
routing is the master chain, as preview now plays it.
- Comment blocks over four lines cut; webAudioTransport.setRate split so
its CRAP score is under the Fallow threshold.
— Jerrai
* fix(audio): master chain reaches tracks the render adds; honest plain-limiter copy
- Master chain: it was carried on each parsed AudioElement, so a track the
producer adds itself (appendAutoDetectedVideoAudio, the browser-discovered
audio in probeStage) never had it, and a composition whose only audio was
such a track exported with no master chain, no ceiling and no warning. It is
now a composition property: parseMasterFxChain(html) at compile, carried on
CompiledComposition and CompositionMetadata, passed to processCompositionAudio
by runAudioStage (in-process and distributed plan). AudioElement.masterFxChain
is removed so there is one source. Each forward (compile stage, local
orchestrator, distributed plan) has a test that fails when it is dropped.
- Plain limiter: it follows the level with no lookahead and Level after is
applied past the ceiling, so it cannot promise "nothing gets past" or "never
clips". Studio copy (description, panel text, knob label, summary) and the
hyperframes-audio skill now say a fast or inter-sample peak can pass it and
point to the true-peak limiter for a delivery limit. A copy test guards it.
— Jerrai
* fix(producer): keep master bus comments within the ratchet; run the plan test with the ffmpeg tests
Drop three one-line field comments that raised their files' comment share,
and list plan.masterBus.test.ts with the integration tests because plan()
reads the ffmpeg version.
— Jerrai
* chore(ci): repin the producer test-classification source in the reachability map
The reachability map pins the hash of test-classification.mjs, which now
lists one more integration test. The map's producer lane command already
covers every test under src, in both the unit and integration matrix lanes.
— Jerrai
* fix(core): an element its script hides stays hidden while studio preview plays
* fix(core): a stamped element re-timed in the timeline hides outside its new window
Studio timing edits drop the runtime-stamped mark, and a script-hidden stamped
container no longer holds back its images.
* fix(core): a preview-only stamp copied into a project no longer changes the render
Only the Studio preview honours the stamp, copied live markup drops it, and a
lane-only move makes a stamped element authored like the save does.
* fix(core): preview plays a split sub-composition's videos from its in-point
* fix(cli): timeline shows a split sub-composition's clips from its in-point
* fix(core): split halves cut nested media and keep each clip's loop, ramp and fades
* fix(core): a scene's media plays past a shorter root animation again
* fix(core): only a host with an in-point shifts or cuts what it holds
* test(core): a split half with no id stops at its own end
* fix(core): the playhead and Studio's clip list skip what an in-point cuts
* fix(core): keep clips under a cut-away nested host nested in studio
* refactor(core): name why a cut-away nested host stays listed
* fix(core): a sub-composition timeline with a non-function duration no longer aborts the runtime
* fix(core): every registered-timeline duration read survives a partial or throwing entry
* fix(core): a runtime that fails to start reports its real error instead of a half-built page
* fix(core): a timeline post that throws after start-up is named too, and a later good post clears it
* fix(cli): check skips a custom clock whose time() returns null instead of reading it as stuck at 0
* fix(core): a recorded runtime start-up error stays set instead of being cleared by a later post
* fix(core): the runtime failure message says what to fix
* fix(core): the runtime failure is recorded even when the thrown value cannot be printed
* chore(core): format and regenerate the position-edits bundle for the startup error recorder
* fix(cli): keyframes and validate close the browser when the runtime fails to start
* fix(studio): text in Studio reads without em or en dashes
* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable
* test(studio): the dash check test covers every entity form
* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too
* fix(producer): the gate check stops early on the engine's new fallback wording
* feat(core): add a lookahead true-peak limiter FX (truepeak)
The existing limiter is an envelope follower with no lookahead and no
inter-sample detection, so a -14 dB ceiling can leave the true peak several dB
over. truepeak estimates the true peak on a 4x polyphase interpolator, takes the
minimum required gain over a lookahead window and smooths it with a boxcar of
the same length, so the gain is already down when the peak leaves the delay
line. Release is exponential and can only lower the gain further; channels
share one gain.
The offline render lengthens the OfflineAudioContext by the chain's latency and
drops the lead-in, so a limited clip stays sample-aligned with its source.
Preview plays the lookahead (about 3.3 ms at the default).
— Jerrai
* docs(audio): truepeak ceiling is a 4x estimate; measure it with an ideal interpolator
The unit test's meter was a 96-tap Kaiser sinc, which rolls off near Nyquist and
reads full-band noise about 0.5 dB low, so the PR reported a 0.3 dB broadband
overshoot. Against an ideal 16x interpolation (zero-padded FFT, cross-checked with
a 16x soxr resample) hot full-band white noise ends 1.2 to 1.7 dB over the ceiling
(median 1.4 over 28 runs of 10 to 30 s). ffmpeg's ebur128 true peak is also a 4x
meter and reads the same files 0.1 dB over.
- The test meter is now the FFT interpolation. Tolerances are what it measures on
each fixed signal: tones 0.01 dB (they land on the ceiling), the dense program
0.5 dB (reads 0.445, the old 0.4 passed only because the meter read low), and a
new full-band noise case at -1, -6 and -14 dBTP with 1.35 dB (reads 1.09 to 1.31).
- audio-effects.mdx and the hyperframes-audio skill say the ceiling is a 4x
estimate, give the measured overshoot and recommend a 2 dB margin under a hard
delivery limit.
- skills-manifest.json regenerated.
— Jerrai
* fix(core): truepeak UI copy no longer promises a hard ceiling
The registry description and the rack summary said the true peak is never above
the ceiling. The limiter holds a 4x estimate, and full-band noise can land up to
about 1.7 dB above it, as the docs now say. The summary reads "held near", the
description names the estimate and the overshoot, and the rack blurb says "holds
... to a ceiling". No skill file changed, so the skills manifest is unchanged.
— Jerrai
* fix(core): Studio truepeak copy states the 4x-estimate margin
The ceiling control was labelled "Never exceed" and the module said the mix
"must not clip", so a Studio user never saw that dense full-band content can end
up to about 1.7 dB above the ceiling. The label is now "Target ceiling" with a
hint that names the 4x estimate and the headroom to leave, the blurb says
"toward a target ceiling", the rack summary says peaks are pulled down toward the
value, and the registry hint no longer says "highest true peak allowed". The
audio skill's "hard dBTP ceiling" wording is gone and the skills manifest is
regenerated. A copy test keeps the promise out.
— Jerrai
* fix(core): truepeak clears on a seek, and the review nits (fallow, comments)
- Unexport TRUE_PEAK_OVERSAMPLE, TRUE_PEAK_DETECTOR_DELAY and
truePeakLookaheadSamples: nothing imports them (Fallow audit).
- A persistent group bus keeps its chain across a seek, so the limiter's
delay line and held gain carried over. The worklet takes __hfReset, the
graph exposes FxChainHandle.reset(), and reanchor() calls it.
- The processor-loading harness is shared by the truepeak and worklet tests
(it was a 12-line clone); the test FFT no longer repeats audioCarve's
bit-reversal; two test bodies are under the CRAP threshold.
- Comment blocks cut to four lines or fewer (Comments check).
- Docs state the preview delay in ms.
— Jerrai
* fix(engine): copy border-width/style/color onto the replacement video frame
injectVideoFramesBatch substitutes each <video> with a sibling <img> holding
an extracted still frame, copying an allow-listed set of CSS properties from
the video's computed style onto the img. border-width/border-style/
border-color were absent from that allow-list, so any border authored
directly on a <video> never reached the replacement image and disappeared
from render/snapshot output. border-radius and clip-path were already on the
list and already clip a replaced element's content correctly without
needing overflow:hidden, confirmed by a real-Chromium test that exercises
the actual capture path and reads real screenshot pixels.
Also corrected a stale comment in frameCapture.ts claiming
detectCssEffectRisk can return "clip-path" as a risk value -- it can't; that
string only ever comes from a separate, animation-only at-risk-props gate
further down the same file.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* test(producer): regenerate style-9-prod golden for the border fix
style-9-prod's video has an authored 8px border + 16px border-radius
(design_review.md calls out the "floating" look) that never rendered before
this fix, so its committed golden baked in the pre-fix (borderless) output.
Regenerated via `tsx src/regression-harness.ts style-9-prod --update`;
verified the diff is exactly the border/radius becoming visible (extracted
frames before/after) and the fixture passes its own visual/audio checks
again (100/100 checkpoints, correlation 1.000).
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(engine): measure the video's box before styling its replacement frame
injectVideoFramesBatch measured the video's used box (offsetLeft/Top/
Width/Height) after creating, inserting, and styling the replacement
<img> sibling -- once that copy loop started carrying border-width
(added for the border-visibility fix), the freshly-inserted, still
in-flow, now-bordered <img> competed for space in a flex row before
the video's own box was ever read. In a flex-centered layout (video
width:100%, sharing a row with the img sibling), that shrank the
measured video box by the exact width of the border, so the img ended
up sized from the video's post-shrink box instead of its authored one.
Moved the measurement to before the <img> is created/inserted/styled
at all, and set img.style.boxSizing = "border-box" explicitly after
the style-copy loop, since offsetWidth/offsetHeight (and the
getBoundingClientRect fallback) are always a border-box measurement
regardless of what box-sizing value that loop copies from the video's
own computed style.
Added a flex-centered regression test asserting the replacement img's
box stays identical to the video's box; confirmed it fails with the
old measurement order (a 16px border shrinks a 500px box to 484px) and
passes with the fix. Regenerated the one fixture (style-9-prod) whose
committed golden had baked in the shrunk-frame geometry.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(engine): consolidate video-box measurement into one object
Follow-up to c88890357: group the four measurement locals into a
single videoBox object instead of four loose consts, and trim the
surrounding comments down to their load-bearing content. No behavior
change -- same measurement order, same fallback conditions, same
applied style values; re-verified red (reverting the measurement order
still reproduces the 500px -> 484px shrink) and green.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(engine): trim the border-fix comments to the invariant
* test(producer): keep main's style-9-prod compiled.html; timings are unchanged
* test(producer): repin the classification source hash for test reachability
* fix(core): a color-graded bordered source keeps its canvas inside its border box
* fix(core): a graded source with an empty content box still shows its border
* fix(core): a border-only graded canvas stays hidden until the source has drawn
* fix(core): border-only graded canvas shows only while grading hides the source
* test(core): pin the border-only canvas hide after a context loss
* fix(core): grading canvas carries no border, so a graded source never paints it twice
* fix(core): graded injected frame drops its border once grading draws over it
* fix(core): a released grading entry gives the injected frame its border back
* fix(core): a released injected frame takes the video's current border style
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(runtime): graded and snapshotted pictures stay readable in sandboxed players
* refactor(shader-transitions): keep the picture cache private
* fix(runtime): drop the snapshot picture cache, html2canvas cannot run in a sandboxed page
* fix(runtime): a CORS copy redraws every grading waiting on it
* fix(runtime): a loading CORS copy redraws each waiting grading once
* fix(player): render the preview at its shown size so heavy compositions stop dropping layers
* test(player): check a small preview rasters its will-change layers at the shown size
* fix(player): accept device-pixel snapping in the zoom probe
* fix(player): probe the initial frame and preserve editing coordinates
* fix(player): defer every initial source reload until setup
* fix(studio): convert measured frame coordinates once
* test(studio): bank reduced preview opening work
* fix(core): keep centered layers exact when the preview is zoomed
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.
* test(studio): bank the two resize cases that now pass edit accuracy
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.
* fix(core): keep stylesheet-centered layers exact under preview zoom
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.
* fix(core): restore only centering, the one percentage GSAP infers
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.
* fix(core): restore centering only where GSAP would infer it
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).
* fix(player): fit the preview before the composition starts loading
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.
* test(player): pin the first fit before the composition source
* revert(player): drop the zoom fit so the preview keeps the export's layout
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.
* fix(core): raster a small preview at the size it is shown
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.
* fix(core): keep a small preview's raster swap out of filters edits can 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.
* fix(core): drop preview raster hints only where nothing depends on them
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.
* fix(core): keep raster hints that pseudo-elements, other hints or inline 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.
* fix(core): re-read a marked layer's own hints and follow any attribute 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.
* perf(core): only lift a raster mark on the element that changed
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.
* fix(core): decide raster marks before writing them in a full 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.
* test(player): hold the player's own scale while the raster check turns 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.
* test(player): give the raster check's 3D probe a solid fill
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.
* fix(studio): keep the preview raster mark out of stacking and saved text 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.
* fix(core): keep a marked video through a scene swap
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.
* fix(core): a data script in a sub-composition no longer stops every timeline
A sub-composition's <script type="application/json"> was wrapped and run as
JavaScript, and because the bundle merges every composition script into one,
its parse error stopped every scene's timeline. Data scripts now stay in the
DOM as authored, and a script run that does not parse is left unmerged so a
bad script fails alone.
* fix(core): render and preview keep a broken composition script from stopping the rest
The render path's script merge had the same gap as the bundler: one script
that does not parse stopped every timeline. Both now share one parse check.
A run left unmerged, and every scene-part script, is still escaped, so a
string holding "</script>" can no longer end the script early in preview.
* refactor(core): carry the bundler's script-merge reasons in names, not comments
* fix(core): every composition path runs only the scripts a browser would
A script check now matches the browser (esbuild accepted export and top-level
await), so such a script can no longer stop every timeline. Inline template
compositions keep data scripts and run module and import map scripts as such.
A data script beside the composition root is mounted next to it, a nomodule
script never runs, and the render path merges scripts on a page with no head.
* fix(core): keep inline-template data scripts and run a script with a top-level return alone
- parsesAsScript compiles with vm.Script, so a top-level return or new.target
no longer passes as mergeable and stops every timeline.
- The bundler's inline <template id="X-template"> path asks the shared
assembly plan for its root and mounts the inert scripts authored beside it.
- nomodule only stops classic scripts; type="module" nomodule runs, as in
browsers.
* refactor(core): the inline template path reads its root id from the shared assembly plan
* fix(core): a broken composition script stays apart under Bun as well as Node
parsesAsScript now parses with acorn in script mode instead of vm.Script.
Bun 1.3's vm.Script compiles lazily and accepted any text, so the Studio dev
server merged a broken script with the rest and every timeline stopped.
acorn is already locked for parsers and lint; core now declares it.
* test(core): name the parse-check test for what it pins
* style(core): wrap the data-block comment under the line limit
* fix(core): preview stops at the film's end when a scene loops forever
* refactor(core): the loop length threshold lives with the composition length
* test(core): pin the endless-loop length at the boundary and through the runtime
* style(core): keep composition length comments under the file's share
* refactor(studio): preview duration guard reads the core loop length limit
* docs(core): length doc says a loop yields only to a floor or fallback
* fix(core): write only authored hue curves, so a single hue curve passes lint
* fix(cli): apply over a grading saved with empty hue curves instead of refusing it
* fix(core): read grading properties from computed style, and stop advising an inline start
* fix(cli): preserve validation during legacy hue repair
* fix(cli): preview grading writes without apply
* fix(core): derive generated media labels from current source
* fix(core): decode generated media filenames
* fix(core): share filename decoding across URL forms
* fix(core): strip filename suffixes without unsafe indexing
* fix(studio): rename unreferenced generated media ids on replace
* fix(core): register media ID exports and trim names in linear time
* perf(studio): preview decodes hard-to-play videos at the size they are shown
Studio's copy of an HEVC or ProRes clip was made at the source's full size, so a 4K clip was decoded at 4K inside a preview a few hundred pixels wide, and back-to-back 4K clips flashed the background at their cuts. The player now reports how large it shows the composition, the runtime asks for a copy sized to the shown video (on a ladder of sizes, never above the source), and Studio stops pre-warming source-size copies. CLI play, static serving, publish and renders keep the source-size copy.
* fix(studio): sized preview copies follow zoom, hover previews and the newest size
Studio reports the preview's on-screen scale when a zoom settles and for composition hover previews, which no player measures. The runtime loads only the newest size asked for, regrows a first copy made while the frame grew, and never replaces a copy with one smaller on either side. The player's scale is measured against the frame's own layout width.
* fix(studio): keep comment shares and file sizes within the repo ratchets
* test(studio-server): end the sizing test's ffprobe options before its input
* fix(studio): hover previews report their size once their runtime is ready
* perf(core): render a playing frame once when the host also ticks it
* fix(core): reset host tick dedup on playback start
* fix(core): deduplicate host seeks by rendered timeline time
* test(core): advance host tick clocks deterministically
* feat(core): support deterministic async frame sources
* feat(core): bridge preserved film HTML to native capture
* feat(core): bound imported scene timing to source intervals
* test(producer): verify film bridge pixels in real browser
* fix(runtime): retain queued draws and disable unsafe dedup
* fix(runtime): fail promptly on runner reloads and send errors
* fix(runtime): align film draws with export windows
* docs(core): mark frame-source hosts with data-no-timeline
A composition that follows the frame-source contract registers no GSAP
timeline, so lint reported missing_timeline_registry and every render
waited 45 s for timeline registration (17.6 s -> 1 min 47.9 s for the
14 s imported film sample). Document the existing data-no-timeline
opt-out for the host and a timeline-less root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(core): require data-no-timeline on imported film hosts
Imported film wrappers have no GSAP timeline. Without data-no-timeline
on the scene hosts and root, lint fails with missing_timeline_registry
and each render waits 45 s for timeline registration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): avoid transport redraws after render seeks
* test: set explicit film fixture canvas background
* test: diagnose film capture timeout phase
* test: foreground film pages before screenshot capture
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): support deterministic async frame sources
* feat(core): bridge preserved film HTML to native capture
* fix(runtime): retain queued draws and disable unsafe dedup
* fix(runtime): fail promptly on runner reloads and send errors
* docs(core): mark frame-source hosts with data-no-timeline
A composition that follows the frame-source contract registers no GSAP
timeline, so lint reported missing_timeline_registry and every render
waited 45 s for timeline registration (17.6 s -> 1 min 47.9 s for the
14 s imported film sample). Document the existing data-no-timeline
opt-out for the host and a timeline-less root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(core): require data-no-timeline on imported film hosts
Imported film wrappers have no GSAP timeline. Without data-no-timeline
on the scene hosts and root, lint fails with missing_timeline_registry
and each render waits 45 s for timeline registration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): avoid transport redraws after render seeks
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): support deterministic async frame sources
* fix(runtime): retain queued draws and disable unsafe dedup
* docs(core): mark frame-source hosts with data-no-timeline
A composition that follows the frame-source contract registers no GSAP
timeline, so lint reported missing_timeline_registry and every render
waited 45 s for timeline registration (17.6 s -> 1 min 47.9 s for the
14 s imported film sample). Document the existing data-no-timeline
opt-out for the host and a timeline-less root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): avoid transport redraws after render seeks
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): the preview loads the next scene's images before it starts
* fix(cli): check stops reporting image loads that its own scrubbing cancelled
* fix: preload only a coming clip's own images, and judge aborted images by whether they still decode
* fix(cli): count an aborted image as broken only when its decode fails
* style(cli): keep the aborted-image comment within the line limit
* test(cli): pin that a reload still pending at the cap is not reported
* test(cli): run the cap test on whatever timers are pending, and always restore real timers
* fix(core): keep nested scenes in place during a drag when the root has no timeline
* fix(core): reuse the missing-root composite so a late root timeline still binds
* fix(core): count a nested scene in its composite length so a rebind keeps it at the playhead
* fix(core): rebuild a held composite whose length went stale so the player length stays right
* test(core): reuse the no-root-timeline loader for the stale-length case
* fix(core): renders keep a fromTo's from-only values from the frame it starts on
* refactor(core): prime a tween at its start in one helper
* fix(core): a keyframed fromTo also keeps its from-only values at its start
* fix(core): each staggered target keeps its from-only values from the frame it starts on
* fix(core): namespace SVG ids in runtime-mounted compositions
* fix(core): keep SVG selectors and captured ids working after loaded scenes rename them
* chore(core): generated artifacts carry a plain do-not-edit banner
* test(catalog): record the font subsets the new scene-script installer requests
* test(catalog): keep earlier mirror recordings, as re-records only add
* test: pin the guards that keep script-placed scenes and unparseable scripts out of the in-place move
* test: make the script-placed scene check able to fail and pin its page order
* fix(engine): a render fails when a composition script throws before its timeline binds
* fix(engine): an unrelated page error no longer fails a render whose timeline registers late
* fix(engine): only the composition's own scripts fail a render, local or distributed
* style(engine): format the script failure change
* fix(engine): a third-party browser rejection no longer fails a render
* refactor(engine): one page URL for navigation and error attribution
* fix(engine): a rejection after a hash change or in an iframe no longer fails a render
* fix(engine): a widget's error no longer fails a render once its script is inlined
* fix(core): name injected and inlined scripts without building code from library input
* fix(engine): a third-party embed's eval or inserted script no longer fails a render
* fix(producer): a widget's inline handler or a sub-composition no longer misreports a render
* refactor(engine): read each script response once and keep comments within 120 characters
* fix(engine): a scene that throws after registering its timeline no longer cuts a render short
* fix(engine): a composition script failure names the error and its real cause
* fix(engine): a failed VFX chain fails the render at once instead of shipping a missing layer
* fix(core): a VFX layer that is empty on one frame renders empty instead of failing or going stale
* fix(core): a VFX layer that grows from zero size renders instead of staying missing
* fix(core): a VFX layer hidden or late while empty renders once it has a size
* test(producer): cover a VFX layer that is 0×0 under a host that starts later
* test(producer): pin both VFX 0×0 marks, with a GSAP-shaped wipe and a real late host
* test(producer): a late VFX host with a GSAP wipe catches a walk that skips hidden hosts
* fix(core): a sub-composition follows its host clip when the host moves without a reload
* perf(studio): move live tweens in place for a sub-composition host's clip too
* fix(core): keep sub-compositions nested where a fresh load nests them across rebinds and re-runs
* fix(studio): match the preview's re-printed script by its tokens and check every other timeline
* fix: compare preview scripts by syntax tree and keep scenes a re-run script places
* fix(parsers): keep a tagged template's raw text and a __proto__ key in the script compare
* fix(parsers): keep only a tagged template's raw text in the script compare
* perf(studio): read each preview script once across repeat timeline drags
* style(studio): format the preview script read test
* perf(studio): keep only script shapes and refresh them on reuse