Commit Graph
1794 Commits
Author SHA1 Message Date
Miguel Ángel ea494b4672 fix(studio): even out levels lifts the right part of a sped-up or slowed clip (#4636)
* fix(studio): read and write a clip's in-point the way playback does

* fix(studio): a group head trim keeps the minimum clip length off the grid

* fix(studio): a group head trim stops where a single-clip trim does and never goes below zero

* fix(studio): group members on the grid move together when a trim stops at its limit

* fix(studio): level-out measures and places its lift on the clip clock at any speed

* fix(studio): round level-out lane times and tighten the half-speed test
2026-09-28 06:09:11 +00:00
Miguel Ángel 7fedc689b8 fix: run the configured ffmpeg and ffprobe everywhere and say when an upload was not checked (#4611)
* fix(studio-server): ffmpeg and ffprobe run from the configured binary; unchecked uploads say so

* fix(studio-server): the upload result type lists unchecked files

* fix(media-use): spawn the configured ffmpeg and ffprobe, and name a missing configured ffprobe

* style: format the ffprobe path test, doctor spawns and upload check

* fix(studio): show the not-checked upload notice as info, not an error

* fix: a configured ffmpeg or ffprobe path that cannot run fails loudly

* fix: refuse a folder as the ffprobe path, and report broken paths through --json

* test: reuse the file manager test setup and split the upload stream check

* fix(media-use): a configured ffmpeg or ffprobe must run -version, and a relative one runs itself

* fix(media-use): a configured ffprobe path must name ffprobe, not another tool
2026-09-28 06:09:00 +00:00
Miguel Ángel ff14263c03 fix(studio): a host that swaps its multi-clip trim handler gets the new one (#4630) 2026-09-28 06:03:42 +00:00
Miguel Ángel 358c04e2e2 fix(studio): playback keys do nothing while a dialog is open (#4623)
* fix(studio): playback keys do nothing while a dialog is open

With a modal dialog open over Studio (the lint results, for example),
Space, the arrow keys, J/K/L, M, I/O and A/E still reached the player,
so the film behind the dialog played and seeked.

The playback shortcut filter now ignores keys while a visible modal
dialog is in the page: an aria-modal dialog or a native dialog opened
with showModal, wherever focus sits. Dialogs left mounted but hidden,
display:none or inert do not block keys.

* fix(studio): a dialog hidden behind fullscreen preview leaves playback keys working

* test(studio): the no-checkVisibility case runs without an earlier stub
2026-09-28 06:03:31 +00:00
Miguel Ángel f55c0bbcb4 fix(studio): the live clip list carries mute, volume and sound, and refreshes on change (#4626)
* fix(studio): the live clip list says whether each clip is heard and refreshes when that changes

* fix(studio): a muted clip reads as muted and a timed wrapper does not take its video's sound

* test(studio): a timed wrapper ignores the data-has-audio of the video inside it

* refactor(studio): keep the clip change check and audio reader under the complexity gate

* fix(studio): a fade-only change refreshes the live clip list
2026-09-28 04:38:07 +00:00
Miguel Ángel e1e2b16e21 perf(studio): a film with no manual edits skips the edit reapply on each seek (#4592)
* perf(studio): a film with no manual edits skips the edit reapply on each seek

The reapply runs after every timeline seek, so on every playback frame, and
ran six document queries plus the motion rebuild even when Studio had never
edited the film. One combined query now answers "any edits?" first.

* test(studio): the seek skip still clears a motion timeline after an undo

An undo removes a motion edit's marks while its timeline stays registered,
and the reapply is what kills it. A test now fails if the skip stops
treating that timeline as an edit. The moved comments are cut to three
lines and lose an all-caps word.

* perf(studio): the per-seek edit check no longer walks large films or adds a lookup with edits

The combined comma selector took Chrome off its lone-attribute fast path: about 150 us per
seek on a 1.5k-element film, against 6 us for the whole reapply on main. The check now uses
one selector per mark and caches its answer per document, rescanning only after a
MutationObserver sees a mark attribute or the tree change.

* test(studio): the seek edit cache sees inserted edits and ignores style writes

Covers the two observer settings the cache depends on: a node inserted with its edit mark
after a seek is reapplied on the next seek, and a style write between seeks causes no rescan.
2026-09-28 04:17:27 +00:00
Miguel Ángel 078c493448 perf: the preview loads and decodes only the images of scenes on screen or due next (#4578)
* perf(core): skip hidden clips' content so the browser stops decoding their images

Hidden timed clips get content-visibility:hidden while their inline visibility is hidden,
from script evaluation on. A clip due within the look-ahead (2 s by default) keeps its
content live, so its images load before it shows.

* perf(studio-server): load preview images lazily so hidden scenes fetch nothing

* fix(core): stop the readiness scan waiting on and decoding later scenes' images

* perf(studio-server): keep first-frame images eager so the preview opens as fast as before

Only images inside a clip with a numeric start after 0 go lazy; a start the route
cannot read stays eager.

* perf(core): keep clips that start at 0 laid out before the first pass so the first frame is on time

* perf(core): open the preview on time by hiding only lazy images before the first pass

GSAP reads the computed style of the elements it animates while timelines build. Inside a
content-skipped clip every read forces a style update, which cost about 65 ms at open on the
bench composition. Before the first pass the rule now takes only lazy images out of layout, so
hidden later scenes still do not load theirs; after it, the skip rule keeps their content skipped.

* fix(core): hold a paused jump on the previous picture until the next scene's images decode

* fix(studio): swap in a reloaded preview only after its restore seek has painted

* fix(core): apply a held paused jump after 1 s when an image never decodes

* fix(core): keep hidden-scene image skipping and held jumps to Studio's preview, never renders

* fix(studio): reapply manual edits once a held paused jump lands

* fix(studio): copy a clip without the preview's lazy-loading marks

* fix(studio-server): mark preview images lazy without re-encoding the document's entities

* fix(core): share one preview-mark constant between the runtime and the preview route

* fix(studio): reapply manual edits a second time only after a seek that was held

* refactor(core): share the hidden-ancestor walk between clip visibility and image holds

* fix(core): import the studio preview mark from its own subpath

* fix(studio-server): find preview images with the linear html scanner

* chore(core): drop two doc comments the scanner's names already carry

* fix(studio-server): serve the preview mark as its own meta, apart from the GSAP fallback script

* fix(studio-server): capture thumbnails and frames from the preview without lazy images or the mark

* fix(core): let the full readiness scan wait on every image except lazy ones off the first frame

* fix(studio-server): keep an authored loading attribute in any letter case on preview images

* fix(core): leave template content out of findStartTags so preview images match the DOM

* fix(studio): send Ask agent the live markup without the preview loading marks

* chore(core): drop the template note from findStartTags, its test carries it

* perf(studio-server): splice lazy attributes into the preview in one pass

* fix(studio): keep authored image loading on copy and Ask agent by marking preview-made lazy images

* test(studio-server): follow the capture preview URL back to its sub-composition for every project id

* refactor: name the upcoming-clip and GSAP fallback marks once

* fix(studio-server): keep capture builds out of the on-disk preview store
2026-09-28 04:03:36 +00:00
Miguel Ángel bc73c411f0 feat(studio): let a host app mount the timeline toolbar, undo buttons, meters and clipboard (#4629)
* feat(studio): let a host app mount the timeline toolbar, undo buttons, meters and clipboard

* test(studio): lock in history button rules and trim duplicate export tests
2026-09-28 03:37:32 +00:00
Miguel Ángel 2ebfcc04a3 fix(studio): read and write a clip's in-point the way playback does (#4624)
* fix(studio): read and write a clip's in-point the way playback does

* fix(studio): a group head trim keeps the minimum clip length off the grid

* fix(studio): a group head trim stops where a single-clip trim does and never goes below zero

* fix(studio): group members on the grid move together when a trim stops at its limit
2026-09-28 03:08:13 +00:00
Miguel Ángel 471e90c057 perf(studio): playback no longer relayouts the timecode and playhead every frame (#4593)
* perf(studio): playback no longer relayouts the timecode and playhead every frame

The timecode shows MM:SS but was rewritten on every playback frame, and the
timeline playhead moved by style.left. Both forced a layout and a repaint per
frame. The timecode is now written only when its text changes, and the
playhead moves by a composited transform.

* fix(studio): the moving playhead stays on whole device pixels

As a composited translate the 1px playhead line is anti-aliased, so at a
half-pixel offset it drew as two half-bright columns, where the old left
position snapped it to one. The transform now rounds to device pixels. The
helper moves to its own module as a named function with full parameter
names, which also keeps timelineLayout under the file-size limit.

* refactor(studio): drop the timecode write comment

The comment ratchet does not let a file's comment share rise; the reason
lives in the PR body.

* fix(studio): the playhead moves smoothly while playing and rests on its tick

Rounding the playhead to device pixels on every update made slow playback
stair-step: at fit zoom on a two-minute film, 120 frames of playback gave
22 distinct positions where main gives 120. While playing the transform now
keeps fractional pixels (120 of 120). At rest (paused, or a seek while
paused) it rounds to device pixels the same way layout rounds the ruler
ticks, so the resting line is one full column on its tick at 1x and 2x.
Pausing snaps the line even when the paused time equals the store's.
2026-09-27 23:21:50 +00:00
Miguel Ángel e188dc0a0a fix(studio): timeline clip pictures fit the clip instead of zooming in (#4582) 2026-09-27 22:47:25 +00:00
Miguel Ángel d4801086c4 fix(studio): quick saves no longer refuse each other as outside changes (#4612)
* fix(studio): quick timeline saves no longer refuse each other as outside changes

* fix(studio): canvas edits and block writes wait their turn in the file queue

* fix(studio): animated clip moves and the last direct writes wait their turn in the file queue
2026-09-27 22:27:40 +00:00
Miguel Ángel c1bf4aefff chore: release v0.8.81 (#4605) 2026-09-27 16:41:42 -04:00
Miguel Ángel d6e8542a38 perf(studio): the lint error badge pulses three times, then holds (#4594)
* perf(studio): the lint error badge stops pulsing after three beats

The badge pulsed forever, so a Studio with a lint error painted a frame on
every display refresh, idle or paused. It now pulses three times and holds.

* fix(studio): the lint badge counts findings, never files

The badge showed the manual result's finding count while its dialog was open
and the number of files with findings once it closed, while its screen-reader
label always said findings. The hook now owns one count, the findings in the
list the error pulse also reads, and the badge shows it in both states.

* fix(studio): the lint badge pulses again when its finding count changes

A CSS animation does not restart on a text change, so after its three
pulses a background lint that found more problems left the badge still.
The badge is keyed on its count, so a new count mounts a new element and
pulses three more times.

* refactor(studio): the lint badge names its pulse count, and App reads the hook as one object

The pulse count moves from a comment into a constant name, so the file's
comment share does not rise. App takes useLintModal as one object instead
of a nine-line destructure, which keeps App.tsx under 600 lines once
merged with main.
2026-09-27 15:06:46 +00:00
Miguel Ángel 3855623dd9 fix(studio): list and grade only real compositions, not generated preview files (#4590)
* fix(studio): list only real compositions under Variables, not generated preview files

* fix(studio): project color grading edits only real compositions, not generated preview files
2026-09-27 12:30:31 +00:00
Miguel Ángel fcfcafa445 chore(release): cut v0.8.80 (#4591) 2026-09-27 07:26:39 -04:00
Miguel Ángel b3ab28aa44 fix(studio): keep a clip in sync when its head is trimmed at non-1x speed (#4581)
* 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
2026-09-27 11:10:44 +00:00
Miguel Ángel e00baefb5c fix(studio): reload the preview only when a file it loaded changes (#4570)
* fix(studio): reload the preview only when a file it loaded changes

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(studio-server): record every link a preview read passes through, resolved as the system does
2026-09-27 10:05:53 +00:00
Miguel Ángel 1a53681de1 fix(studio): an automation lane edit is refused on a locked clip and reports how it saved (#4533)
* 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
2026-09-27 09:05:54 +00:00
Miguel Ángel c83e958576 fix(studio): the dev server leaves catalog installs to hyperframes preview (#4574)
* 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
2026-09-27 08:16:11 +00:00
James Russo 31c00d2f0e fix(studio): avoid preview reloads for temporary and excluded files (#4553)
* 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
2026-09-27 02:23:25 +00:00
James Russo 91b21ae06f perf(studio): serve bounded JPEG timeline thumbnails (#4560) 2026-09-27 02:16:18 +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) 2026-09-26 21:05:52 +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 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 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 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 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 23937497a9 chore(release): cut v0.8.78 (#4515) 2026-09-25 22:58:59 -04: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 dd88b5f5b7 chore: release v0.8.77 (#4489) 2026-09-25 12:51:35 +00:00
Miguel Ángel 4dc1bee9c2 fix(studio): preview lookups use the right element when an id repeats across files (#4486)
* fix(studio): preview lookups find the element the timeline bound when an id repeats

* fix(studio): preview lookups prefer the element's own file, and a pasted element gets its own hf-id

* fix(studio): scrub audio, panel reads and gestures act on the bound element, one scan per pass

* refactor(studio): one owner for the selected node lookup, and scrub indexes only audio

* fix(studio): the panel's tween list matches class tweens against the selected element itself
2026-09-25 11:42:10 +00:00
Miguel Ángel 224388ad29 fix(studio): every project opens its preview at Fit, and a zoom says so (#4473)
* fix(studio): every project opens its preview at Fit, and a zoom says so

Preview zoom was one saved value for every project, so a single pinch
made each project open at 245%. It now lasts only while the project is
open. While the preview is not at Fit, a chip at the top says how far it
is zoomed with a Fit button, and a navigator shows the visible part.

* fix(studio): Fit keeps the selection, and the zoom chip and navigator follow review

A click on Fit no longer reaches the pane that clears the timeline
selection, focus returns to the preview, one rule decides at-Fit, and a
project switch resets the zoom before paint.

* chore(studio): the zoom change keeps each file's comment share at or under main's

* refactor(studio): the zoom chip and navigator live beside the preview, under the file size cap
2026-09-25 11:36:44 +00:00
Miguel Ángel 8b4c3a1d3d chore: release v0.8.76 (#4485) 2026-09-25 11:20:26 +00:00
Miguel Ángel caf7049503 fix(studio): undo works right after pasting or duplicating clips (#4482) 2026-09-25 10:53:54 +00:00
Miguel Ángel 828c434f76 refactor(studio): drop recordEdit's kind, which the project history never read (#4474) 2026-09-25 10:48:46 +00:00
Miguel Ángel 11ac9bce69 fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files

* fix(studio-server): a GSAP save on a served id writes that id to the file too
2026-09-25 10:04:38 +00:00
Miguel Ángel 93df8a814d fix(studio): a timeline row binds to its own composition's element when an id repeats (#4481)
* fix(studio): a timeline row binds to its own composition's element when an id repeats

* fix(studio): keep the scoped lookup's comments within the file's comment share

* fix(studio): a unique id stays bound through a healed host, and Layers re-finds in its own file

* fix(studio): try every lookup in the clip's composition before a lone match

* perf(studio): skip the id selector when the id is not in the document
2026-09-25 09:36:15 +00:00
Miguel Ángel 36f8bd0e91 feat(studio): undo and redo step through the project history, whoever made the change (#4456)
* feat(studio-server): an undo taken from Studio is not mistaken for an outside edit

* feat(studio): undo and redo step through the project history, whoever made the change

* feat(studio): the dev server keeps each project history, in the same folder as preview

* fix(studio-server): an undo that deletes a file reads as Studio's, and dev histories close

* fix(studio-server): a claim keeps an agent's earlier write the agent's and takes back Studio's own

* chore: allow the removal of Studio's browser edit history files

* test(studio): mock the project history in the runtime smoke

* fix(studio): step past a jammed undo and say why a step fails

* fix(studio-server): an agent turn cut by a claim commits its part before the claim

* fix(studio-server): a claim's cut ends a held drag and takes only the cut files out of a turn

* fix(studio-server): a held drag ends when someone else changed a file it already holds
2026-09-25 08:43:04 +00:00
Miguel Ángel 4064b183e6 fix(studio): render a missing preview poster in either order and bank the open counts (#4479)
* ci(studio): bank the open-journey counts after #4401 and allow a reasoned raise

* fix(studio): render a missing preview poster whichever of the 404 and the live frame comes first
2026-09-25 08:39:46 +00:00
Miguel Ángel 73519b6452 ci(studio): gate opening a film in Studio on work counts that only go down (#4457)
* ci(studio): gate Studio open and timeline scroll on work counts that only go down

Beside the timeline viewport gate's ms budgets, count the work two Studio journeys do and hold
each count under a checked-in ceiling in perf-ceilings.json: thumbnail renders, page loads and
requests for opening a 12-scene film, and React commits, style recalcs and layouts per scroll
tick. Counts repeat exactly run to run, so a rise names the work that grew.

* ci(studio): hold style recalcs per scroll tick at the CI runner's count

* ci(studio): gate the median scroll run and list which thumbnails rendered

* ci(studio): set work-count ceilings from the CI runner's counts on main

* ci(studio): fail the open count loudly when Studio never starts

* ci(studio): fail a work-count gate that measured nothing

* ci(studio): a scroll counter missing from any measured run reads as not measured

* ci(studio): split the perf ratchet's CLI and page counters into small steps, and let its unit test count

* ci(studio): count scroll work in an untimed pass and commit the open fixture's scenes

* ci(studio): gate only the open journey's counts; scroll counts follow runner speed

* ci(studio): fail a dropped journey and an unreadable base in the work-count ratchet

* ci(studio): split the ratchet's checks into small functions
2026-09-25 05:39:44 +00:00
Miguel Ángel f873734cc4 perf(studio): open a large film ready to play in about 4 s instead of 32 s (#4401)
* perf(studio): keep lint off the preview open path

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

* perf(parsers): parse identical GSAP source once

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* perf(studio): keep lint off the boot

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The main merge brought a second vi.mock of @hyperframes/studio-server into studioServer.test.ts;
the later one replaced the first, so createProjectSignature was no longer a spy and the cached
signature test read .mock of undefined. One mock now carries both overrides.
2026-09-25 04:56:55 +00:00
Miguel Ángel 2623b666c2 test(studio): wait for the off-canvas indicator instead of a fixed number of frames (#4458) 2026-09-24 22:45:17 -04:00
Miguel Angel Simon Sierra a08433acef chore: release v0.8.75 2026-09-24 21:51:48 -04:00