* 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
* perf(studio): reuse filmstrip frames when a zoom changes a strip's width
A zoom that changed a strip's frame count decoded the whole strip again
on the main thread, and showed the clip's single poster frame until it
landed. Frames are now sampled at each slice's left edge, so a strip
twice as long holds every frame of the shorter one; decoded frames are
shared per source and time, so zooming out decodes nothing and zooming in
decodes only the new half. The strip on screen stays until the new width
is ready.
* perf(studio): redraw only the waveforms on or near the screen after a zoom
A zoom resizes every mounted clip, and every mounted waveform redrew in
that same frame, on screen or not. A waveform now draws only while it is
on or near the screen (one shared observer, a screen of margin each side)
and draws when it comes near if a zoom changed it meanwhile.
* perf(studio): never start a beat-analysis stage while the timeline zooms
Analyzing a long music track runs for over a second on the main thread,
and when it started late it landed inside a zoom. The analysis now awaits
an optional pause before each long stage; Studio's waits for the timeline
to rest and the browser to go idle. Results are unchanged. The tempo
detector's stage is one call, so a zoom that starts inside it still waits
for it.
* fix(studio): reuse a filmstrip frame only where its own strip would show it
A shared frame was keyed by the time a strip asked for, not the time it
was decoded at, so a coarse strip's keyframe-early frame could land in a
finer strip and run its frames backwards. A frame now carries its decoded
time and is reused only when the strip would have accepted that keyframe
itself. The frame-count ladder now tops out at a power of two (32), so
its top step shares frames with the one below instead of none.
* feat(studio): show the clip's last frame at the end of every filmstrip
Left-edge sampling never showed a clip's final slice, so an end card or a
fade-out was missing from its strip. Each strip now also decodes the
clip's last frame (at its own time, never an earlier keyframe), shared
across zoom steps like the others, and the last tile shows it.
The strip on screen is now held per clip and range, not per props: a
clip crossing between on and off screen keeps it, and a clip narrowed to
one frame lets it go, so widening again never re-decodes a dropped width.
* docs(studio): keep the filmstrip files at or under main's comment share
The comment ratchet allows no file a higher share of comment lines than on
main; the added explanations move to the PR description.
* fix(studio): keep a held filmstrip leased so a full cache cannot evict it mid-zoom
The hold leased the strip on screen only once a new width was requested,
so for one commit it had no lease; with the cache over budget it was
evicted, the poster showed, and the old width decoded again. The strip on
screen is now leased for as long as it is the clip's, and shown only while
held. A frame that fails to decode takes the frame before it, so every
slot keeps its time and the last tile still shows the end.
* refactor(studio): one owner for which clip a held filmstrip belongs to
The hold checked the clip twice, in its lease and again before showing
it; the lease alone decides now, and the cross-clip test pins it. A test
now also pins that a filled frame is not charged to the cache.
* fix(core): hold the tempo detector until idle after a slow import
* fix(studio): scope shared video frames to timeline sessions
* chore(core): keep beat analysis comments within the ratchet
* fix(cli): load telemetry without the core and engine barrels, and stop the history test racing
* fix(cli): load history from its leaf subpath, and fail the history test fast on a crashed CLI
* 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(studio): an offline preview says GSAP could not load instead of an unhandled Event
* fix(studio): the preview error list survives a second capture attach without duplicates
* fix(studio): the console capture keeps listening after a long early error list
* refactor(studio): the console capture owns every reset of its findings
* 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
* perf(studio): move the live tweens in place after a timeline move instead of re-running the script
* fix(studio): pair a live retime with the script's own tweens, not the nested composition timelines
* fix(studio): leave the runtime's filler tweens out of a live retime's pairing
* refactor(studio): move the live tweens in their own step of the timing sync
* perf(studio): read nested files only when a timing edit re-runs the script
* fix(studio): move live tweens only when the live timeline is exactly what the script parser read
* fix(core): a rebound timeline is padded and timed like a fresh load
* refactor(studio): decide unknown tween lengths in the plan and check order swaps only on a move
* fix(studio): re-run the script whenever moving the live tweens could differ from a fresh load
* fix(studio): keep trims and reformatted scripts on the fast path, re-run for delays and hosts
* fix(studio): re-run for tweens inside a sub-composition host or that change order on an element
* fix(studio): ignore layout inside raw script values when matching a reformatted preview script
* refactor(studio): check each moved element once and scan nested timelines only for moved targets
* docs(studio): keep the live-retime comments within the merge hook's rules
* feat(core): the renderer's font pipeline is importable from @hyperframes/core/fonts/embed
* style(core): format the font pipeline and state the bundled-subset invariant
* test(producer): repin the test lane map after the font tests moved to core
* fix(core): images build fonts once, CLI bundles one pipeline, font tests pass on Windows
* test(core): font retry tests get a budget no slow machine can exhaust
* test(core): the Retry-After budget test fails when the budget is ignored
* fix(core): bundled local scripts keep their own place, defer and async
The bundler joined every local <script src> into one inline script at the first one's position. That dropped defer and async (a deferred main.js ran before a deferred CDN gsap), moved later files ahead of CDN scripts between them, and let one file's error stop the rest. Each local script is now inlined where it stands with its attributes; a deferred or async one keeps its tag with its file as a data: URL. Inline-script merging also no longer folds in nomodule scripts, which dropped the attribute.
* fix: preview scripts run after the runtime when the bundler fails, and inlined files are escaped
- When the bundler returns nothing or throws, the preview places the runtime
where the bundled page does (end of head) instead of after the composition's
scripts or not at all; core's placement moves to insertRuntimeTag.
- Every HTML-spec JavaScript MIME type counts as a classic script for merging
and the web-font gate.
- Inlined local files and merged runs are escaped with escapeInlineScriptSource.
* fix: deferred local scripts run late without data: URLs, and authored runtime reads survive
- Local defer/async scripts are inlined in their own tag with the attribute
kept; the font-gate runner (and its old-runtime fallback) runs an inline
classic defer script after the classic scripts, in document order, so a page
policy that allows inline scripts but not data: still runs them.
- stripEmbeddedRuntimeScripts matches the bootstrap attribute and runtime file
names on the tag, not runtime words in an authored script's text.
- The preview applies its runtime on every path, so an adapter page without
one still gets it; the inline escaper writes <!-- as \x3C!--, valid in a
unicode regex.
* fix: a deferred inline script waits for the deferred CDN script before it to load
* fix: an authored script that mentions the runtime marker no longer stops the runtime being added
* fix: only inlined deferred files run late, head ones included, and renders skip a CDN runtime link
* fix: deferred head files run as before, and one selector owns the deferred-file rule
* fix: a local file's legacy <!-- comment no longer breaks its merged run, and escapes keep case
* fix: a local file inlined on its own keeps working with a legacy <!-- comment
* test(core): a local non-JavaScript file keeps its text as written
* fix(core): a cropped element's edge stays exact while GSAP tweens it
* fix(core): configure GSAP while the runtime script runs, before compositions set elements
* test(studio): bank the six crop-scale cases that now pass
* test(producer): regenerate the png-sequence and mov-prores goldens for 2D GSAP transforms
* test(core): a non-configurable window.gsap is configured without a crash
* test(studio): re-bank the edit accuracy baseline from this branch's current head
* fix(studio): a first animation in a full-document sub-composition plays
* fix(studio): keep the comment share of the two touched files at their base
* fix(studio): a first animation also plays when <html> carries the composition id
* fix(studio): a root whose id sits on <html> keeps its first animation in <body>
* fix(studio): a composition id on the template tag keeps the first animation inside it
* fix(core): the preview redraw leaves animations after the playhead as the seek drew them
The redraw that re-renders a timeline at its playhead steps silently to just
below it and back. That backward step drew every reversed child that starts
later at its end (a reversed set showed 0.8 where the seek showed 0.2), and the
landing never revisited it. Children starting after the playhead now sit out
both steps. The redraw also puts back the zero-duration state of every
zero-duration child, not only those with callbacks, so two sets on the playhead
keep their authored order on the next seek and a call in an otherwise empty
nested timeline fires as often as without the redraw.
* refactor(core): collect children after the playhead without a per-child array
* fix(core): a scene's opening set disappears when you step back off the scene's start
The redraw's silent round trip left a nested timeline's start-crossing mark
(_zTime) on the wrong side, so the next backward seek skipped the scene and its
opening set stayed drawn. The redraw now restores that mark for every nested
timeline as well as every zero-duration child.
* refactor(core): name the redraw's playhead tolerance instead of commenting it
* fix(core): a pause inside a reversed repeating scene fires once after a redraw
The redraw renders a nested timeline the seek had left inactive, which marks it
active. With its start-crossing mark restored but not its active flag, the next
seek re-ran the scene's pause. The redraw now restores the active flag too.
* fix(core): a held redraw no longer cuts a paused scene short in renders
Holding later children at time scale 0 let GSAP recompute a nested timeline's
duration during the redraw's round trip without them, so a paused
sub-composition kept a shortened length and froze for the rest of the render
(style-5-prod's typed intro). After restoring the time scales, every ancestor
of a held child is marked dirty and the duration is settled again.
* fix(core): the redraw puts back the scene lengths it measured, not re-measures them
Re-settling after the hold recomputed the root's length from whatever was paused
at that moment, which the redraw should not change. The cached lengths of the
held children's ancestors are now snapshotted after the entry settle and
restored in the finally.
* fix(core): composition scripts start after web fonts are ready
A scene script that measures text (offsetHeight, getBoundingClientRect) ran while
the page parsed, often before its web font loaded, so it laid the scene out with
fallback metrics. Renders of the same film differed run to run, and preview could
differ from export.
The compilers now give every body script a type the browser does not run
(text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called
at the end of the bundler, the producer compile and the sub-composition preview.
The Studio motion script is emitted the same way so it still runs after them.
The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for
document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script
in its own place in parser order (classic in order, awaiting src loads; then
module and defer scripts in insertion order), boots as before, and calls the
DOMContentLoaded and load listeners the scripts added. On timeout the scripts
run anyway and runtime_font_wait_timeout names the families still loading.
Scene swaps and the composition loader use the same wait.
* fix(core): a page whose runtime predates the font gate still runs its scripts
A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that
runtime is older than the font gate, nothing would run the deferred composition
scripts and the film would be blank.
Whenever the marking helper defers scripts, it also puts one small inline script
first in the head. At DOMContentLoaded it runs the deferred scripts in parser
order itself, without waiting for fonts and with a console warning, unless a
runtime has claimed them. The gate's runtime claims them when it loads, before
DOMContentLoaded, so the scripts run exactly once either way.
* fix(studio-server): motion paths animate in Studio preview when gsap loads in the body
The MotionPathPlugin tag Studio inserts after a body gsap script now carries
that script's type, so the runtime runs it right after gsap once fonts are
ready, instead of before gsap exists.
* fix(cli): layout, motion-shot and validate wait for the composition scripts to run
The three commands waited on the timeline registry, which exists as soon as the
runtime file loads, or on a flat sleep. They now wait for the runtime's
render-ready flag through one shared helper, keeping each command's timeout and
proceeding as before when it passes.
* fix(core): a script that wraps addEventListener keeps its wrapper after its scripts run
While composition scripts run, the runtime patches addEventListener to hold
load events that already passed. Putting the original back now happens only if
the patch is still in place, so a library wrapper installed meanwhile survives.
* docs: comments on script timing match scripts that wait for web fonts
The early stub's flush and the player's iframe-load handler described page
scripts as done by DOMContentLoaded and the runtime as ready before load.
Composition scripts now wait for web fonts, so both can come later. Comment-only.
* test(core): pin script order after an external script and the scene swap's font wait
An inline composition script that follows an external one sees its global, and
a scene swap runs the new scene's script only once web fonts are ready.
* docs(producer): the early stub comment names what drains late font-gated work
* fix(studio): motion paths add without a flash when gsap loads after web fonts
Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready.
* test(cli): pin the runtime wait before each command first samples the page
* fix(studio): the motion path preload stops waiting once the preview runtime has booted
A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter.
* fix(studio): a preview frame that never boots is not kept alive by the motion path preload
Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected.
* style: comments added by the font gate move into names
* test(core): pin the authored floor's direct children, the one-frame media skip and lottie sources
* test(core): pin the loaded-Lottie-library half of the pending rule
* test(core): pin how the runtime resolves a composition's length and size
* refactor(core): move composition length and root size rules into a pure module
* test(core): pin the one-frame timeline floor against a derived length
* fix(core): build and declare the composition-length subpath, reject a non-finite declared length
* fix(studio-server): freeze still appears whole, so a slow extraction cannot break its undo
The freeze route reserved the still's final name with an empty file before
ffmpeg ran. When extraction outlasted the history's quiet time, history
committed that empty file as an outside change, and Cmd+Z then restored a
0-byte PNG. ffmpeg now writes under an atomic temp name the history skips,
and the still is published with createFileAtomically (EEXIST still refuses
a taken name).
* fix(core): a direct exclusive write that fails midway leaves no partial file
Without hard links, createFileAtomically writes the destination directly.
A full disk then left a partial file behind (a corrupt upload or freeze
still); it is now removed, and a name another writer holds is never
touched. Freeze tests: the slow-extraction test leaves a half-written temp
file while history commits, and concurrent extractions meet at a barrier
instead of a real timer.
* fix(core): a quota reported only at close still removes the partial file
* test(core): the direct write closes once and keeps its write error when cleanup fails
* test(core): a failed direct write closes its descriptor exactly once
* refactor(studio-server): freeze names carry what their comments said
* fix(core): a call on the playhead fires once when the preview redraws it
The silent redraw steps off the playhead and back, which re-armed a call sitting exactly on it, so the following seek fired it again. Keep each such call's fired state across the redraw.
* fix(core): a redraw keeps every call's fired state, wherever the call sits
Restoring one field for calls found by position missed a fresh call at 0, calls in repeating or reversed timelines and double-speed scenes. Snapshot ratio and _zTime of every zero-duration callback tween instead.