* fix(studio-server): an edit aimed at a clip never lands on an audio group sharing its id
* refactor(studio-server): one comment line, the rest in names
* fix(cli): retire old snapshot run folders
* chore: satisfy snapshot comment limit
* fix(history): read and release snapshot leases through verified handles
* test(history): run lease races in Node and check released markers
* fix(studio): verify snapshot leases from open descriptors
* fix(studio-server): history copies a changing file without stalling its sweep
* fix(studio-server): a failed history copy leaves no partial file and keeps the mode
* style(studio-server): name the history copy for what it does
* feat(cli): init --keep-original copies a clip as is, without transcoding
`hyperframes init --keep-original` copies the --video file under its own
name and wires it into the project the way a web-safe clip is wired today:
no transcode and no prompt, interactive or not. Default behaviour is unchanged.
Theora leaves init's web-safe codecs and joins the preview proxy's
browser-hostile codecs, so init converts it and the preview proxy serves an
H.264 copy instead of a blank picture.
* fix(studio-server): keep the codec table's comment share at its base level
* fix(inspector): report nested hosts' authored window, one location per source line
The runtime strips data-duration/data-end from nested composition hosts after
boot and keeps them as data-hf-authored-*. Timeline inspection built its
resolver without authored attrs, so a host with data-duration="4" reported
its GSAP timeline length (3.72s) instead.
Timeline text and events[].sources listed the same file:line once per mapped
snippet (creation, caller, dependency), so a one-line script printed one
location up to nine times.
* refactor(inspector): carry the dedupe reason in the helper's name
* fix(inspector): end continuation on a page that fits no snippet; fill budget with candidates
* fix(inspector): timeline groups by component, names attr values, eases, film end
* fix(studio-server): stamp hf ids into a file without reformatting it
* fix(inspector): find an element's tag before its served hf id reaches the source
* fix(inspector): live element map shows authored classes and text, not the playhead's
* fix(inspector): collapse script-built text so the element map ignores the playhead
Class names stay in source order, a runtime-stripped id is shown as id="…"
rather than a selector, and a grouped row names which children it covers.
* fix(inspector): keep paging, stamping, and the live map still on the reported edges
A snippet larger than the page ended the walk, so later snippets could not
be reached. Raw text ended at a longer tag name, which could stamp an id
inside a script. An empty or mixed source element showed live text, and an
unknown duration read as a film that ends at 0s.
* fix(inspector): mint ids for an empty hf id and keep an unquoted class
An empty data-hf-id was treated as already assigned, so stamping left it
blank while the older stamper minted one. An unquoted class was read as no
class, so the live map dropped it.
* fix(inspector): narrow markup before reading a child tag
skipMarkup returns a boolean, so the build still sees Markup where a tag
is required. Narrow on the kind first so the inspector typechecks.
* fix: quiet check false positives and name the ancestor of an id-less target
Endpoint contact, disjoint fromTo pairs, and a negative z-index under a
stacking-context parent were warnings with bad advice. Layout and contrast
blamed a dot grid, a circular avatar's panel, and a visibility override.
A faint texture veil is no longer the click target.
* fix(lint): drop a history sentence and six gsap comments
The comments check treats "load-bearing" as history, and gsap.ts added six
comment lines. The names already carry what those lines said.
* fix(parsers): ignore an empty id written inside another attribute
The empty-id scan matched the first data-hf-id="" in the tag, including one
inside a quoted title. It now skips quoted text, so the real attribute is minted.
* fix(lint): keep real covers and a negative z-index under a card
A gradient scale-down treated every fill like a radial dot, so a panel
that starts transparent stopped counting as a cover. A stacking context
on any ancestor cleared a negative z-index, and a faint wrapper made
images inside it unpickable.
* fix: keep an emoji from shifting a stamped id
The quote mask replaced one emoji with one space, so the id landed before
the closing quote. A radial cover that turns opaque after a clear stop
stays fully painted.
* fix(lint): score a letter-only dot as a dot, not a color name
namesAColor saw #fff and rgba as words, so those dot grids counted as a
full cover. Hex and rgb colors are stripped before that word check.
* feat(studio-server): accept a crf override on the render route
The Studio render route only forwarded draft/standard/high, so a Studio or
desktop export could not ask for a specific CRF the way `hyperframes render
--crf` can. Film-grain finishing passes need CRF 12 to keep the grain.
The route now takes an optional integer `crf` (0-51 for mp4, 0-63 for webm),
rejects an out-of-range value with 400 instead of dropping it, ignores it for
mov, and both Studio adapters pass it to the render job.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore(studio-server): keep the crf change free of new comment lines
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(studio-server): accept videoBitrate, videoFrameFormat and hdrMode on the render route
Same options `hyperframes render` already takes (--video-bitrate,
--video-frame-format, --sdr/--hdr), so Studio callers are not limited to
the quality presets. crf and videoBitrate are an either/or, matching the CLI;
an invalid value is a 400. Parsing moves to helpers/encoderOptions.ts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(inspector): stop audio waveform caches from making every live question stale
* fix(inspector): drop the comment and format the test
* refactor(core): share the folders Studio's preview ignores
* fix(core): register the project-folders subpath and bundle it into the inspector
* feat(cli): save check, snapshot and render artifacts per revision
* fix(cli): keep clean --snapshots inside the project
A symlinked .hyperframes or artifacts folder was followed, so the command
deleted snapshot and check files outside the project.
* test(cli): keep check, snapshot, and render successful when recording fails
A held history or a corrupt output registry warns and leaves the command result alone.
* fix(cli): drop snapshot records when a replaced run is not recorded
A reused --output rewrites the files even when history is busy.
Those records are removed so the old revision does not keep the new pixels.
The preview checkpoint stops waiting after five seconds.
A bad PNG header no longer fails the snapshot.
* fix(studio-server): keep the video-state comment share from rising
The forget helper's doc comment pushed the file above its base share.
* fix(cli): post the preview checkpoint and keep cleanup inside artifacts
A preview checkpoint with no body was sent as GET, so Studio never ran it.
Cleanup now requires the real path to stay under the artifacts folder.
A failed follow-up drop does not warn twice.
The studio-server unpacked budget covers the record-dropping helper.
* docs(cli): shorten the snapshot skill notes
The command examples already show range and composition.
The prose only keeps the output path and the flags that are easy to misuse.
* chore: leave package headroom for artifact cleanup
* test(cli): compare artifact paths portably
* style(cli): format portable artifact assertion
* 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
* feat(cli): guard history turns with --exclusive, --who and --if-open
* fix(cli): make exclusive history turns atomic
Re-read the open turn only after acquiring history ownership so
concurrent exclusive begins cannot both claim the same project.
Cover the race with a deterministic two-process regression test.
* fix(cli): refuse a second exclusive turn while a preview is open
The preview's begin window now rejects another agent's live turn, and an
idle turn no longer blocks exclusive begin. The two-process test waits on
pids collected from claim drafts instead of seeing both files at once.
* fix(cli): check warns when a video's browser-playable copy cannot be made
* fix(cli): big projects stop failing proxy prep, and a failed copy is reported once
* fix(cli): proxy failures name the real ffmpeg error, and a broken ffmpeg is reported once
* chore(studio-server): shorten the batch proxy resolver doc comment
* fix(cli): history reads answer while another app has the project open
* refactor: Fewer comment lines in the history read path
* refactor: Keep a history comment within the line limit
* 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
* fix(studio-server): the public proxy cache path names boxed preview copies too
* fix(studio-server): keep the proxy cache path docs within the comment budget
* 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-server): a failed save reports its real error when a later file was never written
* refactor(studio-server): share the file writer type and harden rollback test cleanup
* fix(lint): check passes valid sub-compositions and scene-relative asset paths
* fix(lint): keep the browser lint build free of node modules
* refactor(lint): tidy sub-composition lint helpers and guard bare node imports
* fix(lint): resolve stylesheet asset paths from the stylesheet's own folder
* fix(lint): catch every written form of a path that climbs out of the project
* fix(parsers): sub-composition asset paths written with backslashes load in preview
* fix(parsers): an encoded backslash stays in the file name and a leading backslash stays absolute
* fix(lint): an encoded %2f counts as a separator when checking for a climb out of the project
* fix(studio-server): the preview's proxy copy jumps queued thumbnails, and abandoned copies drop
* fix(studio-server): a re-ask right after a dropped proxy copy gets a fresh copy
* fix(studio-server): leave Studio's preview route on the normal proxy queue
* docs(studio-server): shorten the proxy transcoder's detachment note
* fix(studio-server): a refused proxy copy still invalidates thumbnail captures
* 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
* 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
* 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
* 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): 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
* fix(studio-server): keep the desktop app's hand-off and chat history out of the file routes
The file and preview routes reach any path inside the project. The desktop
app keeps two files there that hold the person's agent session and their
chat: .hyperframes/agent-handoff.json and .hyperframes/app-history.jsonl.
No route now reads, writes, renames onto, duplicates or uploads over them,
by any spelling or through a link. The rest of .hyperframes/ (Studio's
motion file, prepared GIF assets) is served as before.
* fix(studio-server): keep the desktop app's run log out of the file routes too
The app records every chat run, the person's words included, in
.hyperframes/agent-runs.jsonl. Only the app's main process reads it.
* fix(studio-server): refuse everything under .hyperframes/ but Studio's own files
Review (Jerrai, Rames): a list of three names missed the read marks the
desktop app and the CLI also keep there, and renaming the folder reached
them under a new name. The check is now an allow-list: no route reaches
.hyperframes/ or anything in it, by any spelling or through a link, except
Studio's studio-motion.json, studio-manual-edits.json and prepared-assets/.
Studio's own internal files (backups, preview documents, history id) are
read by path inside the server, not through a request path.
The project's file tree leaves out the same paths. The upload test now
sends a file uploads accept elsewhere, so the folder is the only reason
it is refused.
* fix(studio-server): serve the app's request pictures, close the renders and rename paths
Review (Rames, Jerrai):
- Desktop loads each request's picture from .hyperframes/requests/ through
the preview route, so that folder joins the allow-list. An allowed
folder's own path is allowed too.
- The render-file route resolves against renders/, so a renders/ linked
into .hyperframes/ reached the records. It now also checks the file
against the project.
- A rename's reference update walked every JSON/MD file, records
included, and could rewrite them. It now skips private paths.
- The allow-list reuses STUDIO_MOTION_PATH and STUDIO_MANUAL_EDITS_PATH.
* chore(studio-server): keep render.ts's comment share at the base
* fix(studio-server): list, view and download only renders the render-file route would serve
Review (Jerrai, Rames): the render list found files in renders/ with join
and statSync, which follow links, and registered them for /render/:id/view
and /download, which read them with no check. A project shipping
renders/leak.mp4 -> ../.hyperframes/app-history.jsonl had its chat served.
The list now keeps an entry only when resolveWithinProject accepts it and
it isn't private, and registers the resolved path. View and download serve
only a plain file, so a link swapped in after the list is refused. This
predates the PR; it's the same shipped-link case.
* test(studio): bench a plain element animated through auto-record and Add keyframe
* fix(studio): seed a first tween from the offset a plain move left in CSS translate
* test(studio): bench resize then drag on a GSAP tween, root and nested
* fix(studio): a resize under auto-record keys size at the playhead, held at all times
* fix(studio): keyframe tweens Studio writes end on their last key, so a seek matches playback
* test(studio): keep the plain-to-keyframes moves inside the nested frame
* fix(studio): a resize moves its anchor from the drag stamp it read at release
* fix(studio): an undo shown in place is no longer replaced by an older reload
* refactor: split the trailing keyframe trim and drop an unused GSAP reader
* test(studio): settle the resize-then-drag bench steps so a two-save resize is one undo
* fix(studio): a preview reload waits for an undo shown before its write lands
* fix(studio): Add keyframe reads a subtracted calc() translate with its sign
* refactor: tidy the keyframe hold, trim and resize code after review
* fix(parsers): the keyframe trim leaves looping, chained and relatively placed tweens alone
* fix(parsers): a size hold pins only a lone key, so an edit never changes frames left alone
* fix(studio): every undo applied in place counts as a preview change, script-only ones too
* fix(studio): a resize keys size only when a move or size is keyframed, not a fade
* test(studio): a bench step whose save never lands fails the case
* fix(parsers): holds and trims change only the tweens an edit touches
* test(studio): bank the plain-to-keyframes and resize-then-drag bench cases
* fix(studio): undo takes back the first edit of a film whose media is still being copied
* fix(studio): the first undo of a film repaints the preview in place
* fix(studio): background media copies keep the media ledger and survive a history prune
* fix(studio): a busy project history never makes writes wait for its lock again
* refactor(studio): name the restore stamping instead of commenting it
* perf(studio): undo stamps only a restore side that has no stamps
* fix(studio): closing a project history finishes its media copy first
* fix(studio): media copies stay resumable until recorded and skip a replaced folder
* fix(studio): the history lock wait comes back after a successful open
* fix(studio): place GSAP step lists and duration-less tweens where GSAP plays them
* refactor(studio): drop narrated comments and unnest the extend clamp
* fix(studio): match keyframes in time, resolve partial step timing, start readout from prior tween
* fix(studio): time step-list keyframes from the parts' last end and format
* refactor(studio): trim comments to the package's comment share
* fix(parsers): apply timeline defaults as GSAP does to step lists and motion paths
* fix(studio): keep GSAP's ease for motion paths with keyframes and read finished tweens
* refactor(studio): read a live motion path in its own step
* refactor(parsers): leave step-list timing to its one owner and skip timeline defaults there
* refactor(studio): move the live motion-path read into its own module
* test(parsers): pin an unresolved length for a step list stretched by a variable duration
* fix(studio): time keyframes linearly under an outer ease that overshoots or steps
* fix(studio): time a converted expression-duration tween from its live tween, and pin no rewrite
* feat(studio): hand-move and hand-resize any number of selected clips
* test(studio): bench dragging and resizing every clip, frame cost and drop time
* test(studio): give the bench browser launch the run timeout
* test(studio): bench request timeline offsets
* fix(studio): joining or leaving a multi-clip drag restyles a clip instead of remounting it
* fix(studio): restore the linked-selection import and mark the drag bench complex
* test(studio): find a clip's track row through the per-clip wrapper
* test(studio): resolve track rows through the per-clip wrapper in the remaining cases
* perf(studio): write a group edit's GSAP changes as one request per file
* test(studio): cover the batched GSAP write and keep the owned-chain cases
* test(studio): bench profile of the drag's pointer moves
* perf(studio): sync a group edit's root duration once per file, not once per clip
* feat(studio): a timeline move registers a still-saving revert so Cmd+Z right after it need not wait
* test(studio): the reverted move stays reverted when its save lands
* test(studio): bench prints the heaviest call chain on request
* perf(studio): preflight a many-clip selection against one index of the live tweens
* test(studio): share one timeline across the indexed lookups
* test(studio): bench failure output skips resource 404 noise
* fix(studio): the live-tween index skips timeline entries that are not objects
* fix(studio): the live-tween index skips a null timeline entry
* test(studio): bench reports the first drag frame apart from the steady ones
* test(studio): bench lists inclusive time per function on request
* test(studio): bench reports when the last write of an action landed
* test(studio): bench reports medians of save, frame and first-frame times
* refactor(studio): move the live-tween index out of gsapRuntimeKeyframes to keep it under the line cap
* fix(studio): wire the live-tween index imports and drop an unused test binding
* style(studio): format the files CI flagged
* fix(studio): keep the bench and the scale helper out of fallow's findings
* refactor(studio): one exact tween-index lookup, finish a batch file once, reuse the duration sync
* fix(studio): the tween index lists a tween once per key
* fix(studio): restore the batch-finish and named-parameter helpers
* fix(studio): undo reverts an edit still saving before painting an older predicted step
* style(studio): format the batch duration sync
* chore(studio): keep the new comments within the block and share limits
* test(studio): settle the pending edit even when the undo-order test fails
* perf(studio-server): retime every clip in a GSAP batch with one parse and one hold sync
* test(studio-server): a batch of clip retimes writes what single requests in turn write
* style: format the retime fold and its tests
* perf(parsers): clip matchers in one retime share a DOM query cache
* perf(parsers): look up every clip id in one DOM walk when retiming
* perf(studio): re-resolving the same selected node keeps its source check
* perf(studio): a drop re-renders only the selection that actually changed
* test(studio): report main-thread task time of a bulk drop
* perf(studio): a group drop updates the timeline store once, not once per clip
* fix(studio): re-reading index.html keeps sub-composition keyframes
* refactor(studio): one owner for clip-retime no-op rules and selection options
* fix(studio): reuse a source check only for the same target, and re-check on a new cache version
* fix(studio): undo confirms a reverted save only once its history claim counted
* refactor: split a retime's application out of the one-parse fold; share test fixtures
* perf(studio): drop the per-clip wrapper node so timeline scrolling stays within budget
* refactor(studio): name the drag passenger style after the passenger it styles
* fix(studio): undo keeps a move undone when its history claim landed before the key
* fix(studio): undo a timeline move and its tween shift in one step
* refactor(studio): one owner of a timeline gesture's undo window
* refactor(studio): move single-clip move and resize into their own hook
* fix(studio): a reload or second tab never joins another page's undo step
* fix(studio): a delete and its ripple stay one undo step however late the ripple lands
* fix(studio-server): date a held undo step by its last write, not by the edit that ends it
* test(studio-server): pin how an older history log takes in the media ledger
Four guards in the take-in had no test that failed without them: the start already naming the ledger, a change that made it, setting the keepsLedger mark, and saving it.
* test(studio-server): put the undoNewest note back on undoNewest
* fix(core): bundled local scripts keep their own place, defer and async
The bundler joined every local <script src> into one inline script at the first one's position. That dropped defer and async (a deferred main.js ran before a deferred CDN gsap), moved later files ahead of CDN scripts between them, and let one file's error stop the rest. Each local script is now inlined where it stands with its attributes; a deferred or async one keeps its tag with its file as a data: URL. Inline-script merging also no longer folds in nomodule scripts, which dropped the attribute.
* fix: preview scripts run after the runtime when the bundler fails, and inlined files are escaped
- When the bundler returns nothing or throws, the preview places the runtime
where the bundled page does (end of head) instead of after the composition's
scripts or not at all; core's placement moves to insertRuntimeTag.
- Every HTML-spec JavaScript MIME type counts as a classic script for merging
and the web-font gate.
- Inlined local files and merged runs are escaped with escapeInlineScriptSource.
* fix: deferred local scripts run late without data: URLs, and authored runtime reads survive
- Local defer/async scripts are inlined in their own tag with the attribute
kept; the font-gate runner (and its old-runtime fallback) runs an inline
classic defer script after the classic scripts, in document order, so a page
policy that allows inline scripts but not data: still runs them.
- stripEmbeddedRuntimeScripts matches the bootstrap attribute and runtime file
names on the tag, not runtime words in an authored script's text.
- The preview applies its runtime on every path, so an adapter page without
one still gets it; the inline escaper writes <!-- as \x3C!--, valid in a
unicode regex.
* fix: a deferred inline script waits for the deferred CDN script before it to load
* fix: an authored script that mentions the runtime marker no longer stops the runtime being added
* fix: only inlined deferred files run late, head ones included, and renders skip a CDN runtime link
* fix: deferred head files run as before, and one selector owns the deferred-file rule
* fix: a local file's legacy <!-- comment no longer breaks its merged run, and escapes keep case
* fix: a local file inlined on its own keeps working with a legacy <!-- comment
* test(core): a local non-JavaScript file keeps its text as written
* fix(studio-server): undoing a change takes the media ledger back with the film
* fix(studio-server): a log 0.8.123 wrote takes the media ledger in, so the first Undo undoes the edit
* fix(studio-server): only a log from before the ledger fix takes it in, once
* fix(studio-server): an older log takes in the media ledger only as the sweep would see it
A ledger behind a link was read straight into the baseline, then filed as a
deletion by the first sweep. The take-in now asks the same scan the sweep uses.
The cutout test also pins that other .media files and other manifest.jsonl
names stay out of history.
* fix(studio-server): motion paths animate when gsap has a query string or loads deferred
Studio puts MotionPathPlugin right after the composition gsap tag. A gsap URL with a query string or hash was not recognised, so the plugin went to the head and ran before gsap; a deferred gsap kept the plugin undeferred, with the same result. The tag is now found with a query or hash, and the plugin copies its type and defer, read with the HTML parser the server already uses.
* fix(studio-server): finding the gsap tag stays fast on any preview HTML
The gsap tag pattern from the previous commit could backtrack polynomially on long runs of gsap-like src text. The preview now walks script tags with index searches, decides gsap core from the URL path, reads attributes case-insensitively, and skips tags inside comments and templates.
* refactor(studio-server): the gsap tag scan is small single-purpose steps with no whitespace regexes
Splits the scan so each step has one job (walk tags, read one script tag, find its closing tag, skip a comment or template), and skips whitespace by character instead of with \s* patterns, which code scanning reads as polynomial.
* fix(studio-server): the gsap tag search reuses the core HTML scanner, so style blocks and odd comments cannot hide gsap
The hand-written walk missed raw-text bodies, empty comments and a comment opener inside an attribute value; any of those before the gsap tag sent the plugin to the head again. findStartTags already skips all of them and is what lazyPreviewImages pairs with the parsed document; the preview now does the same.
* fix(studio): a first animation in a full-document sub-composition plays
* fix(studio): keep the comment share of the two touched files at their base
* fix(studio): a first animation also plays when <html> carries the composition id
* fix(studio): a root whose id sits on <html> keeps its first animation in <body>
* fix(studio): a composition id on the template tag keeps the first animation inside it
* fix(studio-server): history never files a hidden file as a change
* fix(studio-server): an older log never makes history write a hidden file
* fix(studio-server): an older log's hidden-file entry no longer stalls Undo
* fix(studio-server): an older log's emptied Undo still keeps its target undone
* fix(studio-server): an older log keeps every entry, and undoing an emptied one still lands
* refactor(studio-server): history's hidden-path rule says itself in names, not comments
* fix(core): composition scripts start after web fonts are ready
A scene script that measures text (offsetHeight, getBoundingClientRect) ran while
the page parsed, often before its web font loaded, so it laid the scene out with
fallback metrics. Renders of the same film differed run to run, and preview could
differ from export.
The compilers now give every body script a type the browser does not run
(text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called
at the end of the bundler, the producer compile and the sub-composition preview.
The Studio motion script is emitted the same way so it still runs after them.
The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for
document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script
in its own place in parser order (classic in order, awaiting src loads; then
module and defer scripts in insertion order), boots as before, and calls the
DOMContentLoaded and load listeners the scripts added. On timeout the scripts
run anyway and runtime_font_wait_timeout names the families still loading.
Scene swaps and the composition loader use the same wait.
* fix(core): a page whose runtime predates the font gate still runs its scripts
A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that
runtime is older than the font gate, nothing would run the deferred composition
scripts and the film would be blank.
Whenever the marking helper defers scripts, it also puts one small inline script
first in the head. At DOMContentLoaded it runs the deferred scripts in parser
order itself, without waiting for fonts and with a console warning, unless a
runtime has claimed them. The gate's runtime claims them when it loads, before
DOMContentLoaded, so the scripts run exactly once either way.
* fix(studio-server): motion paths animate in Studio preview when gsap loads in the body
The MotionPathPlugin tag Studio inserts after a body gsap script now carries
that script's type, so the runtime runs it right after gsap once fonts are
ready, instead of before gsap exists.
* fix(cli): layout, motion-shot and validate wait for the composition scripts to run
The three commands waited on the timeline registry, which exists as soon as the
runtime file loads, or on a flat sleep. They now wait for the runtime's
render-ready flag through one shared helper, keeping each command's timeout and
proceeding as before when it passes.
* fix(core): a script that wraps addEventListener keeps its wrapper after its scripts run
While composition scripts run, the runtime patches addEventListener to hold
load events that already passed. Putting the original back now happens only if
the patch is still in place, so a library wrapper installed meanwhile survives.
* docs: comments on script timing match scripts that wait for web fonts
The early stub's flush and the player's iframe-load handler described page
scripts as done by DOMContentLoaded and the runtime as ready before load.
Composition scripts now wait for web fonts, so both can come later. Comment-only.
* test(core): pin script order after an external script and the scene swap's font wait
An inline composition script that follows an external one sees its global, and
a scene swap runs the new scene's script only once web fonts are ready.
* docs(producer): the early stub comment names what drains late font-gated work
* fix(studio): motion paths add without a flash when gsap loads after web fonts
Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready.
* test(cli): pin the runtime wait before each command first samples the page
* fix(studio): the motion path preload stops waiting once the preview runtime has booted
A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter.
* fix(studio): a preview frame that never boots is not kept alive by the motion path preload
Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected.
* style: comments added by the font gate move into names
* refactor(studio): the auto-id write posts through the one patch-element client
* test(studio): pin the auto-id write's saves, refusals and unsafe targets
* test(studio): pin the auto-id write's plain refusal and missing changed flag
* test(studio): the auto-id write needs a toast handler and sends the write token
* refactor(studio): one request for every GSAP mutation write
* fix(studio): a refused id write no longer leaves the id on the previewed element
* style(studio): drop the two comments the names already carry
* fix(studio): keep minting the id on the element so overlapping adds never share one
* fix(studio): an id the file already holds counts as saved, and no project leaves no id
* fix(studio): the server picks an added effect's element id, so ids never repeat
The browser minted the id from the live preview and rolled it back when the
write failed. A reply lost after the id was saved left the file holding an id
the preview had dropped, so the next add on a sibling wrote the same id again;
and a refused add could strip the id an overlapping saved add had just given
the same element.
A new patch-element op, ensure-id, keeps the element's id or makes the proposed
one unique in the file, and every patch-element reply names the element's id.
Studio sets the id on the preview only from that reply, so there is nothing to
roll back. nextUniqueId moves to sourceMutation as the one owner (split and
duplicate already used it).
* test(studio): pin the gsap write token and the refused-write toast
* refactor(studio): one unique-id helper on the server, and shorter id comments
* style(studio): keep the id helper doc under the line limit and the test-utils header true
* fix(studio): an added effect's id stays unique across every composition file
* style(studio): keep the files route's comment share at the base
* fix(studio): count an id written right after a quote or slash as taken
* fix(studio): a quote-ended string no longer hides another file's id
* fix(studio-server): freeze still appears whole, so a slow extraction cannot break its undo
The freeze route reserved the still's final name with an empty file before
ffmpeg ran. When extraction outlasted the history's quiet time, history
committed that empty file as an outside change, and Cmd+Z then restored a
0-byte PNG. ffmpeg now writes under an atomic temp name the history skips,
and the still is published with createFileAtomically (EEXIST still refuses
a taken name).
* fix(core): a direct exclusive write that fails midway leaves no partial file
Without hard links, createFileAtomically writes the destination directly.
A full disk then left a partial file behind (a corrupt upload or freeze
still); it is now removed, and a name another writer holds is never
touched. Freeze tests: the slow-extraction test leaves a half-written temp
file while history commits, and concurrent extractions meet at a barrier
instead of a real timer.
* fix(core): a quota reported only at close still removes the partial file
* test(core): the direct write closes once and keeps its write error when cleanup fails
* test(core): a failed direct write closes its descriptor exactly once
* refactor(studio-server): freeze names carry what their comments said
* fix(studio): load MotionPathPlugin at the composition's own gsap version
* fix(core): list the gsap-cdn subpath where core's exports are generated from
* fix(core): every default gsap URL comes from the one gsap-cdn owner
* fix(core): the gsap a new composition or first tween gets comes from the gsap-cdn owner
* fix(studio): undoing a freeze frame removes the still it made
* fix(studio-server): a failed frame extraction leaves no partial still
* fix(studio-server): a freeze claims its still's name before extracting
* fix(studio-server): a freeze past the media's end fails instead of saving an empty still
* refactor(studio-server): freeze route names say what its comments said
* fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween
* fix(studio): undo pressed while an edit saves undoes that edit, not one started after the key
* fix(studio): an edit stays saved when the history reply after it cannot be read
* feat(studio): export ColorField and GradientField; a session style commit takes a map
* refactor(studio): name the map form instead of commenting it
* feat(studio): a host can move layers by exact film px, one undo per call
handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each
selection by its delta in composition px, for Desktop's align and distribute.
It reuses the group drag's own path: a drag member per element (press-time
route, measured offset map), the delta carried to screen px through the
preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared
coalesce key gives one undo entry and GSAP members take the GSAP write.
A member that can't move refuses the call before anything is written; the
refusal rejects with its reason and shows no Studio toast (the group commit
takes refusalToast: false; telemetry stays). Zero deltas are skipped.
Tests: a pre-offset element and one under a half-scale parent move by exactly
{10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg
rotated ancestor lands within the 0.001 px a translate is written with; a
GSAP member goes through the GSAP write; three refusal kinds write nothing,
leave the boxes where they were, show no toast and leave no gesture mark,
while a canvas group drag still toasts; the session hands the call to the
group commit with refusalToast: false. Each mutation (no frame mapping, no
zero skip, no restore, no teardown, no capability check, toast on refusal)
fails at least one of them.
* fix(studio): an outside change during a drag waits for the drop to show
While a gesture mark is live in the preview, refreshPlayer takes the shadow load instead of
swapping scenes in place. A swap replaced the node under the pointer mid-drag; the shadow load
is then held by the existing gesture hold and, if the drop saved, reloaded fresh by the stale
rule, so this adds no second "released" signal.
Test: an outside change during a live drag on a preview that can swap scenes leaves the dragged
node in place, shows nothing under the pointer, and after the drop loads fresh with the dropped
position. It fails on the parent without the condition ("swapped under the pointer").
Cost case, measured in a real headless preview on the edit-accuracy bench's per-frame teleport
check. Every GSAP instant patch was forced to miss and fall back to reloadPreview during its own
commit; move and rotate of a held element (gsap.set and tl.set holds), px and xPercent
placement, r0 and r30, root and nested, at 100%:
- The gesture mark was live when the fallback asked for the reload, but had always ended by the
time refreshPlayer ran (refreshKey is consumed in an effect after the commit settles). So the
new condition never fired there: root previews offer no scene swap and load shadow on both
builds, and nested previews swap on both builds.
- Worst post-release frame offset, 32 cases on this build and 32 on the parent: 0.00 px over
76-106 frames each (release to settle). No frame was off by more than 0.5 px.
- Sensitivity: a 3 px nudge injected 50 ms after the fallback reads back as 3.00 px.
* fix(studio): an edit no longer reopens the live-updates stream, and thumbnails ask one at a time
* fix(studio): an undo no longer waits behind paused preview videos for a connection
* feat(studio): a host whose API needs same-origin cookies can have Studio send them
* docs(studio-server): a host on same-origin cookies sets the Studio credentials opt-out
* test(studio): an undo is not held up by the preview's paused videos
* fix(studio): a resize inside one gesture writes a mixed tween once, in place, never beside it
* chore(studio): keep the carried fixes inside the comment ratchet; telemetry posts through studioApiFetch
* test(studio): a drag on a mixed tween is one write under the gesture key
* fix(studio): freeze-frame, loudness and peak-map requests go through studioApiFetch too
* fix(studio): an undo with no project open reloads in full instead of skipping nested files
* refactor(studio): drop the soft reload's reparse seeding, the held-element sweep covers it
* test(studio): keep the undo-restore browser test off the network and inside its timeout
* test(studio): nested composition reads expect the cookie-free request studioApiFetch sends
* fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times
* fix(studio): a rotate on a tween that turns another axis holds its angle and never turns early
* fix(studio): plan every group member before the first write so one refusal moves none
A group drag or nudge preflighted each member against the move refusal rule only. The value-at-playhead planner ran later, in the commit loop, after earlier members were already flushed to disk, so a tween with extras, eased keyframes or an unknown ease left the group half moved.
The group preflight now runs the commit's own route and keyframe plan for every member with its real offset, so any refusal refuses the whole group with nothing written and one toast attributed to the refusing member. The plan is not reused in the commit: earlier members' writes change the file under it, so each member re-plans against the fresh tween, from the same planValueEdit.
* chore(studio): the fetch lint rule also catches window.fetch, globalThis.fetch and self.fetch
* docs(studio-server): set the credentials flag before Studio loads; a mounting host injects it
* test(studio): the undo socket test needs six pinned videos under Vite and checks each undo restores
* fix(studio): Studio leaves cookies out of its requests only when served from loopback
* fix(studio): undo takes back the first edit, and the first save never waits on media
* fix(studio): undo of a move whose save failed stays undone and steps back no older edit
* fix(studio): a style set by its JavaScript name saves under its CSS name
* test(studio): the undo socket test holds round 0 to a full restore too
* fix(studio): the host revert keeps returning what shows the edit again
* fix(studio): an entrance rewritten at the playhead still shows its start state before it starts
* fix(studio): undo takes back the edit pending at the key even after a later edit claims
* chore(studio): lift the late-write quarantine now that undo never waits on videos; format
* test(studio): pin an entrance's start render on the written tween, not a happy-dom GSAP run
* revert(studio): leave entrances as on main, immediateRender does not render a keyframed start
This reverts commit ee9d130d3c and its written-vars test.
* revert(studio): undo goes back to the newest claim after the key, as on main
This reverts commit 5b9009a823.
* revert(studio): first-undo history opening moves to the next PR
This reverts commit 4e1e75d7f4.
* revert(studio): the undo socket test exempts round 0 again until first-undo lands
This reverts commit 1f8f97047c.
* test(studio): bank the edit accuracy cases this PR makes pass
* refactor(studio): share one project file fetch between the plain and optional reads
* test(studio): share the WebMCP host shim between the browser gates
* test(studio-server): share one stub adapter across the route tests
* refactor(studio): type the preview slot props from the NLE context
* test(studio): share the setup the new editing tests copied between files
* refactor(studio): split playhead tween picking and undo path merging into small tested helpers
* test(studio): pin when a resize at the playhead also writes the anchor move
* test(parsers): pin rewriting a tween in place as keyframes
* chore(studio): cut comments the new helper names already say
* fix(studio): composition thumbnails keep two fetches in flight, as the document lifetime fix expects
* fix(studio): a playhead edit parses a tween not yet played and matches only its own tween
* fix(parsers): a new position argument goes after the last argument, so trailing commas stay valid
* fix(studio): a second undo pressed during one save steps back to the edit before it
* test(studio): pin the slot an aborted thumbnail frees and the shared tween refusal
* refactor(studio): split the unplayed-tween parse into small helpers and name the claim release
* fix(studio): a playhead edit keeps the tween's default ease, delay timing and loop settings
* fix(studio): a group move refuses layers animated in two files and plans at the real drop
* style(studio): format two test files
* refactor(studio): check a group's files inside the preflight helper
* refactor(studio): move the group preflight checks out of the editing hook, under 600 lines
* fix(studio): read an unreached tween without redrawing other layers, and compare write files
* test(studio): use the shared selection helper in main's keyframe-at-time telemetry test
* chore(studio): fix a comment that read as a repo path and cut one added comment
* fix(studio): read a later tween's start as playback does, and count every keyframe add
* fix(studio): two undo presses during one save step back two edits, not one twice
* fix(studio): an undo that succeeds by any route retires the claim on the edit it undid
* fix(studio): a claim is never handed back to an edit another undo has already undone
* fix(studio): an undo picks its claimed edit at the press and skips it once undone
* fix(studio): a press refuses, as main did, once its edit is undone and a newer edit exists
* fix(studio): an unplayed keyframe tween clears every keyframe's channels before it is read
* fix(studio): reading an unplayed tween puts back every transform value its clear removed
* fix(studio): reading an unplayed tween restores each layer's whole style and GSAP state
* fix(studio): reading an unplayed tween skips plain-object targets and restores attributes
* fix(studio): shared tween-target filter uses GSAP's own styled-element test
* fix(studio): match a parsed tween on any target, and check a later keyframe channel's start
* docs(studio): say the unplayed-tween read restores styled elements only
* test(studio): a drag on a fromTo start shows the dropped x through the runtime seek
* perf(studio): keep timeline bulk actions under a second, hand-move at most 3 clips
Dragging or keyboard-moving a selection of 4 or more clips is refused with a
toast pointing at the prompt box; 1 to 3 clips move as before.
Root causes of the multi-second waits on large projects:
- every selected clip fired its own source-existence probe; they now leave as
one request per file
- a timeline delete sent one request per clip, each re-parsing the file; it is
now one request that parses once
- each click on a clip from another file re-fetched GSAP data for every
composition; covered files are kept and unchanged files answer 304
- any root index.html write re-captured every composition thumbnail; only a
root head edit does now, since a scene page borrows nothing else from it
Adds timeline-bulk-actions.mjs, a browser benchmark of select-all, move 3 and
delete-all on a real project.
* chore(studio): trim the bulk benchmark header to the comment limit
* refactor(studio): tidy the bulk-action fixes after review
Root head memo is keyed by the root file's content so every subscriber of one write gets one answer, and a thumbnail request can no longer overwrite it. The GSAP read reuses the shared content version as its ETag.
* fix(studio): keep a head edit's thumbnail refresh when a later write replaces it
File changes coalesce to the newest while a drain is in flight, so the affected-composition lists are merged instead of replaced. A failed GSAP read stays unloaded and is retried, and the GSAP read's ETag is salted with the parser version.
* feat(studio): cap hand-resizing a multi-selection at 3 clips like moving
Dragging a resize edge with 4 or more clips selected is refused with the same toast as a move; the rule is one constant and one helper shared by pointer move, pointer resize and keyboard pickup.
* fix(studio): keep a head edit's thumbnail refresh through a replaced conflict
A newer blocked change replaces the held one, so it now inherits the thumbnails the held one owed.
* fix(studio): keep a held head edit's thumbnail refresh when a later write saves cleanly
The clean-save path and the failed-cleanup path now merge the held blocked change's affected compositions, and the blocked-state merge runs inside the state update so two conflicts in one tick cannot lose the first.
* chore(studio): keep useTimelineClipDrag under the 600-line cap
* feat(studio): cap by the clips an edit would change, and cut the added comments
The 3-clip rule counts the clips a move or trim would write to: a locked clip swept into a move stays put, and a trim some member cannot take edits only the grabbed clip. Comment share is back at or under the base in every changed file.
* fix(studio): clear fallow audit and comment length on the multi-select cap
* fix(studio): keep bench script comments under the block limit
* fix(studio): keep the failed file's payload when another file's snapshot delete fails
* test(cli): time history turns on one injected clock instead of real waits
* fix(studio-server): end an idle history window by its own clock, not a bare timer
* fix(studio-server): cap a history window's idle timer at Node's timer range
* fix(studio): show each timeline row its own layer instead of a grey bar
A row got a frame only if it spanned under 92% of the film and its id did not
look like a backdrop; every other row drew a plain grey bar. Every element row
now gets its frame, and the capture fades the row element's siblings first, so
the frame shows that layer and not the whole film. Studio's dev server and the
CLI share one capture helper; Studio's own copy is gone.
* fix(studio): read timeline rows from any realm so each keeps its selector
* refactor(studio-server): always isolate the element in a thumbnail clip
* fix(cli): undo a thumbnail's isolation before its page takes the next capture
* chore(core): generate the dom-realm export and trim thumbnail clip comments
* fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys
* fix(studio-server): keep faded siblings in a page-owned list so restore is exact
* feat(studio): draw a text layer row as its own live text instead of a captured picture
* refactor(studio): move timeline text reading into its own module
* fix(studio): keep text rows readable and current, and leave painted layers as pictures
* fix(studio): refresh a canvas-edited text row by its own identity and source file
* fix(studio): refresh a canvas-edited text row known only by its data-hf-id