Commit Graph
1652 Commits
Author SHA1 Message Date
Miguel Angel Simon Sierra 638e6b7b33 chore: release v0.8.123 2026-10-04 05:06:49 -07:00
WaterrrForever bd0dc8c3ae feat(studio,cli): add an Edit with Framey button and hyperframes open for HyperFrames Studio (#4951)
* 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
2026-10-04 10:52:23 +00:00
Miguel Ángel 6037d22844 Merge pull request #5009 from heygen-com/release/v0.8.122
chore: release v0.8.122
2026-10-04 04:03:15 -07:00
Miguel Ángel 7faf2cc6c3 fix(studio): dragging a keyframed layer from its middle moves the layer, not a path node (#4941)
* 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
2026-10-04 10:44:48 +00:00
Miguel Angel Simon Sierra b7baa5fde1 chore: release v0.8.122 2026-10-04 03:39:06 -07:00
Miguel Ángel 44b5836250 fix(core): composition scripts start after web fonts are ready, so renders repeat (#4980)
* 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
2026-10-04 10:24:42 +00:00
Miguel Ángel 7f3464921c test(studio): the edit bench render serves GSAP from the repo, never the CDN (#4996)
* 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
2026-10-04 09:40:33 +00:00
Miguel Ángel 7859c0f003 fix(studio): trimming keeps the playhead still; clips snap to the ruler grid (#4986)
* 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
2026-10-04 08:54:17 +00:00
Miguel Ángel b04081b376 fix(studio): the host can name Split's key on the toolbar and bind the clip menu's link keys (#4994)
* 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
2026-10-04 08:46:31 +00:00
Miguel Angel Simon Sierra a6fc2592e2 chore: release v0.8.121 2026-10-04 01:30:55 -07:00
Miguel Angel Simon Sierra ccd80ea621 chore: release v0.8.120 2026-10-03 23:34:55 -07:00
Miguel Ángel dda0e757b1 fix(studio): the host can name the Split key shown in the clip menu (#4985)
* 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
2026-10-04 06:13:16 +00:00
Miguel Ángel 48308e3708 fix(studio): the server picks an added effect's element id; one client for patch and gsap writes (#4962)
* 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
2026-10-04 06:05:17 +00:00
Miguel Angel Simon Sierra e5fbb01fc0 chore: release v0.8.119 2026-10-03 21:31:22 -07:00
Miguel Ángel 417dccb97a Merge pull request #4983 from heygen-com/release/v0.8.118
chore: release v0.8.118
2026-10-03 20:59:12 -07:00
Miguel Ángel 98763899d6 fix(studio): an audio track can be muted again after it is unmuted (#4977)
* fix(studio): an audio track can be muted again after it is unmuted

The audio track header showed its toggle only while the track was muted, so a track unmuted once could never be muted again. The toggle now shows on every audio-only track as a speaker (Mute track N / Unmute track N), decided by the same rule the undo entry already used.

* refactor(studio): one owner for the track toggle words, drop comments the mute fix made false

* fix(studio): an audio clip or group can be muted and unmuted from the property panel

The panel offered its hide toggle on audio only while already hidden, and a selected <hf-audio-group> had no toggle at all (it has no timeline row). The panel now offers Mute/Unmute both ways; a group writes data-hidden through the group attribute writer. Track rows, the panel and their undo entries share one verb and icon helper.

* style(studio): format the panel test and keep the comment share flat

* fix(studio): the panel's mute label follows the write, without a reselect

* fix(studio): after a panel mute, refresh what is selected now, not the muted item

* perf(studio): read the selection ref from the actions context so the inspector does not re-render on hover

* refactor(studio): move the panel's mute toggle into usePanelHiddenToggle
2026-10-04 02:30:24 +00:00
Miguel Angel Simon Sierra ccab31b558 chore: release v0.8.118 2026-10-03 19:25:43 -07:00
Miguel Ángel 239ea1aecd feat(studio): a host can put its own controls before the timeline's thumbnails toggle (#4973)
* feat(studio): a host can put its own controls before the timeline's thumbnails toggle

* fix(studio): keep the toolbar under the comment ratchet
2026-10-04 02:16:53 +00:00
Miguel Ángel cd1a34ceca fix(studio): one owner for gsap CDN URLs, so MotionPathPlugin matches the composition's gsap (#4976)
* fix(studio): load MotionPathPlugin at the composition's own gsap version

* fix(core): list the gsap-cdn subpath where core's exports are generated from

* fix(core): every default gsap URL comes from the one gsap-cdn owner

* fix(core): the gsap a new composition or first tween gets comes from the gsap-cdn owner
2026-10-04 00:44:50 +00:00
Miguel Ángel f7987bb1f2 chore: release v0.8.117 (#4978) 2026-10-03 17:29:44 -07:00
Miguel Ángel 237a984bb8 fix(studio): undoing a freeze frame removes the still it made (#4971)
* fix(studio): undoing a freeze frame removes the still it made

* fix(studio-server): a failed frame extraction leaves no partial still

* fix(studio-server): a freeze claims its still's name before extracting

* fix(studio-server): a freeze past the media's end fails instead of saving an empty still

* refactor(studio-server): freeze route names say what its comments said
2026-10-03 17:02:20 -07:00
Miguel Ángel b71aa9338b fix(parsers): a keyframe edit no longer overrides an earlier tween at the start (#4935)
* fix(parsers): a keyframe edit no longer overrides an earlier tween at the start

Writing a keyframe into a tween that starts later added a t=0 hold for every position prop of
its first keyframe, so a from() that already moves x before it lost its start value. The hold
now skips props an earlier tween on the same target writes; both writers share one helper.

* fix(parsers): keep the hold for label-placed tweens and re-sync one-line scripts stably

The recast parser leaves a label start unresolved; an unknown start no longer counts as earlier,
so the hold stays. The acorn insert now ends the hold on its own line when the next statement
shares the timeline line, matching what removal takes, so a second sync changes no bytes.

* fix(parsers): the recast hold sync reads tween starts from the acorn parse

The recast parser cannot resolve label positions, so an earlier label-placed tween looked
unordered and kept a hold it should not. Both writers now decide holds on the same parse.

* revert(parsers): keep the recast hold sync on its own parse

Deciding recast holds on the acorn parse lost selectors only recast resolves (an element bound
through a helper). Recast is the migration fallback and keeps its old hold for a start it cannot
resolve; the earlier-label case is decided by the default acorn writer, which resolves labels.

* style(parsers): name the hold insert's line ending instead of commenting it

* style(parsers): fit the hold helper's doc in four lines

* test(studio): bank the from/to edits this fix makes accurate and the resizes #4920 fixed
2026-10-03 15:55:35 -07:00
Miguel Ángel 9106772593 fix(studio): a rotation saves through its press route; a host hook reloads only the host's way (#4961)
* fix(studio): a rotation saves through the route its press chose

* fix(studio): a host hook reloads its preview only the way the host says
2026-10-03 15:54:29 -07:00
Miguel Ángel 2f14008b3c test(studio): serve the edit bench GSAP from the repo, never the network (#4968)
* test(studio): serve the edit bench GSAP from the repo, never the network

* test(studio): serve the installed gsap dist and never crash the bench on a closed request

* test(studio): serve only regular files from the bench gsap dist
2026-10-03 22:24:40 +00:00
Miguel Ángel 2cfbe3d257 fix(studio): an undo or drop on an animated layer keeps it where it was (#4934)
* fix(studio): an undo or drop on an animated layer keeps it where it was

* test(studio): bank the edit accuracy cases the soft reload fix makes pass

Spliced from gate run 37151157876 (merged with main 7dea1e58): the 207 newly passing lines only, byte for byte; every other line is main's.
2026-10-03 14:35:50 -07:00
Miguel Ángel e27cae0261 fix(studio): a resize saves through the route its press chose, as a move does (#4943) 2026-10-03 14:32:46 -07:00
Miguel Ángel 248ee7c00a test(studio): pin what the one DOM edit writer saves, records and reloads (#4947)
* test(studio): pin what the one DOM edit writer saves, records and reloads

useDomEditPersist had no test file of its own. These cases pin its current behaviour before the
patch-element and gsap-mutations clients are folded into one each.

* test(studio): pin the DOM writer's partial save, SDK fallbacks and save counter

* test(studio): pin that the DOM writer reloads only after its history record settles
2026-10-03 14:28:42 -07:00
Miguel Ángel da17c03c05 fix(studio): the preview reloads when any write a commit saved is not patched on screen (#4920)
* fix(studio): soft-reload a batch when one of its writes has no in-place patch

A corner resize saves size and position in one batch; only the position had an in-place patch,
so the preview patched it and skipped the reload, and the new size snapped back until reload.

* fix(studio): a deferred write without an in-place patch still reloads at the group's end

A group drag defers each member's preview sync and carries a missed patch to the final render.
A write with no patch at all never set that latch, so a last member that patched in place
skipped the reload and the earlier member kept its old position on screen.
2026-10-03 14:27:48 -07:00
Miguel Ángel 6149ec87e8 chore: release v0.8.116 (#4969) 2026-10-03 13:31:30 -07:00
Miguel Ángel 7dea1e581f fix(studio): playhead keyframe edits keep their ease, undo names its edit and never waits on videos (#4889)
* fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween

* fix(studio): undo pressed while an edit saves undoes that edit, not one started after the key

* fix(studio): an edit stays saved when the history reply after it cannot be read

* feat(studio): export ColorField and GradientField; a session style commit takes a map

* refactor(studio): name the map form instead of commenting it

* feat(studio): a host can move layers by exact film px, one undo per call

handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each
selection by its delta in composition px, for Desktop's align and distribute.

It reuses the group drag's own path: a drag member per element (press-time
route, measured offset map), the delta carried to screen px through the
preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared
coalesce key gives one undo entry and GSAP members take the GSAP write.
A member that can't move refuses the call before anything is written; the
refusal rejects with its reason and shows no Studio toast (the group commit
takes refusalToast: false; telemetry stays). Zero deltas are skipped.

Tests: a pre-offset element and one under a half-scale parent move by exactly
{10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg
rotated ancestor lands within the 0.001 px a translate is written with; a
GSAP member goes through the GSAP write; three refusal kinds write nothing,
leave the boxes where they were, show no toast and leave no gesture mark,
while a canvas group drag still toasts; the session hands the call to the
group commit with refusalToast: false. Each mutation (no frame mapping, no
zero skip, no restore, no teardown, no capability check, toast on refusal)
fails at least one of them.

* fix(studio): an outside change during a drag waits for the drop to show

While a gesture mark is live in the preview, refreshPlayer takes the shadow load instead of
swapping scenes in place. A swap replaced the node under the pointer mid-drag; the shadow load
is then held by the existing gesture hold and, if the drop saved, reloaded fresh by the stale
rule, so this adds no second "released" signal.

Test: an outside change during a live drag on a preview that can swap scenes leaves the dragged
node in place, shows nothing under the pointer, and after the drop loads fresh with the dropped
position. It fails on the parent without the condition ("swapped under the pointer").

Cost case, measured in a real headless preview on the edit-accuracy bench's per-frame teleport
check. Every GSAP instant patch was forced to miss and fall back to reloadPreview during its own
commit; move and rotate of a held element (gsap.set and tl.set holds), px and xPercent
placement, r0 and r30, root and nested, at 100%:
- The gesture mark was live when the fallback asked for the reload, but had always ended by the
  time refreshPlayer ran (refreshKey is consumed in an effect after the commit settles). So the
  new condition never fired there: root previews offer no scene swap and load shadow on both
  builds, and nested previews swap on both builds.
- Worst post-release frame offset, 32 cases on this build and 32 on the parent: 0.00 px over
  76-106 frames each (release to settle). No frame was off by more than 0.5 px.
- Sensitivity: a 3 px nudge injected 50 ms after the fallback reads back as 3.00 px.

* fix(studio): an edit no longer reopens the live-updates stream, and thumbnails ask one at a time

* fix(studio): an undo no longer waits behind paused preview videos for a connection

* feat(studio): a host whose API needs same-origin cookies can have Studio send them

* docs(studio-server): a host on same-origin cookies sets the Studio credentials opt-out

* test(studio): an undo is not held up by the preview's paused videos

* fix(studio): a resize inside one gesture writes a mixed tween once, in place, never beside it

* chore(studio): keep the carried fixes inside the comment ratchet; telemetry posts through studioApiFetch

* test(studio): a drag on a mixed tween is one write under the gesture key

* fix(studio): freeze-frame, loudness and peak-map requests go through studioApiFetch too

* fix(studio): an undo with no project open reloads in full instead of skipping nested files

* refactor(studio): drop the soft reload's reparse seeding, the held-element sweep covers it

* test(studio): keep the undo-restore browser test off the network and inside its timeout

* test(studio): nested composition reads expect the cookie-free request studioApiFetch sends

* fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times

* fix(studio): a rotate on a tween that turns another axis holds its angle and never turns early

* fix(studio): plan every group member before the first write so one refusal moves none

A group drag or nudge preflighted each member against the move refusal rule only. The value-at-playhead planner ran later, in the commit loop, after earlier members were already flushed to disk, so a tween with extras, eased keyframes or an unknown ease left the group half moved.

The group preflight now runs the commit's own route and keyframe plan for every member with its real offset, so any refusal refuses the whole group with nothing written and one toast attributed to the refusing member. The plan is not reused in the commit: earlier members' writes change the file under it, so each member re-plans against the fresh tween, from the same planValueEdit.

* chore(studio): the fetch lint rule also catches window.fetch, globalThis.fetch and self.fetch

* docs(studio-server): set the credentials flag before Studio loads; a mounting host injects it

* test(studio): the undo socket test needs six pinned videos under Vite and checks each undo restores

* fix(studio): Studio leaves cookies out of its requests only when served from loopback

* fix(studio): undo takes back the first edit, and the first save never waits on media

* fix(studio): undo of a move whose save failed stays undone and steps back no older edit

* fix(studio): a style set by its JavaScript name saves under its CSS name

* test(studio): the undo socket test holds round 0 to a full restore too

* fix(studio): the host revert keeps returning what shows the edit again

* fix(studio): an entrance rewritten at the playhead still shows its start state before it starts

* fix(studio): undo takes back the edit pending at the key even after a later edit claims

* chore(studio): lift the late-write quarantine now that undo never waits on videos; format

* test(studio): pin an entrance's start render on the written tween, not a happy-dom GSAP run

* revert(studio): leave entrances as on main, immediateRender does not render a keyframed start

This reverts commit ee9d130d3c and its written-vars test.

* revert(studio): undo goes back to the newest claim after the key, as on main

This reverts commit 5b9009a823.

* revert(studio): first-undo history opening moves to the next PR

This reverts commit 4e1e75d7f4.

* revert(studio): the undo socket test exempts round 0 again until first-undo lands

This reverts commit 1f8f97047c.

* test(studio): bank the edit accuracy cases this PR makes pass

* refactor(studio): share one project file fetch between the plain and optional reads

* test(studio): share the WebMCP host shim between the browser gates

* test(studio-server): share one stub adapter across the route tests

* refactor(studio): type the preview slot props from the NLE context

* test(studio): share the setup the new editing tests copied between files

* refactor(studio): split playhead tween picking and undo path merging into small tested helpers

* test(studio): pin when a resize at the playhead also writes the anchor move

* test(parsers): pin rewriting a tween in place as keyframes

* chore(studio): cut comments the new helper names already say

* fix(studio): composition thumbnails keep two fetches in flight, as the document lifetime fix expects

* fix(studio): a playhead edit parses a tween not yet played and matches only its own tween

* fix(parsers): a new position argument goes after the last argument, so trailing commas stay valid

* fix(studio): a second undo pressed during one save steps back to the edit before it

* test(studio): pin the slot an aborted thumbnail frees and the shared tween refusal

* refactor(studio): split the unplayed-tween parse into small helpers and name the claim release

* fix(studio): a playhead edit keeps the tween's default ease, delay timing and loop settings

* fix(studio): a group move refuses layers animated in two files and plans at the real drop

* style(studio): format two test files

* refactor(studio): check a group's files inside the preflight helper

* refactor(studio): move the group preflight checks out of the editing hook, under 600 lines

* fix(studio): read an unreached tween without redrawing other layers, and compare write files

* test(studio): use the shared selection helper in main's keyframe-at-time telemetry test

* chore(studio): fix a comment that read as a repo path and cut one added comment

* fix(studio): read a later tween's start as playback does, and count every keyframe add

* fix(studio): two undo presses during one save step back two edits, not one twice

* fix(studio): an undo that succeeds by any route retires the claim on the edit it undid

* fix(studio): a claim is never handed back to an edit another undo has already undone

* fix(studio): an undo picks its claimed edit at the press and skips it once undone

* fix(studio): a press refuses, as main did, once its edit is undone and a newer edit exists

* fix(studio): an unplayed keyframe tween clears every keyframe's channels before it is read

* fix(studio): reading an unplayed tween puts back every transform value its clear removed

* fix(studio): reading an unplayed tween restores each layer's whole style and GSAP state

* fix(studio): reading an unplayed tween skips plain-object targets and restores attributes

* fix(studio): shared tween-target filter uses GSAP's own styled-element test

* fix(studio): match a parsed tween on any target, and check a later keyframe channel's start

* docs(studio): say the unplayed-tween read restores styled elements only

* test(studio): a drag on a fromTo start shows the dropped x through the runtime seek
2026-10-03 13:10:54 -07:00
Miguel Ángel f7a91f6982 feat(studio): export useThumbnailStill so a host's own pictures go through the thumbnail scheduler (#4955) 2026-10-03 19:54:04 +00:00
Miguel Ángel 5561b8cb2f ci: a test-only change under studio or player needs no captures (#4956)
* ci: a test-only change under studio or player needs no captures

Tests are not behaviour a user sees, like the markdown the check already skips.
A bench-harness PR (#4953) was asked for Before and After screenshots.

* ci: pin a mixed source and test change, and say tests are not counted
2026-10-03 17:04:44 +00:00
Miguel Ángel dbe6bb7f9c test(studio): the edit accuracy bench uses the port its server bound (#4953)
* test(studio): the edit accuracy bench uses the port its server bound

A busy port made the CLI bind the next free one, and the bench then failed
the case with "studio moved from port N to N+1" instead of using it.

* test(studio): drop a comment the doc line already says

* test(studio): the bench server test removes its temp dir

* test(studio): the bench stops its Studio server on Windows too

Windows has no process groups, so the group kill never reached the server and
the stop waited forever (the new server test timed out on windows-latest).
taskkill /T, the CLI helper for the same job, ends the tree there.

* test(studio): a failed taskkill surfaces, and the server opens no console on Windows
2026-10-03 16:34:56 +00:00
Miguel Ángel 461d83cd69 chore: release v0.8.115 (#4958) 2026-10-03 09:34:27 -07:00
Miguel Ángel ce08f2042e fix(studio): a hovered menu row fills its row, and a host can hide the clip menu tools (#4921)
* fix(studio): a hovered menu row fills its row, with no band around it

* refactor(studio): one shared menu style owns every context menu's panel, rows and dividers

* fix(studio): menus clip their rows to the panel's corners and cover every hand-built panel

* fix(studio): asset menu Delete shows red, trim menu owner comments, test submenu placement

* docs(studio): shorten a submenu comment

* feat(studio): a host can hide the clip menu tools without losing the edits fades need

* test(studio): the clip menu tools fixture is splittable

* test(studio): the default clip menu shows Voice, the control for hiding it

* docs(studio): shorten the menu owner comment

* docs(studio): drop comment lines the ratchet counts

* docs(studio): the menu owner carries one comment

* style(studio): format the files the repo formatter listed

* test(studio): the menu style scan names files with forward slashes on Windows too
2026-10-03 11:55:09 +00:00
Miguel Ángel 5632356c09 docs: show agents where Studio edits go and how tests build, and let docs skip captures (#4936)
* fix(ci): let markdown-only changes under studio and player skip the captures check

* docs: map the Studio edit path, the edit accuracy bench and test setup for agents

* docs: name the Studio captures rule and the local comment checks

* docs: point the Studio gate notes at the scripts that own the rules

* docs: correct the Studio save paths and add the producer stage map

* docs: name where producer sizes workers and which captures encode inline
2026-10-03 10:15:47 +00:00
Miguel Ángel 70dde41b5c perf(studio): keep timeline bulk actions under a second, hand-move at most 3 clips (#4932)
* perf(studio): keep timeline bulk actions under a second, hand-move at most 3 clips

Dragging or keyboard-moving a selection of 4 or more clips is refused with a
toast pointing at the prompt box; 1 to 3 clips move as before.

Root causes of the multi-second waits on large projects:
- every selected clip fired its own source-existence probe; they now leave as
  one request per file
- a timeline delete sent one request per clip, each re-parsing the file; it is
  now one request that parses once
- each click on a clip from another file re-fetched GSAP data for every
  composition; covered files are kept and unchanged files answer 304
- any root index.html write re-captured every composition thumbnail; only a
  root head edit does now, since a scene page borrows nothing else from it

Adds timeline-bulk-actions.mjs, a browser benchmark of select-all, move 3 and
delete-all on a real project.

* chore(studio): trim the bulk benchmark header to the comment limit

* refactor(studio): tidy the bulk-action fixes after review

Root head memo is keyed by the root file's content so every subscriber of one write gets one answer, and a thumbnail request can no longer overwrite it. The GSAP read reuses the shared content version as its ETag.

* fix(studio): keep a head edit's thumbnail refresh when a later write replaces it

File changes coalesce to the newest while a drain is in flight, so the affected-composition lists are merged instead of replaced. A failed GSAP read stays unloaded and is retried, and the GSAP read's ETag is salted with the parser version.

* feat(studio): cap hand-resizing a multi-selection at 3 clips like moving

Dragging a resize edge with 4 or more clips selected is refused with the same toast as a move; the rule is one constant and one helper shared by pointer move, pointer resize and keyboard pickup.

* fix(studio): keep a head edit's thumbnail refresh through a replaced conflict

A newer blocked change replaces the held one, so it now inherits the thumbnails the held one owed.

* fix(studio): keep a held head edit's thumbnail refresh when a later write saves cleanly

The clean-save path and the failed-cleanup path now merge the held blocked change's affected compositions, and the blocked-state merge runs inside the state update so two conflicts in one tick cannot lose the first.

* chore(studio): keep useTimelineClipDrag under the 600-line cap

* feat(studio): cap by the clips an edit would change, and cut the added comments

The 3-clip rule counts the clips a move or trim would write to: a locked clip swept into a move stays put, and a trim some member cannot take edits only the grabbed clip. Comment share is back at or under the base in every changed file.

* fix(studio): clear fallow audit and comment length on the multi-select cap

* fix(studio): keep bench script comments under the block limit

* fix(studio): keep the failed file's payload when another file's snapshot delete fails
2026-10-03 08:16:01 +00:00
Miguel Ángel 703d0e6621 test(studio): print PASS for edit accuracy cases that pass every metric (#4925)
* test(studio): print PASS for edit accuracy cases that pass every metric

* test(studio): pin that a smoothness-only failure fails the case
2026-10-03 01:34:56 +00:00
Miguel Ángel ada4e0d289 fix(studio): show each timeline row its own layer instead of a flat bar (#4917)
* fix(studio): show each timeline row its own layer instead of a grey bar

A row got a frame only if it spanned under 92% of the film and its id did not
look like a backdrop; every other row drew a plain grey bar. Every element row
now gets its frame, and the capture fades the row element's siblings first, so
the frame shows that layer and not the whole film. Studio's dev server and the
CLI share one capture helper; Studio's own copy is gone.

* fix(studio): read timeline rows from any realm so each keeps its selector

* refactor(studio-server): always isolate the element in a thumbnail clip

* fix(cli): undo a thumbnail's isolation before its page takes the next capture

* chore(core): generate the dom-realm export and trim thumbnail clip comments

* fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys

* fix(studio-server): keep faded siblings in a page-owned list so restore is exact

* feat(studio): draw a text layer row as its own live text instead of a captured picture

* refactor(studio): move timeline text reading into its own module

* fix(studio): keep text rows readable and current, and leave painted layers as pictures

* fix(studio): refresh a canvas-edited text row by its own identity and source file

* fix(studio): refresh a canvas-edited text row known only by its data-hf-id
2026-10-03 00:22:49 +00:00
Miguel Ángel 852782bbb0 feat(studio): track completed preview editing gestures (#4916)
* feat(studio): track completed preview editing gestures

* fix(studio): preserve gesture receipts and suppress duplicate usage

* fix(studio): count semantic keyframe writes once per gesture

* fix(studio): isolate recording state during pending saves

* fix(studio): count marquee selection changes from owner receipts

* fix(studio): retain accurate empty marquee receipts

* refactor(studio): simplify recording transitions and selection tests

* refactor(studio): share recording timing and marquee fixtures

* fix(studio): preserve recording targets and batch attribution

* fix(studio): retain per-member telemetry suppression

* style(studio): format batch receipt attribution

* test(studio): match writer receipts and animation metadata

* docs(studio): clarify paused position save rejection

* fix(studio): replace telemetry route names with route identity

* style(studio): format route identity regression

* Use random tab-local route identities in Studio telemetry

* Preserve route identities inherited through session storage

* Separate route storage validation from token creation
2026-10-03 00:13:18 +00:00
Miguel Ángel 01c6f6004a chore: release v0.8.114 (#4922) 2026-10-02 17:14:16 -07:00
James RussoandMiguel Ángel 5c52f72399 fix(studio): parse modern CSS colors without a new dependency (#3931)
* fix(studio): parse modern CSS colors without a new dependency

* refactor(studio): one colour parser for the text toolbar, stricter rgb numbers

The inline text toolbar kept its own canvas fallback for named colours.
parseCssColor now resolves them, so the toolbar uses it alone.
rgb() channels must be well-formed numbers; that replaces the NaN check
after parsing and rejects rgb(1. 2 3) as CSS does.

* refactor(studio): drop comment lines the comment ratchet refuses in colorValue

* fix(studio): linear-time rgb() patterns and no new colour literal in the parser

The rgb() number pattern now has one way to match each digit run, which clears
CodeQL's slow-regex alerts. The canvas reset uses transparent so the Studio
colour-literal ratchet stays at its baseline.

* fix(studio): legacy colours resolve in browsers without relative colour syntax

parseBrowserColor reads the colour itself from the canvas first and accepts a
#rrggbb or rgba() result, then tries color(from ...). Named, hsl() and percent
rgb() colours resolve again in the text toolbar on Chrome before 119 and Safari 17.

* test(studio): a colour the canvas rejects parses to null, not transparent black

* fix(studio): an inserted gradient stop takes the colour the gradient paints there

The stop colour is color-mix() in oklab, or srgb when both stops are legacy,
resolved by the browser, so it matches the painted midpoint including
premultiplied alpha; the channel blend stays as the fallback. The parser tries
color(from ...) before the legacy read, keeping alpha precise, and calls
CSS.supports inside its try. A Chrome-147-shaped fake canvas covers the modern
path in unit tests.

* test(studio): keep the fake browser canvas inside its test file

* fix(studio): mix an inserted gradient stop in the space the whole gradient uses

* fix(studio): let an unresolved var() stop keep a legacy gradient in sRGB

* fix(studio): only a bare var() stop leaves the gradient space to its neighbours

* fix(studio): pick light-dark() text colour from what the element paints

* refactor(studio): drop the neighbour clause the whole-gradient check already covers

* fix(studio): resolve the picker colour from the element that paints the selected text

* fix(studio): read the picker colour from the character the swatch colour came from

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-10-02 22:26:00 +00:00
Miguel Ángel dc9c38efd8 fix(studio): keep a keyframe edit from copying other tweens' channels (#4918) 2026-10-02 22:12:53 +00:00
Miguel Ángel 438ea9d59f perf(studio): manual edits stay under 8 ms per frame (#4898)
* ci(studio): measure any edit cases on quiet runners, five runs in parallel

* perf(studio): an undo or save stops re-rendering every panel

* test(studio): the edit bench types one key per frame, as a person does

* fix(studio): the edit bench measures frame work when a task outside the drag has no thread time

* refactor(studio): hooks hand back stable handlers themselves, keeping App.tsx under 600 lines

* test(studio): the edit bench says why a case's frame work is unknown

* fix(studio): the edit bench counts a task without thread time at its wall time

* perf(studio): draw toasts on an opaque surface without backdrop blur

* fix(studio): keep stable handlers bound to the project they were made in

* fix(studio): every stable handler names the project it belongs to

* style(studio): format the stable handler call sites

* refactor(studio): name the timeline editing handler objects before wrapping them
2026-10-02 21:15:19 +00:00
Miguel Ángel 00b61b8b76 fix(studio): one GSAP-writes check that sees every keyframe form (#4781)
* fix(studio): one GSAP-writes check that sees every keyframe form

* refactor(studio): keep the shared GSAP-writes check comment-free

* refactor(studio): import the crop GSAP checks in one line
2026-10-02 21:09:46 +00:00
Miguel Ángel d5b56426c2 test(studio): give the real-Chrome undo test a time budget Windows can meet (#4912)
* test(studio): give the real-Chrome undo test a time budget Windows can meet

The first case launches a page in a cold Chrome. On the Windows studio-2 runner it took over 5s and hit Vitest's default test timeout; its afterAll browser.close hit the 10s hook default. One 60s budget now covers setup, each case and teardown.

* test(studio): set the real-Chrome test budget once per file
2026-10-02 19:25:38 +00:00
Miguel Ángel aca4bc2f49 chore: release v0.8.113 (#4913) 2026-10-02 12:14:00 -07:00
Miguel Ángel ea55218eba chore: release v0.8.112 (#4909) 2026-10-02 08:36:30 -07:00
Miguel Ángel 05145bf73c feat(studio): export timeline row order and fade handle boxes, readable fx badge (#4904)
* feat(studio): export the timeline's row order and fade handle boxes for hosts

displayTrackOrder(elements, collapsedGroupIds) gives the rows the timeline
draws, built by the same function as the timeline hook. fadeHandleBoxes gives
where TimelineClipFades puts its fade handles. The z-order mirror's sorted
lane list is renamed ascendingTrackOrder.

* style(studio): wrap the row derivation's doc comment

* style(studio): format the fade handle geometry

* refactor(studio): reuse timelineTrackOrder and give fade handle boxes their height

* fix(studio): make the inactive fx badge readable at 4.5:1

* docs(studio): fit the fade handle geometry comment in four lines
2026-10-02 08:21:18 -07:00
Miguel Ángel 67dbcf7bdb feat(studio): preview timeline track insertion (#4907)
* feat(studio): preview new tracks at drop boundaries

* fix(studio): preserve group ownership across overlapping moves

* refactor(studio): share timeline drop decisions and fixtures

* fix(studio): open an empty audio zone below visual tracks

* fix(studio): cancel thumbnails when project pages leave

* refactor(studio): share thumbnail regression setup

* test(studio): gate thumbnail socket release in browser CI

* chore(studio): keep optimistic revision comments within ratchet

* fix(studio): pin regression proxy requests to its backend

* test(studio): bound the proxy origin probe

* test(studio): probe proxy origin through the ready document
2026-10-02 08:04:29 -07:00