Page-side shader transitions clone the outgoing and incoming scenes,
ids included, into staging layers. Color grading grades the copied
<video> too, and findInjectedRenderFrame looked its frame up with
getElementById, which returns the live scene's <img>. The live and the
copied grading entries then both kept their canvas right after that one
<img>, moving each other on every mutation, and the loop blocked the
page: a graded <video> inside a transition stopped the render at the
transition's first frame.
Look through the element's following siblings first, where the injector
puts the frame, and fall back to the id lookup only when it isn't there.
* fix: readers never see an empty or half-written project file
* test: pin the create routes and block install to whole-file writes
* fix(sdk): a write through a link to a missing file creates that file
* fix: saves through a linked folder reach the real file, atomic writes handle long names
* test(sdk): check for the stray file, not the folder listing
* fix(core): creates work on Windows FAT drives, and links through folders resolve as the system does
* fix: agree the helper move in the deletion check, and match temp paths without a path regex
* test: check POSIX file modes only off Windows
* fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name
* fix(studio): a copy's tweens start where their originals do and stay in the timeline's scope
* fix(studio): a copy takes only tweens it can place exactly, and never moves the film's own
* fix(studio): a copy leaves out a tween that sits inside a callback or a guard
* fix(studio): a copy leaves out a set that runs only inside an if or an else
* chore(studio): keep the copy change under the comment ratchet
* fix(core): a video's sound plays at its boosted volume in the preview
A <video> with data-volume above 1 now plays through the same Web Audio gain as <audio>, so the preview matches the export. Videos at or below 1 keep their native output.
* style(core): keep the runtime's comment share at its base
* fix(core): a boosted video hidden after its volume drops goes silent
* fix(core): namespace SVG ids during composition inline to prevent cross-scene collisions
Two nested compositions that each declare their own SVG ids (`<clipPath
id="clip">`, `<symbol id="shape">`, `<filter id="fx">`) are legal per
file and pass `hyperframes check`, but collide once both are inlined
into one render/preview document. `url(#id)` funcrefs (`clip-path`,
`filter`, `mask`, `fill`, `stroke`, `marker-start/mid/end`) and
fragment `href`/`xlink:href` refs (`<use href="#id">`) are resolved by
the browser's native SVG/CSS engine, which always binds to the first
matching id in document order — so the later scene either clips to
nothing or paints the earlier scene's content.
`getElementById` was already scoped per composition in #646, and media
pipeline ids got a parallel `data-hf-render-id` attribute in #3340.
Neither covers this: native `url(#id)`/`href="#id"` resolution can't be
intercepted by a JS proxy, so the `id` attribute itself has to become
document-unique.
Add `svgIdNamespacing.ts`: during `inlineSubCompositions`, every id
declared on an `<svg>`-subtree element is prefixed with the
composition's document-unique runtime id, and every same-document
reference to it — DOM attributes (`href`, `xlink:href`, any `url(#id)`
value including inside `style`) and the composition's own extracted
`<style>` text (both `#id` selectors and `url(#id)` declaration
values) — is rewritten to match. Renamed elements keep their original
id on `data-hf-authored-id`, the same attribute #646 already added for
the composition root, so an inline script's own
`document.getElementById(originalId)` keeps resolving via the existing
`__hfGetElementById` scoping shim.
Extract `selectorIdTokens.ts` from `compositionScoping.ts`'s existing
single-id selector scan so the new many-id rewrite reuses the same
guarded-region logic instead of a second copy.
Closes#3490
Co-Authored-By: Miga <noreply@anthropic.com>
* fix(core): only namespace SVG ids that have native url/href references
SVG ids referenced exclusively by JavaScript (e.g. GSAP's
`tl.to("#cut-1")`) must not be renamed — global libraries access
`document` directly and bypass the composition-scoped querySelector
Proxy, so renamed ids break animation targeting.
Now namespaceSvgIds pre-scans for url(#id) funcrefs and href="#id"
fragment refs (both attribute values and <style> text content) and
only renames ids that appear in at least one native reference. Ids
with no native reference (only used by JS) keep their original
names.
Fixes style-17-prod regression where GSAP selectors targeted
#cut-1/#tear-path-1 etc. which were unreachable after rename.
Co-Authored-By: miga-heygen <miguel.sierra_miga@heygen.com>
* fix(core): decode CSS identifier escapes when rewriting id selectors
`replaceSelectorIdTokens` compared the raw text after `#` against the
candidate ids, so an escaped selector such as `#fx\.1` never matched the
element whose id attribute is `fx.1`, and callers spliced in unescaped
replacements that were invalid CSS for ids containing `.`, `:` or a
leading digit.
The scanner now reads the token as a CSS identifier per CSS Syntax
Level 3 (`\.`-style and `\HEX `-style escapes, U+FFFD for out-of-range
code points) before comparing, and exports a spec-faithful
`escapeCssIdentifier` (the `CSS.escape` algorithm) for emitting
replacements. Consuming the whole identifier also makes the token
boundary check exact for non-ASCII and escaped name characters.
Also removes the ambiguous `[^\]]` branch from the guarded-segment
regex so a bracket body cannot match both the quoted and bare
alternatives (flagged as potential exponential backtracking).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(core): rename only colliding SVG ids and keep authored selectors resolving
Renaming every natively referenced SVG id broke author code even in a
single composition: with `<path id="shape"/><use href="#shape"/>`,
`svg.querySelector("#shape")` returned null because the id had been
prefixed although nothing collided. The getElementById shim did not
cover Element selector APIs or GSAP selector arrays, and escaped
stylesheet selectors (`#fx\.1` for `id="fx.1"`) stopped matching after
the rename.
Namespacing now runs once per assembled document, after every instance
is inlined, and is collision-driven: an id is renamed only when the
merged document would otherwise carry it more than once. One colliding
element keeps the authored id - the first in document order that cannot
be renamed (top-level document content, or a JS-only id), else the
first in document order - so native `url(#id)`/`href="#id"` resolution
keeps binding to it, and every other renamable duplicate gets a
document-unique id with its references rewritten. Ids that never
collide, including every single-composition project, are byte-for-byte
unchanged. The demand-driven gate is kept: JS-only ids are never
renamed, and the residual cross-instance collision for that class is
documented in `collectNativelyReferencedIds`. Nested hosts are excluded
from their parent's scope, minted ids are checked against every id in
the document, and an existing data-hf-authored-id is never overwritten.
The composition script runtime keeps `#authoredId` selectors resolving
for renamed elements by rewriting the token to
`:is(#authoredId, [data-hf-authored-id="authoredId"])` - a superset, so
the same selector still matches an untouched instance:
- scoped `document.querySelector/querySelectorAll` and the GSAP proxy
(`gsap.to`, `timeline.to`, `gsap.utils.toArray/selector`);
- GSAP array targets, whose string entries now resolve through the same
scoped lookup instead of the global document;
- `Element.prototype.querySelector/querySelectorAll`, patched once per
document and only when at least one id was actually renamed, so a
document without collisions runs untouched natives. The renamed-id
set is discovered from `[data-hf-authored-id][id]` in the compiled
DOM, so no extra plumbing between compiler and runtime is needed.
`Document.prototype` is deliberately left alone: an unscoped
document-wide lookup by a third-party library cannot know which
instance it means and resolves to the first in document order, exactly
as before. `matches`/`closest` and selectors built at runtime with hex
escapes are likewise outside the shim.
Stylesheet rewriting matches ids through CSS identifier escapes and
emits escaped replacements, so `#fx\.1 { ... }` becomes
`#scene-b--fx\.1 { ... }` and `url(#fx.1)` becomes `url(#scene-b--fx.1)`.
Tests: unit coverage for the collision/keeper rules, exclusion, id
minting, escaped selectors and hex escapes; end-to-end regressions that
execute the compiled scoped scripts in a real DOM for (i) a single
composition with native and script references to the same element,
(ii) two colliding compositions where native refs and every script
lookup form resolve to their own element, and (iii) an escaped id that
is styled and natively referenced keeping its rule after rename.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(core): stop importing htmlBundler from the inline sub-composition suite
`inlineSubCompositions.test.ts` imported `assignBundledRuntimeCompositionIds`
from `./htmlBundler` only to give duplicate hosts distinct runtime ids.
`htmlBundler.ts` statically imports esbuild, and esbuild refuses to load
inside a jsdom test realm on Windows ("new TextEncoder().encode('')
instanceof Uint8Array is incorrectly false"), which made the whole suite
fail to load in the Windows test job while every other suite passed.
The test now carries a small local mirror of the bundler's assignment
(`<id>__hf<n>` for hosts whose authored id repeats), so the suite has no
esbuild dependency. Test behaviour is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Miga <noreply@anthropic.com>
Co-authored-by: James <james.russo@heygen.com>
* perf(core): the preview loads only the media on screen or due next
Studios preview bound every timed video and audio at preload=auto and called
* perf(core): a far clip in the preview stays unloaded until it comes due
A trimmed clip outside the window now waits at preload=none, reloaded so a fetch
the parser started stops too; at metadata, 60 clips' header reads still queued
on the origin under a slow link. An untrimmed clip keeps metadata, since its
window is read from the element's duration.
* fix(core): a far preview clip makes no request before it comes due
Chromium keeps fetching a clip once it has started, whatever preload says after,
so the reload at preload=none from the bind only restarted the fetch. In Studio's
preview the runtime now sets preload=none on each trimmed clip after the first
frame as it is parsed, and reloads only a clip the window armed when it leaves.
* perf(core): a playing clip loads the next clip on its track
At 3 MB/s a cold 1080p clip needed about 3.3 s to show a frame, longer than the
2 s look-ahead, so every clip on a one-track film was still empty when it came
due. While the film plays, a clip that starts now arms the next clip on the same
track (authored track index within one composition host, read as the timeline
payload reads it), and the window pass keeps that clip armed. Paused or
scrubbing, only the look-ahead applies.
* chore(core): say in one line why the preview parses later clips at preload none
* fix(core): the Studio timeline stops waiting on clips the preview left unloaded
Studio's timeline readiness scans every media element for canplay, so each far
clip the preview deferred held it until the 8 s timeout. In a document with the
Studio preview mark, a clip the runtime has not set to buffer now counts only
if it is on the first frame, as a lazy image already does.
* fix(core): a clip leaving the preview window closes its fetch on a slow link
Chromium keeps a fetch in flight when preload drops to none and load() runs, so
on a 3 MB/s link each played clip kept its connection and the armed next clip
queued behind them with no bytes. The release now drops src around the reload
and puts the same attribute back, which ends the fetch without a new one.
* refactor(core): name the authored-length check for what it reads
* fix(player): a clip the preview stops preloading drops its parent-frame copy
The player adopts each iframe clip whose preload turns auto into a parent-frame
proxy that fetches the whole file, but never let go when the runtime set the
clip back to none. On a 3 MB/s link those proxies and the thumbnail reads held
all six connections, so the armed next clip got no bytes. A demoted clip now
releases its proxy through the same path a removed clip uses.
* chore(player): keep the proxy adoption note in one place
* perf(studio): pause timeline thumbnails while playing preview media waits
A playing preview clip that fires waiting now holds every thumbnail job and
aborts the ones in flight until the transport stops; they re-run afterwards.
An aborted job frees its slot at once, and a filmstrip decode closes its
range requests on abort.
* Revert "perf(studio): pause timeline thumbnails while playing preview media waits"
This reverts commit 36926ebf22.
* fix(core): preview keeps loop-start clips loaded and drops arms a jump leaves behind
Studio's loop tick now tells the runtime where a wrap lands (window.__hf.setLoopStart), and
while playing the preview keeps the clips due there loaded, so a wrap no longer starts cold.
A next-on-track arm lasts only while the clip that set it plays on screen, so a jump away
releases it. A clip with no authored length stays out of the window and loads as in a
render, so no reload drops its duration. A clip is held until 4 s ahead after it is armed
at 2 s, so a scrub or a pause at the edge does not refetch it. The parse-time watcher is
installed only in the preview, once per page. One helper stops a clip's download, <source>
children included, for released and detached media alike.
* fix(core): a loop wrap also keeps the loop-in clip's next clip loaded
The loop start's clips are held out to the 4 s release distance, as a clip being left is, so
the wrap no longer reloads the clip after the loop-in one from nothing.
* fix(core): the preview leaves clips on <source> children out of its release
Putting a clip's <source> children back after the reload started a new download and moved them
ahead of its other children, which a scene swap reads as an edit. Release now stops only a src
clip's fetch; detached media is dropped as on main. Tests cover the loop hold's playing check,
its time check, and the untrimmed guard on the next-clip arm.
* style(core): drop a release comment the commit message already carries
* fix(core): arm a nested clip timed in root seconds by its real start
* fix(core): keep a clip loaded for a step back across its end in the preview
* fix(core): keep print-only and non-CSS head styles apart when bundling
* fix(core): inline each linked stylesheet where its link was, keeping its media
* fix(core): merge a titled head style only with styles of the same title
A style with a non-empty title belongs to a style sheet set; the browser
enables only the preferred set, so merging it into an untitled
(persistent) style or one with another title changes what applies.
An empty title is persistent, like no title; titles compare
case-sensitively. An inlined linked sheet carries its link's title.
The merge key returns undefined for a non-CSS type (never merged or
inlined) and treats media="all" as no media, as the browser does.
* fix(core): leave a disabled stylesheet link as a link instead of inlining it
* fix(producer): render keeps conditional head styles apart, as the preview does
* test(core): pin case and padding in head style merge keys, drop a subsumed case
* fix(core): keep a composition style's media, and leave its non-CSS styles out of CSS
* fix(core): emit a conditional composition style as its own element, keeping media and title
* test(core): make the non-CSS composition style fixture able to fail
* fix(core): a composition's link keeps its own media and title when another links the same file
Hoisted composition links are deduped on href, rel, condition, type and disabled
rather than href alone, in the bundle, the render and the live mount, and keep
their type and disabled state.
* fix(core): the bundle keeps a font's only applied copy when an alternate style repeats it
The font dedupe asks cssStyleMergeKey whether a style always applies, so a titled
style no longer counts as always applied.
* fix(core): the bundle inlines a linked sheet whose type names a charset
Chrome applies <link type="text/css; charset=utf-8">, so a link's type is read by
its MIME essence; a style's type still has to be exactly text/css. Adds a test for a
template with no matching root and a non-CSS style.
* style(core): drop a comment the constant's name already says
* fix(core): hoisted links dedupe on CORS mode, integrity and referrer policy, not noscript copies
One helper, hasSameLink, decides whether a hoisted link duplicates a live one, for the
bundle, the render and the mount. inlineSubCompositions no longer dedupes its result on
its own. Tests cover rel, disabled, non-CSS type, CORS, integrity, referrer policy and a
root noscript link.
* fix(core): a root link and a composition's same link differing only in fetch attributes ship once
The link key goes back to href, rel, condition and disabled; crossorigin, integrity and
referrerpolicy are not compared, so a root snippet repeated in a composition is emitted
once and the root's override styles keep their order.
* fix(studio): clips inside nested compositions sit at their master time
* fix(studio): nested clip starts read authored scene lengths as the runtime does
* test(studio): drop a reference-start case the DOM fallback never reads
* test(studio): a clip inside a referenced scene sits after its authored length
* fix(studio): drag and trim a nested clip to where it is drawn
* fix(studio): keep a root-time nested video's drag in master time
* fix(studio): duplicate and keyframe edits on a nested clip use its own clock
* fix(studio): nested clip drops stop at the host start and trims scale on its clock
* fix(studio): a nested clip's head trim stops at its host's start
* fix(studio): a dropped nested clip stops at its host's start before snapping
* fix(media-use): record agent-made voice, music and sound effects in the project's media manifest
* fix(media-use): record in place only when --source says the agent made the file
* test(media-use): a rewritten agent file keeps one manifest record
* fix(media-use): leave a person's own sound effect unlabelled and mark locally made music generated
* refactor(media-use): record agent audio in-process through the shared manifest library
* style(media-use): format the in-process recording and refresh the skills manifest
* chore(skills): refresh the skills manifest for the media-use changes
* fix(media-use): report a broken ffprobe setting plainly and document the project sfx source
* style(skills): format the pipeline manifest assertion
* chore(skills): refresh the skills manifest
* fix(media-use): a regenerated file gets a fresh record, and lookups skip the replaced one
* test(media-use): a replaced record no longer answers a prompt lookup in the CLI
* chore(skills): refresh the skills manifest
* chore(skills): refresh the skills manifest from a clean tree
* fix(media-use): never give a recorded file the id of a download in flight
* chore(skills): refresh the media-use manifest hash
* fix(media-use): list only a file's newest record in candidates and the media index
* revert(media-use): leave promote as it was, nothing calls it
* test(media-use): give the index test's two assets their own files
* chore(skills): refresh the media-use manifest hash
* fix(core): a figma import lists each media file once, like media-use
* feat(player): play and loop a range of the film with range-start and range-end
The runtime takes a set-play-range control and stops on the range end frame itself.
The player wraps to range-start with loop, fires ended without it, and reports a
range cut to the film with rangeclamped. Video and __timelines players check it per tick.
* fix(player): hold a range on its last frame and let the runtime own range jumps
A runtime with the play-range capability jumps to range-start itself when a new range leaves
the playhead outside, so a stale state no longer ends the film; older runtimes keep the
player fallback, guarded until a state lands inside. Every range stop shows the last frame
before range-end; a paused playhead re-parks only when it falls outside the new range.
* fix(player): stop a range a check early and enforce its end on a video in a background tab
* test(player): share the range video setup
* fix(player): a slow frame no longer cuts a range short; a video without a range is unchanged
* fix(player): a range still stops early where video time moves once per frame
* refactor(player): cut the range look-ahead comment to one line
* fix(player): a per-frame video time rounded down still stops a range early
* test(player): pin no rounding tolerance at a film's own end
* test(studio): expect the play-range capability on control messages
* fix(core): a sound effect starts on its time instead of a frame late
* style(core): format the early-start change
* test(core): a clip past its end is never started early
* fix(core): a seek stops a clip started early, and the lead never passes 40 ms of real time
* test(core): drive the early-start tests the way the runtime receives a seek and a speed
* fix(core): the early-start lead never passes 40 ms of film time at fast speeds
* fix(core): keep a sped-up sound effect inside strict sync when it starts early
* test(core): pin the early-start cap at fast speed
* fix(core): the playhead no longer jumps back when an audio clip starts
While no Web Audio buffer plays, the transport clock follows an audio element, and each frame it re-picked the first in-window <audio> in page order. A sound effect that started during a voice line took the clock over, and since a clip starts late (seeked, asked to play, not yet producing sound) the playhead jumped back by that delay, 30 to 450 ms in real films, dragging every other track with it. The followed clip now keeps the clock while it plays. And audio may pull the playhead forward or hold it while it catches up (starting, seeking, buffering), but never push it back; a jump back past a hard sync, such as a native loop wrap, is ignored. The clock never runs ahead of its source, so media sync never chases it. Also: a playing element with a media error no longer leads the clock, and a speed change sets the clock's rate before the elements'.
* style(core): trim the handover fix's comments to what the code cannot say
* style(core): name the clock's no-rewind rule instead of commenting it
* style(core): format the clock
* test(core): pin the clock's hold while seeking and its skip of a failed clip
* fix(core): the playhead keeps its place on a speed change, a short loop or a failed clip
* fix(core): a late looping bed holds the playhead, and a clip with no source never leads it
* refactor(core): split the clock's behind-the-playhead rule into its own method
* fix(core): an audio clip with no source never leads or freezes the playhead
* fix(studio-server): a cold proxy copy answers 202 at once and the preview retries until it lands
* fix(studio): timeline filmstrips wait for a clip's proxy copy instead of failing on the 202
* fix(studio-server): a copy that cannot be made reports its failure instead of answering 202 forever
* test(studio-server): pin that a full queue is not remembered and a reset probe stops waiting
* test(studio-server): pin that a failure outside ffmpeg is remembered too
* style(studio-server): keep comment share at base in the transcoder and decoder
* fix(core): a clip's proxy URL goes on the video only once the copy is served, so every reader of it works
* fix(player): the parent media mirror follows a clip's src, so it plays the preview copy once it lands
* perf(player): the mirror re-points a clip only when the parent takes over playback
* style(core): shorten the sync-state comment in the proxy swap
* fix(studio): the timeline follows a clip re-pointed at its preview copy
* fix(core): a failed readiness check is retried instead of leaving a clip on its original
* fix(studio-server): a composition card rendered while preview copies are made is not cached
* fix(core): frame captures wait for a pending preview copy as long as for a loading video
* fix(studio-server): the preview-copy check uses the shared realpath from main
* fix(player): re-pointing a clip onto another clip's file no longer plays that file twice
* fix(player): a clip that replaces another on the same file keeps its sound
* fix(studio-server): a save never recreates a project folder renamed away
* style(cli): wrap imports after merging main
* fix(studio-server): proxy and upload answer that the project folder is gone instead of 502 or an empty 201
* test(studio-server): rename the project folder while an upload is being read
* fix(studio-server): a proxy or backup answers that a vanished project folder is gone
* test(studio-server): clean up the renamed folder after the proxy test
* refactor(studio-server): drop an unused re-export and share the folder-gone test setup
* fix(studio-server): any request failing after its project folder vanished says the folder is gone
* fix(studio-server): the folder-gone answer works on every host and drops the failed route's headers
* fix(studio-server): the folder-gone 404 keeps the host's own headers
* fix(core): no folder outside a project is created once the project folder is gone
* test(studio-server): a rename that makes the codec probe fail answers that the folder is gone
* test(studio-server): a refused render leaves no outside renders folder behind
* docs(core): wrap the mkdirWithinProject comment under the line limit
* refactor(studio-server): let the folder-gone middleware's names carry what its comments said
* fix(studio-server): say the folder is gone when a render names a composition
* fix(studio-server): check renders against the folder path and share one folder-gone answer
* test(studio-server): pass the project folder to the preview document store
* fix(studio-server): a save never recreates a project folder renamed away
* style(cli): wrap imports after merging main
* fix(studio-server): proxy and upload answer that the project folder is gone instead of 502 or an empty 201
* test(studio-server): rename the project folder while an upload is being read
* fix(studio-server): a proxy or backup answers that a vanished project folder is gone
* test(studio-server): clean up the renamed folder after the proxy test
* refactor(studio-server): drop an unused re-export and share the folder-gone test setup
* fix(core): no folder outside a project is created once the project folder is gone
* test(studio-server): a refused render leaves no outside renders folder behind
* docs(core): wrap the mkdirWithinProject comment under the line limit
* test(studio-server): pass the project folder to the preview document store
* test(core): findProjects stop tests build two projects, not four hundred files
* test(core): stop tests keep a folder queued at the stop, with room for Windows setup
* fix(runtime): a scene swap refuses when an animation outside the scene moves its elements
* test(runtime): pin the scene swap volume re-probe, host CSS variables and diagnostics-free swap
* fix(runtime): a scene swap keeps a video its scene script animates instead of reloading it
* fix(runtime): a scene swap stops every animation the old scene script started
* fix(runtime): record scene script animations only on pages a scene swap can act on
* docs(core): reword the scene animation recording comment
* fix(runtime): watch media as the page parses only on pages a scene swap can act on
* perf(runtime): a scene swap checks for outside tweens in one pass over the film's tweens
* test(runtime): pin the single revert and the cleared animation record of a scene swap
* fix(runtime): a scene swap rebuilds media when the edit changes the scene script
* fix(runtime): a scene swap undoes the sets a scene script made
* fix(runtime): a scene swap rechecks outside tweens and runs new scripts after every host
* test(runtime): pin that a tween started during the caption wait leaves the page untouched
* fix(runtime): a scene swap refuses when its script set a value on an element the swap keeps
* docs(core): say the scene recording drops a finished tween that the timeline would keep when paused
* fix(runtime): a scene swap refuses when two of its animations write to an element it keeps
* fix(runtime): a scene swap resets kept media and refuses animations outside the scene
* fix(runtime): a scene swap keeps a moved video in place and swaps tweens on plain objects
* chore(core): regenerate the inlined position-edits script for the kept-video move reapply
* fix(runtime): a scene swap checks the timeline registry as it is after the caption wait
* fix(runtime): a scene swap refuses when a scene changed during the wait or a revert re-registers
* fix(runtime): a scene swap stops every animation a revert chain registers and rechecks its hosts
* fix(runtime): a scene swap refuses a moved host or a cancelled swap and stops at a shortened end
* fix(runtime): a scene swap refuses when a scene leaves its place or the film during the wait
* fix(runtime): a scene swap checks only its host's place, so a later body append does not refuse it
* fix(runtime): a scene swap refuses when the film's scene layout changed during the wait
* fix(runtime): a scene swap refuses when the order around a scene changed during the wait
* test(runtime): pin that a scene swap checks the film root order when every scene is wrapped
* fix(runtime): a scene swap gives kept media the mute, rate and loop a fresh load gives it
* fix(runtime): a scene swap rebuilds media a script wrote attributes on, keeping the rest
* fix(runtime): a scene swap keeps media carrying the runtime classes or reordered attributes
* fix(runtime): a scene swap resets a kept video's volume as a load does, not through the volume probe
* perf(core): a bundled film inlines each font once instead of once per composition
* fix(core): font dedupe keeps the last copy, so the rule the browser uses never changes
* fix(core): font dedupe merges only exact copies, from styles that always apply
A style postcss cannot parse ships as authored instead of failing the bundle.
The key keeps !important and whitespace inside quoted family names.
Styles behind a media query, a non-CSS type, <noscript> or <svg> neither lose
a copy nor keep one.
* perf(core): font dedupe runs before inlining, so bundling is no slower
Deduping after inlining made postcss parse every inlined font: a three-scene
fixture went from about 40 ms to about 450 ms. The same url text inlines to the
same bytes, so deduping first gives the same output.
* fix(core): a scene-part bundle keeps each scene's own font copy, so a scene still swaps alone
* chore(core): shorten the font dedupe comment
* chore(core): trim the font dedupe comments to keep the file's comment share
* feat(studio): export snapping, the marquee and picker describe for a host's own preview
* fix(studio): a host pick type must bring its own marquee resolver
* fix(producer): gif renders survive rgb frames among transparent ones
* test(producer): run the gif mixed-frame encode test in the ffmpeg lane
* fix(producer): gif encode test passes the argv, reachability and comment checks
* fix(producer): transparent gifs clear every frame so an opaque frame never lingers
* fix(producer): only gifs that turn translucent after an opaque frame get whole cleared frames
* fix(core): plain text keeps its own gif control block; gif encode failures share one shape
* feat(cli): hyperframes projects finds the HyperFrames projects on this machine
* refactor(core): keep the project rule's comment share under its package's
* fix(cli): projects keeps Spotlight hits under a symlinked root and refuses a missing root
* style(cli): format the projects walker and its tests
* fix(cli): projects tests pass under a symlinked temp folder and a bad root errors on stderr
* test(cli): projects tests hold on Windows and cover an unreadable root
* style(cli): format the projects command and tests
* refactor(cli): split the projects Spotlight check and regenerate the skills manifest
* refactor(core): findProjects moves to @hyperframes/core/projects so Desktop can import it
* chore(core): publish the projects subpath
* fix(core): findProjects fails on a root it cannot read instead of finding nothing
* docs(core): name the project marker writers without naming one app
* refactor(core): ship findProjects as a core export only, with no CLI command
* feat(core): findProjects stops on an abort signal and defaults to the home folder
* fix(core): findProjects checks an aborted signal first and stops when onProject throws
* perf(core): playback housekeeping runs on a timer, and rebinds stop re-sampling silent media
While playing, the transport re-posted the clip manifest every 20 frames and
re-bound media every 30, frame-counted, so a 120 Hz display did both twice as
often for a film that was not changing. Playing, those jobs now run when the
composition changes (rate-limited as before) and on a one-second poll for
what nothing can push.
A rebind re-probed every media element's volume by seeking the whole timeline
60 times per second of clip, about 1,500 seeks and 50-140 ms in one frame.
The probe seeks with events suppressed, so only a tween on the element that
names volume can move it; an element no tween touches is now answered from
the tweens without seeking.
* fix(core): a data-width or data-height change applies while playing and parked
postTimeline applies the root's sizing, and it now runs when the composition
revision moves. The revision only watched timing attributes, so a size edit
waited for a pause while playing, and on main it was missed while parked
too. The observer now also watches data-width and data-height. The tick
comment is trimmed to three lines, the tick reuses its playing flag, and the
cadence test is renamed to what it checks now.
* test(core): share one 120 Hz frame step in the transport park tests
The two playing tests each defined the same frame helper; one frame120Hz
now sits beside settle and all four loops use it. No source change.
* refactor(core): drop the playing poll doc comments
The comment ratchet does not let a file's comment share rise; the reason
lives in the PR body.
* fix(core): a playing clip's track, label and z-index reach Studio again
The playing path had dropped the periodic manifest post, and the
composition observer does not see data-track-index, data-timeline-label or
an inline z-index on an existing clip (watching style would fire on every
GSAP frame). On the playing poll the runtime now builds the manifest and
posts it only when it differs from the last one posted, so an edit lands
within a second and a steady film still posts nothing.
* fix(core): keep sampling volume when a tween drives a non-DOM object
A tween on a plain-object proxy whose setter writes the element's volume
names neither the element nor volume, so the fast skip dropped it and
WebAudio-routed preview played the static data-volume (0 where the film
asks for 0.375). The skip now applies only when every tween targets DOM
nodes; any other target falls back to the full volume probe, as on main.
* fix(core): skip the volume probe again for spacers, calls and counters
The non-DOM fallback fired on any plain-object or function target, so a
tl.to({}, {duration}) spacer, a tl.call or a tweened counter brought back the
full probe on ordinary films. It now falls back only when a tweened property
resolves to a setter on the target or its prototype chain, which is what a
gain proxy or a class with a volume setter uses; data properties skip.
* fix(core): post a clip edit made while playing when playback stops
A label, track or inline z-index edited while playing and then paused before
the next one-second poll was never posted: once parked, nothing watches it.
The tick where playback stops now runs the same manifest diff once and posts
only if the manifest changed.
* perf(core): skip hidden clips' content so the browser stops decoding their images
Hidden timed clips get content-visibility:hidden while their inline visibility is hidden,
from script evaluation on. A clip due within the look-ahead (2 s by default) keeps its
content live, so its images load before it shows.
* perf(studio-server): load preview images lazily so hidden scenes fetch nothing
* fix(core): stop the readiness scan waiting on and decoding later scenes' images
* perf(studio-server): keep first-frame images eager so the preview opens as fast as before
Only images inside a clip with a numeric start after 0 go lazy; a start the route
cannot read stays eager.
* perf(core): keep clips that start at 0 laid out before the first pass so the first frame is on time
* perf(core): open the preview on time by hiding only lazy images before the first pass
GSAP reads the computed style of the elements it animates while timelines build. Inside a
content-skipped clip every read forces a style update, which cost about 65 ms at open on the
bench composition. Before the first pass the rule now takes only lazy images out of layout, so
hidden later scenes still do not load theirs; after it, the skip rule keeps their content skipped.
* fix(core): hold a paused jump on the previous picture until the next scene's images decode
* fix(studio): swap in a reloaded preview only after its restore seek has painted
* fix(core): apply a held paused jump after 1 s when an image never decodes
* fix(core): keep hidden-scene image skipping and held jumps to Studio's preview, never renders
* fix(studio): reapply manual edits once a held paused jump lands
* fix(studio): copy a clip without the preview's lazy-loading marks
* fix(studio-server): mark preview images lazy without re-encoding the document's entities
* fix(core): share one preview-mark constant between the runtime and the preview route
* fix(studio): reapply manual edits a second time only after a seek that was held
* refactor(core): share the hidden-ancestor walk between clip visibility and image holds
* fix(core): import the studio preview mark from its own subpath
* fix(studio-server): find preview images with the linear html scanner
* chore(core): drop two doc comments the scanner's names already carry
* fix(studio-server): serve the preview mark as its own meta, apart from the GSAP fallback script
* fix(studio-server): capture thumbnails and frames from the preview without lazy images or the mark
* fix(core): let the full readiness scan wait on every image except lazy ones off the first frame
* fix(studio-server): keep an authored loading attribute in any letter case on preview images
* fix(core): leave template content out of findStartTags so preview images match the DOM
* fix(studio): send Ask agent the live markup without the preview loading marks
* chore(core): drop the template note from findStartTags, its test carries it
* perf(studio-server): splice lazy attributes into the preview in one pass
* fix(studio): keep authored image loading on copy and Ask agent by marking preview-made lazy images
* test(studio-server): follow the capture preview URL back to its sub-composition for every project id
* refactor: name the upcoming-clip and GSAP fallback marks once
* fix(studio-server): keep capture builds out of the on-disk preview store