Commit Graph
1170 Commits
Author SHA1 Message Date
Miguel Ángel 4ed269ad1e perf(core): playback housekeeping runs on a timer, and rebinds stop re-sampling silent media (#4595)
* 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.
2026-09-28 05:58:37 +00:00
Miguel Ángel 078c493448 perf: the preview loads and decodes only the images of scenes on screen or due next (#4578)
* 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
2026-09-28 04:03:36 +00:00
Miguel Ángel c1bf4aefff chore: release v0.8.81 (#4605) 2026-09-27 16:41:42 -04:00
Miguel Ángel 59e3850f6a fix(studio-server): a write through a link lands on the file the system resolves (#4589)
* fix(studio-server): a write through a link lands on the file the system resolves

* fix(cli): resolve catalog install paths as the system does, matching the install root

* fix(cli): list installed files relative to the real project folder
2026-09-27 12:30:17 +00:00
Miguel Ángel fcfcafa445 chore(release): cut v0.8.80 (#4591) 2026-09-27 07:26:39 -04:00
Miguel Ángel e00baefb5c fix(studio): reload the preview only when a file it loaded changes (#4570)
* fix(studio): reload the preview only when a file it loaded changes

* test(studio): read the owner id from the new change-owner shape

* refactor(studio): drop a comment the parser name already says

* fix(studio): keep reloading until the preview is built, and count what its document names

* style(studio): format the reference pattern and name the kept reload

* fix(studio-server): scan preview references in linear time and refresh all thumbnails on a merged reload

* fix(studio): keep a waiting film change when a file the preview never loaded arrives behind it

* test(studio): name the preview ownership test for what it checks

* chore(ci): allow the watchIgnore removals and trim the new comments

* fix(studio-server): reload the preview when a folder holding a file it loaded moves in or out

* perf(studio-server): check a changed folder in one lookup and cover every reference form

* fix(studio-server): record read folders for a project at the filesystem root

* fix(studio-server): reload the preview when the file a symlinked asset points at changes

* fix(studio-server): resolve symlinks on every preview read and key projects by their real folder

* fix(studio-server): record every link a preview read passes through, resolved as the system does
2026-09-27 10:05:53 +00:00
Miguel Ángel 8dfdceb167 fix(core): a CSS animation in a later clip shows the right frame after a seek (#4554) 2026-09-27 03:07:04 +00:00
Miguel Ángel 3def3514d1 fix(engine): export draws a video in exactly the frames it is shown in (#4565)
* fix(engine): export draws a video in exactly the frames export visibility shows it

* test(producer): guard that chunk frame planning snaps clip windows like export

* refactor(engine): set the render fps once per frame table and name its clip window

* chore(engine): trim frame table comments
2026-09-27 03:05:57 +00:00
James Russo cc439b0e59 fix(core): preserve in-flight video preloads during runtime binding (#4562) 2026-09-27 02:16:19 +00:00
Miguel Ángel 984ad28c7c fix(core): clips that meet within float rounding count as meeting in edits and playback (#4535)
* fix(core): clips that meet within float rounding count as meeting in edits and playback

* fix(engine): hand a shared clip boundary to the incoming clip in export, as preview does
2026-09-26 22:14:14 +00:00
Miguel Ángel 0ae223773a chore(release): cut v0.8.79 (#4548) 2026-09-26 21:05:52 +00:00
Miguel Ángel dce0acd6d2 perf(studio): tag each scene's parts in the preview so one scene can be swapped (#4503)
* perf(studio): tag each scene's parts in the preview so one can be swapped

With sceneParts (Studio preview only) the bundler tags each top-level scene's host, styles and
inline scripts with data-hf-scene; nested scenes belong to their top-level scene. The preview
route then adds a manifest: a hash of everything outside the scenes and one hash per scene,
so the preview can tell when a rebuilt document differs only inside some scenes. Renders are
unchanged.

* fix(core): keep scene order in the preview manifest and mark unswappable scenes

A moved or reordered scene now changes the shared hash, a nested scene
keeps its @import first, and a scene whose script leaves work running
is marked so edits to it take the full reload.

* refactor(core): keep the scene swap side-effect check in the bundler

* fix(core): refuse swapping a scene that runs its own script file

A swap never re-runs external scripts, so a scene file keeps driving the
replaced nodes. Library URLs stay swappable.

* fix(core): refuse swaps for async scene setup and keep the shared @import first

Scenes that finish building after an await or a .then, animate elements
directly or touch the page head and body now take the full reload, and
splitting out scene styles no longer leaves the shared import late.

* fix(core): refuse swaps for scene scripts that throw when run again

Custom element and CSS property registration, sockets, draggables and
infinite tweens now refuse too, and ticker or animate inside a name no
longer does.

* fix(core): refuse swapping a scene that runs a module script or import map

* fix(core): run a scene's local script file in source order in the preview

* fix(core): refuse a scene swap only for defineProperty on a global

* refactor(core): cut scene swap comments to what names and types do not say

* fix(core): find the head tag for the scene manifest in linear time

* fix(core): leave a document with an unclosed head tag without a scene manifest

* fix(core): keep preview scene parts in render order and refuse scene-only script URLs

* fix(core): keep render @import precedence in the preview and swap only known libraries

* fix(core): keep preview scene styles where a render merges them and resolve library URLs

* fix(core): scene swap refuses standalone gsap tweens and root scripts that hold scene nodes

* fix(core): refuse to swap exactly the scenes a script outside them selects

* perf(core): the preview scene scan looks up ids and classes once instead of querying per string

* docs(core): trim three scene-parts comments to the facts a reader needs

* fix(core): refuse to swap a scene a script outside it reaches by tag name

* fix(core): keep scenes swappable when a script outside them only creates elements by tag

* test(core): check scene tags on elements, not in the inlined runtime's text

* fix(core): refuse swaps for chained scene timelines and any-case or universal root selectors

* fix(core): refuse swaps for root selectors through tags above the scenes

* fix(core): put the scene manifest inside the head when a head attribute holds a quoted >

* fix(core): place the scene manifest with the shared head scanner instead of a regex
2026-09-26 20:23:13 +00:00
Miguel Ángel e0e6442d9c feat(core): the runtime reports where a composition's animations end, one cycle each (#4541)
* feat(core): the runtime reports where a composition's animations end, one cycle each

* fix(core): the reported animation end no longer moves with the playhead

* fix(core): the animation end skips script-made WAAPI and counts one stagger cycle

* fix(core): the animation end follows stretched keyframes and a Lottie's scene start

* refactor(core): tidy the Lottie animation end reader and pin its inferred length
2026-09-26 19:18:53 +00:00
Miguel Ángel 22eb0ce5e5 fix(core): a picked heading is named by its text, not just Heading (#4545)
* fix(core): a picked heading is named by its text, not just Heading

* fix(core): a picked label never cuts a character in half
2026-09-26 18:19:33 +00:00
Miguel Ángel 36db44d9b7 perf(core): a paused preview stops running its audio output (#4534)
* perf(core): a paused preview stops running its audio output

The preview runtime opened its AudioContext at load and left it running for the whole
session, so a paused Studio kept an audio output stream rendering silence in Chrome's
audio service and output threads, and on Chrome's fake sink once a context has been silent
for 30 s.

The transport now suspends the context once it has been paused for a second with no
captured track playing, and resumes it for Play and for a captured track that plays while
paused (the scrub route). The delay keeps a stop-and-reschedule inside one play (a rate
change, a hidden track, a seek while playing) from bouncing the output. A Play that lands
while a suspend is pending resumes anyway, and a Pause that lands while Play waits on
resume() lets the context go back to sleep.

* fix(core): decoded audio after an idle pause starts where the picture is

Review follow-ups to the idle suspend:
- A decoded track read its start time before waiting for the context to wake, so every
  Play after an idle pause anchored the clock that far back and the picture stepped back.
  The transport now reads the time after the wake.
- The transport kept every element it ever captured. It now holds only captured tracks
  that are playing, so a replaced track is free once it pauses.
- A failed suspend and destroy() both clear the pending-suspend flag, so the next pause,
  or a fresh init(), can suspend again.
- A test pins the one-second idle delay; the redundant "ended" listener is gone, since a
  track that ends fires "pause" first.

* fix(core): a preview whose browser starts audio late still goes quiet when idle

The idle timer was armed only if the context was already running when the transport opened
it or resumed it, so a browser that starts the context a moment after construction left it
running until the first pause. The transport now arms the timer from the context's own
statechange event, which Chrome fires after construction and on every resume, so the
separate arming in init() and after a stale Play are gone.

* fix(core): a reloaded track no longer keeps a paused preview's audio awake

Reloading a playing captured track (el.load(), a src swap) stops it without a "pause" event,
so the transport kept counting it as playing and a paused preview never went quiet. The
idle check now drops tracks that are already paused before it counts, and "emptied" is
handled like "pause" so the idle timer is armed when a reload stops a track.

* perf(core): a paused preview waits 10 s before its audio output goes to sleep

* docs(core): say why a paused preview's audio waits 10 s before it sleeps

* fix(core): a browser-side audio suspend drops the preview's idle timer
2026-09-26 15:26:03 +00:00
Miguel Ángel 473c86248c fix(core): the picker offers what is drawn under the pointer, not see-through layers over it (#4536) 2026-09-26 15:11:26 +00:00
Miguel Ángel 89fe898cd5 fix(core): an added sub-composition keeps its declared variable defaults when bundled (#4539)
* fix(core): an added sub-composition keeps its declared variable defaults when bundled

parseHTMLContent only treated input as a full document when it started with
a doctype or <html>. Files installed by `hyperframes add` start with a marker
comment, so they were wrapped as a fragment in a new <html> and lost their own
<html data-composition-variables>. Leading comments are now skipped first.

* fix(studio-server): an installed block's preview keeps its <html> variables

isFullHtmlDocument moves to core beside parseHTMLContent and both callers use
it, so the standalone preview of a file that `hyperframes add` installed (a
marker comment above the doctype) is treated as the full document it is. The
leading-comment skip also handles an empty `<!-->` comment, and `<html-card>`
style elements no longer count as a document.

* fix(core): a doctype written without a space still reads as a full document
2026-09-26 14:16:42 +00:00
Miguel Ángel 0b24a0ea82 fix(core): pick mode keeps its highlight on the hovered element and offers it on click (#4538) 2026-09-26 13:33:52 +00:00
Miguel Ángel cb8e7995a2 perf(runtime): swap edited scenes into the running preview from a rebuilt document (#4502)
* perf(runtime): swap edited scenes in place from a rebuilt preview document

window.__hfSwapScenes(html) compares the preview manifests. When the rebuilt document differs
only inside existing scenes, it replaces each changed scene's host, styles and scripts, drops
the old scene timelines (nested ones included), reruns the passes boot runs on scene DOM,
releases the old media, rebinds and keeps the playhead. Anything else rejects before touching
the film, so the caller does its full reload.

* fix(runtime): refuse unsafe scene swaps and redraw the swapped scene like a seek

The swap now refuses scenes marked unswappable or with more than one
host, keeps the style order, lets adapters find the new scene, redraws
through the transport seek and rejects when torn down mid-swap.

* fix(runtime): scope swap caption overrides to the swapped scene and swap only manifest pages

Other scenes kept re-dimmed caption words after a swap, and pages without
a scene manifest offered a swap that could only be refused.

* fix(runtime): repaint caption colours that were overridden after their tween rendered

A swapped scene, or a film reloaded mid-way, rendered its caption tweens
before the overrides rewrote their colours, so words kept the old colour.

* fix(runtime): repaint caption colours of a swapped scene the playhead has passed

* refactor(core): cut scene swap comments to what names and types do not say

* fix(runtime): a scene swap keeps other media loaded and waits for new media by reloading

* fix(runtime): a scene swap resolves bound and proxied media before refusing

The new-media check compared the new scene raw src against the live DOM, which variable
bindings and the media proxy have already rewritten. A scene with a customised data-var-src
logo, or a proxied video, was refused on every edit. Both sides now resolve src the same way:
the bound variable value, else the src attribute before any proxy swap, which the proxy now
records where it rewrites the src.

* fix(runtime): a scene swap binds variables only in the swapped scene, before media setup

The swap re-ran variable bindings over the whole document after binding media, so a proxied
video in any scene got its original, undecodable src back and a bound text in an untouched
scene was rewritten. Bindings now run only inside the swapped hosts and before media binding,
as boot does, and the src guard compares against the src the element had before its proxy.

* fix(runtime): a scene swap keeps unchanged video and audio playing

A text edit beside a video rebuilt the video from scratch, so it went blank until it loaded
again. A video or audio in the edited scene whose src matches one in the old scene (same src,
same order among those) is now the old element, moved into the new scene and given the new
element attributes and children, except src and the runtime-owned preload.

* fix(runtime): a scene swap refuses more new-media forms and names the scene

The new-media check missed uppercase URL(), image-set() and @import strings, SVG image href
and xlink:href, and object data, so a new asset could pop in after the swap. Its refusal now
says the media is new to this scene, which is what it checks. The existing inline style url(),
poster and srcset checks get tests of their own.

* fix(runtime): a scene swap loads caption overrides before changing the page

The swap wrote the new scene first and only then fetched caption-overrides.json, so a stalled
request left the preview half swapped when the caller gave up and reloaded. The overrides are
now fetched before the first write and applied from the fetched data. A swap that another swap
finished first while it waited now refuses, since its document is the older one.

* refactor(runtime): keep the proxy src record within the comment share

* fix(runtime): a scene swap keeps media only when the edit left it as written and ungraded

Keeping a live video or audio and copying the new attributes onto it was not the same as a
fresh load: muted only sets the initial state, and a graded video left its grading canvas in
the discarded scene, so it went black. The swap now keeps an element only when its authored
markup, captured before the runtime writes to it, equals the new one, and the grading runtime
says it is not graded. Anything else is rebuilt fresh; the attribute copy is gone.

* fix(runtime): a waiting scene swap refuses after any other swap changed the page

The check compared the scene manifest, which misses a scene changed and changed back while a
captioned swap waited for its overrides; that swap then went on with stale elements and wrote a
wrong manifest. A generation count the swap owns now moves on every page change by a swap, so
any intervening swap makes the waiting one refuse.

* fix(runtime): a scene swap warns about a non-media data-var-src once, as a load does

The new-media scan resolved every bound src through the warning path, so each swap logged the
ignored data-var-src again for the old and the new element. The scan now resolves silently and
only the bindings pass warns, once for the new element.

* refactor(runtime): keep the grading query within the comment share

* fix(runtime): a scene swap replaces each of a scene's styles in its own place

The bundle can now give one scene several style runs separated by another scene's, when its
nested scene comes later in source order. The swap put all new styles before the first old one,
so a later same-named @keyframes moved ahead of the other scene's and the cascade differed from a
fresh load and the render. Each new style now replaces its old counterpart in place, and a scene
whose style count differs is refused before any write.

* test(core): check the bundled stylesheets, not the inlined runtime, for a leftover @import

* fix(runtime): a video kept through a scene swap starts its new tweens from its authored values

* fix(runtime): a from() caption colour tween with an override still lights its word up

* fix(runtime): a scene swap reverts the old timelines, so a kept video shows the edited CSS

* fix(runtime): a caption word with a dim-colour override still lights up when spoken

* fix(runtime): a word whose colour override lands late still looks dim before it is spoken

* fix(runtime): a caption override keeps class highlights and resolves var() colours per word
2026-09-26 13:18:17 +00:00
Miguel Ángel fb3fdd4f0c fix(core): muting or unmuting an audio group while playing takes effect at once (#4532)
* fix(core): muting or unmuting an audio group while playing reaches the mix

* fix(core): a group mute uses its bus gain; only an unmute restarts the mix

* fix(core): unmuting a group captures members that have yet to play, without restarting routed ones

* fix(core): unmuting a group skips members muted on their own
2026-09-26 10:34:42 +00:00
Miguel Ángel 79144f914b fix(core): a film stops at its declared length instead of playing its animation past it (#4530)
* fix(core): a film stops at its declared length instead of playing its animation past it

* docs(core): the root-length floor comment names what it still does

* fix(core): report the real length of a film shorter than one second

* test(core): pin sub-second lengths with and without timed clips

* fix(core): any positive declared length stops the film, even a single frame

* test(core): pin every declared length in one table and read the player test by its fields
2026-09-26 09:21:19 +00:00
Miguel Ángel 5e962a4eeb fix(player): end and loop films whose length falls between two frames (#4504)
* fix(player): end and loop films whose length falls between two frames

* fix(player): end a stopped film within half a frame, not while it still plays

* fix(player): end a film at the runtime's exact time so a pause on the last frame stays a pause

* fix(player): end a film when the runtime reports its end, not by comparing two lengths

* fix(player): keep a pause the runtime marks as not ended, even at exactly the length

* fix(player): trust a not-ended report only from a stopped runtime
2026-09-26 01:35:50 -04:00
Miguel Ángel f2c63ca424 fix(core): a video's voice keeps pace with the playhead instead of trailing it (#4511)
* fix(core): a video's voice keeps pace with the playhead instead of trailing it

A playing video is exempt from seek-based drift correction, because seeking resets its decoder and stutters. Nothing else corrected it, so a talking-head video that started a few frames late (92 ms measured in Studio) kept its voice behind the playhead for the whole clip, and further behind after each loop. Media sync now runs a video that drifts past the 40 ms sync tolerance 3% fast or slow (pitch preserved) until it is within 10 ms, then returns it to the authored rate. The rate is written only when steering starts or stops, since every rate change costs Chrome a frame; large jumps still hard-seek. Measured in Studio: 92 ms constant on main, about 10 ms steady with the fix.

* fix(core): video steering holds through speed ramps and hard syncs

Review follow-ups: steering inferred itself from the element's current rate, which a speed ramp moves every tick, so a ramped video stopped steering at the 40 ms tolerance instead of 10 ms; the direction is now kept per element. A hard-sync tick plays the authored rate, since its pre-seek offset is stale, and a rate that reads back at lower precision is not rewritten every tick.

* test(core): pin clearing and write-once for video steering

Review follow-up: nothing failed when the steering state was left uncleared on eviction or pause, or when the rate compare went back to strict equality. Tests now cover all three, and the sync-state test seam reports the steering map so the proxy's leak test sees it.

* chore(core): format the video steering and keep its tolerance module-private

* chore(core): shorten the video steering comment
2026-09-26 05:02:44 +00:00
Miguel Ángel 416d009a6b fix(core): scrubbing stays audible after the first Play (#4517)
* fix(core): scrubbing stays audible after the first Play

Pause unplugged every track Web Audio had captured, and a captured track has no native output, so Studio's paused scrub audition went silent after the first Play for the rest of the session. A captured track outside a play now reaches the speakers the way an uncaptured one does, and Play takes that route away before building its own, so there is one path out at a time.

* fix(core): a track muted while playing goes silent at once

Review follow-ups to the idle route: stopAll hands every captured track back its own volume on that route, so a track hidden while playing sounded for one frame before media sync zeroed it; the hide reschedule now zeroes hidden tracks in the same task. stopAll also advances the play generation, so a schedule still waiting on resume() when Pause lands is dropped instead of giving the track a second path out.

* fix(core): a replaced audio capture no longer mutes its track

When a newer play pass replaced a capture still waiting on resume(), the old pass saw null and treated it as a failed capture, muting a processed track at a non-unit rate for the rest of the session. The fallback now ignores results from a stale generation.

* test(core): reset the pending resume between idle-route tests

* chore(core): trim the scrub fix's comments to the ratchet
2026-09-26 04:10:45 +00:00
Miguel Ángel 259f65c000 fix(core): a project path typed in another letter case stays inside the project (#4514)
* fix(core): a project path typed in another letter case stays inside the project

* fix(core): project paths still resolve on volumes that refuse the native realpath
2026-09-26 03:00:47 +00:00
Miguel Ángel 23937497a9 chore(release): cut v0.8.78 (#4515) 2026-09-25 22:58:59 -04:00
Miguel Ángel 77b2260f2b fix(core): report one composition size in stage-size and timeline messages (#4496)
* fix(core): report one composition size in stage-size and timeline messages

The runtime posted the composition size twice per timeline post, read two
ways: stage-size used the lenient reader the stage layout uses, the timeline
payload a strict one. A root with data-width="1080px" reached the player as
1080x1920, then 1920x1080, so a portrait film was framed as a cropped
landscape picture. Both messages and the stage layout now share
parseCompositionDimension, and stage-size sends the value unrounded.

* fix(core): the layout diagnostic and nested roots read the same composition size

* fix(core): read composition sizes as whole px, as the renderer and Studio do

* chore(core): one comment line on the composition size reader

* fix(core): keep fractional layout sizes and report the same root in both size messages

Layout (the stage, timed clips, flattened nested roots, the layout
diagnostic) reads data-width/data-height as before, fractions kept. Only the
size the runtime reports in stage-size and timeline reads whole px. Both
messages and the stage layout now take the root from one finder, so an
earlier composition no longer replaces the explicit data-root's size.

* fix(core): use the size messages root for the clip tree and analytics
2026-09-25 21:59:01 +00:00
Miguel Ángel 02147b8d15 fix(core): a clip gets its own inline display back when the runtime shows it (#4498)
* fix(core): a clip gets its own inline display back when the runtime shows it

The visibility pass wrote a plain display:none over a hidden clip's inline display and removed
the property when showing it, so an author's display:flex came back as block and an author's
display:none !important showed the clip. The data-hidden path kept the value but not its
priority. Both paths now save the author's value and priority once and put exactly that back.

* fix(core): a plain inline display:none still shows at the clip's start

A plain none on a hidden clip can be a runtime hide left behind (a Studio reveal restoring it),
so it is not saved as the author's value; the clip shows at its start as before. Values with a
priority, and every other value, are still put back.

* fix(core): keep an author's plain display:none through a data-hidden toggle

The leftover-hide rule applies to the timed hide only; a data-hidden hide saves the author's
plain none as it is, so un-hiding keeps the clip hidden, as on main.

* docs(core): say the leftover rule belongs to the timed hide
2026-09-25 20:18:51 +00:00
Miguel Ángel aadeaf9ddd fix(core): a clip that starts later keeps the place and size its CSS gives it (#4495)
* fix(core): a clip that starts later keeps the place and size its CSS gives it

A timed root clip that had not started when the runtime laid clips out was display:none,
so it measured as auto: it was pinned to the top-left corner, and an empty full-frame clip
got no size. Measure a hidden clip as it will show and never pin, so it lays out like a
clip showing at load.

* perf(core): lay out hidden clips in one pass and pin the !important restore

Lift every hidden clip's display:none at once so a layout pass forces one layout instead of
one per hidden clip; it runs every 20 frames during playback. Add a test that an author's
display:none !important comes back with its priority, and fix comments that described the
removed pin.

* test(core): describe what the applyClipLayout test block covers

* refactor(core): name the display lift instead of commenting it
2026-09-25 18:02:24 +00:00
Miguel Ángel e266ad5e5c fix(core): a frame capture keeps waiting for a video between its range requests (#4490) 2026-09-25 14:12:43 +00:00
Miguel Ángel dd88b5f5b7 chore: release v0.8.77 (#4489) 2026-09-25 12:51:35 +00:00
Miguel Ángel f1f370e96c fix(studio-server): inject page tags at the document's own head and body, not inside a script (#4484) 2026-09-25 12:05:06 +00:00
Miguel Ángel a486c0f8d9 fix(core): a frame capture waits for a video that is still loading its first data (#4487) 2026-09-25 12:03:48 +00:00
Miguel Ángel 8b4c3a1d3d chore: release v0.8.76 (#4485) 2026-09-25 11:20:26 +00:00
Miguel Ángel fba943ea3a fix(cli): render --quiet stops printing producer info and trace lines (#4478) 2026-09-25 10:39:01 +00:00
Miguel Ángel ab540fc42e perf(player): let paused players check in once a second with low-power-idle (#4483)
* perf(player): let paused players check in once a second with low-power-idle

* refactor(player): trim low-power-idle comments and tidy its test

* fix(core): keep the fast paused heartbeat until a timeline is bound

* fix(core): wait for sub-compositions to bind before slowing the paused heartbeat
2026-09-25 10:15:42 +00:00
Miguel Ángel 11ac9bce69 fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files

* fix(studio-server): a GSAP save on a served id writes that id to the file too
2026-09-25 10:04:38 +00:00
Miguel ÁngelandMiguel Angel Simon Sierra 2455a3d0ee fix(core): a seek during playback lands a playing video on the new time (#4467)
* fix(core): a seek during playback lands a playing video on the new time

* fix(core): the seek fix passes the comment ratchet without a new comment

* fix(core): keep why a pausing seek still lands, folded into the paragraph above

---------

Co-authored-by: Miguel Angel Simon Sierra <miguelangelsisi098@gmail.com>
2026-09-25 09:48:57 +00:00
Miguel Ángel 830f28bf4e fix(core): preview time follows a voiceover that plays through Web Audio (#4476) 2026-09-25 09:13:32 +00:00
Miguel Ángel ad87313ee2 fix(core): a Lottie animation in a mounted composition plays from that composition's start (#4471) 2026-09-25 04:43:49 +00:00
Miguel Ángel f873734cc4 perf(studio): open a large film ready to play in about 4 s instead of 32 s (#4401)
* perf(studio): keep lint off the preview open path

The bundler linted every build only to print a StaticGuard warning; a preview bundle now
skips it through a staticGuard option, since Studio lints on its own route. That route now
lints in the CLI child-process worker the render path already uses, so seconds of parsing
no longer stall the preview and every other Studio request on the server event loop.

* perf(parsers): parse identical GSAP source once

Studio re-requests the parsed animations of unchanged source on every open, and several lint
rules parse the same script in one run. A small memo keyed by the script text serves repeats;
each caller gets its own copy.

* perf(studio): build each preview document once per content version

The preview route rebuilt the document for every browser without a cached copy. It now serves
one build per ETag from memory, and the CLI keeps the last build on disk salted with its own
file, so a restarted server serves the previous document without stale code.

* perf(studio): build the opening film's preview while the browser starts

The preview server built the document only when the player asked for it, after
Studio had booted, so a first open waited about a second of idle server time.
The CLI now requests the opening project's preview right after listening, and
requests that arrive while a build runs share it instead of starting another.

* perf(studio): request the preview document at page load

Studio asked for the preview only once the player mounted, about 640 ms into a
first open. It now requests it the moment the page loads, and again when the
hash names another project, so the build or cache read overlaps Studio's boot.

* feat(studio): show the cached frame 0 while the live preview boots

Studio covers the live preview with frame 0 as the thumbnail route last rendered it, sized
from the image so the stage does not shift, and removes it when the live frame is ready to
show. The request is cache-only, so a miss never starts a render on the open path; the
missing poster is rendered after the live frame shows, for the next open.

* perf(cli): start Studio before the startup lint finishes

hyperframes preview linted the whole project before it listened, 4 to 6 s on a large film, and
did so in --json mode where the result is discarded. The lint now runs in the CLI worker while
Studio starts and prints its summary when done; --json skips it.

* perf(studio): render thumbnails from one loaded page, after the preview boots

Every thumbnail opened a page and loaded the whole composition to shoot one frame, and the
Compositions card asked for one while the live preview was still booting. The server now keeps
one loaded page per preview document and seeks it per frame, reloading only when the project
changes; card thumbnails wait for the project first ready preview.

* perf(studio): parse GSAP animations after the preview boots

Studio asked the server to parse the composition animations while the preview was opening; on
a fresh server that cold parse ran on the event loop ahead of the preview build. The request
now waits for the live preview to boot, or to fail loading, since only keyframes need it.

* perf(cli): find orphaned browsers with one process-table read

preview ran pgrep three times and then ps once per matching browser, all
synchronously before listening: about half a second on a machine running
other headless Chrome. One ps listing now finds every orphan in ~20 ms.

* perf(studio): keep lint off the boot

Opening a project started two lint processes while Studio booted: the
terminal's startup lint and Studio's background lint, ~4 s of CPU next to the
boot. Studio's background lint now waits until the live preview has booted,
and lint processes run at the lowest CPU priority so they only take idle CPU.

* perf(studio): open the editing session after the preview boots

Opening a project read the composition source and parsed it for the editing
session (linkedom plus hf-id assignment) on the main thread while the live
preview was booting on the same thread: ~300 ms on a large film. The session
now opens once the preview has booted; nothing the first frame shows needs it.

* perf(studio): park the editor overlays until the preview boots

The overlay frame loop polled preview geometry and off-canvas indicators from
the first frame, forcing layout on a preview document that was still loading,
on the thread its boot runs on: ~200 ms before the first frame on a large film.
The loop now skips its subscribers until the preview has booted, then wakes.

* perf(studio): treat the preview as booted once it can play

Work deferred until the boot (editing session, lint, overlays, GSAP parse,
card thumbnails) started at timeline-ready, which fires before the asset gate,
so the editing-session parse ran inside the wait for play. The Player now
latches the boot when the preview can show and play, or fails to load.

* fix(studio): keep a left project's deferred work out of the one now open

Deferred open-time work (source parse, background lint, editing session) waited on one global
boot flag, so leaving a project before its preview booted let the next project's boot run the
old project's work and write its results. The wait is now per project and resolves false once
another project opens. A preview stuck without an error stops deferring that work after 5 s.

* perf(cli): let the play page revalidate instead of refetching

hyperframes play served the composition, its assets, the player and the runtime with no
validator, so every reload refetched everything, the mp4 in full three times. They now carry
an ETag and stay no-cache, so unchanged bytes cost a 304 and an edit is still seen.

* fix(cli): serve an edited text asset even when its size and mtime match

The play page tagged every asset by mtime and size, so a same-size rewrite within one
filesystem tick kept the old tag and the browser kept stale bytes. Text assets now get a
content-hash tag; media keeps the mtime and size tag Studio already uses.

* fix(studio): close idle thumbnail pages so an idle Studio runs no composition

Kept-loaded thumbnail pages stayed open for the life of the server, each running a live composition,
so an idle Studio cost server CPU and memory that main does not. A page now closes after 10 s unused,
and a page is closed only after the frame still running on it finishes.

* fix(studio): stop a first open from reloading the preview once per scene file

Studio stamped hf-ids into a composition the first time that composition was served, so opening a
film of external scenes wrote to index.html and every scene file mid-session. Each write reached
Studio as an outside edit and reloaded the preview, which failed for a large film. The server now
stamps every composition once before it starts watching the project.

* fix(studio): ignore play until the preview can play, so it is never undone

Space (and any play() caller) could start playback before the preview frame finished loading. Its
load step then paused and rewound to the restore point, so the play was silently lost. play() now
waits for timelineReady, the same gate the Play button already uses.

* fix(studio): hold play on a composition switch until the new preview loads

Readiness was reset only when the project changed. Drilling into another composition kept the
old ready flag while the new preview loaded, so Play or Space could start a play that its load
step then undid.

* refactor(studio): split background lint and thumbnail page reuse into single-purpose steps

Keeps the boot wait in the background lint only, moves page lookup and idle close out of
withPage, and drops a suppression the startup worker no longer needs. Behaviour unchanged.

* chore(cli): declare the render-setup worker as a fallow entry

It is spawned by path from cancellableProcess.ts, so it has no import-graph referrer; the entry list
is where the repo records that, replacing the per-file ignore removed in the previous commit.

* fix(cli): close a thumbnail page whose load fails

The page never leaves the promise that loads it, so when the load throws (a WebGPU composition with
no adapter, or the navigation timeout on a big film) nothing could close it.

* fix(cli): match text assets by exact type and keep Range on them

The pattern anchored only its first alternative, and text assets skipped buildRangeResponse, so a
ranged request for a stylesheet or SVG got a full 200.

* test: open the next project as Studio does, and read the stamped file once

The absent-read guard test switched project without beginning its timeline session, so the
per-project boot wait held the read. The stamp test checked then read the same file.

* test(cli): rewrite the scene file without opening it first

Opening the file by path counted as a check before the later read of the same path.

* perf(cli): take a thumbnail's version from the cached project signature

Each thumbnail walked the project and hashed every text file for its page version; the adapter
already caches that signature and clears it from the watcher. An aborted request now also stops
before its screenshot.

* fix(studio): keep Play off until the preview's load step has run

Play could enable before the preview document's load event, from the runtime's timeline message
or from the blank page's load step settling on the preview's first message. The load step then
paused and rewound the preview, so a Play pressed in between did nothing; a film with large
images loads late enough for this to hit a cold open. Readiness now waits for the document's load
step, whichever path asks for it.

* refactor(studio): move the preview poster's state into usePreviewPoster

NLEPreview.tsx passed the 600-line limit once main grew it; the poster's state and callbacks belong
beside PreviewPoster. No behaviour change.

* fix(cli): take an earlier thumbnail on a fresh page

A composition need not draw the same frame when seeked back, so a kept page that had shown 20 s
drew a different 10 s thumbnail than a fresh load. A kept page now only seeks forward, as a render
does; an earlier time loads a fresh page.

* perf(cli): close a kept thumbnail page a second after its last frame

Held for 10 s, the page went on running its composition after the open's thumbnail
burst, about 5 % of a core in the thumbnail browser while Studio sat idle. A burst's
requests arrive back to back, so it still loads the page once.

* fix(studio): count the preview as booted at its first frame

The boot waited for every video and audio in the preview to finish buffering, which on a
project with media took until the 5 s fallback. The thumbnails, editing session and lint
that wait for the boot then started seconds late and kept an idle Studio busy. They now
start once the first frame is up; media finishes buffering alongside.

* perf(studio): render one thumbnail per composition for its card and clip

A composition's sidebar card asked for its frame at 3.00 s and its timeline clip for the
clip's midpoint, so opening a project rendered every scene twice. The clip now asks for the
card's frame (the midpoint still, for compositions under 3 s), and the thumbnail cache
serves both from one render: 20 scene renders drop to 12 on a 12-scene project.

* chore: keep each changed file's comment share at or under main's

The comment ratchet counts the comment lines this branch added. Each is cut, or kept as one
line where it states an invariant (the GSAP parse cache returns copies; the boot starts waiting
work at the first frame). The orphan-cleanup docblock is condensed and now names the process-table
read it uses instead of the pgrep scan it replaced. No code line changed.

* fix(studio): keep a 3 s clip off its end frame and give card and clip one encoded URL

A composition exactly 3 s long asked for its thumbnail at 3.00 s, its end instant, which after a
fade-out is an empty frame; compositions of 3 s or less now use their midpoint. The card built its
thumbnail URL from the raw path while the clip encoded it, so names with & + [ or % asked for two
different URLs, and a % in the name made the card's request fail on the server; both now encode.
The readiness comment says the boot latches at the first frame, as it has since the boot moved.

* fix(studio): encode the card's live preview URL too, and document the bundler's static guard

The card's hover preview built its URL from the raw composition path, so a % in the file name made
the preview route fail to decode it. The card now builds its composition URL in one place, encoded,
for both its thumbnail and its hover preview. The static-guard bundle option keeps a one-line doc
(its default and what it warns about); a neighbouring docblock is rewrapped to keep the comment
share.

* test(cli): mock studio-server once so the signature spy and the history hook both apply

The main merge brought a second vi.mock of @hyperframes/studio-server into studioServer.test.ts;
the later one replaced the first, so createProjectSignature was no longer a spy and the cached
signature test read .mock of undefined. One mock now carries both overrides.
2026-09-25 04:56:55 +00:00
Miguel Ángel 278bc712d7 fix(core): a mounted composition's import map and module scripts run as such (#4446)
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl

* test(core): a template composition inside a mounted file resolves assets from that file

* docs: assetUrl is for paths beside the composition, not project-root paths

* docs: say assetUrl misses a project-root path

* fix(core): a mounted composition's import map and module scripts run as such

* refactor(core): scope a mounted module script under the same condition as a classic one

* fix(core): a mounted module script loaded by src stays a module

* fix(core): a mounted import map keeps the trailing slash of a prefix address

* chore(core): keep the module-script helpers' comment share at the base
2026-09-24 23:44:04 -04:00
Miguel Ángel f888db950e fix(core): paint no timed clip until the runtime decides its visibility (#4460)
* fix(core): paint no timed clip until the runtime decides its visibility

Until its first visibility pass, a paused page showed every timed clip at once: an embed without
a loader flashed a later scene before the first, and a player's hidden iframe still decoded every
scene's images. The runtime now hides timed non-media clips from the moment its script runs,
and the first pass that decides them removes that rule, so each clip is shown only by the pass.

* refactor(core): name media with the realm-safe helper in the first-pass check

* fix(core): share the first-pass hide across runtime copies and shader captures

A second evaluation of the runtime (a sub-composition carrying its own runtime tag, or a page that
includes it twice) added a hide rule nothing removed, so the film stayed invisible. The rule and its
flag now live on the page: a copy that finds either, or finds the runtime already initialised, adds
none, and the first pass removes every copy. HyperShader's capture clone forces its scene visible
with !important, so a transition captured before the first pass still shows the scene's clips.
The runtime test now also pins evaluation-time hiding, inline visibility, media, and two copies.

* fix(core): grade media inside a clip once the first pass shows it

Color grading registers only media it can see. Media with no timing of its own inside a timed clip
inherited the first-pass hide, so grading skipped it, and lifting the rule is not a body mutation,
so nothing registered it again: the image or video stayed ungraded. The pass now refreshes grading
when it lifts the rule. The second-copy test now asserts on a paused page, before any seek.

* chore(core): keep the comment share of the first-pass hide files at or under their base

The comment ratchet counts one added comment line each in entry.ts, init.ts and capture.ts, and a
new file above its package's share. The three lines are gone, and the one note that has to stay
(why the hide runs at script evaluation) now lives in the new module's shorter header.
2026-09-24 23:04:23 -04:00
Miguel Angel Simon Sierra a08433acef chore: release v0.8.75 2026-09-24 21:51:48 -04:00
Miguel Ángel b8dd023a97 feat(core): a composition's script can load its own files with __hyperframes.assetUrl (#4434)
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl

* test(core): a template composition inside a mounted file resolves assets from that file

* docs: assetUrl is for paths beside the composition, not project-root paths

* docs: say assetUrl misses a project-root path
2026-09-24 21:13:27 -04:00
Miguel Angel Simon Sierra 3a47b46057 chore: release v0.8.74 2026-09-24 17:04:02 -04:00
Miguel Ángel 861fcc4dd0 fix(core): a preview that is never seeked shows only the clips of its current time (#4422)
The visibility pass ran only on seek, pause, play and during playback, and at init for video
and img alone, so a paused page published __renderReady with every caption and composition host
visible until someone seeked. Readiness now runs that pass for non-media timed elements; media
keeps its init pass and seeks, which own color grading and audio scheduling.
2026-09-24 14:18:53 -04:00
Miguel Ángel 6f7f463fc5 chore: release v0.8.73 2026-09-24 11:53:31 -04:00
Miguel Ángel 6a0fd68584 chore: release v0.8.72 2026-09-24 07:23:30 -04:00
Miguel Ángel 261af22c16 fix(core): frame captures wait for a seeking video to land its frame (#4406)
* fix(core): frame captures wait for a seeking video to land its frame

A paused seek set video.currentTime and returned; the seek-completion barrier resolved while the
video was still seeking, so a capture right after the first seek on a fresh preview painted the
previous frame. The media sync now joins each seeking video to that barrier until seeked, error,
emptied or abort.

* style(core): format the seek barrier test
2026-09-24 07:07:59 -04:00