Commit Graph
4815 Commits
Author SHA1 Message Date
Miguel Ángel 76723c614c fix(cli): snapshot shows a short video's last frame instead of going black (#4552)
* fix(cli): snapshot holds a short video's last frame instead of going black

* fix(cli): snapshot falls back to a video's real last frame when a held tail lands past it
2026-09-27 01:05:56 +00:00
Miguel Ángel 640e990431 fix(player): a reloaded preview waits for its assets once, not twice (#4546)
* fix(player): a reloaded preview waits for its assets once, not twice

* fix(studio): keep the preview's comment share at its base

* refactor(player): record the ready document where ready is sent

* fix(player): a source change stops the previous film's audio right away

* fix(player): the next film no longer replays the previous film's audio

* fix(player): an audio-src track sharing the film's URL survives a film change

* fix(core): first-frame readiness counts media a script adds before load

* fix(player): media a script adds before load still holds the first paint

* fix(player): hosts see a load's readiness only after the player has handled it

* fix(player): a slow first film no longer reports a missing timeline

* test(player): pin the connected flag with a mid-insertion load
2026-09-27 00:40:07 +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) v0.8.79 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 7eb6430e4e fix(studio-server): history refuses once closed and reopens when a new project takes its folder (#4537) 2026-09-26 19:01:05 +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 8798e40da8 fix(registry): carousel cards fall back to their sample image inside the assets folder (#4543)
* fix(registry): carousel cards fall back to their sample image inside the assets folder

When an image variable resolves to nothing, the 25 carousel blocks loaded the
sample card by bare file name, which 404s beside the block and draws a white
card. The fallback now keeps assets/carousel-images/, the folder every card
image is installed into. A guard test fails on any block with a bare fallback.

* test(registry): the carousel fallback guard covers every block that uses it

The guard now picks each block that reads an image variable and fails when its
fallback no longer matches, instead of silently skipping it, and ignores stray
files in the blocks folder.
2026-09-26 15:26:19 +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 c238abfe6f fix(media-use): tests never write into the person's media library (#4459)
* fix(media-use): tests never write into the person's media library

* fix(media-use): guard the media home only in this repo's test runs

* test(media-use): probe the media home from a plain child process

* fix(media-use): keep miss logging best-effort when home is unresolvable

* test(media-use): miss logging survives an unresolvable media home

* docs(media-use): describe the media library without naming an app

* chore(media-use): refresh the skills manifest hash
2026-09-26 14:13:56 +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 162de1a6ab fix(studio-server): a gesture sent without an idle time holds until another key (#4513) 2026-09-26 10:35:42 +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 9faade09e7 fix(studio-server): undo of a studio edit keeps a save that landed just before it (#4524)
* fix(studio-server): undo of a studio edit keeps a save that landed just before it

* fix(studio-server): undo keeps a save across several studio writes and a whole-file follow-up

* fix(studio-server): undo claims survive studio writes that loop back and stale write receipts

* fix(studio-server): a claim forgets only the write receipts it walked through

* chore(studio-server): drop a claim comment the comment ratchet counts against the stacked base

* docs(studio-server): keep the claim walk comment under the line limit

* refactor(studio-server): pull the pending-hash keep set out of the budget fold
2026-09-26 10:00:34 +00:00
Miguel Ángel 5b034cab74 fix(studio): an effect save resolves with whether it landed, was refused or failed (#4531)
* fix(studio): an effect save resolves with whether it landed, was refused or failed

setElementFxAttribute.setQuiet and setAudioGroupAttribute.setQuiet, which
every timeline effect save goes through, resolved the same way whether the
write landed, the host's canEdit refused it, recording blocked it, or the
write failed; only Studio's toast told them apart. They now resolve with a
TimelineEditOutcome: saved, refused with the reason, or failed with the
error. The lock guard resolves with the refusal handler's result, and a
clip that cannot be addressed in its file reports failed instead of saved.

* fix(studio): the lock guard hands its reason to the refused effect save

The refusal reason comes from the gate's own canEdit pass instead of a
second call, a group save with nothing to address reports failed like the
clip save, and the failure message says the clip has no id to save it by.
2026-09-26 09:22:39 +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 7c017a08de feat(studio-server): history can copy the files before or after any change into a folder (#4493)
* feat(studio-server): history can copy the files before or after any change into a folder

ProjectHistory.checkout(entryId, side, emptyDir) writes the project as entry entryId found it (before) or left it (after) into an empty folder, leaving the project and its history untouched, so a host can play a change's before and after side by side. The entry's own files come from its recorded change and the rest from the entries logged before it, so a change stays exact when entries were logged out of write order.

* fix(studio-server): checkout refuses an unreadable folder and every replay names its side

* fix(studio-server): checkout lays out only trees that existed and never writes into the project

An entry's tree now includes later-logged entries whose writes it continues (an outside change that turned a file into a folder before an agent turn edited inside it), each file replayed in the order its versions chain. Checkout refuses a folder inside the project (symlinks included), refuses a tree with a path that is both a file and a folder before writing anything, and empties the folder again if a copy fails. manifestAt is back to main's replay.

* fix(studio-server): history logs every change in the order it was written

A window now settles pending writes, the held claim and the outside group before it opens, and commits a held claim before its own entry when it closes, so the log order is the write order and every replay (checkout, restore, peek, reopen) sees files as they were. Removes the dependency replay added for checkout. Visible change: an outside burst that spans an agent turn is two entries, and a drag held across a turn ends at the turn.

* fix(studio-server): checkout recognises its project folder whatever letter case the path uses

* fix(studio-server): overlapping turns, operations and claims log every pending change in write order

settle() (a window opening, an undo, restore or step) and every claim commit the held claim, the outside group and each open window's pending changes as a part, oldest first; the windows stay open. A held drag ends when another write is pending. One commitPending owner replaces the claimed-paths-only cut.

* feat(studio-server): undo is per author, and a turn part undoes alone keeping later edits

* test(studio-server): pin per-author redo and name a person's open window on Cmd+Z

* refactor(studio-server): split Cmd+Z target lookup and claim steps under the audit's thresholds

* fix(studio-server): a file replaced by a folder logs its removal before the folder's files

* fix(studio-server): undo across a file/folder swap works, and a later swap stays the person's

* fix(studio-server): an undo blocked by a file added since refuses before touching any file

* fix(studio-server): an undo never writes through or replaces a link standing in its way

* fix(studio-server): undo and redo across a letter-case file/folder swap work in every direction

* fix(studio-server): a letter-case file/folder swap after an idle turn stays one checkable entry

* fix(studio-server): decide letter-case folding from the project's own disk

* docs(studio-server): keep three history comments under the line limit

* fix(studio-server): checkout checks and fills one resolved folder, so it never overwrites a nonempty one
2026-09-26 09:11:32 +00:00
Miguel Ángel db42e60223 perf(studio): a clip's filmstrip decodes 9 frames instead of 272 and reaches the clip's end (#4526)
* perf(studio): build a video clip's filmstrip from its keyframes

* fix(studio): a filmstrip slot keeps its own time when its keyframe is far away

* perf(studio): a WebM filmstrip downloads each cluster once

Each keyframe lookup now runs just before its frame is decoded, from a
generator the canvas sink pulls, instead of all lookups before the first
decode. A WebM lookup loads its whole cluster, so resolving every lookup
first pushed the early clusters out of the source cache and the decode
pass fetched them again. A cancel during a lookup ends the generator
quietly so the decoder still closes; the cancel is raised after the loop.

* fix(studio): a trimmed video with no reported length fills its filmstrip

When the file reports no duration (a fragmented MP4), the source was
assumed to end at the trim length, so a trim that starts later collapsed
to zero length and every tile showed the trim start. It now ends at the
trim start plus the trim length.

* fix(studio): a video filmstrip reaches the end of the clip

The strip requests a power of two of frames spread over the whole clip,
but tile i showed frame i, so fewer tiles than frames showed only the
start of the clip and more tiles repeated the strip. Each tile now shows
the frame nearest its own place along the clip.
2026-09-26 08:52:21 +00:00
Miguel Ángel 5a74db903d fix(studio): playback no longer re-renders the whole app every frame (#4529)
* fix(studio): playback no longer re-renders the whole app every frame

* fix(studio): pausing by hiding the tab keeps the playhead where it stopped

* fix(studio): hiding the tab stops a reverse shuttle like any other pause

* test(studio): pin the shuttle speed reset on a hidden-tab pause

* style(studio): format the hidden-tab pause test
2026-09-26 08:35:22 +00:00
Miguel Ángel 0f14691c7b fix(studio-server): studio element edits keep a save that lands while they apply (#4510)
* fix(studio-server): a Studio element edit keeps a change that lands while it is applied

applyFileMutations refuses a file that changed since the caller read it even without a version (FileChangedError). The element-patch routes, including the patch-element route every style, text and attribute save uses, re-read and re-fold their patches when another writer lands mid-edit, so both changes are kept, and answer 409 after three raced tries. Every other route that writes through writeMutationResult answers 409 instead of overwriting. The batch route now writes atomically.

* fix(studio-server): element edits re-check after their backup and insert-composition refuses a changed file

* fix(studio-server): an element edit refolds when another save lands during its backup

* test(studio-server): the insert race test uses a page with a composition to insert into

* fix(studio-server): an edit to a linked file updates the link's target and keeps the link

* fix(studio-server): id stamping never writes through a link, and link writes keep their receipt

* fix(studio-server): receipts match across a linked project folder, before and after a delete

* fix(studio-server): a restore into a folder deleted outside the app keeps its receipt

* docs(studio-server): say the real-path fallback climbs to the nearest existing folder

* fix(studio-server): edits pin their target when checked, so a retargeted link can't redirect the write

* fix(studio-server): split cuts pin their target and duplicates never follow a planted link

* docs(studio-server): keep the real-path helper's comment under the line limit
2026-09-26 08:23:45 +00:00
Miguel Ángel 0bb7b2f61a fix(shader-transitions): a runtime seek during the prewarm is where the preview lands (#4523)
* fix(shader-transitions): a runtime seek during the prewarm is where the preview lands

The runtime seeks the timeline through totalTime, which HyperShader did not
wrap. A seek that arrived while the transition prewarm ran moved the
timeline under the capture and was never recorded, so the prewarm restored
the playhead it had before the seek and the film's tweens showed that time
while the player showed the new one. totalTime is now wrapped like time and
seek: while the prewarm runs it records the playhead, and the prewarm
restores it. HyperShader's own play, pause, time and seek calls, which GSAP
routes through totalTime, pass straight through.

* fix(shader-transitions): a speed change during the prewarm does not move the playhead

GSAP re-renders in place through totalTime from timeScale, paused and
reversed, so those now pass through as HyperShader's own seeks instead of
recording the capture frame as the playhead to restore.

* fix(shader-transitions): an outside seek during the prewarm is applied, not dropped

Dropping outside totalTime calls while the prewarm ran also dropped the
runtime's bind-time render, so a warm reload at 0 never drew tl.set states
at 0, and it starved the runtime's volume probe. An outside totalTime call
is now applied as before and recorded as the playhead the prewarm restores;
reading totalTime during the prewarm still returns that playhead.

* fix(shader-transitions): the prewarm captures its sample frame even after an outside seek

Outside seeks now apply during the prewarm, so one that lands while a
capture frame paints (the runtime's volume probe restoring the playhead,
a transport tick, a restored playhead) could move the timeline before the
capture and cache the wrong frame. The capture re-seeks to its sample, with
events suppressed, right after the paint wait.
2026-09-26 08:21:14 +00:00
Miguel Ángel 81d8a03d2e fix(repo): keep one fallow audit base cache per checkout instead of one per main commit (#4525) 2026-09-26 07:36:06 +00:00
Miguel Ángel 516320c28d fix(studio): a copied clip pastes as saved, not with the preview's hidden styles (#4506)
* fix(studio): a copied clip pastes as saved, not with the preview's hidden styles

Copy took each clip's markup from the live preview, so a clip copied before its start carried
the runtime's visibility:hidden and display:none into the pasted source and could never show.
Copy now reads the element from its saved file (matched by data-hf-id, then id, the lookup the
GSAP soft reload already used, now shared). Copy still answers the key at once; paste and cut
wait for the read, so a cut never deletes before its markup is taken.

* fix(studio): copy finds a clip's saved markup in templates and after saves

Copy now stamps the saved file the way the preview does, so a film
without hf-ids on disk still resolves; searches template contents for
sub-composition clips; falls back to the element id; and reads the file
after any save in flight. A failed read keeps the previous clipboard
and a cut with a failed copy deletes nothing.

* fix(studio): a copied sub-composition clip keeps working asset paths when pasted

Copy now rebases the saved markup's relative src, href and style url() paths
to the project root, as the preview does when it inlines a sub-composition,
so a clip copied from compositions/ and pasted into index.html keeps its
images. It also waits for DOM edit saves still in flight, as undo does, and
parses each file once per copy.

The authored-source lookup uses an escaped attribute selector over cached
search roots, searches composition templates only, and falls back to an id
only for an element with no hf-id; copy alone still tries the id when the
hf-id misses.

* fix(studio): copy works after a failed save and with any relative asset path

Copy waits for DOM edit saves only for their order, so a save that failed
no longer makes every later copy, cut or duplicate fail. Asset paths are
joined segment by segment instead of through URL and decodeURI, so a path
with a stray % or a url() with spaces is rebased instead of crashing or
being skipped.
2026-09-26 06:04:29 +00:00
Miguel Ángel 5fc8766731 fix(shader-transitions): a paused preview keeps the runtime's scene visibility (#4522)
* fix(shader-transitions): a paused preview keeps the runtime's scene visibility

After the transition prewarm, a paused preview showed a shader scene
outside its window (a scene with a background covered the film until the
next seek). The prewarm's seeks fired HyperShader's transition callbacks,
which showed both scenes, and its teardown restored a stale visibility
snapshot. The callbacks now skip while prewarming (the prewarm ends after
its restore seek), the snapshot no longer includes visibility, and the
settled state outside a transition only resets opacity.

* fix(shader-transitions): the native scene capture draws a hidden scene visible

The html-in-canvas capture copied the live scene with its inline styles and
ignored forceVisible, so a scene the runtime had hidden captured blank once
the prewarm stopped firing the transition callbacks. It now shows the copy
and its timed clips the way the html2canvas path does. The preview test hides
the scenes after the prewarm has taken its style snapshot, and the end
callback guard and a dead prewarming check are gone.

* test(shader-transitions): drop an unused scene and a timing note the markup does not set

* fix(shader-transitions): transitions leave scene visibility to the runtime

A transition now hides scenes outside its pair with opacity only, so a scene
without timing attributes is not left hidden after a seek that skips events.
The prewarm clears its flag in a finally, so a timeline callback that throws
during the restore seek no longer stops every later seek. The preview test
also checks that the prewarm ran.

* fix(shader-transitions): the prewarm finishes when a callback throws while it restores

A timeline callback that throws during the restore seek is warned about and
the prewarm goes on to repaint, resume and fire its ready event, the way a
failed capture already does.
2026-09-26 05:33:57 +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 b667398207 fix(studio): a picked element's edit writes only that element to source (#4508)
* fix(studio): a picked element's edit writes only that element to source

An edit to a picked element with no id used to write the whole live
preview page into the source file, runtime hide styles and injected
scripts included. It now patches only that element, found by the hf-id
the preview gives it. Text and attribute edits on such an element,
which were dropped, persist the same way. With no saved match nothing
is written.

* fix(studio): a picked element edit lands in its own file when ids repeat

hf-ids are unique within one file only, so an element identical to one
in another file shares its hf-id. The edit now prefers the file the
element was served from, else a sole match, and writes nothing when the
match is ambiguous.

* fix(studio): only an id-less picked element looks up its own source file

The own-file lookup ran before the id branch, so a preview URL it could
not decode stopped edits to elements with an id from saving. Tests now
cover a twin element in both file orders with the host attribute the
preview emits.

* fix(studio): two picker edits before a rerender both reach the saved file

Each edit patched the host's workspaceFiles, which do not change until the
host rerenders, so a second edit in the same batch rebuilt the file from the
original source and dropped the first. The hook now builds each patch on its
own latest write for that file while the host still shows a source from the
same chain of writes. Any other source from the host wins.

* fix(studio): a host undo after a picker edit is not overwritten by the next edit

Pending writes now settle when the host rerenders, the way the property
panel's optimistic commit does: the host showing the newest write or any
source outside the chain (an undo, an outside edit) drops them, and the host
showing an earlier write keeps only the later ones.

* fix(studio): keep every pending picker write until the host shows it

A cap on remembered writes could drop the middle of a long chain when the
host falls behind, and the next edit would then leave those edits out.
2026-09-26 04:53:50 +00:00
Miguel Ángel 0985b45c32 perf(studio): let a paused preview check in once a second instead of every 80 ms (#4512)
* perf(studio): let a paused preview check in once a second instead of every 80 ms

* fix(studio): settle a loaded preview on the next frame, not the next runtime post

* fix(studio): stop waiting for a preview load when the player unmounts

* fix(studio): stop waiting for a preview load when the player resets

* refactor(studio): keep the load retry within the file's comment share
2026-09-26 04:18:01 +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) v0.8.78 2026-09-25 22:58:59 -04:00
Miguel Ángel 2be82f0099 fix(cli): use onnxruntime-node installed beside the CLI when it is the pinned version (#4507)
* fix(cli): use onnxruntime-node installed beside the CLI when it is the pinned version

An optional package (onnxruntime-node, @google/genai) installed next to
the CLI at the pinned version is now loaded from there, before the
~/.cache/hyperframes/optional copy and without an npm install. Any other
version is ignored and the cache path runs as before. doctor reports
that copy's version.

* refactor(cli): name the beside-the-CLI lookup instead of commenting it

* fix(cli): a bad optional package copy beside the CLI no longer breaks doctor

The lookup reads the first copy Node would load and treats an unreadable
manifest as not the pinned copy, so doctor and the loader fall back to
the cache instead of throwing or checking a different copy.

* fix(cli): let Node pick the optional package copy before checking its version

The beside-the-CLI lookup now asks require.resolve which copy it would
load and checks that copy's manifest, so an empty folder or a same-named
file earlier on the path can no longer make the check and the loader
disagree.

* test(cli): cover a symlinked optional package copy beside the CLI

* fix(cli): find a symlinked optional package when Node preserves symlinks

Under --preserve-symlinks require.resolve returns the symlink path while
the candidate folder was compared by its real path, so a pinned copy
Node loads was rejected. Both sides are now real paths. A test runs the
lookup in a Node subprocess with --preserve-symlinks.
2026-09-26 01:38:48 +00:00
Miguel Ángel 5145a136a0 fix(shader-transitions): the render leaves scene visibility to the runtime (#4509)
* fix(shader-transitions): a render no longer shows the last shader scene past its end

The page-side render un-hid the scene settled after the last shader
transition on every later frame, so a scene that followed it rendered
underneath the old one. It now un-hides that scene only while its own
data-start/data-duration window is open.

* fix(shader-transitions): the render leaves scene visibility to the runtime

The page-side compositor un-hid the destination of the last finished
shader transition on every non-transition frame, a workaround for the
runtime hiding a film's final scene before the end. The runtime has
kept the last frame since #4158, so the workaround only showed a shader
scene outside its own window: before it starts and after it ends.
Remove it, with the narrower check this branch added first.
2026-09-26 01:18:44 +00:00
Miguel Ángel 4ef2fc3f1e feat(player): expose picture size, durationchange and a click-to-play opt-out (#4505)
* feat(player): expose picture size, durationchange and a click-to-play opt-out

- compositionWidth/compositionHeight are public, ready carries them, and a
  resize event fires when the picture size changes.
- durationchange fires when the duration changes after ready, on both the
  runtime and the direct-timeline paths.
- disable-click-to-play leaves clicks on the picture to the host.

Nothing changes for a player that does not use the new attribute; ready's
detail only gains two fields.

* docs(player): say composition size, one doc line per size getter

* fix(player): apply scenes before ready fires, keep the zero-size warning, document the new API

* fix(player): fire a message's events after the whole message is applied

* fix(player): queue every player event behind the update that raised it

* fix(player): decide autoplay after ready listeners run, and restart a loop at time 0

* fix(player): keep raising events after a listener throws

* refactor(player): drop a comment the ordering test already pins

* fix(player): fire the rest of an update's events when a listener throws
2026-09-26 00:58:43 +00:00
Miguel Ángel 0f528ff2e8 feat(shader-transitions): add playSeamTransitionLoop to preview a seam on a loop (#4307)
* feat(shader-transitions): add playSeamTransitionLoop to preview a seam on a loop

Plays a catalog transition's real WebGL effect between two already-decoded
frames on a caller-supplied canvas, looping 0->1->0 every second. The
caller supplies the two seam frames itself, so this takes two
CanvasImageSource frames directly rather than resolving any DOM scene.

Reuses the existing webgl.ts primitives hyper-shader.ts's full composition
engine already relies on; no GSAP timeline, no scene capture, no
multi-transition scheduling here, just setup + a render loop.

shaderName is the catalog block's own name. 12 of the 14 shader-tagged
catalog blocks match their shaders/registry.ts key verbatim; two are
aliased internally (domain-warp-dissolve->domain-warp,
chromatic-radial-split->chromatic-split) since those registry keys are
also engine/producer's canonical TRANSITIONS[...] names and weren't
renamed to match the catalog. Every other catalog block (including the
CSS/motion-only ones) has no WebGL implementation and throws, so a caller
can fall back to its own render for those.

Re-exported from @hyperframes/studio (new workspace dependency on
shader-transitions) so a consumer of studio and core needs no deep import.

* fix(shader-transitions): keep the WebGL context alive across stop()

- stop() called WEBGL_lose_context.loseContext(), which permanently kills
  the canvas's WebGL context -- a later playSeamTransitionLoop() call on
  the same canvas (a popover reusing its element across hovers) would pass
  the null-context guard with the same dead context object and silently
  render nothing. Delete the program, textures and buffer this call
  created instead, matching the granular-release idiom hyper-shader.ts
  already uses for its own reusable contexts.
- ready never resolved if stop() ran before the first frame (a hover that
  ends within one frame, well under 16ms), hanging any caller awaiting it
  forever along with everything it closed over. stop() now resolves ready
  too; resolving an already-settled promise is a no-op for the normal path.

Also: resolveReady() only needs calling once, on the first frame, not every tick.

* test(shader-transitions): extract shared running-loop setup, dedup per fallow

* fix(shader-transitions): pass accent colors, fix leaks and validation

- renderShader's colors argument was undefined, so u_accent/
  u_accent_dark/u_accent_bright stayed at WebGL's zero default. 7 of the 14
  shader blocks (domain-warp, ridged-burn, sdf-iris, ripple-waves,
  gravitational-lens, thermal-distortion, light-leak) use those uniforms
  for their glow/burn colour, so the preview silently rendered without it
  for half the catalog -- the "plays the real effect" claim was false for
  those seven. Exported webgl.ts's DEFAULT_ACCENT_COLORS (hyper-shader.ts's
  own fallback, previously only inlined twice there) and pass it through.
- Each linkProgram() call leaked its compiled vertex and fragment shader
  objects (created, attached, linked, never deleted) -- a real WebGL
  browser never frees them on its own, and this call is on a hover-repeat
  path via a reused canvas. Fixed in webgl.ts's linkProgram itself, which
  every caller in this package routes through, not just this new one.
- loopMs was never validated: 0 divided to NaN progress, negative values
  ran the shader backwards, Infinity froze it at 0 -- all silently, no
  caller-visible error. Now throws synchronously for a non-finite or
  non-positive value, matching the function's other synchronous-throw
  contract (unknown shader, no WebGL context).
- getFragSource() and the catalog-alias lookup both did an unguarded
  plain-object index (shaders[name], CATALOG_SHADER_ALIASES[shaderName]),
  which returns Object.prototype members for names like "constructor"
  instead of undefined -- an unusual shaderName could reach the shader
  compiler with a garbled error or an unintended match. Both now use
  Object.hasOwn.

Tests: second half of the loop (progress descending, not climbing past 1),
both catalog aliases (only one was covered before), all four invalid-loopMs
shapes, the accent-color uniforms actually being set, and webgl.test.ts's
new deleteShader assertion. All mutation-verified non-vacuous.

* test(shader-transitions): dedup the two timing tests per fallow

* fix(shader-transitions): apply default accent colors and validate loop options

renderShader now defaults colors to DEFAULT_ACCENT_COLORS instead of the
caller deciding, so playSeamTransitionLoop no longer renders 7 of 14 shader
blocks with a zeroed-out accent. Validates width/height alongside loopMs,
switches the catalog alias map to a Map so a bogus name never resolves an
Object.prototype member, and fixes linkProgram to only delete the shader it
created itself.

* fix(shader-transitions): free GL resources when seam-loop setup throws

A bad fromSource/toSource reaching uploadTextureSource previously left
whatever quadBuf/program/textures had already been allocated dangling,
since playSeamTransitionLoop throws before returning a handle a caller
could call stop() on. Trims three over-limit JSDoc blocks and drops a
redundant prototype-pollution rationale now that getFragSource guards it.

* chore(shader-transitions): trim seam-loop comments to the comment ratchet

* test(shader-transitions): unknown-shader throw covers names on Object.prototype
2026-09-26 00:08:47 +00:00
James Russo edb3747c4c fix(telemetry): honor CLI opt-out in Studio and render CI (#4500)
* fix(telemetry): propagate CLI opt-out to Studio and render CI

* fix(ci): scope container opt-out to HyperFrames
2026-09-25 23:46:40 +00: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 3746c9821a fix(cli): timeline ids never writes over a save that lands while it stamps (#4494)
* fix(cli): timeline ids never writes over a save that lands while it stamps

runIds now passes each file's version to applyFileMutations, like every other timeline command, so a save that lands while ids are minted makes the command stop and roll back instead of being replaced by the stamped old content.

* fix(cli): timeline ids refuses a conflicting save with the same JSON refusal as other timeline edits

* fix(studio-server): a refused file batch never rolls back over another writer's save

applyFileMutations checks every file's version before its first write, so a batch that is already stale writes nothing, and a rollback restores only files that still hold this batch's write; a file whose own write failed is still restored.
2026-09-25 18:37:09 +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 fbaa9f9205 ci: stop running CodeQL on merge queue groups, where its upload races the merge (#4492)
* ci: stop running CodeQL on merge queue groups, where its upload races the merge

The code scanning rule does not apply to merge queue groups, and the queue deletes
a group ref when it merges, so the upload failed on about half the groups.
Pull requests and pushes to main keep scanning.

* ci: catch merge_group in any trigger form and say the upload race is intermittent
2026-09-25 16:50:55 +00:00
Miguel Ángel b2a449df8e fix(studio-server): stamping ids at open never reverts a write that lands meanwhile (#4491)
* fix(studio-server): stamping ids at open never reverts a write that lands meanwhile

stampFileHfIds replaces the file only if it still holds the bytes it stamped, so a save or agent write landing while ids are minted is kept. walkDir skips a subfolder that is unreadable or removed mid-walk instead of aborting the whole project walk; an unreadable project folder still throws.

* fix(studio-server): a stamp never recreates a deleted file and a walk only skips unreadable folders

* fix(studio-server): a stamp keeps a permission change made while ids are minted
2026-09-25 16:38:34 +00:00
Miguel Ángel 2734ee2c02 feat(cli): hyperframes history lists, undoes and restores a project's recorded changes (#4449)
* feat(cli): hyperframes history lists, undoes and restores a project's recorded changes

* fix(cli): an agent's history turn ends after 10 idle minutes on every path

A turn left open no longer takes the person's later edits: the engine
files a CLI turn's writes by the same idle rule as a preview window.
Studio claims take their paths back from an agent's window; refusals,
--json shapes, --limit and time parsing follow the review.

* refactor(cli): the project-history opener lives in its own module

The history command and other commands open a project's history the same
way: through a running preview, or directly under the engine's lock.

* docs(skills): the history loop lines sit in one marked trial block per skill

* fix(cli): a turn marker without its last write time has ended

Idle-limit tests set file times instead of racing the clock, and the
restore refusal test picks a ref no entry id starts with.

* docs(cli): the history entry shape comment names no other app

* refactor(cli): history reuses the busy error class and its who helper

* fix(cli): undo --who reverts the agent's whole last turn and nothing older

When the open turn recorded nothing, undo fell back to the agent's
earlier turn; when a mid-turn command split the turn, it reverted only
the newest part. The turn now keeps the entries it filed, and undo --who
reverts exactly those or refuses. Engine tests pin the write timing
rules, and a date that rolls over (Feb 31) is refused.

* fix(cli): an undo --who that meets a conflict leaves the whole turn as it was

It redoes the parts it already undid, lists the newer entries as they
are now, and offers --just-this and --back-to-before for the whole turn.
The last turn is recorded atomically before its marker is cleared.

* chore(cli): history keeps each file's comment share at or under main's

* fix(cli): history keeps log order past a turn's idle limit and undoes a turn's cut parts

* fix(cli): history drops unused imports and splits undo and reopen steps
2026-09-25 14:26:09 +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) v0.8.77 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