* fix(cli): use onnxruntime-node installed beside the CLI when it is the pinned version
An optional package (onnxruntime-node, @google/genai) installed next to
the CLI at the pinned version is now loaded from there, before the
~/.cache/hyperframes/optional copy and without an npm install. Any other
version is ignored and the cache path runs as before. doctor reports
that copy's version.
* refactor(cli): name the beside-the-CLI lookup instead of commenting it
* fix(cli): a bad optional package copy beside the CLI no longer breaks doctor
The lookup reads the first copy Node would load and treats an unreadable
manifest as not the pinned copy, so doctor and the loader fall back to
the cache instead of throwing or checking a different copy.
* fix(cli): let Node pick the optional package copy before checking its version
The beside-the-CLI lookup now asks require.resolve which copy it would
load and checks that copy's manifest, so an empty folder or a same-named
file earlier on the path can no longer make the check and the loader
disagree.
* test(cli): cover a symlinked optional package copy beside the CLI
* fix(cli): find a symlinked optional package when Node preserves symlinks
Under --preserve-symlinks require.resolve returns the symlink path while
the candidate folder was compared by its real path, so a pinned copy
Node loads was rejected. Both sides are now real paths. A test runs the
lookup in a Node subprocess with --preserve-symlinks.
* fix(shader-transitions): a render no longer shows the last shader scene past its end
The page-side render un-hid the scene settled after the last shader
transition on every later frame, so a scene that followed it rendered
underneath the old one. It now un-hides that scene only while its own
data-start/data-duration window is open.
* fix(shader-transitions): the render leaves scene visibility to the runtime
The page-side compositor un-hid the destination of the last finished
shader transition on every non-transition frame, a workaround for the
runtime hiding a film's final scene before the end. The runtime has
kept the last frame since #4158, so the workaround only showed a shader
scene outside its own window: before it starts and after it ends.
Remove it, with the narrower check this branch added first.
* feat(player): expose picture size, durationchange and a click-to-play opt-out
- compositionWidth/compositionHeight are public, ready carries them, and a
resize event fires when the picture size changes.
- durationchange fires when the duration changes after ready, on both the
runtime and the direct-timeline paths.
- disable-click-to-play leaves clicks on the picture to the host.
Nothing changes for a player that does not use the new attribute; ready's
detail only gains two fields.
* docs(player): say composition size, one doc line per size getter
* fix(player): apply scenes before ready fires, keep the zero-size warning, document the new API
* fix(player): fire a message's events after the whole message is applied
* fix(player): queue every player event behind the update that raised it
* fix(player): decide autoplay after ready listeners run, and restart a loop at time 0
* fix(player): keep raising events after a listener throws
* refactor(player): drop a comment the ordering test already pins
* fix(player): fire the rest of an update's events when a listener throws
* 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
* fix(core): report one composition size in stage-size and timeline messages
The runtime posted the composition size twice per timeline post, read two
ways: stage-size used the lenient reader the stage layout uses, the timeline
payload a strict one. A root with data-width="1080px" reached the player as
1080x1920, then 1920x1080, so a portrait film was framed as a cropped
landscape picture. Both messages and the stage layout now share
parseCompositionDimension, and stage-size sends the value unrounded.
* fix(core): the layout diagnostic and nested roots read the same composition size
* fix(core): read composition sizes as whole px, as the renderer and Studio do
* chore(core): one comment line on the composition size reader
* fix(core): keep fractional layout sizes and report the same root in both size messages
Layout (the stage, timed clips, flattened nested roots, the layout
diagnostic) reads data-width/data-height as before, fractions kept. Only the
size the runtime reports in stage-size and timeline reads whole px. Both
messages and the stage layout now take the root from one finder, so an
earlier composition no longer replaces the explicit data-root's size.
* fix(core): use the size messages root for the clip tree and analytics
* fix(core): a clip gets its own inline display back when the runtime shows it
The visibility pass wrote a plain display:none over a hidden clip's inline display and removed
the property when showing it, so an author's display:flex came back as block and an author's
display:none !important showed the clip. The data-hidden path kept the value but not its
priority. Both paths now save the author's value and priority once and put exactly that back.
* fix(core): a plain inline display:none still shows at the clip's start
A plain none on a hidden clip can be a runtime hide left behind (a Studio reveal restoring it),
so it is not saved as the author's value; the clip shows at its start as before. Values with a
priority, and every other value, are still put back.
* fix(core): keep an author's plain display:none through a data-hidden toggle
The leftover-hide rule applies to the timed hide only; a data-hidden hide saves the author's
plain none as it is, so un-hiding keeps the clip hidden, as on main.
* docs(core): say the leftover rule belongs to the timed hide
* fix(cli): timeline ids never writes over a save that lands while it stamps
runIds now passes each file's version to applyFileMutations, like every other timeline command, so a save that lands while ids are minted makes the command stop and roll back instead of being replaced by the stamped old content.
* fix(cli): timeline ids refuses a conflicting save with the same JSON refusal as other timeline edits
* fix(studio-server): a refused file batch never rolls back over another writer's save
applyFileMutations checks every file's version before its first write, so a batch that is already stale writes nothing, and a rollback restores only files that still hold this batch's write; a file whose own write failed is still restored.
* fix(core): a clip that starts later keeps the place and size its CSS gives it
A timed root clip that had not started when the runtime laid clips out was display:none,
so it measured as auto: it was pinned to the top-left corner, and an empty full-frame clip
got no size. Measure a hidden clip as it will show and never pin, so it lays out like a
clip showing at load.
* perf(core): lay out hidden clips in one pass and pin the !important restore
Lift every hidden clip's display:none at once so a layout pass forces one layout instead of
one per hidden clip; it runs every 20 frames during playback. Add a test that an author's
display:none !important comes back with its priority, and fix comments that described the
removed pin.
* test(core): describe what the applyClipLayout test block covers
* refactor(core): name the display lift instead of commenting it
* fix(studio-server): stamping ids at open never reverts a write that lands meanwhile
stampFileHfIds replaces the file only if it still holds the bytes it stamped, so a save or agent write landing while ids are minted is kept. walkDir skips a subfolder that is unreadable or removed mid-walk instead of aborting the whole project walk; an unreadable project folder still throws.
* fix(studio-server): a stamp never recreates a deleted file and a walk only skips unreadable folders
* fix(studio-server): a stamp keeps a permission change made while ids are minted
* feat(cli): hyperframes history lists, undoes and restores a project's recorded changes
* fix(cli): an agent's history turn ends after 10 idle minutes on every path
A turn left open no longer takes the person's later edits: the engine
files a CLI turn's writes by the same idle rule as a preview window.
Studio claims take their paths back from an agent's window; refusals,
--json shapes, --limit and time parsing follow the review.
* refactor(cli): the project-history opener lives in its own module
The history command and other commands open a project's history the same
way: through a running preview, or directly under the engine's lock.
* docs(skills): the history loop lines sit in one marked trial block per skill
* fix(cli): a turn marker without its last write time has ended
Idle-limit tests set file times instead of racing the clock, and the
restore refusal test picks a ref no entry id starts with.
* docs(cli): the history entry shape comment names no other app
* refactor(cli): history reuses the busy error class and its who helper
* fix(cli): undo --who reverts the agent's whole last turn and nothing older
When the open turn recorded nothing, undo fell back to the agent's
earlier turn; when a mid-turn command split the turn, it reverted only
the newest part. The turn now keeps the entries it filed, and undo --who
reverts exactly those or refuses. Engine tests pin the write timing
rules, and a date that rolls over (Feb 31) is refused.
* fix(cli): an undo --who that meets a conflict leaves the whole turn as it was
It redoes the parts it already undid, lists the newer entries as they
are now, and offers --just-this and --back-to-before for the whole turn.
The last turn is recorded atomically before its marker is cleared.
* chore(cli): history keeps each file's comment share at or under main's
* fix(cli): history keeps log order past a turn's idle limit and undoes a turn's cut parts
* fix(cli): history drops unused imports and splits undo and reopen steps
* fix(studio-server): one process at a time keeps a project's history open
* fix(studio-server): taking over a dead history owner never removes a live owner's lock
Two processes that both found the owner dead could each take the lock,
the second removing the first's. Eviction now happens under its own lock
with a re-read, a close removes the lock only while it names the closer,
and a lock file holding no pid counts as dead.
* fix(studio-server): a history lock that cannot be read fails the open instead of spinning
* 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
* 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
* perf(player): let paused players check in once a second with low-power-idle
* refactor(player): trim low-power-idle comments and tidy its test
* fix(core): keep the fast paused heartbeat until a timeline is bound
* fix(core): wait for sub-compositions to bind before slowing the paused heartbeat
* fix(core): a seek during playback lands a playing video on the new time
* fix(core): the seek fix passes the comment ratchet without a new comment
* fix(core): keep why a pausing seek still lands, folded into the paragraph above
---------
Co-authored-by: Miguel Angel Simon Sierra <miguelangelsisi098@gmail.com>
* 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
* 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
* 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
* fix(producer): give each parallel segmented-capture worker its own browser
Segmented capture with two or more workers failed at the first frame with
"Invalid JPEG input at frame 0: empty frame". Every worker opened its page in
one pooled Chrome, and beginFrame capture does not survive a shared browser:
a worker's first beginFrame returned no screenshot. Fresh worker sessions now
follow shouldDisableBrowserPoolForParallelWorker, the rule the parallel disk
path already uses.
The default session factory was also built once per worker, so with a probe
session every worker reused that same page. It is now built once per run, so
only the first worker takes the probe.
Workers are now created inside the try, so when a later worker fails to
launch, the finally still closes the browsers opened before it.
* fix(producer): close a segmented worker session whose initialization fails
The default segmented session factory opened a fresh browser and then
initialized it with nothing owning it on failure, so a page-load or init
timeout leaked that Chrome. It now closes the session and rethrows, as the
streaming stage does.
* 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
* docs(cli): explain how catalog search works and which model it downloads
* docs(cli): name the model catalog search was chosen against
* docs(cli): keep only live rules in the word-search header comment
* docs(cli): correct the hosted vector size in the local vector build comment
* 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.
* fix(sdk): read a composition's variables from its root when its html has none
* fix: composition metadata and lint also read variables declared on the root
* fix(sdk): keep root declarations on the root through undo and prefer them like the runtime
* fix(sdk): read variables from both <html> and the root, editing each where declared
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl
* test(core): a template composition inside a mounted file resolves assets from that file
* docs: assetUrl is for paths beside the composition, not project-root paths
* docs: say assetUrl misses a project-root path
* fix(core): a mounted composition's import map and module scripts run as such
* refactor(core): scope a mounted module script under the same condition as a classic one
* fix(core): a mounted module script loaded by src stays a module
* fix(core): a mounted import map keeps the trailing slash of a prefix address
* chore(core): keep the module-script helpers' comment share at the base
* fix(core): paint no timed clip until the runtime decides its visibility
Until its first visibility pass, a paused page showed every timed clip at once: an embed without
a loader flashed a later scene before the first, and a player's hidden iframe still decoded every
scene's images. The runtime now hides timed non-media clips from the moment its script runs,
and the first pass that decides them removes that rule, so each clip is shown only by the pass.
* refactor(core): name media with the realm-safe helper in the first-pass check
* fix(core): share the first-pass hide across runtime copies and shader captures
A second evaluation of the runtime (a sub-composition carrying its own runtime tag, or a page that
includes it twice) added a hide rule nothing removed, so the film stayed invisible. The rule and its
flag now live on the page: a copy that finds either, or finds the runtime already initialised, adds
none, and the first pass removes every copy. HyperShader's capture clone forces its scene visible
with !important, so a transition captured before the first pass still shows the scene's clips.
The runtime test now also pins evaluation-time hiding, inline visibility, media, and two copies.
* fix(core): grade media inside a clip once the first pass shows it
Color grading registers only media it can see. Media with no timing of its own inside a timed clip
inherited the first-pass hide, so grading skipped it, and lifting the rule is not a body mutation,
so nothing registered it again: the image or video stayed ungraded. The pass now refreshes grading
when it lifts the rule. The second-copy test now asserts on a paused page, before any seek.
* chore(core): keep the comment share of the first-pass hide files at or under their base
The comment ratchet counts one added comment line each in entry.ts, init.ts and capture.ts, and a
new file above its package's share. The three lines are gone, and the one note that has to stay
(why the hide runs at script evaluation) now lives in the new module's shorter header.
* fix(studio): a playhead scrolled off to the left no longer draws over the track names
The playhead layer (z 100) drew over the sticky track headers when the timeline was scrolled past it. Once a scroll settles, its layer is clipped at the header edge, less the glow's half-width so 00:00 looks as before. The settle-time scroll subscription moves out of the ruler into useSettledScrollLeft, now shared by both.
* test(studio): find the timeline playhead through its layer, not its z-index
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl
* test(core): a template composition inside a mounted file resolves assets from that file
* docs: assetUrl is for paths beside the composition, not project-root paths
* docs: say assetUrl misses a project-root path
* feat(studio-server): preview keeps a project history and names the next undo and redo
The history engine now reaches `hyperframes preview`, and its routes tell
Studio what Cmd+Z and Cmd+Shift+Z would revert next.
- The CLI preview server gives the shared API a project history, opened
on first use under ~/.cache/hyperframes/history (overridable with
historyRoot), and its file watcher feeds every change it sees to it.
Shutdown closes it, so open windows are committed, within the same
bounded wait as renders, so a history still opening cannot hold exit.
- GET /projects/:id/history also returns `back` and `forward`: the entry
each step would revert, with its label, for the Undo and Redo buttons.
- POST /projects/:id/history/window takes an optional idleMs, so a drag's
burst of writes can share one window that ends by itself (capped at
10 minutes, below where a timer overflows).
Nothing in Studio calls these yet.
* feat(studio-server): a writer that records after writing can claim its writes
Studio records an edit after writing it, and many of its writes are
server mutations deep in its save paths, so opening a window before each
write would restructure them. claim() fits that order instead.
- ProjectHistory.claim(who, label, paths, { coalesceKey?, idleMs? })
sweeps now and moves the not yet committed outside changes to `paths`
into one entry of who's, under the edit's label. Other outside writes
stay outside.
- Claims with one coalesceKey merge into one entry (a drag's burst) until
a claim with another key, idleMs (default quietMs) without one, or any
operation; Cmd+Z right after a drag undoes all of it.
- The ceiling is named where it lives: another writer's write to a
claimed file between Studio's write and its claim folds into Studio's
entry, and a write filed to another writer's open window stays there.
- Route: POST /projects/:id/history/claim { label, paths, coalesceKey?,
idleMs? } -> { claimed: { id } | null }.
* fix(studio-server): a drag back to its start claims nothing, and next steps name their files
Deleting a sub-composition clip left it drawn on the timeline indefinitely. The preview still held the removed host until its reload landed, composition enrichment re-read it from there and put it back, and the merge then kept it on every shorter scan. The delete now takes its clips out of the live preview at once, and updates the timeline right after its write instead of after the ripple finishes saving; a ripple that fails to save puts the gap back.
The catalog already loads every item's manifest, preview included, but the
--json rows dropped it, so an app listing the catalog had no way to show an
item before add downloads it. Rows now carry preview when the item has one.
An edit that reloads the preview used to stop playback: the reload saved the
time, paused the live frame and marked the player paused, and the promoted
document started paused. Now forward playback runs on in the live frame while
the new document loads. At promotion the new document takes the live frame's
time, so playback, a stop at the end or a seek made during the load carries
over, and it keeps playing if the live frame was, unless the edit cut the film short of that time. A paused film, a reverse
shuttle and a dropped reload behave as before.
* fix(cli): the snippet hyperframes add prints for a block passes hyperframes check
* fix(cli): the block snippet carries the id the block's own root declares
* fix(cli): warn when an added block declares no composition id for its snippet
* fix(cli): add <tag> --json reports each block's warnings
* feat(studio-server): every write to a project is kept in a history that can be undone or restored
* fix(studio-server): rewrite a history removed while open and report damaged log lines
* fix(studio-server): history ignores foreign ids, ends idle windows and budgets only past history