Commit Graph
100 Commits
Author SHA1 Message Date
Miguel Ángel 89e970fbd7 chore: release v0.7.87 (#2935) 2026-07-31 22:36:46 +02:00
Miguel Ángel ebea530806 Merge pull request #2933 from heygen-com/docs/studio-5419-capture-skill-guards
docs(skills): gate blocked website captures
2026-07-31 22:32:51 +02:00
Miguel Ángel 30f3830741 docs(skills): gate blocked website captures 2026-07-31 20:30:07 +00:00
Miguel Ángel 9ae0007261 fix(cli): preserve capture failure diagnostics 2026-07-31 20:30:07 +00:00
Miguel Ángel ac9458888c fix(cli): omit skipped Lottie previews 2026-07-31 20:30:07 +00:00
Miguel Ángel b38e907404 fix(cli): narrow blocked page titles 2026-07-31 20:30:07 +00:00
Miguel Ángel dfc60797a2 fix(cli): report bounded vision failures 2026-07-31 20:30:07 +00:00
Miguel Ángel 22e3cca966 fix(cli): reject blocked website captures 2026-07-31 20:30:07 +00:00
Miguel Ángel d3607606ee fix(cli): validate capture budget milliseconds 2026-07-31 20:30:07 +00:00
Miguel Ángel 49091e6142 fix(cli): propagate live capture budget 2026-07-31 20:30:07 +00:00
Miguel Ángel 45aead84c9 test(cli): cover bounded capture stages 2026-07-31 20:30:07 +00:00
Miguel Ángel 765a5ae83f fix(cli): bound capture runtime stages 2026-07-31 20:30:07 +00:00
Miguel Ángel c6925e471a feat(studio): enable timeline virtualization by default (#2926)
* feat(studio): enable timeline virtualization by default

* fix(ci): measure timeline performance in production React
2026-07-31 19:43:47 +02:00
Miguel Ángel 723d3381c4 fix(studio): keep dense keyframes readable (#2925)
* perf(studio): define timeline viewport budgets and fixtures

* test(studio): gate timeline viewport performance in Chromium

* refactor(studio): isolate clip drag lifecycle

* refactor(studio): extract timeline render contracts

* perf(studio): centralize timeline viewport geometry

* perf(studio): follow playhead across virtualized rows

* perf(studio): add timeline clip-window index primitive

* perf(studio): virtualize timeline clip windows

* perf(studio): stop timeline scroll work when row virtualization is off

The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.

Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.

The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.

Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.

The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.

Verified against a live Studio dev server on the fixture project:

  flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
  flag off, after:  interactionP95  33.6ms, longest task   0ms, 5/5 runs pass
  flag on,  after:  interactionP95  33.2ms, 4/5 runs pass, exit 0

The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.

* ci(studio): run the timeline viewport gate on studio changes

The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.

Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.

Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.

Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.

The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.

Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.

* fix(ci): preserve both timeline gate evidence arms

* ci(studio): report timeline gate arm statuses

* ci(studio): require timeline gate evidence artifacts

* fix(studio): keep dense keyframes readable

* fix(ci): resolve timeline stack audit findings
2026-07-31 18:05:22 +02:00
Miguel Ángel fbfffb1aa7 fix(studio): release retained preview resources (#2924) 2026-07-31 14:31:43 +02:00
Miguel Ángel b813b17345 fix(studio): prevent negative timeline latency telemetry (#2905) 2026-07-30 22:29:34 +02:00
Miguel Ángel 10b517dab9 feat(studio): track timeline performance (#2898) 2026-07-30 18:53:02 +02:00
Miguel Ángelandmillieyesstore-del 1481fe1ed9 fix(docs): correct mediabunny GitHub link in CREDITS.md (#2897)
The credited repo (github.com/nicoch/mediabunny) doesn't exist. The
correct upstream is github.com/Vanilagy/mediabunny, which matches the
package's npm registry metadata (repository field) and the MPL-2.0
license already cited here.

Co-authored-by: millieyesstore-del <millieyesstore-del@users.noreply.github.com>
2026-07-30 17:15:57 +02:00
Miguel Ángel 14ced90517 fix(skills): extend transition roots without explicit duration (#2873)
* fix(skills): extend roots without explicit duration

fixes reported:1785307750.289819:transitions-extend-tail-root-duration-contract-mismatch; PR #2859 and unrelated claims remain unmodified.

* chore(skills): refresh manifest
2026-07-30 17:06:12 +02:00
Miguel Ángel f81ac74572 fix(studio): stop popovers and tooltips clipping at panel edges (#2890)
The Renders tab format popover rendered as an in-flow absolute panel inside
the right panel, which is overflow-hidden, so it was sliced at the panel
edge. Portal it to the body and position it with the shared floating-panel
helper instead.

The ui/Tooltip bubble clamped only its centre point to the viewport, so a
wide bubble near an edge still hung off-screen (the timeline Selection tool
tooltip lost 32px on the left). Clamp with the measured bubble width.
2026-07-30 02:27:48 +02:00
Miguel Ángel cef3b86c95 chore(studio): remove fully rolled-out studio feature flags (#2889)
## What

Removes six Studio feature flags that have been default-`true` for 7+ weeks. Each is reachable under two env names, so this deletes **12 `VITE_STUDIO_*` env vars**:

| Flag constant | Env names removed | Default-on since |
|---|---|---|
| `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`, `VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | 2026-05-12 |
| `STUDIO_INSPECTOR_PANELS_ENABLED` (+ its `STUDIO_PREVIEW_SELECTION_ENABLED` alias) | `VITE_STUDIO_ENABLE_INSPECTOR_PANELS`, `VITE_STUDIO_INSPECTOR_PANELS_ENABLED` | 2026-05-12 |
| `STUDIO_BLOCKS_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_BLOCKS_PANEL`, `VITE_STUDIO_BLOCKS_PANEL_ENABLED` | 2026-05-18 |
| `STUDIO_GSAP_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_GSAP_PANEL`, `VITE_STUDIO_GSAP_PANEL_ENABLED` | 2026-05-28 |
| `STUDIO_KEYFRAMES_ENABLED` | `VITE_STUDIO_ENABLE_KEYFRAMES`, `VITE_STUDIO_KEYFRAMES_ENABLED` | 2026-06-05 |
| `STUDIO_RAZOR_TOOL_ENABLED` | `VITE_STUDIO_ENABLE_RAZOR_TOOL`, `VITE_STUDIO_RAZOR_TOOL_ENABLED` | 2026-06-10 |

## Why

Every one of these shipped as a rollout gate, went to `true`, and then stayed. Because none of them was ever flipped back, the `false` branch was unreachable in practice while still costing a real import, a real conditional, and a real "what happens if this is off?" question at ~90 call sites across 25 files.

The bigger cost is what the dead branch kept alive. Removing the flags also removes the disabled-Studio code paths that only existed to serve them:

- the greyed-out, `disabled`, "Manual editing is temporarily disabled" Inspector button in `StudioHeader` (and the `STUDIO_MANUAL_EDITING_DISABLED_TITLE` constant behind it)
- the inspector-off reset `useEffect` in `useDomSelection`, which force-cleared selection and redirected the right panel to Renders
- three selection kill-switch early-returns in `useDomSelection` (`applyDomSelection`, `handleTimelineElementSelect`, `applyMarqueeSelection`)
- the tab-redirect branch in `normalizeStudioUrlPanelTab`, whose `options.inspectorPanelsEnabled` parameter had no production caller at all (only tests passed it)

## How

No behavior change: every flag was removed by keeping its default-`true` side.

The call-site edits are three mechanical boolean shapes (`X && rest` → `rest`, `rest && X` → `rest`, `!X || rest` → `rest`), applied by script for uniformity. Everything else (ternaries, `if` guards, unreachable blocks, JSX wrappers that had no other condition) was done by hand and the whole diff was read line by line afterwards.

`resolveStudioBooleanEnvFlag` and the `import.meta.env` / `window.__HF_STUDIO_ENV__` plumbing stay: three flags still use them (`STUDIO_FLAT_INSPECTOR_ENABLED`, `STUDIO_SDK_CUTOVER_ENABLED`, `STUDIO_SDK_RESOLVER_SHADOW_ENABLED`). Its unit tests kept their coverage but now exercise a live flag pair instead of retired env names, so no dead `VITE_STUDIO_*` string is left in the repo.

Net **-191 lines** (236 insertions, 427 deletions across 25 files); most insertions are reindentation of JSX that lost a wrapper.

### Deliberately not in scope

Flags authored by other people are untouched, even where they look similarly settled:

- `VITE_STUDIO_ENABLE_FLAT_INSPECTOR` / `VITE_STUDIO_FLAT_INSPECTOR_ENABLED` (default true, but not mine)
- `VITE_STUDIO_SDK_CUTOVER_ENABLED`, `VITE_STUDIO_SDK_CUTOVER_FAMILIES`, `VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (SDK cutover canary, still soaking)
- `VITE_HYPERFRAMES_NO_TELEMETRY`

Mine but genuinely long-lived configuration rather than rollout gates, so they stay: `VITE_STUDIO_DISCOVERY_PORTS`, `VITE_HYPERFRAMES_FEEDBACK_INTERVAL`, `VITE_HYPERFRAMES_NO_FEEDBACK` (a documented user opt-out), plus the `HYPERFRAMES_*` binary paths, API URLs, cache sizes, and timeouts.

`VITE_STUDIO_ENABLE_MOTION_PANEL` / `VITE_STUDIO_MOTION_PANEL_ENABLED` were already retired from production code before this PR; they only survived as placeholder names inside the resolver's unit tests, and this PR swaps those out.

## Test plan

- [x] Unit tests added/updated - dropped the two tests asserting removed flag defaults; retargeted the `resolveStudioBooleanEnvFlag` cases at a live flag pair; updated `studioUrlState` tests for the narrowed `normalizeStudioUrlPanelTab` signature (now also asserts an unknown tab returns `null`).
- [x] Manual testing performed - see below.
- [ ] Documentation updated (if applicable) - not needed; no removed name appears in `docs/`, `skills/`, or `registry/`. (`docs/changelog.mdx` has one historical entry naming `STUDIO_KEYFRAMES_ENABLED`; changelog history is left as written.)

```
packages/studio: bunx vitest run          # 280 files, 3116 tests pass, 1 skipped
packages/studio: bunx tsc --noEmit        # clean
bun run build                             # green (all packages)
bunx oxlint  <25 changed files>           # 0 warnings, 0 errors
bunx oxfmt --check <25 changed files>     # clean
```

Two extra checks, because part of this diff was script-generated:

1. Zero references to any removed flag constant or env name remain anywhere outside `docs/changelog.mdx`.
2. Diffed every string literal in each changed non-test file against `origin/main`. The only differences are the intended removals: the 12 env names, `"Manual editing is temporarily disabled"`, the `"cursor-not-allowed …"` disabled class, the 3-column `"1fr 1fr 1fr"` grid, and the `"renders"` redirect literals. No user-facing label, tooltip, or class string changed by accident.
2026-07-30 02:00:05 +02:00
Miguel Ángel 87791fd01d fix(producer): keep large local fonts file-backed (#2864)
* fix(producer): keep large local fonts file-backed

* fix(producer): avoid local font file races

* fix(producer): bound local font stream reads

* fix(producer): cache large font file-backed decisions
2026-07-29 22:11:54 +02:00
Miguel Ángel b7160f69bb fix: exclude hidden audio from render mix (#2870)
* fix: exclude hidden audio from render mix

* fix(engine): honor hidden media ancestry in audio mix
2026-07-29 22:10:21 +02:00
Miguel Ángel 4ad1cf4551 fix(parsers): validate Windows FFmpeg discovery candidates (#2871)
* fix(parsers): validate ffmpeg discovery candidates

fixes reported:1785304892.118879:unicode-home-ffmpeg-discovery; PR #2859 remains unmodified.

* fix(parsers): avoid Windows console path decoding
2026-07-29 22:01:48 +02:00
Miguel Ángel 04e0ccce42 fix: preserve plateaus in sampled audio automation (#2863)
* fix: preserve audio automation plateaus

* fix(audio): align automation probe windows
2026-07-29 20:51:33 +02:00
Miguel Ángel 85f0c9d354 fix(cli): select host-compatible cached browser (#2861)
* fix(cli): select host-compatible cached browser

* test(engine): make browser cache fixture portable

* fix(browser): reject foreign ARM cache binaries
2026-07-29 20:51:01 +02:00
Miguel Ángel fdc5932897 fix(cli): honor check navigation timeout (#2860)
* fix(cli): honor check navigation timeout

* test(cli): clarify diagnostic timeout precedence
2026-07-29 20:50:20 +02:00
Miguel Ángel 6cfb05e38b fix(render): preserve transparency in GIF output (#2327)
## What

- Treat GIF as an alpha-capable output format and capture its frames as RGBA PNGs.
- Encode transparent GIFs with explicit FFmpeg palette semantics: `reserve_transparent=1` and `alpha_threshold=128`.
- Keep page-side shader compositing enabled for GIF while the resulting composite is captured through the RGBA disk-frame path.
- Extend the real render harness to verify decoded GIF alpha and compare a GIF shader-transition frame against the existing MP4 golden.
- Preserve the existing opaque encoder contract: `needsAlpha=false` continues to use JPEG frames without alpha-only palette filters.

## Why

Direct `--format gif` renders silently flattened transparent compositions when frames are captured as JPEG, because the palette encoder receives no alpha plane to preserve.

GIF also needs page-side shader compositing. A blanket `needsAlpha` exclusion disabled that path after enabling RGBA capture, while the layered compositor intentionally excludes GIF. That left shader GIFs on the DOM fallback and produced hard cuts instead of the authored WebGL blend.

## How

- Centralize output alpha detection in `outputNeedsAlpha`, shared by in-process and distributed planning.
- Select PNG or JPEG GIF frame input from the resolved alpha requirement.
- Make palette transparency flags explicit and conditional so the legacy opaque path retains its existing arguments.
- Add an explicit output-format capability for page-side shader compositing: MP4 keeps its opaque streaming path, GIF uses RGBA PNG disk frames, and WebM/MOV/PNG sequence retain their existing paths.
- Add `data-no-timeline` to the static transparency fixture so the artifact regression does not wait for a timeline it intentionally does not register.

## Test plan

- [x] RED on base: direct GIF decoded with an opaque corner instead of alpha 0.
- [x] RED on the previous PR head: the real GIF shader-transition frame scored 11.05 dB against the existing golden because neither shader compositor was active.
- [x] `bun test packages/producer/src/services/render/renderFormat.test.ts packages/producer/src/services/render/stages/encodeStage.test.ts packages/producer/src/services/render/capturePlan.test.ts` — 23 passed.
- [x] `bun run --filter @hyperframes/producer typecheck`
- [x] `bun run --filter @hyperframes/producer build`
- [x] `bun run --filter @hyperframes/producer test:transparency` — WebM, GIF, and PNG sequence alpha assertions passed; GIF shader control/transition frames scored 28.11/26.57 dB against the golden.
- [x] `bun run --cwd packages/producer tsx src/regression-harness.ts page-side-shader-compositor-render-compat --sequential` — all 100 visual checkpoints passed, stream parity passed, and audio correlation was 1.000.
- [x] Changed-file oxlint, oxfmt check, pre-commit checks, and `git diff --check`.
2026-07-29 20:05:42 +02:00
Miguel Ángel d8d626537b fix(studio): resolve Chrome on Windows (#2878) 2026-07-29 17:31:40 +02:00
Miguel Ángel 6cab53a681 docs(skills): name the media-treatment policy targets instead of alluding to them (#2879)
Two routing surfaces pointed agents at media "policies" and "contracts" without
naming a file. An agent that goes looking and finds nothing fills the gap with
an invented rule.

- skills/hyperframes/SKILL.md: "Load its media-treatment policy" now names
  references/media-treatments.md and states the policy governs how footage is
  treated, never whether media may be used.
- skills/general-video/SKILL.md: "adoption, resolution, provider, provenance,
  and reuse contracts" now names references/resolve.md and
  references/setup-providers.md. Drops "provenance", which is ledger
  bookkeeping in operations.md, not a contract to follow.
- The scaffolded project templates carried the same unnamed pointer and ship to
  every hyperframes init; both updated and kept byte-identical.
2026-07-29 17:27:07 +02:00
Miguel Ángel b904343949 chore: release v0.7.82 2026-07-29 11:25:04 +00:00
Miguel Ángel f1655b9302 fix(studio): honor selected render resolution (#2876) 2026-07-29 13:20:47 +02:00
Miguel Ángel 675afc5654 Merge pull request #2868 from heygen-com/release/v0.7.80
chore: release v0.7.80
2026-07-29 04:44:42 +02:00
Miguel Ángel 5829515932 chore: release v0.7.80 2026-07-29 02:22:54 +00:00
Miguel Ángel b2d4fcac65 Merge pull request #2695 from heygen-com/codex/studio-timeline-c-ease-mode-switch-v2
fix(studio): switch keyframe ease modes optimistically
2026-07-29 03:59:07 +02:00
Miguel Angel Simon Sierra 5dad52370f fix(studio): keep hidden state on expanded sub-composition rows
An expanded sub-composition child row is built from a manifest clip, which
carries none of the host element's attributes, so data-hidden never reached
it. The eye on that row therefore always reported the element visible: the
first click hid it, and every click after wrote data-hidden again instead of
removing it. The element could not be shown again, not even after a reload,
because the attribute was already in the source.

The flat store element for the same child is built with its host element, so
the child row inherits hidden, timelineLocked and timelineRole from it.
2026-07-29 03:44:32 +02:00
Miguel Angel Simon Sierra adb7de5358 fix(studio): open the path node menu on arc waypoints
Right-clicking a motionPath waypoint in the preview overlay opened Chrome's
own context menu on top of the editor: the handler returned before
preventDefault for every node that was not an x/y keyframe. Both node kinds
now open Studio's menu. A waypoint has no percentage of its own, so Move to
Playhead is hidden and Delete acts on the path index, matching the hover x
badge; Delete is withheld entirely on a two-anchor arc, where the writer
refuses the removal and the entry would silently do nothing.
2026-07-29 03:44:31 +02:00
Miguel Angel Simon Sierra 659e22656e fix(studio): switch keyframe ease modes optimistically 2026-07-29 03:44:31 +02:00
Miguel Angel Simon Sierra 6b11d37433 fix(studio): publish keyframe cache refresh atomically 2026-07-29 03:44:30 +02:00
Miguel Angel Simon Sierra 23ab104aff refactor(studio): split bulk easing helpers 2026-07-29 03:39:52 +02:00
Miguel Angel Simon Sierra 10d45def05 feat(studio): bulk-edit easing for merged keyframes 2026-07-29 03:39:51 +02:00
Miguel Ángel 7482c22d82 fix(studio): target colliding keyframes exactly (#2692) 2026-07-29 03:39:17 +02:00
Miguel Ángel 9bbb6d50a0 fix: offset nested template video timing (#2859)
* fix: offset nested template video timing

* test(producer): cover nested sequential video render

* fix: share canonical nested media timing
2026-07-29 03:36:04 +02:00
Miguel Ángel 4f344c50b0 Merge pull request #2855 from heygen-com/fix/secure-runtime-dependencies
fix: remove vulnerable runtime dependency paths
2026-07-29 01:14:46 +02:00
Miguel Ángel b5be874582 Merge pull request #2858 from heygen-com/release/v0.7.79
chore: release v0.7.79
2026-07-29 01:09:09 +02:00
Miguel Ángel a68729bcf1 chore: release v0.7.79 2026-07-28 23:06:45 +00:00
Miguel Ángel a996e91e5d Merge pull request #2857 from heygen-com/fix/codex-imagegen-feature-flag
fix(media-use): accept renamed codex image_generation feature flag
2026-07-28 23:46:48 +02:00
Miguel Angel Simon Sierra b6da5bd6b3 chore: regenerate skills-manifest for media-use change 2026-07-28 23:31:32 +02:00
Miguel Angel Simon Sierra 02c57609ab fix(media-use): accept renamed codex image_generation feature flag
The codex image provider's fail-fast gate required an `imagegenext` row in
`codex features list`. Codex CLI 0.145 renamed that flag to `image_generation`
and dropped the old row, so the gate rejected every up-to-date CLI:

    media-use: codex image upsell unavailable: codex imagegenext unavailable
    (upgrade Codex CLI)

`resolve --type image --provider codex` returned that without ever attempting
a render, on hosts where image generation works fine. Codex itself now warns
`[features].imagegenext is deprecated. Use [features].image_generation`.

Resolve the flag name from the feature list instead of hardcoding it, and pass
whichever name the installed CLI exposes to `--enable`. The old name is
preferred when present, since CLIs that still list it reject the new one.

`codexUnavailableReason()` now returns `{ flag }` / `{ reason }` so the
resolved name reaches the exec.

Verified against codex-cli 0.145.0: `resolve --type image --provider codex`
generates and freezes an image. New unit tests pin both flag layouts so a
future rename fails loudly instead of silently disabling the provider.
2026-07-28 23:28:22 +02:00
Miguel Ángel 13ac9e3905 fix: remove vulnerable runtime dependency paths 2026-07-28 20:45:43 +00:00
Miguel Ángel 557569d18e Merge pull request #2849 from heygen-com/fix/studio-new-tween-target
fix(studio): author every new tween against one element
2026-07-28 21:57:52 +02:00
Miguel Ángel b1632f6676 Merge pull request #2848 from heygen-com/fix/studio-track-numbers-aria
fix(studio): announce real track numbers and wire lane aria-controls
2026-07-28 21:57:38 +02:00
Miguel Ángel b635e2fff2 Merge pull request #2847 from heygen-com/fix/studio-playhead-keyframe-target
fix(studio): target one element when adding a keyframe at the playhead
2026-07-28 21:57:24 +02:00
Miguel Ángel 1f2fff7f49 Merge pull request #2846 from heygen-com/fix/studio-lane-tween-attribution
fix(studio): lane every tween and attribute tweens to their real target
2026-07-28 21:57:09 +02:00
Miguel Ángel 6e6babe933 Merge pull request #2845 from heygen-com/fix/studio-subcomp-clip-timing
fix(studio): sub-composition clip timing and expanded rows
2026-07-28 21:56:56 +02:00
Miguel Ángel 7b74d99c23 Merge pull request #2844 from heygen-com/fix/studio-color-hex-shortcuts-panel
fix(studio): colour hex editing and shortcuts panel dismissal
2026-07-28 21:56:13 +02:00
Miguel Ángel 87241f8634 Merge pull request #2843 from heygen-com/fix/studio-ruler-seek-pointer-targets
fix(studio): timeline ruler seek, retime rounding, and pointer target sizes
2026-07-28 21:44:41 +02:00
Miguel Angel Simon Sierra 1f3fd2800c fix(studio): give the motion path and the fallbacks a one-element target
The narrowing this branch adds missed the motion-path overlay, and every
caller that could not narrow fell back to the exact bare class the narrowing
exists to replace.

- motionPathSelection.selectorFor now goes through writeTargetSelector. It
  feeds both the geometry read and the "set destination" write, so a class
  sibling measured its home off the FIRST sibling and then authored
  add-motion-path onto all of them. The toolbar toggle hides when no
  one-element form exists rather than arming a press that is dropped.
- The five new-tween writers that fell back to the selection's own selector
  now drop the commit instead. A gesture that does not persist reverts on the
  next reload; a tween silently aimed at five elements does not.
- tweenTargetsElement only follows the DOM to a target that matches exactly
  one element. A target the element merely shares with its siblings is a group
  tween, and these callers mutate what they find, so an individual nudge was
  rewriting the group's own tween and moving all five.
2026-07-28 21:21:37 +02:00
Miguel Angel Simon Sierra 3d92436066 fix(studio): author every new tween against one element
U3 fixed "add keyframe at playhead" widening a write to every sibling
sharing a class, but wired writeTargetSelector into only two paths. The
same bug was still reachable from the add-animation button, drag, resize,
rotate, gesture recording, and the property panel: each derived its target
from selectorFromSelection, which hands back a bare class for an id-less
element, so one edit authored a tween over all five siblings and the
timeline collapsed their rows into one.

Route every path that authors a NEW tween through the existing ladder:

- ensureElementAddressable now accepts selection.selector only when it
  addresses exactly one element, so the id-minting fallback right below it
  (previously unreachable whenever any selector was present) does the work.
- gsapDragCommit's five new-tween branches go through one newTweenTarget
  helper; instant patches reuse the written target so the runtime moves the
  element the source write names.
- useGestureCommit and useAnimatedPropertyCommit keep the existing selector
  for matching/retargeting and author new tweens with a separate write
  selector.

Retargets of an EXISTING tween are deliberately untouched: they keep
anim.targetSelector, so a tween aimed at a whole group stays aimed at it.

Narrowing the write alone regressed idempotency, verified by test: the
"is there already a write for this element" lookups matched targetSelector
by string, so the next nudge missed the write it had just made and appended
a second, conflicting one. The read half now falls back to the live DOM
(tweenTargetsElement, same contract as getAnimationsForElement), which also
still matches a deliberate group tween.

Tests reproduce each site through a real writer, re-parse with the real
parser, and resolve through resolveSelectorElementIds (what feeds the
keyframe cache and the lanes), plus pins for the new-tween vs
retarget-existing distinction so a future change cannot collapse the two.
2026-07-28 21:21:37 +02:00
Miguel Angel Simon Sierra ba0d6406d2 fix(studio): scope lane ids per timeline and stop inventing a track row
Two latent defects in the announcement path this branch adds.

- lanesId was keyed by render row alone, so a second TimelineLanes on the
  page (a mini-timeline beside the main one) would mint the same
  timeline-lanes-track-0 and every caret's aria-controls would resolve to
  whichever instance mounted first. The prefix now comes from useId, with the
  colons stripped so the id stays a legal CSS selector.
- trackDisplayNumber returned trackOrder.length + 1 for a key it could not
  find, which is indistinguishable from a real row: the label announced a row
  the user can see is wrong and nothing upstream could tell it had guessed. It
  returns null now, and trackDisplaySuffix drops the number from the label
  rather than inventing one.
2026-07-28 21:21:27 +02:00
Miguel Angel Simon Sierra f04cdb79c5 fix(studio): never re-author a target the DOM proved is not unique
writeTargetSelector returned the selection's bare selector whenever the
structural walk failed, including when a live DOM was there to check
against. An element detached between selecting and committing takes that
path, so the add re-authored the exact `.group` string the function exists
to replace. Return null instead: a failed walk against a live DOM is
evidence, not absence of it. Callers that cannot drop a user edit opt back
in with `?? selectorFromSelection` where the trade is visible.

The replace-with-keyframes paths had the mirror defect. The server deletes
and re-adds the tween, so their target string is a full rewrite, and they
derived it from the selection: promoting a set on a tween already narrowed
to `#scene > div:nth-child(3)` widened it back onto every class sibling,
undoing the narrowing an earlier add had made. They now keep the tween's
own authored target, matching what eight sibling commit modules already do.
2026-07-28 20:37:52 +02:00
Miguel Angel Simon Sierra 477916cbe2 fix(studio): label undo history with the track display row
The timeline track key is a fractional z-order sort key: an expanded
sub-composition child gets `host.track + n / (siblings + 2)`. The undo
history entry for the eye toggle interpolated that key directly, so
hiding an expanded child recorded "Hide track 0.16666666666666666".

Give the key-to-display-row conversion a single owner
(timelineTrackDisplay.ts) and route both the track header labels and the
history label through it, so the two cannot drift apart again. The raw
key still routes the callbacks and lookups that need it.

Adds a regression test that toggles a track keyed 1 / 6; a test on track
0 formats cleanly and proves nothing.
2026-07-28 20:37:52 +02:00
Miguel Angel Simon Sierra fd5555be75 fix(studio): target one element when adding a keyframe at the playhead
"Add keyframe at playhead" on an element with no id authored the bare class
buildStableSelector hands back, so one add on a `.group` wrote
`tl.to(".group", ...)`: a tween that animates all five siblings and that
resolveSelectorElementIds reads back as all five, collapsing their timeline
rows into one. It survived a reload, so the written file stayed un-editable.

writeTargetSelector is the write-side counterpart to selectorFromSelection
(which must keep returning the exact string findTweenAtTime compares against).
It resolves the element's own identity to a selector that addresses exactly
one element: `#id`, else `[data-hf-id="..."]`, else the selection's selector
when it is already unique, else a `:nth-child` path anchored on the nearest
identifiable ancestor (the selector + selectorIndex pair, resolved through the
DOM the index was counted in).

Applied to the two paths that author a NEW tween: the no-animation branch of
useEnableKeyframes and commitKeyframeAtTimeImpl. replace-with-keyframes still
writes the selection's own selector, since retargeting a tween the author
aimed at a whole group is a different decision from adding a keyframe.
2026-07-28 20:37:52 +02:00
Miguel Angel Simon Sierra 1faa0cbdad fix(studio): announce real track numbers and point aria-controls at the lanes
The timeline's track key is a fractional z-order sort value, and the header
built its visibility label straight from it, so screen readers announced
"Hide track 0.16666666666666666". A track's 1-based display row is now passed
alongside the key: the row number goes in every label, the key keeps routing
every callback (visibility toggle, lane context menu). The same fix covers the
`Track N` fallback used when a track holds no labelled element.

The layer disclosure caret's aria-controls named a div in the sticky label
column. That subtree is not empty, it holds the per-lane keyframe controls, but
its children are all absolutely positioned so the div computes to 0x0, and the
diamonds the caret visibly reveals live on the canvas instead. The caret expands
two disjoint subtrees and was naming the less useful one. TimelinePropertyLanes
now renders one static wrapper (static, not relative, so it establishes no
containing block and the absolutely-positioned lanes keep resolving against the
track-content div with identical geometry) and takes the id. TimelineLanes mints
that id, since it is the only place that sees both ends of the disclosure, and
mounts the wrapper for the track's keyframe clip in both disclosure states so
the reference still resolves while collapsed.

TimelineLaneBaseProps moves to its own module: it is the contract shared by
TimelineCanvas and TimelineLanes, and lifting it out keeps TimelineLanes.tsx
well under the 600-line cap instead of pushing past it.
2026-07-28 20:37:52 +02:00
Miguel Angel Simon Sierra be3451aae4 fix(studio): resolve a panel edit through the lane groups it can see
animIdForProp matched on the parser's whole-tween propertyGroup, which is
undefined for a legacy mixed tween such as {x, opacity}. Such a tween never
matched, so an edit to either property fell through to the selection's
default animation, a different tween than the lane the user is editing.
Resolve through animationLaneGroups, the same per-keyframe helper the
rendered lanes and the reserved row heights already count groups with.
2026-07-28 20:37:21 +02:00
Miguel Ángel 880021411d fix(cli): make telemetry opt-out durable (#2852)
* fix(cli): make telemetry opt-out durable

* fix(cli): make telemetry status trustworthy
2026-07-28 20:35:54 +02:00
Miguel Angel Simon Sierra 59a818e80a fix(studio): lane every tween and attribute tweens to their real target
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
2026-07-28 19:02:25 +02:00
Miguel Angel Simon Sierra acad7b268e fix(studio): keep every host row on the drill path, not just the top
Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.

Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
2026-07-28 19:02:05 +02:00
Miguel Angel Simon Sierra d48440a9ba fix(studio): keep the host row when drilling into a sub-composition
The sub-composition drill-in replaced the host row with its children. Since
expansion is also driven by the playhead alone (paused auto-expand), an
ordinary seek into a sub-composition made the host row disappear, taking its
keyframe lane with it: diamonds render per row from keyframeCache.get(
elementKey), so no row means no diamonds. Reproduced live with no drag at
all, seek 0 gave 3 diamonds, seek 7.68 gave 0, seek 0.2 gave 3.

Make the expansion additive instead. The host row stays and its children are
appended directly below it. The synthetic fractional lanes already used for
children sit strictly between the host's lane and the next integer, so the
host keeps its own row without colliding with anything.

The time-keyed auto-expand itself is unchanged.
2026-07-28 18:04:37 +02:00
Miguel Angel Simon Sierra 12546985f5 fix(studio): expand sub-comp children against their resolved parent host
buildExpandedElements synthesized DOM-only sub-composition children against
the top-level element rather than the parentHost it resolves immediately
after. Under two-level nesting every child row therefore inherited the
top-level window instead of its own host's, so the rows drew at the wrong
offset and duration.
2026-07-28 18:04:37 +02:00
Miguel Angel Simon Sierra 3f0c20f633 fix(studio): compute keyframe percentages in the tween's own time frame
A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.

resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
2026-07-28 18:04:37 +02:00
Miguel Angel Simon Sierra 86f633f985 fix(studio): accept 3-digit hex shorthand in the colour field
The gesture resolver gated on six digits while parseCssColor accepts both
lengths, so #F00 previewed as nothing and committed nothing. The old onBlur
path parsed it, making this a behavioural loss rather than a pre-existing gap.

Also asserts that an incomplete hex is restored on outside-click, not merely
left uncommitted.
2026-07-28 18:04:21 +02:00
Miguel Angel Simon Sierra 7f0cadcbb1 fix(studio): drop aria-modal from the non-modal shortcuts popup
The panel does not trap focus and leaves the rest of the editor operable,
so aria-modal would tell assistive tech the whole app is inert while it is
open. role=dialog plus aria-controls and Escape is the correct non-modal
disclosure shape.
2026-07-28 18:02:09 +02:00
Miguel Angel Simon Sierra aa2811642f fix(studio): dismiss the shortcuts panel on escape and outside press
Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
2026-07-28 18:02:09 +02:00
Miguel Angel Simon Sierra 55614033e5 fix(studio): let the colour hex field be edited and commit it on outside press
Split hex-draft ownership so the hex input is the sole author of its
own text while editing (updateColorDraft no longer stamps a canonical
hex back over every keystroke), fixing snap-back on backspace and the
silent wrong-colour clobber on non-repeating hex values. Route hex
typing through the shared gesture transaction so outside-click and
Escape settle/cancel it like the other inspector fields, instead of
relying on a private onBlur commit that never fires once the panel
unmounts on outside-click.
2026-07-28 18:02:09 +02:00
Miguel Angel Simon Sierra b82506363f fix(studio): grow the ease target only where the segment has room
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds,
so on a segment narrower than 24px it overhung them and won their hit test at
fit zoom. Gate the overlay on the clear span between the two diamonds and let
the button keep its 16x16 box below that.

Also renames the pointer target suite to say it asserts the classes that
produce the size, not the measured geometry, which happy-dom cannot see.
2026-07-28 18:01:46 +02:00
Miguel Angel Simon Sierra bb24d0037a style(studio): keep StudioRightPanel under the 600 line cap 2026-07-28 16:44:43 +02:00
Miguel Angel Simon Sierra 4e74eefddd fix(studio): meet the 24x24 pointer target minimum on toolbar and lane controls 2026-07-28 16:44:42 +02:00
Miguel Angel Simon Sierra 69020699df fix(studio): seek ruler clicks to the pressed position and round retime percentages
A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
2026-07-28 16:44:42 +02:00
Miguel Ángel 3c857d768b chore: release v0.7.77 2026-07-28 02:12:41 +00:00
Miguel Ángel 07e63b3758 Merge pull request #2791 from heygen-com/codex/studio-timeline-b-expanded-lanes-v2
feat(studio): wire expanded keyframe timeline lanes
2026-07-28 02:37:23 +02:00
Miguel Angel Simon Sierra 8470b88aa1 fix(studio): settle boundary retimes, delete every keyframed tween, tighten the test locks
Review follow-ups on the expanded keyframe lanes.

Writer:
- `onMoveKeyframe`'s flat-tween boundary branch answered `true` the moment it
  dispatched update-meta, so a rejected write left the diamond parked at its drop
  position. `observeGsapMutation` now resolves to whether the mutation landed and
  the boundary branch returns it, matching the other branches.
- "Delete All Keyframes" cleared only the first keyframed tween on the layer, so
  a layer with position AND opacity keyframes kept half of them. It now walks
  every keyframed tween, serially, through the clicked element's selection.
- The post-convert lookup in `commitFlatViaKeyframes` matched by target selector,
  which picks an arbitrary tween when a target carries several. Match by id first.

Interaction and a11y:
- A rejected retime whose commit settled after a newer drag reverted the
  selection to its own source keyframe, undoing a retime the user could see. The
  revert now only runs while it is still the lane's latest gesture.
- Diamonds key on the authored identity instead of index plus rendered clip-%, so
  a neighbour's retime no longer remounts the button mid-drag.
- The disclosure caret gets `aria-controls` on an always-mounted lanes container,
  and both it and the property-group toggle grow to the 24x24 WCAG 2.2 minimum.
- `LayerDisclosureRow` takes the same adaptive `columnWidth` as its sibling lane
  rows instead of hardcoding LABEL_COL_W over the canvas.

Test locks:
- The timeline callbacks harness resolves a DISTINCT selection per element, so
  the clicked-element writes are actually pinned; three assertions that passed
  either way now name the clicked element's selection.
- New: null-selection aborts every mutation, delete-all covers both tweens, a
  rejected boundary retime reports `false`, and a stale revert leaves selection.
- The playhead-percentage assertion checks 25, not `expect.any(Number)` (which
  also accepts NaN); ease segments assert their label ORDER, not just that the
  three curves differ; the collapsed-diamond callback asserts the whole target.
- Dropped a duplicate `selection override` describe left by a rebase.
2026-07-28 01:19:05 +02:00
Miguel Ángel 37295b341f fix(cli): wait for stalled download cleanup (#2835)
## What

Fix Windows cleanup ordering for timed-out CLI downloads so `downloadFile()` rejects only after the active response pipeline has closed and the `.tmp` file has been removed.

## Why

Main CI run 30299854376 failed in `packages/cli/src/utils/download.test.ts` because the timeout path rejected from the request `error` handler while the response pipeline still held the temporary file open. Unix permits unlinking an open file, but Windows reports the file as locked; that cleanup error was swallowed and the promise settled while the partial file still existed.

The race was introduced by #2415 / `75eedf5cc1`, which added the inactivity timeout and separate request-error cleanup path.

## How

- Track whether a response pipeline owns the temporary file.
- When the request errors after streaming has begun, destroy the active response and let `pipeline()` finish tearing down the writer.
- Preserve the original request/timeout error, then remove the partial file before rejecting.
- Make the regression deterministic on every OS by simulating Windows `EBUSY` until the response closes.

## Test plan

- [x] Unit test updated to reproduce the Windows locked-file lifecycle on Linux
- [x] Focused regression test (20 consecutive runs)
- [x] Full CLI suite: 164 files passed, 1 skipped; 2,162 tests passed, 2 skipped
- [x] CLI typecheck
- [x] Full workspace build
- [x] Changed-file oxlint, oxfmt, Fallow, tracked-artifact check, and pre-commit hooks
- [ ] CI green on Linux, macOS, and Windows
2026-07-28 01:12:15 +02:00
Miguel Ángel 0bf33cb117 fix(cli): await stalled download cleanup 2026-07-27 22:58:17 +00:00
Miguel Angel Simon Sierra c20c5366da fix(studio): commit lane edits through the edited element's own selection
An explicit null selection override now aborts the write instead of falling
back to domEditSelection: a caller that resolved a selection for its own
element and found none was committing onto whichever element happened to be
selected. Ease changes and the playhead keyframe toggle resolve the edited
element's animations and selection instead of the current selection's, and
lane header rows follow the real label-column width so a narrowed column no
longer hangs its value readout over the canvas.
2026-07-28 00:40:47 +02:00
Miguel Angel Simon Sierra 3181657147 refactor(studio): share keyframe clip-% precision across lane writers
Review follow-ups on the expanded-lanes tip:

- TimelinePropertyLanes.groupKeyframes re-derived the clip-relative
  percentage inline and skipped toClipKeyframes' rounding, the one
  precision every keyframe-cache writer has to agree on (selection keys
  embed the number). It now loops the shared helper per animation with
  the group filter and only overrides the lane's own group and ease.
- TimelineClipDiamonds rebuilt and re-sorted each tween's sibling row
  inside the marker loop, so a row of N diamonds allocated N sorted
  copies of itself on every playhead tick. Built once per render instead,
  keyed by animation id.
- Add the missing symmetry test at the real callback boundary: the
  diamond test mocks onMoveKeyframe, so nothing proved the rapid-second
  retime resolves a pending clip-% the keyframe cache has not caught up
  to. The new test asserts the identity-carrying target retimes and that
  the same drag without identity fields cannot.
2026-07-28 00:40:47 +02:00
Miguel Angel Simon Sierra e317f1fbe3 refactor(studio): double-cast test fixtures and split three dense functions
CONTRIBUTING.md allows `as unknown as T` with a justification, not a bare
`as T`; the gsapShared fixtures only carry the fields under test.

The fallow complexity gate flagged three functions on this branch. Each is
split at its natural seam rather than suppressed: the auto-expand scan moves
out of the effect, the four repeated attribute guards in
nodeMatchesManifestClip collapse into one table-driven check, and the
segment-% interpolation moves out of onPathDown.
2026-07-28 00:40:46 +02:00
Miguel Angel Simon Sierra b8ff8bf0f3 fix(studio): close the review findings that survived the stack
Selector reads now go through one inverse of `idSelector`. Every writer emits
`[id="01-hook-hero"]` for an id a `#id` selector can't address, but the readers
still matched `#id` only, so the post-commit keyframe-cache refresh, the AST
load and the remove-all-keyframes clear all silently skipped exactly the ids
`idSelector` was added to support.

A keyframe merged from two tweens with different eases kept whichever ease
iterated last. Readers that don't check `easeAmbiguous` showed a curve from a
different animation than an edit would target, so the ambiguous flag now clears
`ease` instead of leaving an arbitrary one behind.

One tolerance for "the playhead is on this keyframe". The motion-path drag used
0.05% while the toolbar and the playhead apply used 1%, so a drag that landed a
fraction of a percent off an authored waypoint skipped the update-point branch
and appended a near-duplicate. `buildTemporalArcKeyframes` now owns the
invariant and replaces any keyframe inside the tolerance, rather than trusting
each caller's own pre-check.

The pending-retime bookkeeping matches on keyframe identity, not just on
"something is near that percentage" — an evenly spaced row cleared the entry off
an unrelated sibling. The neighbour clamp composes pending destinations in
before sorting, so a second drag can't cross a neighbour that already moved.

Also: `keyframeCache`/`gsapAnimations` setters return the same state for a write
that changes nothing (every no-op re-rendered every subscriber), the auto-expand
set drops clips that left the source so an undo/paste under the same id expands
again, `invalidateGsapCache` has a stable identity instead of re-creating the
whole timeline edit context each render, the studio test hook deletes its window
key rather than leaving it enumerable as undefined, and the past-last-row
extrapolation documents why it uses TRACK_H where the pre-first-row branch uses
row 0's own height.

Covers `idFromSelector` round-trips, the insert boundary band across plain,
expanded and unusable row heights, and the collapsed selection key for a
colon-bearing element id.
2026-07-28 00:40:46 +02:00
Miguel Angel Simon Sierra 675cbe194d fix(studio): retime flat tween boundaries and target the clicked keyframe's own tween
Two more regressions the QA triage attributed to this stack.

Dragging a flat tween's boundary diamond did nothing: the handler bailed on
`!anim.keyframes` even though resolveKeyframeRetime already resolves that case
to a position/duration resize. The empty remap now dispatches update-meta, which
moves the window without rewriting the authored flat tween into keyframes form
(what the keyframed-resize writer would do as a side effect).

The property panel's keyframe gutter guessed one animation per property group,
so clicking a keyframe authored by a sibling tween on a merged row named the
wrong tween and the writer silently removed nothing. The diamond now reports the
clicked keyframe's own animationId, with the group guess kept as the fallback for
cache rows that carry no identity.
2026-07-28 00:40:45 +02:00
Miguel Angel Simon Sierra 8b38a8562e fix(studio): close the timeline regressions the QA triage attributed here
The QA fleet's 295 findings were replayed against the merge-base. Most were
pre-existing, but these were caused by this stack:

Deleting one keyframe destroyed the whole tween. The lane-header remove toggle
escalated a flat tween to a whole-animation delete, which took the authored
`tl.to(...)` and its source comment with it on a single click. The base build
posts remove-keyframe and lets the writer refuse it; restore that.

A keyframed layer could not be hidden at all. The visibility eye had moved off
the always-mounted layer row onto a hover-gated property-group row, so it only
existed while the lanes were expanded AND the pointer was over that lane. A
keyboard-only user could reach no eye at all, and its label named a track its
row did not act on. It goes back on the layer row.

A drag from the centre of a clip bar did nothing, because the 16px inline ease
button sits exactly there and swallowed the press. It now lets the press through
to the clip and keeps only the click, dropped if the pointer travelled.

Dragging a diamond onto a neighbour silently discarded the retime. The clamp
bounded the dragged keyframe by the whole merged row, so two animations
colliding at one percentage pinned each other in place and the drag resolved
back to a click. Clamp against the dragged keyframe's own tween instead.

Also: floor the diamond hit box at 12px (the gap-derived size fell to ~7px at
the zoom floor), round the diamond tooltip percentage, and prune the keyframe
caches when a composition switch drops a file from the scan set — each file only
ever cleared its own entries, so the previous composition leaked every element
into both keyframeCache and gsapAnimations, with nothing to evict it.
2026-07-28 00:40:45 +02:00
Miguel Angel Simon Sierra 538264463c refactor(studio): tighten the expanded-lane cache and height helpers
Validate the parse response before reading `.animations` instead of casting
the JSON blind, and narrow the fetch's return type to the slice callers read.
Route the AST cache load through the shared clip-keyframe and cache-key
helpers so it can't drift from the other writer. Drop the unused numeric
track-count branches from `trackHeights`/`getTimelineCanvasHeight`, and pick
the widest keyframed clip with a reduce so there's no index assertion.
2026-07-28 00:40:44 +02:00
Miguel Angel Simon Sierra 629631a72e fix(studio): drop the property-group gate in the AST cache load
Carries the gsapAnimations/keyframeCache symmetry fix into the extracted
AST loader: an ungrouped tween now lands in both stores, so the collapsed
row and the expanded lanes agree on what a clip animates.
2026-07-28 00:40:44 +02:00
Miguel Angel Simon Sierra 3e342cff9c fix(studio): keep sub-compositions expanded at the end of the timeline
Auto-expansion picks the composition whose clip window contains the
playhead, and those windows are half-open. With the playhead parked on
the last clip's end - where playback stops - it sat inside nothing, so
every expanded sub-composition row and its keyframe lanes collapsed to a
single host row.

Accept the closing boundary, but only when the strict pass matched
nothing, so a playhead on the seam between two adjacent clips still
expands the one that is starting.
2026-07-28 00:40:43 +02:00
Miguel Angel Simon Sierra 099d891fd3 fix(studio): populate keyframe lanes for every timeline composition
The keyframe cache was fetched for a single source file: the selected
element's, else the active composition. On open nothing is selected, so
a project whose clips live in sub-compositions loaded only index.html
and every property lane rendered empty until a clip was clicked.

Load the cache for each composition file the timeline has rows for, so
keyframed clips are expanded on open as intended. The AST load path
moves to keyframeCacheAstLoad.ts to keep useGsapTweenCache.ts under the
600-line cap.
2026-07-28 00:40:43 +02:00
Miguel Angel Simon Sierra 282cc883e5 fix(studio): address family B timeline review findings
- revert diamond selection when a rejected retime leaves the source in place
- clear project-local ease focus and expansion on player store reset
- share one static-position-hold predicate across the tween cache
- invalidate the GSAP cache even when a group timing rewrite throws
- stamp each lane keyframe's ease from its own source tween
- memoize property lanes and row offsets so memo'd diamond lanes hold
- use the editable tween duration for drag position commits
- restore the pre-t=0 pad in the all-collapsed content origin
- clamp the drag ghost and drop placeholder to the collapsed clip height
- aria-expanded on the layer disclosure, aria-pressed plus state-specific
  labels on the keyframe toggle, 24px chevron targets, focus-visible parity
2026-07-28 00:40:42 +02:00
Miguel Angel Simon Sierra 4a12eb9d9c feat(studio): wire expanded keyframe timeline lanes 2026-07-28 00:40:42 +02:00
Miguel Angel Simon Sierra 6b58678d94 fix(studio): drop dead keyframe menu wiring and close the R1 review items
The diamond context menu still declared `onChangeEase` and `onCopyProperties`
props, and `TimelineOverlays` still threaded `onChangeKeyframeEase` plus a
`keyframeCache` it never read. Nothing on any timeline branch calls them, so
they are removed along with the `onChangeKeyframeEase` callback implementation.

Also from review:
- `deleteSelectedKeyframes` only falls back to the sole keyframed animation when
  there is exactly one. A collapsed selection key carries no animation id, so
  taking the first of several deleted an arbitrary tween's keyframe.
- The duration-less retime test asserts the real 87.601% instead of
  `expect.any(Number)`, so a wrong timing basis fails it.
- `Timeline` wires the keyframe handlers' `onSelectSegment` through to the
  diamonds; it was built and then dropped, so segment ease selection never fired.
- The flat text section arms auto-focus in state rather than reading and
  clearing a ref during render, which Strict Mode's double render swallowed.
2026-07-28 00:37:31 +02:00
Miguel Angel Simon Sierra eddc5e9224 fix(studio): pass the whole keyframe identity out of the diamond menu
The context menu handed its actions loose positional arguments, so the
overlay adapters forwarded the percentage alone and dropped the property
group, tween percentage, and animation id the menu had resolved. The target
then fell back to first-match-by-percentage and deleted or retimed the wrong
animation whenever two collide at one percentage. Every action now carries a
TimelineKeyframeTarget, which leaves no adapter shape that can drop it.
2026-07-28 00:25:41 +02:00
Miguel Angel Simon Sierra f5d3c7a3d1 refactor(studio): split the diamond row's data contract into its own file
TimelineClipDiamonds.tsx had grown past the 600-line studio file gate, and
its keyframe/props types were declared a second time next to the ones the
lanes already share. The types, the render constants, and the keyframe
identity helper move to timelineDiamondTypes.ts; the rendering stays put.
2026-07-28 00:13:47 +02:00
Miguel Angel Simon Sierra cbdd61d102 refactor(studio): give every keyframe diamond one identity
The collapsed clip row dropped a keyframe's property group and animation id
before handing it to a callback, so the same keyframe hashed to a different
selection key than the expanded property lane did. Selecting a diamond in one
view left it unselected in the other, and retime/delete on the collapsed row
lost the animation id they use to pick between two animations that collide at
one percentage.

Diamonds now always carry their full identity, the collapsed shim just curries
the element id, and Timeline reuses useTimelineKeyframeHandlers instead of its
own inline copy of the same three handlers.

Neighbour geometry moves into one marker record per diamond, which drops the
index-lookup non-null assertions the connector pass needed.
2026-07-28 00:13:46 +02:00