mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 20:37:30 +00:00
sync/hyperframes-codegen-aeb39e2e
266
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9c56240bf5 |
fix(studio-server): probe each proxied clip once instead of on every request (#4557)
* fix(studio-server): probe each proxied clip once instead of on every request * fix(studio-server): retry a failed codec probe and use the server probe cache * fix(studio-server): probe again when a clip symlink is repointed * test(studio-server): pin equal mtimes in the symlink retarget test |
||
|
|
984ad28c7c |
fix(core): clips that meet within float rounding count as meeting in edits and playback (#4535)
* fix(core): clips that meet within float rounding count as meeting in edits and playback * fix(engine): hand a shared clip boundary to the incoming clip in export, as preview does |
||
|
|
dce0acd6d2 |
perf(studio): tag each scene's parts in the preview so one scene can be swapped (#4503)
* perf(studio): tag each scene's parts in the preview so one can be swapped With sceneParts (Studio preview only) the bundler tags each top-level scene's host, styles and inline scripts with data-hf-scene; nested scenes belong to their top-level scene. The preview route then adds a manifest: a hash of everything outside the scenes and one hash per scene, so the preview can tell when a rebuilt document differs only inside some scenes. Renders are unchanged. * fix(core): keep scene order in the preview manifest and mark unswappable scenes A moved or reordered scene now changes the shared hash, a nested scene keeps its @import first, and a scene whose script leaves work running is marked so edits to it take the full reload. * refactor(core): keep the scene swap side-effect check in the bundler * fix(core): refuse swapping a scene that runs its own script file A swap never re-runs external scripts, so a scene file keeps driving the replaced nodes. Library URLs stay swappable. * fix(core): refuse swaps for async scene setup and keep the shared @import first Scenes that finish building after an await or a .then, animate elements directly or touch the page head and body now take the full reload, and splitting out scene styles no longer leaves the shared import late. * fix(core): refuse swaps for scene scripts that throw when run again Custom element and CSS property registration, sockets, draggables and infinite tweens now refuse too, and ticker or animate inside a name no longer does. * fix(core): refuse swapping a scene that runs a module script or import map * fix(core): run a scene's local script file in source order in the preview * fix(core): refuse a scene swap only for defineProperty on a global * refactor(core): cut scene swap comments to what names and types do not say * fix(core): find the head tag for the scene manifest in linear time * fix(core): leave a document with an unclosed head tag without a scene manifest * fix(core): keep preview scene parts in render order and refuse scene-only script URLs * fix(core): keep render @import precedence in the preview and swap only known libraries * fix(core): keep preview scene styles where a render merges them and resolve library URLs * fix(core): scene swap refuses standalone gsap tweens and root scripts that hold scene nodes * fix(core): refuse to swap exactly the scenes a script outside them selects * perf(core): the preview scene scan looks up ids and classes once instead of querying per string * docs(core): trim three scene-parts comments to the facts a reader needs * fix(core): refuse to swap a scene a script outside it reaches by tag name * fix(core): keep scenes swappable when a script outside them only creates elements by tag * test(core): check scene tags on elements, not in the inlined runtime's text * fix(core): refuse swaps for chained scene timelines and any-case or universal root selectors * fix(core): refuse swaps for root selectors through tags above the scenes * fix(core): put the scene manifest inside the head when a head attribute holds a quoted > * fix(core): place the scene manifest with the shared head scanner instead of a regex |
||
|
|
7eb6430e4e | fix(studio-server): history refuses once closed and reopens when a new project takes its folder (#4537) | ||
|
|
89fe898cd5 |
fix(core): an added sub-composition keeps its declared variable defaults when bundled (#4539)
* fix(core): an added sub-composition keeps its declared variable defaults when bundled parseHTMLContent only treated input as a full document when it started with a doctype or <html>. Files installed by `hyperframes add` start with a marker comment, so they were wrapped as a fragment in a new <html> and lost their own <html data-composition-variables>. Leading comments are now skipped first. * fix(studio-server): an installed block's preview keeps its <html> variables isFullHtmlDocument moves to core beside parseHTMLContent and both callers use it, so the standalone preview of a file that `hyperframes add` installed (a marker comment above the doctype) is treated as the full document it is. The leading-comment skip also handles an empty `<!-->` comment, and `<html-card>` style elements no longer count as a document. * fix(core): a doctype written without a space still reads as a full document |
||
|
|
162de1a6ab | fix(studio-server): a gesture sent without an idle time holds until another key (#4513) | ||
|
|
9faade09e7 |
fix(studio-server): undo of a studio edit keeps a save that landed just before it (#4524)
* fix(studio-server): undo of a studio edit keeps a save that landed just before it * fix(studio-server): undo keeps a save across several studio writes and a whole-file follow-up * fix(studio-server): undo claims survive studio writes that loop back and stale write receipts * fix(studio-server): a claim forgets only the write receipts it walked through * chore(studio-server): drop a claim comment the comment ratchet counts against the stacked base * docs(studio-server): keep the claim walk comment under the line limit * refactor(studio-server): pull the pending-hash keep set out of the budget fold |
||
|
|
7c017a08de |
feat(studio-server): history can copy the files before or after any change into a folder (#4493)
* feat(studio-server): history can copy the files before or after any change into a folder ProjectHistory.checkout(entryId, side, emptyDir) writes the project as entry entryId found it (before) or left it (after) into an empty folder, leaving the project and its history untouched, so a host can play a change's before and after side by side. The entry's own files come from its recorded change and the rest from the entries logged before it, so a change stays exact when entries were logged out of write order. * fix(studio-server): checkout refuses an unreadable folder and every replay names its side * fix(studio-server): checkout lays out only trees that existed and never writes into the project An entry's tree now includes later-logged entries whose writes it continues (an outside change that turned a file into a folder before an agent turn edited inside it), each file replayed in the order its versions chain. Checkout refuses a folder inside the project (symlinks included), refuses a tree with a path that is both a file and a folder before writing anything, and empties the folder again if a copy fails. manifestAt is back to main's replay. * fix(studio-server): history logs every change in the order it was written A window now settles pending writes, the held claim and the outside group before it opens, and commits a held claim before its own entry when it closes, so the log order is the write order and every replay (checkout, restore, peek, reopen) sees files as they were. Removes the dependency replay added for checkout. Visible change: an outside burst that spans an agent turn is two entries, and a drag held across a turn ends at the turn. * fix(studio-server): checkout recognises its project folder whatever letter case the path uses * fix(studio-server): overlapping turns, operations and claims log every pending change in write order settle() (a window opening, an undo, restore or step) and every claim commit the held claim, the outside group and each open window's pending changes as a part, oldest first; the windows stay open. A held drag ends when another write is pending. One commitPending owner replaces the claimed-paths-only cut. * feat(studio-server): undo is per author, and a turn part undoes alone keeping later edits * test(studio-server): pin per-author redo and name a person's open window on Cmd+Z * refactor(studio-server): split Cmd+Z target lookup and claim steps under the audit's thresholds * fix(studio-server): a file replaced by a folder logs its removal before the folder's files * fix(studio-server): undo across a file/folder swap works, and a later swap stays the person's * fix(studio-server): an undo blocked by a file added since refuses before touching any file * fix(studio-server): an undo never writes through or replaces a link standing in its way * fix(studio-server): undo and redo across a letter-case file/folder swap work in every direction * fix(studio-server): a letter-case file/folder swap after an idle turn stays one checkable entry * fix(studio-server): decide letter-case folding from the project's own disk * docs(studio-server): keep three history comments under the line limit * fix(studio-server): checkout checks and fills one resolved folder, so it never overwrites a nonempty one |
||
|
|
0f14691c7b |
fix(studio-server): studio element edits keep a save that lands while they apply (#4510)
* 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 |
||
|
|
3746c9821a |
fix(cli): timeline ids never writes over a save that lands while it stamps (#4494)
* 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. |
||
|
|
b2a449df8e |
fix(studio-server): stamping ids at open never reverts a write that lands meanwhile (#4491)
* 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 |
||
|
|
2734ee2c02 |
feat(cli): hyperframes history lists, undoes and restores a project's recorded changes (#4449)
* 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 |
||
|
|
f1f370e96c | fix(studio-server): inject page tags at the document's own head and body, not inside a script (#4484) | ||
|
|
31441701fc |
fix(studio-server): one process at a time keeps a project's history open (#4448)
* 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 |
||
|
|
828c434f76 | refactor(studio): drop recordEdit's kind, which the project history never read (#4474) | ||
|
|
11ac9bce69 |
fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files * fix(studio-server): a GSAP save on a served id writes that id to the file too |
||
|
|
36f8bd0e91 |
feat(studio): undo and redo step through the project history, whoever made the change (#4456)
* feat(studio-server): an undo taken from Studio is not mistaken for an outside edit * feat(studio): undo and redo step through the project history, whoever made the change * feat(studio): the dev server keeps each project history, in the same folder as preview * fix(studio-server): an undo that deletes a file reads as Studio's, and dev histories close * fix(studio-server): a claim keeps an agent's earlier write the agent's and takes back Studio's own * chore: allow the removal of Studio's browser edit history files * test(studio): mock the project history in the runtime smoke * fix(studio): step past a jammed undo and say why a step fails * fix(studio-server): an agent turn cut by a claim commits its part before the claim * fix(studio-server): a claim's cut ends a held drag and takes only the cut files out of a turn * fix(studio-server): a held drag ends when someone else changed a file it already holds |
||
|
|
f873734cc4 |
perf(studio): open a large film ready to play in about 4 s instead of 32 s (#4401)
* perf(studio): keep lint off the preview open path The bundler linted every build only to print a StaticGuard warning; a preview bundle now skips it through a staticGuard option, since Studio lints on its own route. That route now lints in the CLI child-process worker the render path already uses, so seconds of parsing no longer stall the preview and every other Studio request on the server event loop. * perf(parsers): parse identical GSAP source once Studio re-requests the parsed animations of unchanged source on every open, and several lint rules parse the same script in one run. A small memo keyed by the script text serves repeats; each caller gets its own copy. * perf(studio): build each preview document once per content version The preview route rebuilt the document for every browser without a cached copy. It now serves one build per ETag from memory, and the CLI keeps the last build on disk salted with its own file, so a restarted server serves the previous document without stale code. * perf(studio): build the opening film's preview while the browser starts The preview server built the document only when the player asked for it, after Studio had booted, so a first open waited about a second of idle server time. The CLI now requests the opening project's preview right after listening, and requests that arrive while a build runs share it instead of starting another. * perf(studio): request the preview document at page load Studio asked for the preview only once the player mounted, about 640 ms into a first open. It now requests it the moment the page loads, and again when the hash names another project, so the build or cache read overlaps Studio's boot. * feat(studio): show the cached frame 0 while the live preview boots Studio covers the live preview with frame 0 as the thumbnail route last rendered it, sized from the image so the stage does not shift, and removes it when the live frame is ready to show. The request is cache-only, so a miss never starts a render on the open path; the missing poster is rendered after the live frame shows, for the next open. * perf(cli): start Studio before the startup lint finishes hyperframes preview linted the whole project before it listened, 4 to 6 s on a large film, and did so in --json mode where the result is discarded. The lint now runs in the CLI worker while Studio starts and prints its summary when done; --json skips it. * perf(studio): render thumbnails from one loaded page, after the preview boots Every thumbnail opened a page and loaded the whole composition to shoot one frame, and the Compositions card asked for one while the live preview was still booting. The server now keeps one loaded page per preview document and seeks it per frame, reloading only when the project changes; card thumbnails wait for the project first ready preview. * perf(studio): parse GSAP animations after the preview boots Studio asked the server to parse the composition animations while the preview was opening; on a fresh server that cold parse ran on the event loop ahead of the preview build. The request now waits for the live preview to boot, or to fail loading, since only keyframes need it. * perf(cli): find orphaned browsers with one process-table read preview ran pgrep three times and then ps once per matching browser, all synchronously before listening: about half a second on a machine running other headless Chrome. One ps listing now finds every orphan in ~20 ms. * perf(studio): keep lint off the boot Opening a project started two lint processes while Studio booted: the terminal's startup lint and Studio's background lint, ~4 s of CPU next to the boot. Studio's background lint now waits until the live preview has booted, and lint processes run at the lowest CPU priority so they only take idle CPU. * perf(studio): open the editing session after the preview boots Opening a project read the composition source and parsed it for the editing session (linkedom plus hf-id assignment) on the main thread while the live preview was booting on the same thread: ~300 ms on a large film. The session now opens once the preview has booted; nothing the first frame shows needs it. * perf(studio): park the editor overlays until the preview boots The overlay frame loop polled preview geometry and off-canvas indicators from the first frame, forcing layout on a preview document that was still loading, on the thread its boot runs on: ~200 ms before the first frame on a large film. The loop now skips its subscribers until the preview has booted, then wakes. * perf(studio): treat the preview as booted once it can play Work deferred until the boot (editing session, lint, overlays, GSAP parse, card thumbnails) started at timeline-ready, which fires before the asset gate, so the editing-session parse ran inside the wait for play. The Player now latches the boot when the preview can show and play, or fails to load. * fix(studio): keep a left project's deferred work out of the one now open Deferred open-time work (source parse, background lint, editing session) waited on one global boot flag, so leaving a project before its preview booted let the next project's boot run the old project's work and write its results. The wait is now per project and resolves false once another project opens. A preview stuck without an error stops deferring that work after 5 s. * perf(cli): let the play page revalidate instead of refetching hyperframes play served the composition, its assets, the player and the runtime with no validator, so every reload refetched everything, the mp4 in full three times. They now carry an ETag and stay no-cache, so unchanged bytes cost a 304 and an edit is still seen. * fix(cli): serve an edited text asset even when its size and mtime match The play page tagged every asset by mtime and size, so a same-size rewrite within one filesystem tick kept the old tag and the browser kept stale bytes. Text assets now get a content-hash tag; media keeps the mtime and size tag Studio already uses. * fix(studio): close idle thumbnail pages so an idle Studio runs no composition Kept-loaded thumbnail pages stayed open for the life of the server, each running a live composition, so an idle Studio cost server CPU and memory that main does not. A page now closes after 10 s unused, and a page is closed only after the frame still running on it finishes. * fix(studio): stop a first open from reloading the preview once per scene file Studio stamped hf-ids into a composition the first time that composition was served, so opening a film of external scenes wrote to index.html and every scene file mid-session. Each write reached Studio as an outside edit and reloaded the preview, which failed for a large film. The server now stamps every composition once before it starts watching the project. * fix(studio): ignore play until the preview can play, so it is never undone Space (and any play() caller) could start playback before the preview frame finished loading. Its load step then paused and rewound to the restore point, so the play was silently lost. play() now waits for timelineReady, the same gate the Play button already uses. * fix(studio): hold play on a composition switch until the new preview loads Readiness was reset only when the project changed. Drilling into another composition kept the old ready flag while the new preview loaded, so Play or Space could start a play that its load step then undid. * refactor(studio): split background lint and thumbnail page reuse into single-purpose steps Keeps the boot wait in the background lint only, moves page lookup and idle close out of withPage, and drops a suppression the startup worker no longer needs. Behaviour unchanged. * chore(cli): declare the render-setup worker as a fallow entry It is spawned by path from cancellableProcess.ts, so it has no import-graph referrer; the entry list is where the repo records that, replacing the per-file ignore removed in the previous commit. * fix(cli): close a thumbnail page whose load fails The page never leaves the promise that loads it, so when the load throws (a WebGPU composition with no adapter, or the navigation timeout on a big film) nothing could close it. * fix(cli): match text assets by exact type and keep Range on them The pattern anchored only its first alternative, and text assets skipped buildRangeResponse, so a ranged request for a stylesheet or SVG got a full 200. * test: open the next project as Studio does, and read the stamped file once The absent-read guard test switched project without beginning its timeline session, so the per-project boot wait held the read. The stamp test checked then read the same file. * test(cli): rewrite the scene file without opening it first Opening the file by path counted as a check before the later read of the same path. * perf(cli): take a thumbnail's version from the cached project signature Each thumbnail walked the project and hashed every text file for its page version; the adapter already caches that signature and clears it from the watcher. An aborted request now also stops before its screenshot. * fix(studio): keep Play off until the preview's load step has run Play could enable before the preview document's load event, from the runtime's timeline message or from the blank page's load step settling on the preview's first message. The load step then paused and rewound the preview, so a Play pressed in between did nothing; a film with large images loads late enough for this to hit a cold open. Readiness now waits for the document's load step, whichever path asks for it. * refactor(studio): move the preview poster's state into usePreviewPoster NLEPreview.tsx passed the 600-line limit once main grew it; the poster's state and callbacks belong beside PreviewPoster. No behaviour change. * fix(cli): take an earlier thumbnail on a fresh page A composition need not draw the same frame when seeked back, so a kept page that had shown 20 s drew a different 10 s thumbnail than a fresh load. A kept page now only seeks forward, as a render does; an earlier time loads a fresh page. * perf(cli): close a kept thumbnail page a second after its last frame Held for 10 s, the page went on running its composition after the open's thumbnail burst, about 5 % of a core in the thumbnail browser while Studio sat idle. A burst's requests arrive back to back, so it still loads the page once. * fix(studio): count the preview as booted at its first frame The boot waited for every video and audio in the preview to finish buffering, which on a project with media took until the 5 s fallback. The thumbnails, editing session and lint that wait for the boot then started seconds late and kept an idle Studio busy. They now start once the first frame is up; media finishes buffering alongside. * perf(studio): render one thumbnail per composition for its card and clip A composition's sidebar card asked for its frame at 3.00 s and its timeline clip for the clip's midpoint, so opening a project rendered every scene twice. The clip now asks for the card's frame (the midpoint still, for compositions under 3 s), and the thumbnail cache serves both from one render: 20 scene renders drop to 12 on a 12-scene project. * chore: keep each changed file's comment share at or under main's The comment ratchet counts the comment lines this branch added. Each is cut, or kept as one line where it states an invariant (the GSAP parse cache returns copies; the boot starts waiting work at the first frame). The orphan-cleanup docblock is condensed and now names the process-table read it uses instead of the pgrep scan it replaced. No code line changed. * fix(studio): keep a 3 s clip off its end frame and give card and clip one encoded URL A composition exactly 3 s long asked for its thumbnail at 3.00 s, its end instant, which after a fade-out is an empty frame; compositions of 3 s or less now use their midpoint. The card built its thumbnail URL from the raw path while the clip encoded it, so names with & + [ or % asked for two different URLs, and a % in the name made the card's request fail on the server; both now encode. The readiness comment says the boot latches at the first frame, as it has since the boot moved. * fix(studio): encode the card's live preview URL too, and document the bundler's static guard The card's hover preview built its URL from the raw composition path, so a % in the file name made the preview route fail to decode it. The card now builds its composition URL in one place, encoded, for both its thumbnail and its hover preview. The static-guard bundle option keeps a one-line doc (its default and what it warns about); a neighbouring docblock is rewrapped to keep the comment share. * test(cli): mock studio-server once so the signature spy and the history hook both apply The main merge brought a second vi.mock of @hyperframes/studio-server into studioServer.test.ts; the later one replaced the first, so createProjectSignature was no longer a spy and the cached signature test read .mock of undefined. One mock now carries both overrides. |
||
|
|
177464b4c1 |
feat(cli): preview keeps project history, and Studio can claim its own edits (#4437)
* 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
|
||
|
|
56c48a79be |
feat(studio-server): every write to a project is kept in a history that can be undone or restored (#4431)
* 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 |
||
|
|
6a5fcd4cf2 |
fix(studio-server): preview keeps its base URL when a script mentions a <base> tag (#4426)
* 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 |
||
|
|
fed45bac7c |
fix: serve projects and assets whose names contain @ % # & or ? (#4415)
* 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. |
||
|
|
add6783775 |
fix(studio-server): stop mislabeling two more read-route failure shapes (#4363)
* 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>
|
||
|
|
86fa10ced4 |
perf(studio): show a film's first frame sooner by loading preview assets by URL (#4357)
* 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. |
||
|
|
8cb7d06db6 |
perf(studio): re-render only the edited scene's thumbnails after an edit (#4315)
* 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 |
||
|
|
6e5f460a0c |
fix(studio): split the empty-read reason and stop blaming the composition (#4325)
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> |
||
|
|
9f19d9d325 |
fix(studio-server): write served files atomically (#4285)
* fix(studio-server): write project files atomically * fix(studio-server): isolate atomic write temporary files * test(studio-server): document Windows atomic replacement * ci: allow change detection checkout to finish * test(studio-server): assert atomic stamp cleanup without ESM spy |
||
|
|
24c3e197ef |
fix(studio): cutover gate classification, SSE cross-project bleed, benign races (#4269)
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>
|
||
|
|
839ae426dd |
feat(studio): typed audio values, clip fades, audible video drops (#4250)
* 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> |
||
|
|
4e9059ad59 |
feat(cli): add agent timeline workflows (#4241)
* feat(cli): add timeline agent mutation commands * fix(cli): type timeline a2 mutations * style(cli): format timeline a2 commands * test(cli): cover timeline a2 verbs * test(cli): assert ids keep named refs * refactor: centralize timeline duplicate mutation * fix(cli): narrow set assignment field * style(cli): format timeline mutation exports * refactor(cli): split apply plan decisions * fix(cli): tag apply plan refusals * refactor(cli): move A2 runners beside timeline helpers * fix(cli): remove stale A2 imports * style(cli): format A2 command module * fix(cli): avoid A2 command import cycle * refactor(cli): isolate shared timeline mutation logic * fix(cli): restore timeline mutation command factory * fix(cli): remove stale command-surface remnants * fix(cli): complete shared timeline support extraction * fix(cli): close shared conflict helper * fix(cli): import shared timeline formatter * style(cli): format shared mutation runner * refactor(cli): split mutation command orchestration * fix(cli): keep shared mutation primitives in support module * fix(cli): use shared mutation support helpers * refactor(cli): centralize A2 refusals * style(cli): format A2 refusal helpers * fix(cli): clean shared A2 imports * fix(cli): make A2 runner imports explicit * refactor(cli): isolate apply plan target validation * fix(cli): tag prepared plan edits * style(cli): format plan target helper * fix(cli): validate sequential plans and duplicate ids * test(cli): account for stamped composition id * fix(studio): stamp ids on patched html * fix(studio): preserve no-op patch bytes * test(studio): expect stamped patch markup * fix(studio-server): duplicate elements in nested templates * refactor(cli): share timeline mutation receipts * fix(studio-server): harden duplicate and no-op mutations * fix(studio-server): reduce duplicate helper complexity |
||
|
|
f92d017b99 |
feat(cli): add timeline editing verbs (#4221)
* feat(cli): add timeline editing verbs * chore: satisfy fallow complexity gate * test(cli): cover snap rounding at known fps * test(cli): cover snap rounding at known fps * test(cli): cover snap rounding at known fps * test(cli): cover snap rounding at known fps * fix(cli): derive split ids from stable refs * fix(cli): remove timeline gate blockers * fix(cli): close freshness and output gate gaps * fix(cli): close remaining timeline review findings * refactor(cli): simplify mutation decision flow * refactor(cli): split trim and apply paths * refactor(cli): isolate trim validation * fix(cli): satisfy timeline lint gate * style: format timeline mutation files * fix(cli): preserve mutation result types * fix(cli): close timeline receipt and nested bounds gaps * fix(studio): rollback writes that fail during write * fix(cli): bound moved clips by end time |
||
|
|
ff078accda |
feat(studio): say why the SDK session's file read came back empty (#4161)
* feat(studio): say why the SDK session's file read came back empty
`stage: read` was the largest remaining class of SDK-session failures and a
single opaque string covering four different outcomes. It is a hard block, not
a blip: 56 users hit it over a 7-day window and, between them, landed zero
successful SDK edits — while staying active in Studio (3107 playback events,
138 design inputs, 20 commit transactions). Every edit in those sessions fell
back to the server path with nothing recorded, because no session also means no
resolver-shadow event.
readProjectFileOptional now returns a discriminated result instead of
`string | undefined`, and the telemetry carries the branch that fired:
- unsafe_path — the traversal/NUL guard rejected the path client-side
- http_error — the request failed, with its status
- missing_content — 2xx whose body was not the shape we expect
- empty_file — `optional=1` answers an absent file with 200 + "", which
parses into a session with no elements; previously this
looked like a healthy session that merely went unused
Two candidate causes were investigated and ruled out first, with tests kept as
regression cover in studio-server (projectIdEncoding.test.ts): project ids that
percent-encode (the CLI derives the id from the folder name, so a space or
non-ASCII character produces one) and sub-composition paths whose separator
encodes as %2F. Both resolve correctly today because Hono hands over an
already-decoded `c.req.path`. The decoded/encoded seam in resolveProjectPath is
a real latent hazard, hence the tests, but it is not this bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(studio): fold the absent-file read reason into the result union
Review feedback on #4161. `empty_file` was synthesized by the caller after
the helper returned `{ok: true, content: ""}`, so reason attribution was split
across two places and `ProjectFileReadResult` was not the full reason surface.
It now joins the union, and the caller has one branch.
Renamed to `absent_or_empty`: the optional=1 absent-file shim and a genuinely
0-byte file are the same 200 on the wire and cannot be told apart here, so the
literal name says what the tag actually means. For a composition it is always
the former.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a25da94b77 |
refactor(studio): remove the storyboard view and its Storyboard/Preview switch (#4140)
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch * refactor(studio): fold the thumbnail output branches left after the storyboard mode went * docs(studio): drop leftover storyboard references after the view was removed * ci: allow the storyboard view deletions in the no-deletions guard * docs(studio): wrap an over-long comment line in the caption overlay helper * style(studio): reindent App.tsx after the view-mode wrapper was removed |
||
|
|
b44f1f85dc |
feat(lint): flag nested structure, missing timing and caption tracks on the root composition (#4146)
* feat(lint): flag nested structure, missing timing and caption tracks on the root composition One rule set for the root composition: nested layout inside a top-level element, a timed element or media without data-duration, captions not marked data-track-kind=captions, and captions spread over more than one track. Findings are errors when the host is Studio and warnings elsewhere. Sub-composition files are left free to hold layout. * test(lint): keep the structure rules silent on the shipped catalog and skills Runs the rules over every registry and skills html file and asserts no findings, and treats a bare media element with no timing as a static layer. * fix(lint): run the structure rules as Studio in both adapters and skip non-root files The Vite Studio adapter now lints as the studio host, and HTML the project lint did not treat as a root (components, snippets) is linted as a sub-composition. Style, script and template content no longer count as nested layout, sub-composition hosts need no duration, and a caption row without a lane is not a second track. * fix(lint): count legacy data-end as a length and pin the catalog test to real roots |
||
|
|
a8d44b2596 |
fix(studio): keep clips on their row through group, ungroup, and composition recovery (#4068)
* fix(studio): keep a split clip on its original track row Splitting an unauthored clip cloned the DOM node without stamping data-track-index, so the runtime's positional-index fallback renumbered it onto a different track on insertion. Both halves now pin to the element's current resolved track. Also adds A, matching CapCut, to return from the razor tool to select while it is armed. * fix(studio): stop the in-point seek from also firing when A exits the razor tool Both A bindings register on window in capture phase; the razor's listener mounts after the playback one, so relying on preventDefault ordering let the seek fire before the tool switch. Playback now checks activeTool directly instead. * fix(studio): stamp the resolved track through group, ungroup, and composition recovery Same defect class as the split fix: an unauthored clip's track falls back to its position in document order, and any DOM insertion elsewhere can shift that position. Group and ungroup move elements into and out of a wrapper; composition recovery clones a missing element back in. All three now stamp each element's already-resolved track explicitly, the same way split does, so none of them can drop a clip onto a different row. Also closes a gap in the earlier "A" hotkey fix: the razor-armed guard was unconditionally swallowing Shift+A and Alt+A, which should still seek to the in-point since only plain A exits the razor tool. * refactor(studio): reuse the existing patch-target and selection-match types Simplify pass on the track-stamping fix: resolveGroupChildTracks built its patch target by hand instead of calling buildDomEditPatchTarget, already used one function up for the same shape. resolveAuthoredTrack's selection param was a hand-typed copy of ElementMatchSelection instead of importing it. Collapses the three duplicated optional-integer track checks added to the wrap/unwrap/split routes into one isOptionalInteger helper. No behavior change. * fix(studio): match an ungroup child with no DOM id by its stable id findMatchingTimelineElementId had no branch for hfId, the stable content-hash id every element gets regardless of whether it has an authored id. An ungroup child identified only by that id fell through every branch and kept its old track instead of getting the resolved one stamped, leaving it exposed to the same row-drift this PR closes for every other element. * refactor(studio): resolve review lint suppressions with real fixes Three lint suppressions from this branch's earlier commits, resolved instead of left as noise: - unwrapElementsFromHtml's complexity: extracted the child-track map build into its own named function. - handlePlaybackKeyDown's complexity: extracted the razor/A-key check into its own named function. - The duplicated param block between UseDomEditSessionParams and UseDomSelectionParams: pulled the shared fields into one DomEditTimelineParams type both interfaces now extend. No behavior change. * fix(studio): fall back to the resolved track when nothing was authored resolveAuthoredTrack only ever returned authoredTrack, so an implicit or ungrouped child with no explicit authoredTrack resolved to undefined instead of its actual resolved track, silently skipping the row-pin for exactly the unauthored population this change exists to fix. Falls back to the resolved track, matching the function's own existing comment. * fix(studio): pin the razor split's resolved track for unauthored clips buildCutTarget had the identical gap resolveAuthoredTrack did: it only stamped an explicitly authored track, omitting the field entirely for an implicit/unauthored clip, so a split of it still drifted onto a new row. Falls back to the element's resolved track, same as the group and ungroup paths. * refactor(studio): share one track-fallback rule between split and group resolveAuthoredTrack and buildCutTarget each re-implemented "authored track, else the runtime's resolved track" with a different rounding rule (one rounded only the authored branch, the other neither). Extract resolveElementTrack so both flows share the same rule and can't drift out of sync again. |
||
|
|
407cacf0ae |
feat(studio-server): default the GSAP writer to acorn
HYPERFRAMES_GSAP_WRITER=recast stays as the fallback for one watch window before recast is deleted. acornDefaultBlockers -> behavioralParityOps: the 18 ops without a byte-diff oracle are now pinned in a test, not a gate. files.test.ts "stale semantic no-op" pinned to the recast writer: its interleave seam is recast's lazy import, which acorn (statically imported, fully synchronous) never performs. The 409 revalidation it covers is writer-independent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b599f031a9 |
fix(studio-server): make render download headers ByteString-safe (#3984)
Build RFC 6266 Content-Disposition values with an ASCII filename fallback and UTF-8 filename* parameter for each render file response. This preserves inline and attachment disposition semantics while preventing non-Latin-1 filenames from crossing the HTTP ByteString boundary. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
edb6e87ba7 |
fix(studio-server): make sub-composition ETags ByteString-safe (#3982)
Hash the decoded sub-composition path before including it in the preview ETag. This keeps ETags deterministic and path-specific while ensuring Hono receives an ASCII-safe header value for Unicode composition filenames. Add regression coverage for percent-encoded CJK sub-composition paths, including stability and distinctness assertions. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
cccc78c65e |
fix(studio): fail closed on backup failures (#3961)
Fail closed Studio-server mutations when a backup snapshot reports an error. Return the existing backup-failed HTTP 500 response before any raw or structured mutation can change the original. Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
4c6fa9a790 |
fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref
`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.
Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.
Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.
* docs(studio): plan for loading the preview during the shell's first layout
* fix(runtime): a paused preview stays still after any seek
Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.
Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.
- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
now returns every timeline it unpaused to paused in a finally. The second
rearm after the child re-seek only ever changed the leaked state and is
gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
timed media element found running. A parked transport runs no ticks, so a
capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
requests playback through the player so adapter, rAF loop and flag agree.
Regression tests fail without each of the three changes.
* perf(studio-server): stop re-encoding videos the browser already plays on open
Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.
One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.
Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.
Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.
* perf(cli): serve the studio bundle compressed and cache hashed assets
The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.
The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.
* perf(cli): warm the preview route before the browser opens
The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.
The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.
* perf(studio): request the player chunk before the shell's first layout
The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.
* fix(studio): editing a text property no longer reloads the preview
Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.
The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.
A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.
shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.
Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.
* fix(runtime): paused-time media playback is borrowed, not banned
The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.
- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
video while paused to render grading previews. It went dark on the first
paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
music track for ~140 ms while paused so a playhead drag is audible. Same path
killed it.
A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.
Two corrections to the previous commit's reasoning:
The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.
Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.
Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing
* test(runtime): a lease ends when the transport plays or the borrower stops
Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.
* test(studio): the event-stream channel must open /api/events
* fix(studio-server): stop printing a proxy diagnostic line per clip on every render
Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.
The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.
The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.
The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".
Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.
* perf(cli): stop gzipping the studio bundle on loopback
Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.
The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.
The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.
* docs(runtime): say what the paused-media probe actually filters
* revert(cli): drop the preview prewarm that held the CLI event loop
The fire-and-forget warm added in
|
||
|
|
91a34ffc8a |
fix(studio): claim upload filenames exclusively (#3786)
* fix(studio): claim upload filenames exclusively * fix(studio): write uploads through exclusive descriptors |
||
|
|
8958342dd1 |
fix(studio-server): bridge the node web stream type for the Windows build (#3746)
tsc on the Windows jobs rejects a direct cast from node:stream/web's ReadableStream to the global one (TS2352), which broke the build after #3745. Cast through unknown, the bridge the error itself recommends. |
||
|
|
8825def610 |
fix(studio-server): stream preview media byte ranges instead of reading the whole file (#3745)
The Studio preview asset route answered every Range request by reading the entire file into memory with readFileSync and slicing the window out of the buffer. A browser refills a playing <video> or <audio> with a fresh Range request every few hundred milliseconds and issues one per seek, so a source of a few hundred MB cost a full synchronous read per refill and per scrub step. The read also blocked the event loop, so the voice track, saves and the file-change stream all waited behind it. Sources over 2 GiB could not be served at all, because readFileSync refuses them. Stream only the requested window with createReadStream, take the size from stat instead of the buffer, and answer 416 for a range that starts past the end. Text assets keep the in-memory utf-8 round trip. The sibling static project server already did this. |
||
|
|
e5b3514118 |
fix(studio-server): publish waveform caches atomically (#3731)
* fix(studio-server): publish waveform caches atomically * fix(studio-server): reject linked waveform cache directories |
||
|
|
fe2cc92050 |
fix(studio-server): bound preview variable insertion scans (#3715)
* fix(studio-server): bound preview variable insertion scans * docs(studio-server): update preview variables helper reference |
||
|
|
e9250fcc45 | fix(studio-server): simplify normalized group ID trimming (#3711) | ||
|
|
723cd0d785 |
fix(studio-server): block dangling symlink upload escapes (#3661)
* fix(studio-server): block dangling symlink upload escapes * fix(studio-server): contain rename reference updates |
||
|
|
d2741b3a28 |
fix(studio-server): preserve binary file writes and versions (#3653)
* fix(studio-server): preserve binary file writes and versions * fix(studio-server): rely on exclusive file creation for POST * test(studio-server): create race fixture atomically |
||
|
|
3bc46a8ade | fix(studio-server): cascade GSAP cleanup when deleting subtrees (#3655) | ||
|
|
6b5b4cb988 | feat(studio): make agent edits live and explicit (#3581) |