* fix(studio): undo shows a resize, rotate or GSAP edit undone at once while it saves
* test(studio): pin the painted-back draw and redraw rules
* test(studio): the resize intercept also gets the draw that keeps an undone edit undone
* fix(studio): a scale resize reads its drop point on the box as dropped, even after undo
* fix(studio): a group move and a resize settle keep an undone edit undone
* fix(studio): undo paints back a GSAP move saved as a left/top offset
* fix(studio): undo still steps a GSAP edit whose first write saved before a later one failed
* fix(studio): the resize pixel check measures a box undo painted back as the edit drew it
* fix(studio): a GSAP move undone mid-save keeps its drag base, so its save adds the drag once
* refactor(studio): trim the scale-resize finalize comments to what the code cannot say
* fix(core): a cropped element's edge stays exact while GSAP tweens it
* fix(core): configure GSAP while the runtime script runs, before compositions set elements
* test(studio): bank the six crop-scale cases that now pass
* test(producer): regenerate the png-sequence and mov-prores goldens for 2D GSAP transforms
* test(core): a non-configurable window.gsap is configured without a crash
* test(studio): re-bank the edit accuracy baseline from this branch's current head
Section 2 of #5025, each checked against current main:
- studio: sub-compositions.md lives in /hyperframes-core
- music-to-video: bgm.md path is audio/references/bgm.md in /media-use
- remotion-to-hyperframes: when in doubt, return to /hyperframes
- registry: the search-miss report passes the tier that answered
- talking-head-recut: layout files carry bounds only, the storyboard records
card.zone; the field is contentHints; style varies only inside the chosen
group
- embedded-captions: name the real transcription fallback
- motion-graphics: drop the nonexistent resolve.mjs block, list maps
- animation, creative, figma, slideshow: resolve bundled paths from the
installed skill, not the repo root
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* docs(skills): resolve cross-skill contradictions from the doctrine audit
Section 1 of #5025, each direction taken from the code or the shared
contract:
- async timeline build is supported (runtime rebinds an async-registered
timeline; lint only flags registering before the build finishes), so
animation and keyframes now say register after the build completes
- data-track-index is a Studio row, display only; layering is z-index
- the final gate is `check`, which reruns lint
- music-to-video keeps render approval as the one autonomous question,
per the brief contract
- faceless-explainer and product-launch-video pre-install registry blocks
before dispatch, like pr-to-video
- talking-head-recut layout files cover landscape and portrait; 4:5 is
derived
- embedded-captions Cinematic authors cinematic.json; make-cinematic.cjs
derives plan.json
- audio carve has no static mode; remove the stale static and dynamic text
Left open: the figma usage beacon and the skills-update consent rule.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* chore(skills): refresh manifest; treat cinematic.json and safe-zones.json as project files
Both are written into the user's project by the embedded-captions workflow,
so the comment-citation check should not look for them in the repo.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(studio-server): undoing a change takes the media ledger back with the film
* fix(studio-server): a log 0.8.123 wrote takes the media ledger in, so the first Undo undoes the edit
* fix(studio-server): only a log from before the ledger fix takes it in, once
* fix(studio-server): an older log takes in the media ledger only as the sweep would see it
A ledger behind a link was read straight into the baseline, then filed as a
deletion by the first sweep. The take-in now asks the same scan the sweep uses.
The cutout test also pins that other .media files and other manifest.jsonl
names stay out of history.
Wenbo's feedback: the button read like the ghost controls beside it.
It now wears an accent tint and border, a larger Framey and a permanent
New badge, and pulses three times as Studio opens (none under reduced
motion).
* fix(cli): a project .env cannot set the feedback email in another letter case
* fix(cli): capture loads a project .env through the same rules as the CLI
* feat(media-use): clone, list and delete HeyGen voices, and accept a host OAuth token
heygen-voice.mjs clones a voice from an mp3 or wav file, lists cloned voices by name prefix, and deletes one, printing HeyGen's own error message on any refusal. HEYGEN_ACCESS_TOKEN is now the first credential source and is sent as an OAuth Bearer token.
* fix(media-use): heygen-tts prints HeyGen's own refusal instead of a generic line
* chore(media-use): regenerate the skills manifest for the voice scripts
* fix(media-use): voice list stops on a repeated page, delete accepts an empty reply, plain refusals
* test(media-use): a voice list that pages forever fails at once instead of hanging
* docs(media-use): keep heygen-voice's header to three lines, usage lives in tts.md
* fix(studio): a video's sound strip reads like an audio row, at half the clip
* fix(studio): the peak badge on a video's sound strip stays whole and readable
* fix(studio): the strip's peak mark stays above the waveform
* fix(studio): a snap the saved clip would miss no longer moves it
* test(studio): walk that a deep-zoom move lands where it shows, with no jump on release
* test(studio): the release walk waits for the saved start, not the drag's own
* test(studio): find the drag landing slot by its test id
* fix(studio-server): motion paths animate when gsap has a query string or loads deferred
Studio puts MotionPathPlugin right after the composition gsap tag. A gsap URL with a query string or hash was not recognised, so the plugin went to the head and ran before gsap; a deferred gsap kept the plugin undeferred, with the same result. The tag is now found with a query or hash, and the plugin copies its type and defer, read with the HTML parser the server already uses.
* fix(studio-server): finding the gsap tag stays fast on any preview HTML
The gsap tag pattern from the previous commit could backtrack polynomially on long runs of gsap-like src text. The preview now walks script tags with index searches, decides gsap core from the URL path, reads attributes case-insensitively, and skips tags inside comments and templates.
* refactor(studio-server): the gsap tag scan is small single-purpose steps with no whitespace regexes
Splits the scan so each step has one job (walk tags, read one script tag, find its closing tag, skip a comment or template), and skips whitespace by character instead of with \s* patterns, which code scanning reads as polynomial.
* fix(studio-server): the gsap tag search reuses the core HTML scanner, so style blocks and odd comments cannot hide gsap
The hand-written walk missed raw-text bodies, empty comments and a comment opener inside an attribute value; any of those before the gsap tag sent the plugin to the head again. findStartTags already skips all of them and is what lazyPreviewImages pairs with the parsed document; the preview now does the same.
* fix(studio): an audio clip's name pill draws over its waveform
* fix(studio): the out-of-sync badge stays over the audio pill; one owner for the waveform layer
* test(studio): the filmstrip tests reset the timeline in afterEach
* fix(studio): a first animation in a full-document sub-composition plays
* fix(studio): keep the comment share of the two touched files at their base
* fix(studio): a first animation also plays when <html> carries the composition id
* fix(studio): a root whose id sits on <html> keeps its first animation in <body>
* fix(studio): a composition id on the template tag keeps the first animation inside it
* feat(skills): tell the user about HyperFrames Studio at the final preview and after delivery
/hyperframes gains § 6: mention the preview header's Edit with Framey button when the final
Studio preview opens, and after the delivery render pass on the render's own desktop-app line
in plain words — offer to run `hyperframes open` when the app is installed, else give the
download link. Say nothing when the render prints no such line (a draft, a batch row, a run
inside the app). Builds on the CLI lines added in #4951.
* feat(skills): open studio before every final render, pitch the app as the chat way to edit
Users who only ask for a video never saw Studio: autonomous runs asked "preview
first, or render?" and a "you decide" answer rendered. The final look now opens the
Studio preview in every mode, then asks "render now, or what changes?" — opening it
asks nothing, so it also holds when the user said not to ask.
Section 6 says what is true: the Studio preview already edits and saves; the desktop
app adds editing by chat with Framey. The agent names it "the HyperFrames Studio
desktop app" so it is not mistaken for the preview. general-video, the review loop
and the brief contract carry the same final look.
* fix(skills): call the app the hyperframes desktop app, never studio
* fix(skills): pitch edit with framey only when preview shows it, keep direct renders direct
* fix(skills): mention the button only after the line that ships with it
* feat(studio,cli): add an Edit with Framey button and `hyperframes open` for HyperFrames Studio
Point people from the CLI and the Studio preview to HyperFrames Studio, the desktop app.
- Studio header: an "Edit with Framey" button with Framey, the app's cursor mascot (the eye
follows the pointer, a wiggle on hover, honours reduced motion). Until the app opens
handed-over projects (HANDOFF_READY), a press shows a "Meet Framey" card with the download.
The button shows only where the preview server answers GET /api/open-in-desktop on macOS,
so the desktop app's own embedded Studio never shows it.
- `hyperframes open [dir]`: hands the project folder to the app with `open -b` (released app,
then Canary) and points to the download when neither is installed. When run by Claude Code
or Codex it leaves .hyperframes/agent-handoff.json (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID)
so the app's first chat can pick up that conversation.
- render and preview print one line about the app: the download until HANDOFF_READY, then
`hyperframes open` when the app is installed. Nothing for a draft render, a batch row, or a
run inside the app (HYPERFRAMES_DESKTOP_PROJECT).
* fix(studio,cli): gate `hyperframes open` too, guard the open-in-desktop POST, keep one gate
Review of #4951 at 58934a34.
- One gate: HANDOFF_READY lives only in the CLI. `GET /api/open-in-desktop` now says
`handoff`, and Studio's button reads it, so the gate cannot be half-flipped.
- While gated nothing opens and nothing is written: `openInDesktop` returns
`handoff-unavailable` (so `hyperframes open` prints the download line and the POST route
answers with it), and no `.hyperframes/agent-handoff.json` can sit waiting for a later release.
- The POST answers only Studio's own same-origin request (loopback Host, matching Origin,
Sec-Fetch-Site same-origin); a cross-site or rebound-host POST gets 403. Routes moved to
server/desktopRoutes.ts with their own tests.
- `hyperframes open`: exits 1 when nothing opened, answers in JSON under --json for a bad
directory, and tells a missing app from one macOS could not open (`open-failed`). A project
that cannot be written still opens, with nothing handed over.
- Tests drive the live path through `ready`, are path-portable for Windows, and cover the draft
and batch-row hint skips (`wantsDesktopHint`, `batchRowRenderOptions`).
- The load smoke mocks the route.
* feat(cli,studio): hand off on macos now, and open the app on windows and linux
The released Mac app (b254) takes a handed-over folder and its conversation, so
HANDOFF_READY turns on for macOS. Windows and Linux stay on the download until the
desktop change that reads a folder from the command line ships.
- `hyperframes open` finds the app off macOS: the per-user install on Windows, and
on Linux the AppImage the app records in launcher.json; it starts it with the folder
- download links follow the machine: the DMG on macOS, the AppImage on Linux, and
nothing on Windows, which has no public download yet
- the Studio button shows wherever there is a download or an installed app; the card
no longer says "for macOS"
- every render prints the app line except batch rows: music-to-video delivers a draft
- the success line names the app that took the folder (Canary included)
* fix(studio): the edit-with-framey button asks its route through studioApiFetch
* fix(cli,studio): name the app the hyperframes desktop app, apart from the studio preview
* fix(cli,studio): fewer comment lines, so no file's comment share rises
* fix(studio-server): history never files a hidden file as a change
* fix(studio-server): an older log never makes history write a hidden file
* fix(studio-server): an older log's hidden-file entry no longer stalls Undo
* fix(studio-server): an older log's emptied Undo still keeps its target undone
* fix(studio-server): an older log keeps every entry, and undoing an emptied one still lands
* refactor(studio-server): history's hidden-path rule says itself in names, not comments
* fix(core): the preview redraw leaves animations after the playhead as the seek drew them
The redraw that re-renders a timeline at its playhead steps silently to just
below it and back. That backward step drew every reversed child that starts
later at its end (a reversed set showed 0.8 where the seek showed 0.2), and the
landing never revisited it. Children starting after the playhead now sit out
both steps. The redraw also puts back the zero-duration state of every
zero-duration child, not only those with callbacks, so two sets on the playhead
keep their authored order on the next seek and a call in an otherwise empty
nested timeline fires as often as without the redraw.
* refactor(core): collect children after the playhead without a per-child array
* fix(core): a scene's opening set disappears when you step back off the scene's start
The redraw's silent round trip left a nested timeline's start-crossing mark
(_zTime) on the wrong side, so the next backward seek skipped the scene and its
opening set stayed drawn. The redraw now restores that mark for every nested
timeline as well as every zero-duration child.
* refactor(core): name the redraw's playhead tolerance instead of commenting it
* fix(core): a pause inside a reversed repeating scene fires once after a redraw
The redraw renders a nested timeline the seek had left inactive, which marks it
active. With its start-crossing mark restored but not its active flag, the next
seek re-ran the scene's pause. The redraw now restores the active flag too.
* fix(core): a held redraw no longer cuts a paused scene short in renders
Holding later children at time scale 0 let GSAP recompute a nested timeline's
duration during the redraw's round trip without them, so a paused
sub-composition kept a shortened length and froze for the rest of the render
(style-5-prod's typed intro). After restoring the time scales, every ancestor
of a held child is marked dirty and the duration is settled again.
* fix(core): the redraw puts back the scene lengths it measured, not re-measures them
Re-settling after the hold recomputed the root's length from whatever was paused
at that moment, which the redraw should not change. The cached lengths of the
held children's ancestors are now snapshotted after the entry settle and
restored in the finally.
* feat(studio,cli): add an Edit with Framey button and `hyperframes open` for HyperFrames Studio
Point people from the CLI and the Studio preview to HyperFrames Studio, the desktop app.
- Studio header: an "Edit with Framey" button with Framey, the app's cursor mascot (the eye
follows the pointer, a wiggle on hover, honours reduced motion). Until the app opens
handed-over projects (HANDOFF_READY), a press shows a "Meet Framey" card with the download.
The button shows only where the preview server answers GET /api/open-in-desktop on macOS,
so the desktop app's own embedded Studio never shows it.
- `hyperframes open [dir]`: hands the project folder to the app with `open -b` (released app,
then Canary) and points to the download when neither is installed. When run by Claude Code
or Codex it leaves .hyperframes/agent-handoff.json (CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID)
so the app's first chat can pick up that conversation.
- render and preview print one line about the app: the download until HANDOFF_READY, then
`hyperframes open` when the app is installed. Nothing for a draft render, a batch row, or a
run inside the app (HYPERFRAMES_DESKTOP_PROJECT).
* fix(studio,cli): gate `hyperframes open` too, guard the open-in-desktop POST, keep one gate
Review of #4951 at 58934a34.
- One gate: HANDOFF_READY lives only in the CLI. `GET /api/open-in-desktop` now says
`handoff`, and Studio's button reads it, so the gate cannot be half-flipped.
- While gated nothing opens and nothing is written: `openInDesktop` returns
`handoff-unavailable` (so `hyperframes open` prints the download line and the POST route
answers with it), and no `.hyperframes/agent-handoff.json` can sit waiting for a later release.
- The POST answers only Studio's own same-origin request (loopback Host, matching Origin,
Sec-Fetch-Site same-origin); a cross-site or rebound-host POST gets 403. Routes moved to
server/desktopRoutes.ts with their own tests.
- `hyperframes open`: exits 1 when nothing opened, answers in JSON under --json for a bad
directory, and tells a missing app from one macOS could not open (`open-failed`). A project
that cannot be written still opens, with nothing handed over.
- Tests drive the live path through `ready`, are path-portable for Windows, and cover the draft
and batch-row hint skips (`wantsDesktopHint`, `batchRowRenderOptions`).
- The load smoke mocks the route.
* fix(studio): the edit-with-framey button asks its route through studioApiFetch
* fix(studio): a move on a keyframe of an eased tween changes that keyframe in place
GSAP eases a keyframe run as a whole with keyframes.ease || ease. The playhead writer
now edits an existing keyframe under that ease and writes the ease back as the
tween's own; adding a keyframe in eased form stays refused, since the ease moves
where its time lands.
* fix(studio): dragging a keyframed layer from its middle moves the layer, not a path node
The motion path drew an axis the tween does not animate at 0, though GSAP folds the
layer's CSS translate into it, so each node sat beside the layer and its grab ring took
presses aimed at the layer. The path now uses GSAP's live position, the same base the
layer drag reads. A press on the node GSAP renders the layer at, or inside the layer's
box but off another node's drawn dot, starts the layer's own move and write.
* test(studio): stub elementsFromPoint, which happy-dom does not have
* refactor(studio): name the press check for what it answers, and format
* chore(studio): cut the helper comments to the package comment share
* fix(studio): a node press goes to the layer only when the layer takes it
The hand-off to the layer's box falls back to the node drag when the box starts no
gesture or takes no pointer, and a selected node's larger dot stays the node's.
* chore(studio): name the keyframe run ease instead of commenting it
* refactor(studio): share the keyframe-at-percentage check in the playhead writer
* fix(parsers): a keyframed tween keeps GSAP's ease, never a default or a quoted variable
GSAP never gives a tween with keyframes its timeline's default ease, so the parser
no longer does. A keyframes-level ease or easeEach the file names by variable is kept
as that variable, and every ease the writer emits goes through valueToCode, so
ease: E is written back as E instead of the string "__raw:E".
* fix(studio): edit an eased keyframe run only at its ends, and a refused press is not a node drag
GSAP renders a run eased as a whole at ease(t), so only its 0% and 100% keyframes sit
at their clock time; the writer refuses the others as before. A layer press the box
answers with its blocked notice counts as handled, so the node does not drag after it.
* fix(studio): a layer press refused at setup leaves the box live, and variable eases stay pinned
startGesture paused the box's position updates and playback before the drag member
setup could refuse; a press handed off from a path node then left the box frozen, since
its release lands outside the box. It now pauses only once the setup succeeds. Tests pin
that adding, converting and unrolling write an ease the file names by variable as that variable.
* fix(studio): pause playback before drag setup and pin variable eases on convert and unroll
* fix(studio): pause playback before a nudge snapshots timelines, and pin the order
* test(studio): a failed nudge pause test cleans up, and a nudge with nothing to move leaves playback alone
* fix(media-use): say to fix an unreadable credentials path, not to log in again
* fix(media-use): provider=heygen gives the same fix hint as the auth headers
* test(media-use): restore env in the pickProvider test with a plain if
* fix(core): composition scripts start after web fonts are ready
A scene script that measures text (offsetHeight, getBoundingClientRect) ran while
the page parsed, often before its web font loaded, so it laid the scene out with
fallback metrics. Renders of the same film differed run to run, and preview could
differ from export.
The compilers now give every body script a type the browser does not run
(text/hf-after-fonts, or +module), through one helper in scriptRuns.ts called
at the end of the bundler, the producer compile and the sub-composition preview.
The Studio motion script is emitted the same way so it still runs after them.
The runtime entry owns the wait: on DOMContentLoaded it forces a layout, waits for
document.fonts.ready or FONT_WAIT_TIMEOUT_MS (5 s), then re-creates each script
in its own place in parser order (classic in order, awaiting src loads; then
module and defer scripts in insertion order), boots as before, and calls the
DOMContentLoaded and load listeners the scripts added. On timeout the scripts
run anyway and runtime_font_wait_timeout names the families still loading.
Scene swaps and the composition loader use the same wait.
* fix(core): a page whose runtime predates the font gate still runs its scripts
A bundle can be served with a runtime loaded from HYPERFRAME_RUNTIME_URL. If that
runtime is older than the font gate, nothing would run the deferred composition
scripts and the film would be blank.
Whenever the marking helper defers scripts, it also puts one small inline script
first in the head. At DOMContentLoaded it runs the deferred scripts in parser
order itself, without waiting for fonts and with a console warning, unless a
runtime has claimed them. The gate's runtime claims them when it loads, before
DOMContentLoaded, so the scripts run exactly once either way.
* fix(studio-server): motion paths animate in Studio preview when gsap loads in the body
The MotionPathPlugin tag Studio inserts after a body gsap script now carries
that script's type, so the runtime runs it right after gsap once fonts are
ready, instead of before gsap exists.
* fix(cli): layout, motion-shot and validate wait for the composition scripts to run
The three commands waited on the timeline registry, which exists as soon as the
runtime file loads, or on a flat sleep. They now wait for the runtime's
render-ready flag through one shared helper, keeping each command's timeout and
proceeding as before when it passes.
* fix(core): a script that wraps addEventListener keeps its wrapper after its scripts run
While composition scripts run, the runtime patches addEventListener to hold
load events that already passed. Putting the original back now happens only if
the patch is still in place, so a library wrapper installed meanwhile survives.
* docs: comments on script timing match scripts that wait for web fonts
The early stub's flush and the player's iframe-load handler described page
scripts as done by DOMContentLoaded and the runtime as ready before load.
Composition scripts now wait for web fonts, so both can come later. Comment-only.
* test(core): pin script order after an external script and the scene swap's font wait
An inline composition script that follows an external one sees its global, and
a scene swap runs the new scene's script only once web fonts are ready.
* docs(producer): the early stub comment names what drains late font-gated work
* fix(studio): motion paths add without a flash when gsap loads after web fonts
Studio preloads MotionPathPlugin on iframe load. A body gsap now runs after web fonts, often after that load, so the preload retries once the runtime reports ready.
* test(cli): pin the runtime wait before each command first samples the page
* fix(studio): the motion path preload stops waiting once the preview runtime has booted
A composition with no global gsap posts ready before the preload runs; the retry listener then never detached and kept each retired preview document alive. It now waits only while the runtime has not booted, and the tests pin the cleanup and the ready filter.
* fix(studio): a preview frame that never boots is not kept alive by the motion path preload
Only the latest preview waits for the ready message of its runtime; a newer load drops the older wait, so a crashed or missing composition frame can be collected.
* style: comments added by the font gate move into names
* test(studio): the edit bench render serves GSAP from the repo, never the CDN
* test(studio): the edit bench render opens no second page that fetches GSAP
* test(studio): the render test checks the CDN pattern matches the fixture's GSAP URL
* test(studio): match the CDN pattern the way Chrome does
* test(studio): the render test checks the whole interception pattern
* test(core): pin the authored floor's direct children, the one-frame media skip and lottie sources
* test(core): pin the loaded-Lottie-library half of the pending rule
* fix(studio): trimming keeps the playhead still; clips snap to the ruler grid
* fix(studio): an edge frame never becomes the playhead time; grid snaps where clips save
* ci(studio): run the trim and grid snap walk with the other Studio browser checks
* fix(studio): snap guides only where the clip saves; play keeps an explicit rewind
* fix(studio): a snap guide shows only where the saved clip lands; preview frame gets its own store
* fix(studio): judge a moved clip's guide by the duration the move keeps
* chore(studio): trim comments the names already say
* fix(studio): the host can name Split's key on the toolbar and bind the clip menu's link keys
* refactor(studio): early return for typed keys; export test checks identity
* fix(media-use): a slow download no longer leaves an empty asset that undo brings back
* docs(media-use): tell the person an OAuth sign-in makes voice and avatar video free
* docs(media-use): the OAuth free usage line names the monthly allowance
* docs(media-use): the OAuth line says a free allowance
* test(core): pin how the runtime resolves a composition's length and size
* refactor(core): move composition length and root size rules into a pure module
* test(core): pin the one-frame timeline floor against a derived length
* fix(core): build and declare the composition-length subpath, reject a non-finite declared length
* fix(media-use): a credentials folder means no HeyGen credential, not a crash
* fix(media-use): keep heygenCredential never-throws for any unreadable credentials path
* fix(media-use): skip a .env folder when loading HeyGen credentials
heygen-tts.mjs and audio.mjs crashed with EISDIR when a parent folder held
a .env directory instead of a file.
* fix(media-use): still load a .env that is a pipe or a locked folder's file
* fix(media-use): read a .env without checking it first, so it cannot change in between
* fix(studio): let the host name Split's key in the clip menu
* fix(studio): editor shell and timeline pane forward the Split key label
* fix(studio): drop two added doc comments the comment ratchet refuses
* refactor(studio): the auto-id write posts through the one patch-element client
* test(studio): pin the auto-id write's saves, refusals and unsafe targets
* test(studio): pin the auto-id write's plain refusal and missing changed flag
* test(studio): the auto-id write needs a toast handler and sends the write token
* refactor(studio): one request for every GSAP mutation write
* fix(studio): a refused id write no longer leaves the id on the previewed element
* style(studio): drop the two comments the names already carry
* fix(studio): keep minting the id on the element so overlapping adds never share one
* fix(studio): an id the file already holds counts as saved, and no project leaves no id
* fix(studio): the server picks an added effect's element id, so ids never repeat
The browser minted the id from the live preview and rolled it back when the
write failed. A reply lost after the id was saved left the file holding an id
the preview had dropped, so the next add on a sibling wrote the same id again;
and a refused add could strip the id an overlapping saved add had just given
the same element.
A new patch-element op, ensure-id, keeps the element's id or makes the proposed
one unique in the file, and every patch-element reply names the element's id.
Studio sets the id on the preview only from that reply, so there is nothing to
roll back. nextUniqueId moves to sourceMutation as the one owner (split and
duplicate already used it).
* test(studio): pin the gsap write token and the refused-write toast
* refactor(studio): one unique-id helper on the server, and shorter id comments
* style(studio): keep the id helper doc under the line limit and the test-utils header true
* fix(studio): an added effect's id stays unique across every composition file
* style(studio): keep the files route's comment share at the base
* fix(studio): count an id written right after a quote or slash as taken
* fix(studio): a quote-ended string no longer hides another file's id
* feat(cli): feedback says whether a person or an agent wrote it, with the email an app attaches
* refactor(cli): split the .env loader so each part stays simple
* fix(cli): a broken .env loader fails loudly; only a missing .env is ignored
* fix(cli): publish with an expired login stops and asks you to log in again
The server now answers 401 to a rejected credential instead of publishing
anonymously, so the CLI says so and drops the warning for the old fallback.
* fix(cli): publish refreshes an expired login first, and names a rejected API key
An expired access token with a refresh token is refreshed (the cloud commands
already do) instead of being sent and refused. Only the requests that carry the
credential map a 401; an environment API key is named, since logging in would
not replace it.
* fix(cli): an expired login without a refresh token asks you to log in again
A stored login past expiry with no refresh token now resolves as
LOGIN_EXPIRED instead of "not signed in", so publish stops with the
re-login message before sending anything. A signed-in publish that the
server answers with an unclaimed project fails the same way and links nothing.
* fix(cli): keep the resolver comment share at its base
* fix(cli): an API-key publish still succeeds, and only an owned publish is linked
* fix(cli): --update and --space refuse an API key instead of being ignored
* fix(cli): publish uploads with the credential it checked, so --update and --space are never dropped
* fix(cli): a login that expires during the bake is refreshed, not reported as expired
* fix(cli): an expired login that cannot refresh stops publish before anything is sent
* fix(cli): a token refresh never writes over a login that replaced it
* fix(cli): a refresh that cannot read the saved login reports that, not a changed login
* fix(studio-server): freeze still appears whole, so a slow extraction cannot break its undo
The freeze route reserved the still's final name with an empty file before
ffmpeg ran. When extraction outlasted the history's quiet time, history
committed that empty file as an outside change, and Cmd+Z then restored a
0-byte PNG. ffmpeg now writes under an atomic temp name the history skips,
and the still is published with createFileAtomically (EEXIST still refuses
a taken name).
* fix(core): a direct exclusive write that fails midway leaves no partial file
Without hard links, createFileAtomically writes the destination directly.
A full disk then left a partial file behind (a corrupt upload or freeze
still); it is now removed, and a name another writer holds is never
touched. Freeze tests: the slow-extraction test leaves a half-written temp
file while history commits, and concurrent extractions meet at a barrier
instead of a real timer.
* fix(core): a quota reported only at close still removes the partial file
* test(core): the direct write closes once and keeps its write error when cleanup fails
* test(core): a failed direct write closes its descriptor exactly once
* refactor(studio-server): freeze names carry what their comments said
* fix(core): a call on the playhead fires once when the preview redraws it
The silent redraw steps off the playhead and back, which re-armed a call sitting exactly on it, so the following seek fired it again. Keep each such call's fired state across the redraw.
* fix(core): a redraw keeps every call's fired state, wherever the call sits
Restoring one field for calls found by position missed a fresh call at 0, calls in repeating or reversed timelines and double-speed scenes. Snapshot ratio and _zTime of every zero-duration callback tween instead.