* fix(studio): keep a clip in sync when its head is trimmed at non-1x speed
* fix(studio): keep head-trim sync at the media start, on snaps and through splits
* fix(studio): keep group head trims rigid and read in-points like the runtime
* 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
* fix(cli): Studio's catalog install goes through add, so it records the item and honours paths
* fix(cli): Studio's catalog lists the project's registry and install paths survive symlinks
* refactor(cli): name the Studio catalog's registry option plainly
* feat(cli): registry_item_added says whether add or Studio installed the item
* fix(cli): dropping the same catalog block in Studio twice installs it again
* chore(cli): drop a comment that restates add's source option
* fix(cli): Studio's re-record finds lock entries by their resolved path
* test(cli): the symlinked-folder install test checks the resize happened
* fix(cli): Studio mounts the installed item's own file by the path add recorded
* fix(cli): Studio's install never hands back a file the project kept for its own edit
* fix(studio-server): a thumbnail no longer fails when a file it reads is removed mid-request
* fix(studio-server): a thumbnail survives its cache folder being removed mid-request
* fix(studio-server): a stray file where the manifests folder goes no longer fails a thumbnail
* fix(studio-server): drop a comment the code already says
* fix(studio): an automation lane edit is refused on a locked clip and reports how it saved
Lane drags, Delete and paste save through the timeline's gated clip or group
save instead of the property panel's commit, so the host's canEdit applies and
the commit resolves saved, refused or failed. A landed save refreshes the lane
store and the panel's selection, as the panel's commit did.
* fix(studio): a refused automation save puts the preview and the lane back
A save that does not land (recording, no id, a failed write) reverts the live
preview it was given, the lane drops a draft whose save came back refused or
failed, the no-id and no-project failures toast like the others, and the panel
refresh reads the current selection. The panel commit's unused coalesce option
goes, and the lane value reuses automationAttrValue.
* fix(studio): a failed save restores the preview it was given, not an older one
Each timeline save claims the preview's before-value when it starts, so a
gesture begun while it is in flight records its own and a later failed save
cannot put back a value from before an earlier save landed.
* chore(repo): allow the automation gesture key files this branch removes
* fix(studio): a refused automation save leaves the lane store and paste mark as saved
Reverting a live value re-syncs the lane store from the restored preview, so
an overlapping save that landed first cannot leave the store holding a later
value that failed. A paste whose save is refused or fails gives back the
selection and paste chain it replaced.
* fix(studio): a failed save settles on what the file holds and a refused paste marks nothing
A save that does not land settles the preview and store on the file's value
(the claimed before-value only when the file cannot be read), and leaves a
gesture begun meanwhile alone apart from correcting its before-value, so two
overlapping failed saves cannot leave either on a value that never landed. A
paste selects and marks its span only once it lands and only over the
selection it was pasted at.
* fix(studio): a failed save reads the file only after writes queued to it land
The settle read goes through the same per-path queue as the writes, so a save
that fails while another is queued settles the preview, the store and any
newer gesture's before-value on what that queued save wrote.
* fix(studio): an older failed save never settles over a newer edit on the same lane
Live-preview bookkeeping moves to createLiveLanes, one owner for both timeline
saves: each lane key carries a generation that every live write and save start
advances, and a save settles the preview and store (on its landed value, or on
the file after a failure) only while it is still the newest. A paste marks its
span only if it is still the latest paste.
* test(studio): a newer live drag keeps its preview when an older save recovers
* fix(studio): every finished save records what the file holds, even under a newer edit
A save's settle now always writes the file's value (landed, or read back after
a failure) into the lane store and a live gesture's before-value, and moves the
preview only while nothing newer has touched the lane. The clip store is set
from that value rather than copied from the preview. A paste marks its span
only while its clip is still the selected one.
* fix(studio): an older automation save never overrides a newer gesture on its lane
A failed save's recovery read could settle after a newer save started, and the
save's own optimistic preview patch and failure unwind ran over a newer drag.
Every preview write a save makes now goes through the lane's ownership check,
only the newest save records its value, and a refused drag hands the lane back
to the newest pending save.
* fix(studio): timeline attribute saves land in the order they start
An attribute save read the file before joining its write queue, so an older
save with a slow read could land after a newer one, overwrite it on disk and
leave the lane showing the newer value. The save now joins the queue first and
reads, patches and writes inside it. A save moves the preview only while it is
the newest save and no drag is live, which replaces the per-lane generation.
* refactor(studio): name the in-queue history write for the queue it needs
* test(studio): pin save start order and a newer release's preview mid-flight
* fix(studio): a drag keeps its points when an older save lands under it
An older save that landed mid-drag wrote the lane store, the lane dropped its
draft, and the release committed the older points. The lane now keeps its draft
while a gesture is live. A save that cannot read the file back settles on the
last value read or saved on its lane, never on an unsaved one, and an older save
that lands records its value there even when a newer save is pending.
* fix(studio): a lane's first failed save still puts its drag back
With the claimed-value fallback gone, a first save on a lane that failed and
could not read the file back settled on nothing, leaving the drag playing. A
save begun with no other save pending now records the value under its drag as
verified, since that is what the file last settled on.
* fix(studio): only a before-value read with no save pending counts as saved
The value under a drag was trusted when its save started with nothing pending,
but it was read when the drag began, possibly over a paste that later failed.
A lane now marks a before-value clean only when no save was pending as the drag
began. A group lane's preview carries its store mirror, as its live writes do.
* fix(studio): a lane's saved value never crosses into another project
The lane bookkeeping outlives a project switch and its keys had no project, so
a value saved in one project could settle into another project's clip with the
same key when a save there failed and could not read the file back. Lane keys
now carry the open project, and a save moves the preview or store only while
the project it was made in is still open.
* fix(studio): a failed save reads its file back from its own project
A save that failed after a project switch read its recovery from the newly
opened project and recorded that project's value as its own. Each save now
captures its project when it starts and reads back from it.
* fix(studio): a failed save never brings back a value an undo removed
A lane kept its last verified value after its saves settled, so after an undo a
save that failed and could not read the file back settled on the removed value.
The value now lives only while a save on the lane is in flight, and each save
records what its own in-queue file read found. The group preview's store mirror
is no longer needed and is gone.
* test(studio): pass the file-read callback in the direct attribute-save tests
* fix(studio): a drag whose before-value a save settled counts as clean
When a settle rewrites a live drag's before-value to a verified value, that
value is known saved, so the drag's own save can fall back on it even when its
read and read-back both fail. Adds a test for a failed save reading back from
its own project while another save is in flight across a switch.
* fix(studio): a save started in one composition never lands in another of the same project
Live lanes were scoped to the project only, so a save held across a switch
to another composition of that project settled its value into the new
composition's preview (clip and group lanes) and store (group lane).
Lanes are now scoped to the project and the composition open when the save
starts. createLiveLanes moves to its own module for the 600-line cap.
* fix(studio): an undo during or after a lane gesture stays undone when the next save fails
A live gesture records the lane's value before it starts, and a later save
that cannot read the file settles on that value. An undo mid-drag, or after a
gesture that ended without saving, left that value stale, so the save brought
back what the undo removed. Every undo and redo now re-reads each open lane's
value from the files it restored.
* fix(studio): undoing group automation clears it from the members' lanes at once
The group-info cache only noticed a change when its MutationObserver callback
ran, so the soft restore's store sync, which reads it synchronously, mirrored
the pre-undo group automation onto the members. A read now counts mutations
the observer has not delivered yet.
* fix(studio): an undo reaches every lane and pending save it rewrote, in any composition
The undo re-read only covered lanes in the open composition and live gestures.
History is project-wide, so undoing from another composition left the
rewritten composition's lanes on the pre-undo value, and a save released while
the undo was in flight kept that value as its fallback. The re-read now covers
every lane of the project on a restored path, including saves still pending,
and an undone file it cannot read makes the value untrusted instead of dropped.
A missing element reads as unreadable, the same as a save's read-back.
* fix(studio): a cancelled fade, stretch or preset audition leaves no stale lane value behind
A cancel put the gesture's start back through a live write, which left the
lane's before-value in place until the next save trusted it, even after the
property panel, the source editor or an agent had changed the file. The fade
cancel, the edge-stretch cancel and leaving a preset audition now end through
the lanes' revert, so a before-value exists only during a live gesture.
* test(studio): pin that a group write is read back fresh in the same task
* style(studio): drop two comments the names already carry
* fix(studio): a failed group save reads its own file back; fade cancels work without revert
- The group save's failure read-back used the file of whatever composition was
loaded when the write failed. It now reads the file captured when the save
started, as the clip save does.
- A fade cancel writes its start back live before it reverts, like the other
cancel sites, so a host without the optional revert callback still puts the
fade back.
- The edge-stretch cancel comment says what the code now does.
- The save test harness passes the revert callbacks, as the app does; without
them the cancelled-gesture tests exercised a host that lacks them.
* refactor(studio): trim three long comments on live automation saves
* fix(studio): the dev server leaves catalog installs to hyperframes preview
* test(studio): name the dev server's install test for what it checks
* fix(studio): a finished install replaces its 'Adding' toast instead of sitting beside it
* test(studio): pin that the Adding toast goes after the install's own toast
* test: cli and studio-server tests remove the temp folders they create
* test(cli): each temp folder is removed where it is made, even when a test fails
* test(cli): the one-liner helper relies on its project's own cleanup
* 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
* fix(studio): avoid preview reloads for temporary and excluded files
* docs: capture public preview reload reproduction
* fix(studio-server): normalize watch paths in linear time
* fix(studio): register dynamically opened preview projects
* fix(studio-server): probe each proxied clip once instead of on every request
* fix(studio-server): retry a failed codec probe and use the server probe cache
* fix(studio-server): probe again when a clip symlink is repointed
* test(studio-server): pin equal mtimes in the symlink retarget test
* fix(cli): skip background checks in unattended runs and never hold exit on a silent DNS
* fix(cli): probe DNS only on background checks so foreground upgrade and skills commands still wait
* fix(cli): keep update checks for agents and skip them only for CI, dev and the opt-out
* refactor(cli): give the update-check opt-outs one owner shared with auto-install
* docs(cli): stop the auto-install notes claiming runs without a terminal are skipped
* fix(cli): run telemetry flushes one at a time so an overlapping flush never resends an event
* fix(cli): let a telemetry flush run even when the one before it threw
* fix(cli): cap exit wait at one telemetry attempt when an overlapping flush already failed
* fix(cli): --yes in an unattended run no longer overrides a no saved by another process
* fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no
* fix(cli): the thin-results download offer also needs a real terminal
* fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes
* fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no
* fix(cli): a settings lock that cannot be released no longer fails a write that landed
* fix(cli): a leftover settings lock is never taken over, and doctor names it
* fix(cli): a background update that cannot be recorded is not launched again every run
* fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing
* fix(cli): the thin-results offer stops when a yes cannot be saved
* fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once
* fix(cli): the leftover-lock warning follows the attended-terminal rule
* 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
* 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
* 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
* 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
* 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
* 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.
* 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
* 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
* 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
* 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
* 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
* 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
* 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.
* 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
* 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
* 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.
* 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
* 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
* 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.
* 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.
* 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.
* 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
* 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
* 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.
* 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
* 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