* fix(studio-server): a Studio element edit keeps a change that lands while it is applied
applyFileMutations refuses a file that changed since the caller read it even without a version (FileChangedError). The element-patch routes, including the patch-element route every style, text and attribute save uses, re-read and re-fold their patches when another writer lands mid-edit, so both changes are kept, and answer 409 after three raced tries. Every other route that writes through writeMutationResult answers 409 instead of overwriting. The batch route now writes atomically.
* fix(studio-server): element edits re-check after their backup and insert-composition refuses a changed file
* fix(studio-server): an element edit refolds when another save lands during its backup
* test(studio-server): the insert race test uses a page with a composition to insert into
* fix(studio-server): an edit to a linked file updates the link's target and keeps the link
* fix(studio-server): id stamping never writes through a link, and link writes keep their receipt
* fix(studio-server): receipts match across a linked project folder, before and after a delete
* fix(studio-server): a restore into a folder deleted outside the app keeps its receipt
* docs(studio-server): say the real-path fallback climbs to the nearest existing folder
* fix(studio-server): edits pin their target when checked, so a retargeted link can't redirect the write
* fix(studio-server): split cuts pin their target and duplicates never follow a planted link
* docs(studio-server): keep the real-path helper's comment under the line limit
* fix(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(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
* 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
* 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.
* 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
* 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
* fix(studio-server): preview keeps its base URL when a script mentions a <base> tag
* test(studio-server): pin the sub-composition preview's base check
* fix(studio-server): find a <base> with the HTML parser, not a regex
* fix(studio-server): serve projects whose names contain @ % # & or ?
Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.
* fix(studio-server): thumbnails and waveforms stay inside the project for any name
The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.
* fix(cli): play and present serve assets whose names contain @ % # & or ?
Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.
* fix(studio-server): a thumbnail request for the project folder answers 404
An empty thumbnail path resolved to the project directory and crashed reading it (500).
* fix(studio-server): read the thumbnail's composition with one open
Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.
* fix(studio-server): stop mislabeling two more read-route failure shapes
Follow-up to the stale-tree investigation this week — two more cases
where the read route answers with the wrong shape for what happened.
A path a listing (`walkDir`) can show but this route cannot serve, now
correctly reported:
- **Replaced by a directory.** `existsSync` passes, `readFileSync` throws
`EISDIR`, and Hono answers plain-text "Internal Server Error" — not
JSON, and not a reason. `statSync(...).isFile()` after the exists check
turns this into `404 { why: "not_a_file" }`, same shape as any other
missing-file 404 the client already handles.
- **A dangling symlink.** `isSafePath`'s `realpathSync` throws on a link
whose target does not exist, and (by design — see its comment) that
fails closed as unsafe, same as a real traversal attempt. Correct for
containment, wrong for the response: a broken link scans as an attack
when it is broken plumbing. Added `isDanglingSymlink`, checked only
after `resolveWithinProject` has already refused the path, so nothing
about what is permitted changes — only which `why` a refusal carries.
A symlink resolving to something real outside the project is still
`outside_project` 403, unchanged; pinned by test so this can't
regress into loosening containment.
Tests: dir-in-place → `not_a_file`; dangling symlink → `dangling_symlink`;
symlink-to-outside-project still → `outside_project` 403 (the guard the
new branch must not weaken). 102/102 in the touched suite, 247 green
across studio-server routes overall. oxlint, oxfmt, tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio-server): stop dangling_symlink from leaking outside-project existence
Review on #4363: `isDanglingSymlink` ran on the *lexical* path even when
`resolveWithinProject` had already failed — so a path reached via `../` that
happened to be a dangling symlink out there, or a leaf reached through an
escaping directory symlink, came back `404 dangling_symlink` instead of `403
outside_project`. Worse: an in-project symlink pointing outside the project
got `404` if the outside target was missing and `403` if it existed — a
small oracle for "does this path outside the sandbox exist."
`isDanglingSymlinkInProject` now requires containment on both ends: the
link's own directory must resolve inside the project (`isSafePath(dir,
dirname(lexicalPath))`), and its target, read via `readlinkSync` and
resolved against the link's directory, must also resolve inside the project.
Only then does "target exists nowhere" get the new label — anything that
touches outside the project keeps the plain `outside_project` 403, with no
distinction based on what's actually out there.
4 new regression tests: a dangling symlink reached by `..` (403, not 404),
a dangling leaf behind an escaping directory symlink (403, not 404), and a
same-status check across a missing vs. existing outside target (both
`outside_project`, not one of each). files.pathSafety.test.ts 25/25,
files.test.ts 80/80, oxlint/oxfmt/tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio-server): close a TOCTOU CodeQL flagged on the not_a_file check
CodeQL (js/file-system-race, high severity) on #4363: the new `not_a_file`
check stat'd `res.absPath` by path, then a separate `readFileSync` reopened
the same path — a directory-for-file swap between those two calls could
land a stale answer. Opens the file once instead and checks/reads through
that single descriptor (`fstatSync(fd)` then `readFileSync(fd)`), so the
check and the read are provably on the same inode; a swap after open can't
affect either. `openSync` on a directory still succeeds (POSIX allows
O_RDONLY there), so the `not_a_file` 404 still fires the same way; opening
a genuinely missing path still throws and hits the existing "optional" vs.
plain 404 branch unchanged.
105/105 in files.pathSafety.test.ts + files.test.ts, oxlint/oxfmt/tsc clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(studio): serve preview assets by URL instead of inlining them as base64
The Studio preview route serves project files under a base href, but both preview adapters
called the bundler with its self-contained defaults, so every image and font was inlined
once per reference. One exported option set now owns how a preview bundle is built.
* test(studio): cover poster, srcset and sub-composition preview asset URLs
Also types the Vite adapter bundler with BundleOptions, so the shared preview options are
checked against the real option set.
* fix(studio): tag a preview asset for revalidation only once it has settled
Preview assets are now served by URL, so the asset route serves every image and video. It tagged
them by mtime and size; two same-size writes inside one filesystem tick share an mtime, and copy
tools (cp -p, rsync) set mtime back, so a browser could keep stale bytes after an edit. The route now
tags a file by inode, change time and size, and only once its last change is three seconds old; a
younger file is served without a tag and refetched.
* perf(studio): refresh only the edited scene's thumbnails and let the preview load first
One scene edit regenerated every sidebar and timeline thumbnail, and those renders starved the new preview's requests on the Studio server. The thumbnail cache now keys on what a composition renders from, the server names the compositions a write affects, only those thumbnails refresh, and composition thumbnails wait while a shadow preview reload is in flight.
* test(studio): pin scene-scoped thumbnail reuse and the preview-reload hold
Memoizes the scoped thumbnail signature on the project signature so requests keep reusing the adapter's cached one, keeps the fresh-signature check that catches a write the watcher has not reported yet, and adds tests for sibling-scene reuse, root-head invalidation, the affected-composition set, per-path revisions and the scheduler hold.
* refactor(studio): share the file-change payload guard
* fix(studio): match thumbnail revisions for scenes mounted with a ./ path
* perf(studio): keep signature cache keys constant-size
* style(studio): format thumbnail-scope changes
* docs(studio): trim thumbnail input comments
Two residuals from the cutover-dashboard work, both found in the data the
first pass shipped (0.8.61/0.8.62).
`absent_or_empty` was one label over two different failures: the route
answers `content: ""` both from the `optional=1` shim (nothing resolved at
that path) and for a real 0-byte file. The route now marks the shim with
`missing: true`, and the read path with `missing: false` so the field's
PRESENCE is what tells the client this server draws the distinction — a new
server's empty file would otherwise look exactly like either case from an
old one. Client splits them into `absent` and `empty_file`; a server without
the field still reports `absent_or_empty`, so the existing series stays
honest instead of silently folding into one of the new ones.
Why it is worth splitting: on 0.8.62, 75 tabs hit this class and not one of
them ever landed an SDK edit afterwards — it is terminal for the tab, while
the tab keeps playing back and navigating, so nothing surfaces to the user.
Every measured case with a loaded tree carries `path_in_tree: true`: a file
the tree lists and this read cannot get. Which of the two causes that is
decides the fix, and until now the event could not say.
`res.json()` at the end of the read was unguarded, so a 200 carrying HTML —
an SPA fallback or a proxy answering in the route's place — rejected out of
the function entirely and the effect's outer catch filed it as
`stage: "open"`, which asserts the COMPOSITION failed to parse. Observed in
production on 0.8.62: `Unexpected token '<', "<!-- /*!"... is not valid
JSON`. That is the third label to make the same wrong claim, after the
fetch-rejection split in #4240 and #4269. Now `reason: "invalid_json"` with
the response's content-type.
`path_in_tree` now rides all three empty-read reasons, not just the combined
one, so the split keeps the signal that justified it.
Tests: 4 new (absent / empty_file / combined-label fallback / non-JSON 200)
plus a server test pinning `missing` on both answers. 1172 tests green
across studio hooks + studio-server routes; oxlint, oxfmt, tsc clean.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the fixes from the 2026-09-21 cutover dashboard read
(plans/sdk/2026-09-21-cutover-dashboard-fixes-plan.md):
- sdk_cutover_failed now carries `family` and `error_kind`
(network/conflict/http/sdk). The one production gate event was a raw
`TypeError: Failed to fetch` from an unwrapped fetch (readProjectFile /
writeProjectFile's preflight), which an instanceof-only classifier would
still file as "sdk" and trip the rollback gate — added a message-based
fallback for the browser's fetch-rejection strings.
- studio:sdk_session_unavailable's `network` reason now carries `elapsed_ms`
and `hidden` (document.visibilityState at rejection), separating a
near-instant policy block (CSP/PNA/extension) from a dropped connection
the tab or server going away mid-flight.
- The ownership-race branch in useSdkSession's open effect now emits
sdk_session_superseded instead of sdk_session_unavailable{stage:ownership}
— it's a benign race (identity moved on mid-open), not a broken
precondition, and was being counted as one on the dashboard.
- Root-caused and fixed the read-404 "storm": /api/events (CLI host) and
the vite-dev hot-reload channel are one connection per SERVER, not per
project. A stale tab left open after `hyperframes preview` restarts on a
different folder (same port) received every save the CURRENT project
made, reloading its preview (which then 404s) and re-reading its own
composition on each one. Both transports now carry `projectId`; the
client-side coordinator drops a delivery whose projectId doesn't match
the tab's (absent field still passes, for one release of skew).
- studio-server's file routes report `why` on 403/404: `project_dir_missing`
(new — the CLI host's static resolveProject kept answering for a project
whose folder was renamed/deleted under a running server, which used to
fail closed as an indistinguishable path-traversal 403) vs `nul` vs
`outside_project`.
- absent_or_empty reads now carry `path_in_tree` (against the caller's
loaded file tree). Instrumentation only, per the graveyard page
(clearing-activecomppath-on-tree-absence) that refuted the "clear
activeCompPath" fix this was the proposed next step for.
Tests: unit coverage added for every change (sdkCutover.test.ts,
useSdkSession.lifecycle.test.tsx, useExternalFileChangeCoordinator.test.tsx,
studioServer.test.ts, files.pathSafety.test.ts). oxlint/oxfmt clean,
tsc --noEmit clean on studio/studio-server/cli.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(studio): typed audio values, clip fades, audible video drops
Audio rows in the media inspector (Volume, Speed, Media start) take a
typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter.
Speed presets are hidden on <audio> clips.
Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the
preview runtime (per-tick gain), the render mixer (afade after volume) and
Studio (Fade in / Fade out rows, and drag dots at the top corners of audio
clips and audible videos). Linear on both sides so preview and render
agree; fades that outrun the clip scale to meet inside it.
Dropped videos with an audio stream now land with data-has-audio="true"
instead of muted, so the mixer keeps their sound. The media metadata
endpoint reports hasAudio from the ffprobe call it already makes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(audio): one owner for fades, dB, and afade-after-volume
Illegal fade values become no-fade at fadeGain; typed inspector input
still returns null so the readout stays red. Master and group mixes
share one filter chain with volume then afade. Fade-out stays anchored
to the trimmed clip end.
* fix(studio): share audio helpers and bound typed media values
* fix(studio): reserve sibling fade duration when editing
---------
Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>