* chore: release v0.8.154
* ci: give the studio test job fifteen minutes
The queue run installs and builds inside the same ten minute cap.
It was still inside the studio tests when that cap cancelled the job.
* chore: release v0.8.152
* docs(changelog): correct the v0.8.152 release notes
The summary overstated snapshot retention, history turns, and video state.
It also left out the new artifact paths and nullable keyframe times.
* feat(studio): record video state, edit targets and artifact cleanup
* fix(studio): clear video-state CI gates
* fix(studio-server): pin cleanup reads to file descriptors
* chore: account for cleanup descriptor code in package budget
Move the keyframe rest-value table into @hyperframes/core/keyframe-defaults
and import it from the SDK backfill and Studio's gsapShared, replacing the two
hand-synced copies.
Closes#5387
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A 9:16 fixture with a looping A-roll video, an untimed full-frame backdrop above
it that the film's own GSAP script shows only from 6 s to 8 s, captions on top
and two sub-compositions. The check plays it in Studio's preview and reads the
preview's pixels and the element on top at the frame's centre: the video must
show at rest and while playing outside the backdrop's window, and the backdrop
must show inside it. It fails with #5458's visibility skip reverted.
* feat(audio): composition master bus (data-fx-chain on the root)
A clip can belong to only one audio group, so there was no way to put one
effect, such as a limiter, across the whole mix. The data-fx-chain on the
top-level composition root now runs once over the sum of every clip and every
group, after each group's own chain and fader. No new attribute: a sub-composition
root's data-fx-chain was never applied to anything and still is not.
Render: when a master chain exists, mixAudioTracks writes a float WAV instead
of AAC, applyAudioFxChain runs the chain over it, and ffmpeg encodes AAC last,
so the chain reads unclipped samples. An unreadable chain is a mix failure, as
for a group. Preview: the transport splices the chain between the clips' sum and
the monitor, re-anchored per play generation and watched for edits.
Only the top-level root is read. No master automation: the root's data-automation
is not read as the bus's. No lint rule: data-fx-chain is valid on clips, groups
and roots, and the one place it does nothing (a sub-composition root) has always
been silent.
— Jerrai
* fix(core): preview routes every audible video through the master bus
A root data-fx-chain is attached only inside the Web Audio graph, but a plainly
audible <video> joined that graph only for its own chain, group, automation or a
volume over 1. So preview limited the music and left the talking head dry, and a
composition whose only audio is video got no master chain at all, while export
sums every audible element into the master. A master chain on the top-level
composition root is now a reason to join, the same as a video's own chain.
Elements under a root with no chain keep native output. Sub-composition roots
are still ignored.
— Jerrai
* fix(audio): master bus review round 2 (temp dir, tests, docs, Studio meter)
- audioMixer: a fatal FX error in the master pass or a group bus left the
work directory (the full float sum, about 1.4 GB an hour) on disk. The
final mix now runs in try/finally and the group rethrow removes it first.
- Tests: the "once over the sum" case now uses a limiter then a gain, so a
per-clip chain (-10.4) and a doubled one (-18) both fail it; a new case
proves the sum stays float until the chain reads it; the fader case uses
a group volume of 0.5 so the fader's position is exercised; the work-dir
removal is covered for both paths.
- Docs: "Three rules exist" (the master-bus lint rule is gone), a bad master
chain stops the render with audio_processing_failed, a sub-composition
file opened on its own has its root chain as the master bus, and a seek
clears a group or master limiter.
- Studio: TimelineElement.masterChain carries the root's data-fx-chain, so
the program meter shows for a video-only composition whose only audio
routing is the master chain, as preview now plays it.
- Comment blocks over four lines cut; webAudioTransport.setRate split so
its CRAP score is under the Fallow threshold.
— Jerrai
* fix(audio): master chain reaches tracks the render adds; honest plain-limiter copy
- Master chain: it was carried on each parsed AudioElement, so a track the
producer adds itself (appendAutoDetectedVideoAudio, the browser-discovered
audio in probeStage) never had it, and a composition whose only audio was
such a track exported with no master chain, no ceiling and no warning. It is
now a composition property: parseMasterFxChain(html) at compile, carried on
CompiledComposition and CompositionMetadata, passed to processCompositionAudio
by runAudioStage (in-process and distributed plan). AudioElement.masterFxChain
is removed so there is one source. Each forward (compile stage, local
orchestrator, distributed plan) has a test that fails when it is dropped.
- Plain limiter: it follows the level with no lookahead and Level after is
applied past the ceiling, so it cannot promise "nothing gets past" or "never
clips". Studio copy (description, panel text, knob label, summary) and the
hyperframes-audio skill now say a fast or inter-sample peak can pass it and
point to the true-peak limiter for a delivery limit. A copy test guards it.
— Jerrai
* fix(producer): keep master bus comments within the ratchet; run the plan test with the ffmpeg tests
Drop three one-line field comments that raised their files' comment share,
and list plan.masterBus.test.ts with the integration tests because plan()
reads the ffmpeg version.
— Jerrai
* chore(ci): repin the producer test-classification source in the reachability map
The reachability map pins the hash of test-classification.mjs, which now
lists one more integration test. The map's producer lane command already
covers every test under src, in both the unit and integration matrix lanes.
— Jerrai
* fix(core): an element its script hides stays hidden while studio preview plays
* fix(core): a stamped element re-timed in the timeline hides outside its new window
Studio timing edits drop the runtime-stamped mark, and a script-hidden stamped
container no longer holds back its images.
* fix(core): a preview-only stamp copied into a project no longer changes the render
Only the Studio preview honours the stamp, copied live markup drops it, and a
lane-only move makes a stamped element authored like the save does.
* fix(studio): keep the shortcuts panel above the player controls and inside the window
The panel was drawn inline next to its button at z-50, so a host layer
above that (the desktop's playback bar, z-55) covered its Playback rows,
and the preview pane's overflow clip cut its top off in a short window.
It now opens through Studio's Popover on the shared popup layer: portaled
out of the button's boxes, held above the button, and capped to the room
there with a scrolling body.
* fix(studio): the shortcuts button keeps its tooltip description for screen readers
* fix(studio): text in Studio reads without em or en dashes
* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable
* test(studio): the dash check test covers every entity form
* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too
* fix(producer): the gate check stops early on the engine's new fallback wording
* perf(packages): ship npm packages without source maps and guard their size in CI
No package or consumer reads the published source maps, so they are no
longer built. The CLI copies only the studio app it serves instead of the
whole studio dist. verify:packed-manifests now holds every published package
to a size budget that ratchets down, and CI rejects pull requests that add a
large binary outside LFS.
* fix(ci): large-binary check reads every filename and fails on git errors
* ci: run only Build when a skill bundled into the CLI changes
* fix(ci): size guards also catch type changes, odd file names, unreadable blobs and bad budgets
* fix(ci): large-binary check reads blobs by id and skips submodules
* fix(ci): exempt only exact registry copies under docs/public/catalog
* test(ci): cover the staged catalog-copy exemption; drop the local-only override hint in CI
* fix(ci): producer budget follows its slimmed package; size checks name their real cause
* feat(studio): export can encode on the GPU, like render --gpu
* test(studio): the 4K export assertion carries the GPU flag
* refactor(studio): the GPU flag needs no comment beyond its name
* fix(studio-server): only MP4 exports encode on the GPU, and render errors report it
* fix(studio): the disabled GPU option dims like the other export controls
* fix(studio): a stalled font or asset no longer leaves the preview empty or drops reloads
* fix(studio): a page's own late load no longer counts as replaced content
* refactor(studio): one announcement owns when a preview page has loaded
* fix(studio): let hosts with their own player announce a new preview page
* fix(studio): a late ready from the replaced preview page no longer claims the new page
* refactor(studio): keep the Player doc comment on Player
* fix(player): render the preview at its shown size so heavy compositions stop dropping layers
* test(player): check a small preview rasters its will-change layers at the shown size
* fix(player): accept device-pixel snapping in the zoom probe
* fix(player): probe the initial frame and preserve editing coordinates
* fix(player): defer every initial source reload until setup
* fix(studio): convert measured frame coordinates once
* test(studio): bank reduced preview opening work
* fix(core): keep centered layers exact when the preview is zoomed
GSAP infers a -50% CSS translate from the rendered pixel matrix. At a
fractional preview zoom that matrix is snapped, so a centered layer lost
its percentage and shifted by half its height after reload, render and
redo. The runtime now restores the authored percentage before GSAP's
first transform parse. Also restores the original thumbnail ceilings.
* test(studio): bank the two resize cases that now pass edit accuracy
resize-scale-px-r30-{root,nested}-z50-after measured 0.53 px tracking on
main and 0.49 px here. Also drops a comment line the runtime name covers.
* fix(core): keep stylesheet-centered layers exact under preview zoom
The centering repair now also reads percentages from a stylesheet
transform (the common translate(-50%, -50%) pattern), runs for every
property that makes GSAP parse a transform (transformOrigin first, too),
resolves against the border box, and leaves elements without a layout
box alone instead of throwing. The browser check now runs zoomed.
* fix(core): restore only centering, the one percentage GSAP infers
GSAP folds every other CSS percentage translate into pixels, and
compositions rely on that (a translateX(-100%) title tweened to x: 0
slides in). The repair now gives back only a -50% that zoom snapping
lost, and treats a size that is not in px (a hidden element sized in
percent) as no box.
* fix(core): restore centering only where GSAP would infer it
A pixel part in the translate means GSAP never infers -50%, so the
repair leaves it alone, and a layer without a layout box is left to
GSAP whatever size its computed style reports (that differs by Chrome
build for hidden percent-sized layers).
* fix(player): fit the preview before the composition starts loading
The zoom was applied on the first resize callback, after the source was
assigned, so a fast-loading composition could run its scripts and lay
out unzoomed and then reflow. Fitting on connect, before any source,
makes the first layout use the preview zoom.
* test(player): pin the first fit before the composition source
* revert(player): drop the zoom fit so the preview keeps the export's layout
CSS zoom shaped text at the zoomed size, so paragraph heights in the
preview differed from the render by up to a line. The transform fit
stays; the centering adapter and zoom-only bench changes go with it.
* fix(core): raster a small preview at the size it is shown
Chromium never rasters a will-change: transform layer below device
scale, so a preview shown at a quarter size rastered every hinted layer
at full size and could run out of tile memory, dropping layers. While
the player shows the composition below 1x, the runtime swaps the hint
for an identity filter, which keeps its stacking context and
containing block; preserve-3d containers keep the hint.
* fix(core): keep a small preview's raster swap out of filters edits can save
The identity filter that replaced will-change: transform showed up in computed filter, so
Studio's blur slider saved it, GSAP filter tweens and clearProps picked it up, and it cut
backdrop-filter off from what is behind it. A far perspective keeps the same stacking context
and containing block, is never composited for flat content, and nothing reads or writes it.
Also: the runtime guard lint (realm-safe element checks), a class change that drops the hint
undoes the swap, and the browser check measures raster scale with the swap off and on at one
shown size and compares frosted glass and 3D pixels.
* fix(core): drop preview raster hints only where nothing depends on them
The perspective that stood in for will-change: transform could still reach a saved project:
Studio records perspective keyframes from GSAP's computed value, and copying a generated
element serialised its inline styles. It also went stale when a parent class or a later
stylesheet changed the element.
Now the runtime writes no inline style. It marks a hinted layer with a preview attribute
and one injected rule sets its will-change to auto. Only layers whose hint changes nothing
are marked: positioned, with no fixed descendants, and no z-indexed or blending descendants
unless the layer has its own z-index. Every class or tree change re-checks the whole page.
Studio strips the mark from copied markup, as it does the other preview marks.
* fix(core): keep raster hints that pseudo-elements, other hints or inline styles need
A layer's ::before or ::after can sit behind it, blend with it or be fixed to it, so those count
like children. A layer that also hints opacity or filter keeps every hint, since those make their
own boundaries. Inline style changes, which scripts make every frame, are watched cheaply: they
only give the hint back to the layers holding an element that now depends on it, or to a layer
that turned static or changed its z-index. Text changes no longer trigger a full pass; a new
stylesheet still does.
* fix(core): re-read a marked layer's own hints and follow any attribute or late sheet
A marked layer now has its mark lifted while its hints are read, so a class or inline value
that adds opacity or filter gives every hint back. Any attribute change re-checks its subtree,
so attribute selectors are followed, and a linked stylesheet re-runs the pass once it loads.
* perf(core): only lift a raster mark on the element that changed
Lifting a mark to read a layer's own hints repaints it, and a change on a scene container lifted
every marked layer under it (about 6 ms and a re-raster per event). Now only the changed element
is lifted, and a full pass clears all marks at once so the page restyles a single time. Tests
cover leaving marked layers alone, ignoring our own marks, and the linked-sheet load pass.
* fix(core): decide raster marks before writing them in a full pass
A full pass re-marked layers one at a time, so each read restyled the page again. Every mark is
now decided first and written after. The browser check holds off the player's own scale
messages while it turns the swap off and on, since a late resize tick could turn it back on.
* test(player): hold the player's own scale while the raster check turns the swap off
Turning the swap off restyles the page, and the player answers by resending its real scale,
which turned the swap back on within milliseconds, so the off half compared the swapped page
with itself. The check now holds the player's sends on the host page, asserts the swap is off
before the native shots, and removes the layers that keep their hint before either trace.
* test(player): give the raster check's 3D probe a solid fill
Stripes a few pixels wide alias differently at each raster scale, so the probe measured texture
resampling rather than the 3D shape it is there for. A solid fill keeps the shape and position.
* fix(studio): keep the preview raster mark out of stacking and saved text edits
Studio's stacking model read a marked layer's will-change as auto, so a timeline drag could
write a z-index that does nothing in the render; a marked element now still counts as a stacking
context. A text edit copied every live attribute but style, so it could save the mark; the
preview-only attributes are now one list, used by both the copy and the text-edit paths.
* fix(core): keep a marked video through a scene swap
The runtime keeps a video across a scene swap only when its live attributes match the fresh
import, and the preview raster mark made them differ, so a hinted video in a small preview was
rebuilt on every edit. The comparison now ignores every preview-only attribute, which also
fixes the same rebuild for data-hf-upcoming. The shared list is renamed PREVIEW_ONLY_ATTRS, and
the lazy-image strip in copied markup gets a test.
* fix(studio): the timeline viewport gate gives the same verdict on the same code
Steps are timed from the frame the jump is made in, so each is a whole number of frames and the
3.5-frame budget no longer cuts through one cluster of samples. When the head still misses a budget,
it is measured against its base in alternating blocks on the same runner and fails only when it
misses on more steps than the base beyond chance.
* style(studio): format the viewport gate verdict tests
* fix(studio): a head slower than its base fails the viewport gate comparison
* fix(studio): the viewport gate opens head and base fresh before comparing them
* fix(studio): each viewport gate comparison block runs alone in a fresh Chrome
* fix(studio): the viewport gate comparison also fails a head that makes late steps later
* fix(studio): the viewport gate summary names every count behind a comparison verdict
* fix(studio): label the viewport gate frame-gap counts by threshold
* fix(studio): a runner stall cannot fail identical builds in the viewport gate comparison
* fix(studio): the viewport gate serves its base the same projects as the head
* fix(studio): a resized corner stays on the pointer when the size rounds to whole pixels
Resized sizes stay on whole CSS pixels (GSAP centres a -50% translate from the
integer offset size), so the centre-anchored resize left the grabbed corner up to
a third of a pixel times the element's scale off the pointer: 0.53 px on a 1.5x
scaled layer. The centre pin now takes half the rounding remainder along the
element's rendered edges, so the grabbed corner lands where the wanted size puts
it while the written size stays whole.
* fix(studio): a scale resize saves the box exactly where it was dropped
finalizeScaleResizeCommit skipped its position correction whenever the box sat
within 0.5 px of the drop point on each axis, so a sub-pixel drop (now the
norm, with the grabbed corner on the pointer) saved up to half a pixel off.
It now skips only when the correction rounds to nothing at the precision the
file is written in.
* fix(studio): a scale resize lands centred on the box that was dropped
finalizeScaleResizeCommit lined the committed box up with the drop by its
top-left corner. When one scale cannot reproduce the dragged width and height
(the draft rounds each to whole pixels), that put the whole gap on the far
edges, up to half a pixel. The resize is centre-anchored, so it now lines up
centres and the gap splits evenly.
* chore(studio): keep the complexity note on the resize intercept, not the new helper
* test(studio): bank the two scaled resize cases that now pass the edit accuracy bench
* fix(studio): a scale resize leaves animated positions alone and mirrored corners track
* test(studio): bank only the two newly passing scaled resize cases
* fix(studio): a mirrored or parent-rotated layer's resize corner tracks the pointer
* fix(studio): a resize corner on a skewed layer picks its side from the box's own edges
* fix(studio): resizing a layer that renders with no area moves nothing
* fix(studio): clip strips resize on the next frame, ending ResizeObserver loop errors
* test(studio): verify strip resize frame scheduling deterministically
* fix(studio): a menu set to render inline opens beside its trigger
* fix(studio): a popover set to render inline opens beside its trigger
* fix(studio): a remounted clip strip keeps its own size instead of a queued one
* test(studio): zoom thumbnail tests run resize frames on fake timers
* test(studio): zoom preview test runs the resize frame it guards
* fix(core): preview stops at the film's end when a scene loops forever
* refactor(core): the loop length threshold lives with the composition length
* test(core): pin the endless-loop length at the boundary and through the runtime
* style(core): keep composition length comments under the file's share
* refactor(studio): preview duration guard reads the core loop length limit
* docs(core): length doc says a loop yields only to a floor or fallback
* fix(studio): let small picks move when dragged by their middle
* fix(studio): keep the grabbed corner under the cursor while resizing
* fix(studio): let picks a few pixels across resize from their corner
* docs(studio): say which picks the resize guard still lets resize
* fix(studio): serve files whose name has a bare percent sign
A browser sends src="100%.png" with the % unescaped, and decoding the whole path threw, so Studio answered 500. Only well-formed escapes are decoded now.
* fix(cli): serve files with a bare percent sign from the check and snapshot server
One shared decode rule for request paths; the image thumbnail route's malformed-escape 400 could no longer fire, so a name that is not a file is a 404.
* fix(cli): name a failed file with a bare percent sign in check
* fix(studio): keep apostrophes and dollar signs in file names when editing attributes
An attribute value is matched by its own quote kind, so src="it's.png" is replaced instead of duplicated, and every insert uses a function replacer, so $1 or $& in a file name is written as is.
* fix(studio): rename references spelled with %20 or & and read quoted media sources
WIP: not yet tested; see the resume note.
* fix(studio): match whole attribute names when editing media sources
* fix(studio): compare decoded names and escape replacement references
* fix(studio): preserve file references across URL and string consumers
* docs: clarify raw file references and URL fields
* fix(studio): keep raw path identity distinct from URL spelling
* fix: share attribute grammar and decode media URLs at their readers
* fix: preserve local filename identity in HTML URL output
* fix: decode entity references before rename candidate matching
* style: format rename entity handling and regressions
* fix: preserve platform file identity when renaming references
* fix(core): decode CSS escapes before rebasing asset URLs
* fix(core): consume CSS continuations before URL encoding
* fix(core): preserve CSS URL identity and escape rebased suffixes
* fix(core): express CSS suffix controls with Unicode categories
* fix(core): preserve URL backslash identity while rebasing
* test(core): respect platform backslash filename semantics
* fix(studio): a dropped file with %, # or ? in its name loads, and an encoded reference renames
* fix(studio): read an authored src as a URL so dropped files with spaces, %, # or ? load
* fix(studio): media routes find files named with ? or #, and the probe reads a src as a URL
* fix(studio-server): one rule for request file paths, so loudness finds files named with %, # or ?
* chore: drop the design note from the change
* fix(core): rename replaced clips whose src is URL-encoded
* fix(core): derive generated media labels from current source
* fix(core): decode generated media filenames
* fix(core): share filename decoding across URL forms
* fix(core): strip filename suffixes without unsafe indexing
* fix(studio): rename unreferenced generated media ids on replace
* fix(core): register media ID exports and trim names in linear time
* fix(studio): restore the timeline overscan budget
* test(studio): align window witnesses with the restored budget
* fix(studio): publish the viewport when a zoom anchor lands
* fix(studio): publish the viewport when a zoom-out ends at its laid-out scale
An eased zoom-out that ends at the scale already laid out only moves the scroll position,
so the timeline drew from the old position until the next scroll event. The zoom input
now publishes that scroll through the same callback the zoom anchor uses.
* fix(studio): a pan at the laid-out scale publishes its scroll before paint
The same-scale ending of a zoom ran outside React, so its scroll publish rendered on the
next task, after the browser painted the old window. It now flushes like the scale change
beside it, and its test pans at the laid-out scale instead of changing it.
* test(studio): a same-scale pan must render its scroll before paint
Reads the render window outside act, so unwrapping the flush fails it, and records each
published scroll at call time instead of reading the live element afterwards.
* perf(studio): preview decodes hard-to-play videos at the size they are shown
Studio's copy of an HEVC or ProRes clip was made at the source's full size, so a 4K clip was decoded at 4K inside a preview a few hundred pixels wide, and back-to-back 4K clips flashed the background at their cuts. The player now reports how large it shows the composition, the runtime asks for a copy sized to the shown video (on a ladder of sizes, never above the source), and Studio stops pre-warming source-size copies. CLI play, static serving, publish and renders keep the source-size copy.
* fix(studio): sized preview copies follow zoom, hover previews and the newest size
Studio reports the preview's on-screen scale when a zoom settles and for composition hover previews, which no player measures. The runtime loads only the newest size asked for, regrows a first copy made while the frame grew, and never replaces a copy with one smaller on either side. The player's scale is measured against the frame's own layout width.
* fix(studio): keep comment shares and file sizes within the repo ratchets
* test(studio-server): end the sizing test's ffprobe options before its input
* fix(studio): hover previews report their size once their runtime is ready
* test(studio): pin a host's hover box and a starting stage size
* feat(studio): a host can show the hover box and give the stage its starting size
* fix(studio): keep the comment share flat and let a measured size win over the hint
* perf(studio): reuse filmstrip frames when a zoom changes a strip's width
A zoom that changed a strip's frame count decoded the whole strip again
on the main thread, and showed the clip's single poster frame until it
landed. Frames are now sampled at each slice's left edge, so a strip
twice as long holds every frame of the shorter one; decoded frames are
shared per source and time, so zooming out decodes nothing and zooming in
decodes only the new half. The strip on screen stays until the new width
is ready.
* perf(studio): redraw only the waveforms on or near the screen after a zoom
A zoom resizes every mounted clip, and every mounted waveform redrew in
that same frame, on screen or not. A waveform now draws only while it is
on or near the screen (one shared observer, a screen of margin each side)
and draws when it comes near if a zoom changed it meanwhile.
* perf(studio): never start a beat-analysis stage while the timeline zooms
Analyzing a long music track runs for over a second on the main thread,
and when it started late it landed inside a zoom. The analysis now awaits
an optional pause before each long stage; Studio's waits for the timeline
to rest and the browser to go idle. Results are unchanged. The tempo
detector's stage is one call, so a zoom that starts inside it still waits
for it.
* fix(studio): reuse a filmstrip frame only where its own strip would show it
A shared frame was keyed by the time a strip asked for, not the time it
was decoded at, so a coarse strip's keyframe-early frame could land in a
finer strip and run its frames backwards. A frame now carries its decoded
time and is reused only when the strip would have accepted that keyframe
itself. The frame-count ladder now tops out at a power of two (32), so
its top step shares frames with the one below instead of none.
* feat(studio): show the clip's last frame at the end of every filmstrip
Left-edge sampling never showed a clip's final slice, so an end card or a
fade-out was missing from its strip. Each strip now also decodes the
clip's last frame (at its own time, never an earlier keyframe), shared
across zoom steps like the others, and the last tile shows it.
The strip on screen is now held per clip and range, not per props: a
clip crossing between on and off screen keeps it, and a clip narrowed to
one frame lets it go, so widening again never re-decodes a dropped width.
* docs(studio): keep the filmstrip files at or under main's comment share
The comment ratchet allows no file a higher share of comment lines than on
main; the added explanations move to the PR description.
* fix(studio): keep a held filmstrip leased so a full cache cannot evict it mid-zoom
The hold leased the strip on screen only once a new width was requested,
so for one commit it had no lease; with the cache over budget it was
evicted, the poster showed, and the old width decoded again. The strip on
screen is now leased for as long as it is the clip's, and shown only while
held. A frame that fails to decode takes the frame before it, so every
slot keeps its time and the last tile still shows the end.
* refactor(studio): one owner for which clip a held filmstrip belongs to
The hold checked the clip twice, in its lease and again before showing
it; the lease alone decides now, and the cross-clip test pins it. A test
now also pins that a filled frame is not charged to the cache.
* fix(core): hold the tempo detector until idle after a slow import
* fix(studio): scope shared video frames to timeline sessions
* chore(core): keep beat analysis comments within the ratchet
* fix(studio): recover previews after missed file changes
* fix(studio): keep queued reloads with the current owner
* fix(studio): keep a queued outside change when switching compositions
* test(cli): the project watcher closes its parent folder's watch too
* fix(studio): keep the project-folder change out of the shared signature check
* fix(studio): keep the edited file's draft and reload every session after a reconnect
A reconnect or replaced folder is a project-wide change: every open SDK session reloads, a failed save is recorded against the file the draft belongs to, and two queued changes to different files merge into one project-wide reload.
* fix(studio): refresh the preview cache when the .hyperframes folder is replaced
* fix(cli): report the files of a folder moved into the project
Replacing a populated folder such as .hyperframes/ now reaches the signature cache through its files, so the shared signature rule stays as on main and Studio's own writes inside .hyperframes/ still invalidate nothing.
* refactor(studio): name the merged file-change payload and share the test sender
* fix(studio): keep a failed draft's recovery copy until that file itself saves
* style(studio): rewrap the merged payload line after the rename
* fix(studio): deliver a held change's full reload when its block clears
A clean save, Use external and Keep Studio now all deliver the merged scope a blocked change owes, and a moved-in .hyperframes folder reports its manifests so a dropped one still clears the cached signature.
* fix(studio): keep restored drafts out of the reload scope and clear the lint gates
* fix(studio): retain held reload scope before React renders
* perf(studio): coalesce timeline zoom to one write per frame and zoom to a range
* perf(studio): coalesce timeline zoom to one write per frame and zoom to a range
Every zoom input goes through one owner that writes the store once per frame.
zoomTimelineToRange eases to a time range; the zoom buttons use it.
* feat(studio): zoom-to-range reports done or cancelled and is not a person zoom
* perf(studio): place clips in percent of a one-second layer so zoom restyles nothing
A zoom step rewrites one width per row; clip thumbnails start decoding at rest.
* perf(studio): a zoom step re-renders only clips whose drawing it changes
Clip handlers read the lane callbacks when pressed, so a skipped clip never acts on a stale zoom.
* refactor(studio): move at-rest waveform and thumbnail work to its own change
* perf(studio): draw waveforms and decode thumbnails when the timeline rests
Waveforms draw only the visible span of a clip.
A zoom or scroll step stretches what is drawn until the timeline rests.
* test(studio): fade handles measure the clip only while they show
* test(studio): waveforms draw their visible span and redraw at rest
* feat(studio): export currentTimelineRange, the inverse of zoomTimelineToRange
* perf(studio): zoom gestures scale the drawn timeline and lay it out once at rest
No layout or render per gesture frame; past 0.67x or 2x the preview is laid out and continues.
* perf(studio): rebase a zoom preview only past 0.5x or 4x
Clips stay mounted half a viewport each side, so a zoom-out preview to 0.5x shows no gaps.
* fix(studio): keep the zoom readout, edges and Fit right during a preview
The slider and its percent follow a zoom while it is previewed. A zoom in
from below Fit no longer jumps when it lands: the preview now uses the
width the timeline will really have. A zoom-out lays out as soon as it
would show time with no clips mounted, so edges never show empty strips.
Fit, a press or a scroll on the timeline end a pending zoom first. An
older abort no longer cancels a newer zoom-to-range, and an instant one
lands on the next frame instead of inside its caller. Range selection and
gap tints scale with the preview.
* fix(studio): keep a pending zoom when the timeline re-registers its view
The timeline registers its scroll view again whenever clips are added or
removed, which used to drop a zoom still waiting to be laid out. Now only
a real unmount drops it, and unmounting one timeline leaves a newer
timeline's registration alone.
* refactor(studio): share the zoom geometry helpers the timeline already has
Zoom scales come from getTimelinePixelsPerSecond, the range margins from one
helper, and a zoom step starts from the same playhead anchor every zoom uses.
The clip minimum width is named and shared with the drag ghost.
* fix(studio): keep a zoom preview through scrolls and past the content end
A scroll during a zoom preview, including the one a laid-out zoom makes
itself, used to lay the next preview out at once, so a pinch or eased zoom
fell back to a full layout every frame. A scroll now only rescales what it
mounted. Zooming out also lays out before the preview shows past where the
ruler and beat lines are drawn, not only past the mounted clips.
* fix(studio): keep a moved audio clip's waveform drawn where it shows
A waveform now draws the stretch of its clip that the shared strip tracker
reports near the screen, the tracker the filmstrip already uses. It follows
scrolls, resizes and moves (a drag or ripple that shifts the clip), so a
moved clip no longer shows a blank stretch, and a zoom redraws it once.
* perf(studio): lay a zoom-out step out once, at its start
An eased zoom-out (a Zoom out click, or zooming out to a range) used to
lay out partway, once the shrinking preview reached time with nothing
mounted, and again at the end. It now lays the target out first and eases
by scaling that layout down from the old view, so each click lays out
once. Each eased frame also draws its preview in that frame.
* fix(studio): keep previewing a zoom at the content's end and over new rows
The preview now lays out only once it would show past the window the
timeline draws clips, ruler ticks and beat lines in, with two pixels of
slack, so rounding at the content's end no longer lays out every frame.
Rows mounted into the timeline during a preview are scaled before they
paint.
* fix(studio): redraw a waveform when its clip's width changes or it moves
A waveform drawn whole kept its old bars, stretched, after a zoom changed
its clip's width; it now redraws on any resize. A long clip also marks
its undrawn ends for the strip tracker, so a move without a scroll (a
ripple, a drop, an undo) re-measures it instead of leaving a blank
stretch.
* perf(studio): skip the layout when a zoom preview ends on its own scale
An eased zoom-out lays its target out first, so its preview ends on the
scale already laid out. Landing it now only sets the scroll, instead of
writing the same zoom again and counting the person's zoom twice.
* fix(studio): measure filmstrips and waveforms after a zoom preview ends
The strip tracker measured clip boxes while a zoom preview scaled them,
so after a Zoom out click a long clip's filmstrip or waveform could stay
blank across part of the screen. Reads during a preview now wait, and the
tracker re-measures every strip once the preview ends. A zoom-out lays its
target out in its first frame rather than inside the caller, and only when
the new view holds the old one.
* perf(studio): watch a waveform's undrawn ends only when it has some
A clip drawn whole has no undrawn ends, so it no longer mounts the two
empty markers the strip tracker watches; with hundreds of short audio
clips that saved an observer entry each.
* fix(studio): measure strips each time a zoom preview is laid out
A zoom-out lays its target out partway into the ease, after which the
strip tracker kept the stretch it measured before the zoom, so a long
filmstrip or waveform went partly blank near the end of the ease. Every
layout of a preview now re-measures the strips.
* docs(studio): keep the zoom-to-range doc comment within four lines
* docs(studio): keep the zoom files' comment share at or under main's
The comment ratchet failed on ten files this branch touches. Comments are
cut or folded into one line; no code changes.
* test(studio): launch real-Chrome tests like the e2e launcher, bounded by the test budget
* test(studio): share the real-Chrome page setup between the two runtime tests
* test(studio): keep the real-Chrome test helper with the other test helpers
* perf(studio): parse the saved file once to sync the composition length after a timeline edit
* perf(studio): keep empty duration updates free of parsing
* test(studio): pin one parse for rootless moves and the delete length readout
* test(studio): check the delete's length readout before its save lands
* test(studio): bench moves pressed right after Add keyframe
* fix(studio): an undo that reloads the preview keeps the selection, so a drag after it moves
* fix(studio): a drag started while a resize saves keeps its animations paused
* fix(parsers): an edit pins no position on a keyframed tween it did not touch
* fix(studio): a move pressed while the preview changes lands on the new preview, at the time pressed
* test(studio): bench waiting presses, a held box resized twice, and reloads at the last playhead
* refactor(studio): tidy the waiting press, bound its wait, and freeze edit stamps after early exits
* test(studio): load the property panel once so its first test stops timing out
* fix(studio): presses queued on a held box keep their release, hover and newest outline
* test(studio): judge bench undo by the history groups Studio claims, not by file writes
* refactor(studio): one waiting-box lookup in the bench and one raf-pause write per release
Behaviour-identical tidy of the waiting-press work: the bench reads the waiting
box through one helper, releaseActiveGesture sets the raf pause once, clearPointerState
writes the false it always computed, and previewGestureStarts calls the opts setters
directly instead of through aliases named like the gesture hook's gated setters.
* fix(studio): a queued press lands on the element it pressed and survives a window switch
* fix(studio): undo cancels a waiting press, a stuck save stops holding presses, scene swaps hold them
* refactor(studio): keep the complexity waiver on clearPointerState itself
* fix(studio): undo of a queued press leaves nothing waiting, and hosts keep their own undo
* fix(studio): a waiting press keeps the box still after a blur, and undo reports what it took back
* style(studio): bring the PR's comments within the comment limits
* fix(studio): bank the four bench cases this fixes and meet the comment, complexity and duplication gates
* test(studio): pin that a waiting press is never a pending edit, and name the new duplication waivers
* fix(studio): a timeline jump no longer starts thumbnail loads it is about to cancel
* fix(studio): pump inline after the release batch, and pin the synchronous slot free
* fix(studio): an offline preview says GSAP could not load instead of an unhandled Event
* fix(studio): the preview error list survives a second capture attach without duplicates
* fix(studio): the console capture keeps listening after a long early error list
* refactor(studio): the console capture owns every reset of its findings