Commit Graph
2151 Commits
Author SHA1 Message Date
Miguel Angel Simon Sierra feb256df8a refactor(cli): unify seek/settle and Chrome launch across browser commands
seekCompositionTimeline becomes the single seek implementation with
per-caller settle options (rAF mode, font wait, settle sleep), replacing
the divergent local seekTo copies in validate and layout. All three
launch paths now build args via the engine's buildChromeArgs; screenshot
paths keep the engine's software-GPU default for deterministic output.

inspect gains one transient content_overlap warning on product-promo
(t=12.22s): the gsap.ticker.tick flush samples timeline state the old
layout seek missed.
2026-07-10 13:27:52 -04:00
Vance Ingalls 6152437d2a chore: release v0.7.49 2026-07-10 09:57:48 -07:00
Vance IngallsandClaude Sonnet 5 dc6df93de5 fix(cli): fix concurrency race, none-vs-undefined bug, and 3 more DE trial gaps
Six findings from a third max-effort code review, focused on the previous
commit's fixes:

1. --batch-concurrency N>=2 runs genuinely concurrent renderLocal() calls
   (Promise.all workers in batchRender.ts), which can't safely share the
   trial's one process-wide env var + module flag — a row finishing first
   could tear down the env var/flag mid-render for a sibling row still in
   flight. Rather than attempt to make shared process-global state safe
   under real concurrency, added RenderOptions.disableDeParallelRouterTrial
   and set it whenever batchConcurrency > 1 — the trial simply isn't
   offered when it can't be evaluated safely.

2. maybeConsumeDeParallelRouterTrial's "outcome === undefined" no-op guard
   almost never fired: aggregateDrawElement (perfSummary.ts) defaults
   parallelRouter to the string "none" for every render, whether or not
   drawElement/the router ever engaged — never undefined. Every ordinary
   render below the router's own frame threshold (the common case) was
   ticking the render-count backstop, tripping
   DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS after 25 completely unrelated
   renders that never touched the router. Now treats "none" the same as
   undefined.

3. isDeParallelRouterTrialBlocked relied solely on shouldTrack(), which
   memoizes its verdict once per process — during a long --batch run, a
   `hyperframes telemetry off` issued from another terminal mid-batch would
   never be observed. Restored a direct config.telemetryEnabled check
   (read fresh every call, unlike shouldTrack()'s cache) alongside it.

4. maybeConsumeDeParallelRouterTrial's config write had no way to detect a
   losing race against a concurrent process — added a verify-and-retry
   loop (write, re-read fresh, retry up to 3x if a concurrent writer
   landed in between) that narrows the window further without a full
   file-locking rewrite.

5. The trial could arm before the first-run telemetry disclosure
   (showTelemetryNotice) was guaranteed to have printed — that notice runs
   via a fire-and-forget, unawaited dynamic import in cli.ts with no
   ordering guarantee relative to the render command. Rather than touch
   that pre-existing async bootstrap chain, gated the trial on
   config.telemetryNoticeShown: it simply never offers itself on a fresh
   install's very first invocation.

6. Added a dedicated config.test.ts exercising readConfig/readConfigFresh/
   writeConfig through the REAL module (node:fs mocked with an in-memory
   fake, not a HOME-env hack) — readConfigFresh's cache-bypass and the
   type-guarded boolean/number parsing had zero coverage through the real
   implementation before this.

Also fixed the test fixture that was supposed to cover finding #2 but used
an unrealistic `drawElement: {}` shape instead of the real
`{ parallelRouter: "none" }` aggregateDrawElement actually produces.

Extracted applyDeParallelRouterOutcome to keep maybeConsumeDeParallelRouterTrial
under the repo's complexity gate after adding the retry loop.

11 new/updated tests in render.test.ts (56 total) + 7 new tests in
config.test.ts. Verified against fallow's audit gate clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 00:31:11 -07:00
Vance IngallsandClaude Sonnet 5 532dad7cc7 fix(cli): fix batch re-entrancy, config race, exposure cap, and shouldTrack gap in DE trial
Four confirmed findings from a max-effort code review of the CLI trial
mechanism:

1. maybeEnableDeParallelRouterTrial's `process.env.HF_DE_PARALLEL_ROUTER
   !== undefined` guard couldn't distinguish "the user set this" from "an
   earlier renderLocal() call in this same process already armed it" — so
   in --batch (all rows share one process), only row 1's outcome could
   ever reach maybeConsumeDeParallelRouterTrial. A revert on any later row
   was silently never persisted. Added a module-level
   deParallelRouterTrialManagedByUs flag to disambiguate, with a test-only
   reset export since it's process-lifetime state a real CLI invocation
   never needs to reset but a test suite sharing one module instance does.

2. writeConfig is a non-atomic whole-file overwrite with no locking, and
   readConfig's cache never invalidates — a concurrently running second
   CLI process (another terminal, a parallel script; doesn't even need to
   be a render, any command calls incrementCommandCount) could silently
   clobber a just-persisted deParallelRouterTrialFired:true with its own
   stale snapshot. Added readConfigFresh (bypasses the cache) and use it
   immediately before the trial's read-modify-write, narrowing the race
   window without a full config-subsystem locking rewrite.

3. The prior commit's semantics flip removed the only exposure cap — a
   healthy router that never reverts now force-enabled the experimental
   path on every eligible render forever. Added
   DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS (25) as a backstop: the trial turns
   off after this many engaged renders even absent an actual failure.

4. maybeEnableDeParallelRouterTrial only checked config.telemetryEnabled,
   not shouldTrack() — so a dev-mode run or a DO_NOT_TRACK/
   HYPERFRAMES_NO_TELEMETRY user got the experimental path silently armed
   while telemetry was simultaneously blocked underneath it. Now gates on
   shouldTrack() (a strict superset).

Also fixed, lower severity: readConfig's deParallelRouterTrialFired/
deParallelRouterTrialRenderCount parsing now validates the JSON type
explicitly instead of a bare truthy/nullish read, so a hand-edited or
corrupted config can't have the string "false" misread as truthy.

Refactored maybeEnableDeParallelRouterTrial into three smaller functions
(isDeParallelRouterTrialBlocked, stopManagingDeParallelRouterTrial) to
bring cyclomatic/cognitive complexity back under the repo's threshold —
also de-duplicates the "stop managing the env var" logic shared with
maybeConsumeDeParallelRouterTrial.

14 new/updated tests (43 total in render.test.ts), including a direct
regression test for the batch re-entrancy scenario and a loop test for the
render-count cap. Verified the config primitives end-to-end against a real
file, not just the mocked unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 22:55:29 -07:00
Vance IngallsandClaude Sonnet 5 19f90b0b92 fix(cli): keep the DE parallel-router trial on until a real failure, not first engagement
Only consuming telemetry from one data point per install badly undersampled
the "routed" (successful) outcome — the far more common case. Changed
maybeConsumeDeParallelRouterTrial to only turn the trial off when the
router's OWN safety net actually fired (deParallelRouter === "reverted"),
not on a clean "routed" success. This runs the experiment on every eligible
render for an install indefinitely until it hits one real failure, then
stops for that install going forward — trading a slightly higher per-install
ceiling on experimental-path exposure for dramatically more successful-
routing telemetry volume across the fleet.

Also fixed a related edge case while updating this: a render that merely
"routed" (router fired, self-verify never even tripped) but then crashed
for an unrelated reason (e.g. cancellation) no longer counts as a router
failure — only "reverted" (the router's fallback path actually engaged)
does. Cancelling a render isn't evidence the router is unsafe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:49:21 -07:00
Vance IngallsandClaude Sonnet 5 37b6a4e7e5 feat(cli): one-shot DE parallel-router trial per install for real telemetry
HF_DE_PARALLEL_ROUTER is a producer env var with no self-serve opt-in path
for real users, so waiting for someone to manually enable it would never
produce the real-traffic telemetry (revert rate, verify-db distribution)
the router's soak plan calls for.

renderLocal now enables the experiment for free on a fresh install's CLI
renders until it actually engages once (routed or reverted — either
produces telemetry), then persists that to ~/.hyperframes/config.json and
never touches it again for that install. A render whose frame count never
crosses the router's own eligibility threshold doesn't consume the trial —
it stays available for a later render that does qualify.

Never overrides a user's own explicit HF_DE_PARALLEL_ROUTER setting, and
only engages when telemetry is enabled (no point risking the experimental
path if we can't record the resulting signal). Scoped to the in-process CLI
render path only — Docker renders don't thread perfSummary/errorDetails
back to the CLI process, so trial consumption can't be detected there.

Verified the config round-trip against a real file (fresh install ->
undefined -> write true -> persists across reread), not just the mocked
unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:40:16 -07:00
Vance IngallsandClaude Sonnet 5 a355fb2f6b fix(producer,engine,cli): oom wrapping, cancellation, fallback-reason gaps
Three defects found by max-effort code review of this branch:

1. The Bun OOM exact-match regex was defeated by this codebase's own
   parallel-worker error wrapping. executeParallelCapture/formatWorkerFailure
   (parallelCoordinator.ts) always wrap a worker's error as
   "Worker N: <message>", optionally suffixed and joined with other workers'
   segments, all prefixed "[Parallel] Capture failed: ". That wrapping
   defeated the exact-message check for exactly the cohort (deParallelRouter
   routed, N separate Chrome processes) the OOM-drops-to-1 fix targets — a
   real OOM there would retry at the SAME worker count instead of dropping
   to 1. Added a second pattern that recovers the signal by requiring
   "out of memory" appear as the WHOLE content of a "Worker N: ..." segment
   (bounded by end-of-string/"; "), preserving the same exact-match property
   (no bare substring match) while surviving the wrapping. Verified against
   the real wrapping logic, not a hand-typed guess at its shape.

2. shouldRetryViaPinnedFallback didn't exclude cancellation, so aborting a
   render mid-capture on the pinned router/inversion cohort would detour
   through spawning a fresh encoder/capture session before the outer catch's
   RenderCancelledError branch ended the render — delaying "stop" with a
   pointless resource spin-up/tear-down. Added an isCancellation param
   (checked first, before isVerifyError) using the same
   `err instanceof RenderCancelledError || abortSignal?.aborted` check the
   outer catch already uses.

3. deFallbackReason (this PR's new "oom"/"capture_error" values) was set
   locally but never mirrored into RenderCaptureObservability alongside
   deSelfVerifyFallback, so a render that fails AFTER a fallback attempt
   (perfSummary never built) was indistinguishable in render_error telemetry
   from one that never attempted any fallback — undercutting the "how often
   does the OOM retry fire on a render that still ultimately fails"
   question this branch exists to answer. Threaded through
   RenderCaptureObservability → RenderObservabilityTelemetryPayload →
   renderObservabilityTelemetryPayload, mirroring the existing
   deSelfVerifyFallback plumbing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 20:31:24 -07:00
Vance Ingalls a8f242e615 Merge pull request #2112 from heygen-com/vi/figma-scopes-retry
fix(figma): auth/retry/batch hardening, mapper fidelity, skill routing, setup docs
2026-07-09 19:56:52 -07:00
Vance IngallsandClaude Sonnet 5 b3f244a7e9 fix(engine): recognize Bun/JavaScriptCore's OOM message in isMemoryExhaustionError
Found while testing the previous commit's OOM-drops-to-1-worker fallback
end-to-end: the producer's deployed runtime is Bun (JavaScriptCore), not
Node (V8) — see packages/gcp-cloud-run/Dockerfile's `bun dist/server.js`
entrypoint. All 7 MEMORY_EXHAUSTION_ERROR_PATTERNS are V8-specific allocation
failure signatures; JSC's equivalent for the same single-oversized-allocation
RangeErrors is the bare string "Out of memory" (verified against real Bun
behavior), which none of them match. Without this, isMemoryExhaustionError
returns false for genuine production OOM, so the memory-specific worker-count
reduction just added would never actually engage where it's deployed — every
OOM would fall through to the generic capture_error retry path instead.

Matches the FULL (trimmed) message only, not merely a substring — same
rationale as the existing V8 patterns' comment: "out of memory" also appears
in benign WebGL/GPU console noise that must not trip this classifier.

Verified end-to-end from a script inside the producer workspace (importing
the real @hyperframes/engine source, not a stale globally-cached npm dist a
script outside the workspace would otherwise resolve to): a genuine Bun
RangeError from new Uint8Array(Number.MAX_SAFE_INTEGER) now correctly
classifies as memory exhaustion and drives both resolveInversionRetryPlan
and resolveParallelRouterRetryPlan down to workerCount=1 on retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 19:29:14 -07:00
Vance IngallsandClaude Opus c8eff1a4ba fix(core,cli): figma IMAGE fills dropped, rasterize double-paint, tokens false-success
nodeToHtml routed rasterize eligibility off node.type alone, so a
RECTANGLE/FRAME with an IMAGE fill fell through to the generic <div>
path — fillCss() has no IMAGE case, so it rendered an empty box.
IMAGE-filled nodes now route to rasterize like vectors, regardless of
node.type.

Rasterized nodes (vectors, now image fills too) were also getting
their own fill/corner-radius CSS applied on top of the already-
rendered <img> — a flat color block behind/around the real art,
flattening non-rectangular shapes into rounded rects. decorationCss
now skips background and corner-radius/clip for rasterized nodes;
opacity and effects still apply since those aren't baked into the
export.

tokens.ts's styles-fallback path hardcoded entries: [] regardless of
how many published styles were actually found, so the CLI printed
"recorded published style metadata instead" even when styles()
returned zero results. Added styleCount to the result so the message
reflects what happened, and points at the MCP get_variable_defs
fallback when there's nothing to fall back to.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
2026-07-09 19:23:19 -07:00
Vance Ingalls 8c996100cf Merge pull request #2114 from heygen-com/feat/position-edits-render-reapply
fix(core,producer): render SDK position edits in producer pipeline
2026-07-09 19:22:55 -07:00
Vance Ingalls 70ac80b08b fix: clear fallow and windows artifact checks 2026-07-09 18:55:11 -07:00
Vance IngallsandClaude Sonnet 5 51353a7ed6 fix(producer): drop to a single worker on OOM retry instead of the pre-pinned count
shouldRetryViaPinnedFallback retrying OOM was only half the fix: it reused
preInversionWorkerCount/preRouterWorkerCount unmodified, which is
calibration's own pick and can be >= the pinned count that just OOM'd
(calibration wanting 5 while the router pinned to 3). Retrying at equal or
higher parallelism than the failure isn't a remedy — it's the same bet
again, and worsens the odds for this render and anything sharing the host
(PRODUCER_MAX_CONCURRENT_RENDERS runs concurrent jobs in one process).

resolveInversionRetryPlan/resolveParallelRouterRetryPlan now drop to
workerCount=1 specifically when the retry is OOM-triggered — one Chrome
page, the leanest configuration available, not just a different capture
mode at the same worker count. Ordinary self-verify (blank/PSNR) retries
are unaffected — those aren't memory-related, so they keep the
pre-inversion/pre-router count as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 18:52:38 -07:00
Miguel Ángel 3fd340f6ba chore: release v0.7.48 (#2118) 2026-07-09 21:49:58 -04:00
Miguel Ángel ef38321d5d fix(cli): restore tts --text-file compatibility (#2117) 2026-07-09 21:45:35 -04:00
Vance Ingalls 960c31f668 fix(core): make generated artifact formatting deterministic 2026-07-09 18:36:22 -07:00
Miguel Ángel 7bd8c0942e fix(cli): keep slow browser installs from losing their lock [P0] (#2116)
* fix(cli): keep slow browser installs from losing their lock

* refactor(cli): simplify browser install lock policy

* fix(cli): keep browser lock heartbeat failures non-fatal
2026-07-09 21:30:30 -04:00
Vance IngallsandClaude Sonnet 5 df57eb0fde fix(producer): widen DE self-verify retry to generic failures on a pinned worker count
The router/inversion pin a fixed worker count regardless of calibration —
exactly the scenario a host-contention timeout or worker crash is most
likely under. Previously only a drawElement self-verify failure (blank
frame / PSNR breach) triggered the existing fallback to the calibrated,
non-DE parallel-screenshot path; any other capture-stage failure on a
pinned render just hard-failed the whole job instead of reusing that same
tested safety net.

shouldRetryViaPinnedFallback widens the retry to any capture failure while
deWorkerInversion="inverted" or deParallelRouter="routed", excluding OOM
(the fallback's worker count can be >= the pinned count, so retrying would
likely just OOM again — fail fast instead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 18:23:39 -07:00
Vance Ingalls 0971f3b2a7 chore(core): refresh position edits render artifact 2026-07-09 18:21:32 -07:00
Vance Ingalls d2f32c831c fix(core): narrow wrapped seek function type 2026-07-09 18:09:45 -07:00
Vance IngallsandClaude Fable 5 67a4fb6b70 fix(core): use execFileSync for generated-file format step
CodeQL: shell command built from environment values — the oxfmt
invocation interpolated a filesystem-derived absolute path into a shell
string. execFileSync with array args avoids the shell entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 17:52:29 -07:00
Vance Ingalls 68ae4e5160 fix(core,producer): harden render reapply wiring 2026-07-09 17:38:31 -07:00
Vance Ingalls 4e3d639e1a fix(core): preserve position edit fold detection during seeks 2026-07-09 17:38:31 -07:00
Vance Ingalls 85bab88afb fix(core,producer): render SDK position edits in producer pipeline 2026-07-09 17:38:31 -07:00
Miguel Angel Simon Sierra 3aa1cf0d83 chore: release v0.7.47 2026-07-09 20:34:59 -04:00
Vance IngallsandClaude Fable 5 43af582534 docs(figma): sync guide scopes + error table; gate batch line, regen index in finally
Addresses @miguel-heygen's review on #2112:
- BLOCKER: docs/guides/figma.mdx now matches the shipped code/skill —
  adds the Library content: Read-only scope row (+ corrects the
  'falls back, expected' line that was false without it), and the
  troubleshooting table now says bad PATs surface as 403 Invalid token
  (not 401), names the scope in FORBIDDEN, and documents RATE_LIMITED retry.
- nit: the batch summary line no longer claims '1 figma request' when every
  node was a cache hit — says 'all reused from cache — no figma request'.
- nit: index.md regen moved to a finally, so a mid-batch RENDER_FAILED
  leaves index.md consistent with the nodes that did freeze.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:17:39 -07:00
Vance IngallsandClaude Sonnet 5 ec921e143b feat(producer,cli): full telemetry visibility for DE parallel-router/inversion failures
render_error previously carried zero DE-cohort context — a hard failure while
routed (worker crash, OOM, capture timeout from the fixed 3-worker pin
overriding calibration) was indistinguishable from any other failure. The
data existed (RenderCaptureObservability is mutated live and survives into
job.errorDetails on the failure path) but was never projected into the
render_error payload, which only ever drew de_* fields from perfSummary
(success-only).

- RenderCaptureObservability now also records dePreInversionWorkers /
  dePreRouterWorkers — the worker count calibration would have picked absent
  the experiment — so a resource-pressure failure can be correlated with the
  router overriding a lower calibrated count.
- New capture-sourced de_* fields on RenderObservabilityTelemetryPayload,
  shared by trackRenderComplete and trackRenderError. Explicit
  perfSummary-sourced fields still win on render_complete (spread moved
  first in the event object) — this is purely a failure-path fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:31:58 -07:00
Vance IngallsandClaude Fable 5 87e2a70f9a fix(core,cli): address PR review — consistent 403 error shape, cap Retry-After, URL-safe ref split
Rames's inline findings on #2112:
- forbiddenError now RETURNS in every branch (BAD_TOKEN no longer throws
  inside) so the caller's single throw covers all cases — no mixed
  throw/return contract for a future wrapping caller.
- retryAfterMs capped at 60s: a spec-legal Retry-After: 3600 no longer
  silently blocks the CLI for an hour before RATE_LIMITED.
- asset ref gathering extracted to gatherAssetRefs() and made URL-safe:
  bare fileKey:nodeId tokens comma-split, but a figma URL with commas in
  its query (multi-select node-id=1:2,3:4) is kept whole.
- Documented in SKILL that 429 retry lives in the shared request path, so
  EVERY read endpoint retries (not just asset) — blast-radius note the
  reviewer asked for. variables intentionally still retries: its fallback
  is REQUIRES_ENTERPRISE-only, and a 429 there is transient, not a gate.

Tests: retry-cap (3600→60000), non-styles endpoint retry, gatherAssetRefs
URL-vs-bare split. client 24, cli asset 11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:31:11 -07:00
Miguel ÁngelandClaude Opus 4.8 3b93f516b4 feat(media-use): use CLI free HeyGen usage (#2027)
* feat(media-use): use CLI free HeyGen usage

* fix(media-use): address #2027 R1 nits — gate cli-source header to OAuth, export origin constant

- X-HeyGen-Source is now sent only on OAuth (Bearer) requests, not API-key ones —
  the backend ignores it for API-key traffic (normal billing), so it was dead
  metadata there. buildAuthHeaders + heygenAuthHeaders + tests updated.
- Export HEYGEN_CLI_ORIGIN_HEADER ("X-HeyGen-Client-Origin") for future cli:<origin>
  consumers.
- Document the deliberate paid/X4 confirm-before-call decision on heygen.tts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* refactor(cli): drop unused origin-header export, dedup auth-client tests

Fallow flagged 5 findings on this PR:
- major: HEYGEN_CLI_ORIGIN_HEADER was exported but never emitted or
  imported — speculative dead code ("future consumers"). Remove it; a
  real consumer can add the constant when one exists.
- 4x minor duplication in client.test.ts: fold the repeated
  `.rejects.toSatisfy(auth-code)` assertion into expectAuthCode(), and the
  repeated try/catch scrubbed-message assertion into expectRejectionMessage().

No behavior change; auth/client tests still 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:28:26 -04:00
Vance IngallsandClaude Fable 5 1d18b30625 test(cli): add renderNodes to tokens.test figma client mock
CI typecheck caught the tokens.test mock missing the new renderNodes
member on FigmaClient (asset/component mocks were updated, this one was
missed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:22:56 -07:00
Vance IngallsandClaude Fable 5 4fc699fee6 fix(core,cli): parse figma 403 body, batch asset fetch, fix NO_TOKEN box
Extends the scope+retry work from the figma bug-bash (valid report:
9-bugs-with-repros; the skill-not-used report was discarded).

- 403-body parse (bug 4): figma returns 403 {"err":"Invalid token"} for bad
  PATs (NOT 401), and 403 {"err":"Invalid scope(s)… requires X"} for missing
  scopes. get() now reads the body: "Invalid token" reclassifies to BAD_TOKEN
  with re-mint advice; a scope body surfaces figma's own diagnosis verbatim;
  else falls back to the endpoint's scope hint. Reads both err and message
  (variables endpoint uses message). One fix, honest messages for bugs 1/4/9.

- Batch asset fetch (requested): figma asset accepts multiple refs
  (space-separated or comma-joined) of one file and renders them in a SINGLE
  /v1/images call via new client.renderNodes — figma's documented per-minute
  rate-limit workaround. runAssetImport delegates to runAssetImportMany;
  cache-checks per node, batches only the misses, one index.md regen.

- NO_TOKEN box (bug 8): errorBox indented only the first hint line, mangling
  the numbered setup list. Indent every line; single-line hints unchanged.

Verified live: 3 refs -> 3 imports -> 1 request; bad token -> BAD_TOKEN not
scope advice. Client suite 22, cli figma 33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:14:57 -07:00
Miguel Ángel 1614dd3e5a fix(cli): sample real pixels behind hidden text for contrast-audit
## What

Fixes five reported false-positive/false-negative patterns in the WCAG contrast audit (`hyperframes validate --contrast`):

1. **SVG fill vs. text color** — foreground read from CSS `color` instead of SVG `fill`.
2. **Cross-component color bleed** — background estimate bleeds into a neighboring panel/layer.
3. **Backdrop-filter glass text** — background estimate misses the blur/tint and reads the raw backdrop.
4. **Partially-overlapping translucent decoration** — a decorative shape inside or partly touching the text's bbox goes undetected.
5. **Solid-fill pill/button** — investigated, did **not** reproduce; already handled correctly by the existing own-background ancestor walk. Not touched.

## Why

The audit estimated an element's background two ways:
- foreground: always `getComputedStyle(el).color` — wrong for SVG `<text>`/`<tspan>`, which is painted via `fill`, an independent CSS property.
- background: a 4px pixel ring sampled just **outside** the text's bounding box, with a fallback to an ancestor's opaque `background-color` for solid pills/buttons.

The ring is a proximity heuristic. It's wrong whenever what's immediately outside the text differs from what's actually behind it:
- text near the edge of its own panel, with a differently-colored sibling panel/layer just past the bbox — the ring samples the neighbor.
- a `backdrop-filter: blur()` glass panel sized only a couple pixels larger than the text — the ring exits the panel into the raw, unblurred, untinted backdrop.
- a translucent decoration that only partially overlaps the ring, or sits entirely **inside** the bbox — invisible to the ring regardless of size.

## How

**SVG fill (#1):** elements inside an `<svg>` (`el.ownerSVGElement`) now prefer the computed `fill` when it resolves to a solid `rgb()`/`rgba()` color, falling back to `color` for paint values that aren't a plain color (`none`, `context-fill`, gradient/pattern refs).

**Cross-comp bleed / glass blur / partial decoration (#2–#4):** replaced the ring-sampling + own-background-ancestor-walk heuristic with a two-phase capture:
1. `__contrastAuditPrepare()` walks the DOM, computes each candidate's foreground (unchanged logic from #1), and **hides that element's own text paint** (`color`/`fill` → `transparent`, layout-neutral — no reflow).
2. The caller takes **one** screenshot with the glyphs invisible (same number of screenshots as before — just moved after the hide instead of before it).
3. `__contrastAuditFinish(imgBase64, time, candidates)` restores the original paint immediately, then samples the **real composited pixels directly inside each element's own bbox** — no proximity heuristic needed, since these are the exact pixels that were behind the glyphs.

This is a real architectural change to `contrast-audit.browser.js`'s calling contract (single `__contrastAudit` → `__contrastAuditPrepare`/`__contrastAuditFinish`), with `validate.ts`'s `runContrastAudit` updated to match, including a try/finally restore-safety-net so a mid-loop screenshot/decode failure can't leave a later sample auditing a page with stale hidden text.

Mirrored the identical change in `skills/hyperframes-creative/scripts/contrast-report.mjs`, which duplicates the same DOM-walk/sampling logic (not just the WCAG math). There, the **visible** frame for the human-facing overlay image still comes from the producer's normal `captureFrameToBuffer` path (unchanged); only the **background-sampling** capture is a plain `session.page.screenshot()` taken after hiding text — deliberately bypassing `captureFrameToBuffer`, whose static-frame dedup cache knows nothing about the DOM mutation and would hand back a stale pre-mutation buffer.

**Solid-fill pill (#5):** reproduced a rounded pill/button with a busy page background outside it. The existing own-background ancestor walk already resolves the pill's declared `background-color` correctly regardless of the rounded corners — confirmed via repro, both before and after this change report the identical (correct) result. No fix needed; left untouched, and this case is covered by the new architecture too (would give the same right answer even without the ancestor-walk fallback).

Added `packages/cli/src/commands/contrast-sample.ts` (mirroring the existing `contrast-bg.ts`/`contrast-fg.ts` pattern) hosting the pure sample-rect/grid-point computation, unit tested — the browser-injected scripts can't import it directly, so it's kept in sync by hand, same convention as the rest of this file.

## Test plan

- [x] Unit tests: `contrast-fg.test.ts` (SVG fill resolution), `contrast-sample.test.ts` (sample-rect clamping/degenerate cases), plus the full `packages/cli` suite (1424 tests) passes, including an updated `layout-audit.browser.test.ts` case that called the old single-function `__contrastAudit` API directly.
- [x] Manual verification — standalone `puppeteer-core` harness against real `chrome-headless-shell`, one minimal HTML fixture per pattern, comparing the audit's reported ratio/verdict against a hand-constructed ground truth:
  - **SVG fill**: `fill:white` / no `color` on black bg → before: `fg=rgb(0,0,0)` ratio `1:1` (false FAIL); after: `fg=rgb(255,255,255)` ratio `21:1` (correct PASS).
  - **Cross-comp bleed**: text on a black sibling highlight box 2px larger than the text, white page bg outside it → before: `bg=rgb(255,255,255)` ratio `1.23:1` (false FAIL); after: `bg=rgb(0,0,0)` ratio `17.14:1` (correct PASS).
  - **Glass blur**: black text on an 18%-white-tinted `backdrop-filter: blur(14px)` panel over a yellow/blue gradient, panel only ~2px larger than the text → before: `bg=rgb(0,64,255)` (raw gradient color, blur/tint completely missed) ratio `3.18:1` (false FAIL); after: `bg=rgb(159,160,165)` (correct blurred/tinted blend) ratio `8.05:1` (correct PASS).
  - **Partial decoration**: text 92%-covered by a translucent white badge on a dark bg → before: `bg=rgb(16,16,16)` (ring never touches the badge, which sits entirely inside the bbox) ratio `17.45:1` (false PASS); after: `bg=rgb(171,171,171)` (correctly detects the badge) ratio `2.11:1` (correct FAIL).
  - **Solid pill sanity**: unaffected — `bg=rgb(10,10,10)` ratio `19.8:1` before and after.
- [x] End-to-end: ran the actual `hyperframes validate --contrast` CLI command (via `tsx src/cli.ts`) against a real scaffolded project containing all 4 patterns simultaneously — only the genuinely-failing case (the 92%-covered decoration) is reported (`1.09:1`, need `3:1`); the cross-comp-bleed, glass-blur, and solid-pill cases are correctly silent. A second vanilla scaffold with plain white-on-dark text produces zero false positives.
- [x] `oxlint`, `oxfmt --check`, and `tsc --noEmit` all pass on the changed files.
2026-07-09 18:01:48 -04:00
Miguel Ángel dff27cb1df Merge pull request #2111 from heygen-com/feat/timeline-multiselect
feat(studio): timeline multi-select (marquee) + relative group time editing
2026-07-09 17:38:37 -04:00
Miguel Ángel a8f86e653d Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index
2026-07-09 17:37:46 -04:00
Miguel Angel Simon Sierra 1265702edc fix(studio): drop stale timeline-select results to stop selection flicker
handleTimelineElementSelect tags each call with a monotonic token and ignores its result
if a newer selection started while it was resolving, so a rapid A-to-B clip click can no
longer let A's slower async lookup land after B and restore the wrong selection.
2026-07-09 17:29:50 -04:00
Miguel Angel Simon Sierra 076c656d6e fix(studio): fold GSAP timing rewrites into the recorded history entry
A timeline move/resize recorded the timing patch, then a server GSAP rewrite mutated the
same file afterward, leaving the recorded after stale so an undo hit a hash conflict. The
GSAP mutation now snapshots the touched files and records a follow-up edit under the same
coalesceKey, with a per-entry coalesceMs override large enough to survive the GSAP round
trip, so undo restores the original in one step. Applies to single-clip and group edits.
2026-07-09 17:28:37 -04:00
Miguel Angel Simon Sierra 9cf575c6f9 fix(studio): make the player store the single source of truth for selection
setSelectedElementId now always collapses to one element (genuine user intent); a new
setSelectionAnchor moves the anchor within a multi-selection without collapsing it, used
only by the DOM-to-store sync echoes so a group survives a gesture.

applyDomSelection mirrors the whole DOM group into the store via setSelection instead of
writing only the anchor, so the store stays authoritative and a preview click collapses
while a preserved-group echo keeps every member.
2026-07-09 16:54:35 -04:00
Miguel Angel Simon Sierra 6673c32868 fix(studio): keep timeline selection authoritative in the preview sync
The store-to-preview sync no longer applies a partial selection: if a resolvable member's
DOM node is not ready yet it bails and retries on the next effect run, so the write-back can
never shrink the store's selection by dropping an unresolved member.

Marquee row hit-testing reuses shouldShowTimelineLayerGroupHeader instead of re-deriving
the group-header placement rule, keeping one owner for that predicate.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 0fe38e8cc8 refactor(studio): single-source timeline selection id-resolution
The DOM-selection to timeline sync routes through the canonical resolveTimelineIdForSelection
(source-file, ancestor, active-comp fallback) instead of a narrow domId/id match that
mismatched sub-composition clips.

The preview-sync equality check compares selection as sets both ways and includes the
anchor, so duplicate resolutions no longer mask an unsynced member.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 04ddd411ec fix(studio): harden group timeline edits (capabilities, rollback, snapping, marquee)
Group move/resize rejects the gesture when any selected member forbids the op (e.g. a
locked clip), so a group never edits a clip that individually cannot move, and a
persist failure now propagates so the optimistic preview rolls back.

Snapping excludes every moving member, not just the grabbed clip. The marquee hit-test
uses the real pixels-per-second (was floored at 1, wrong below 1x zoom), and a
sub-threshold marquee click scrubs the playhead like a plain lane click.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 3c6c1d3f27 refactor(studio): single-source timeline id-resolution and resize-clamp math
Extract resolveTimelineIdForSelection so DOM-to-timeline id mapping lives in one place
with a single sourceFile / activeCompPath / index.html fallback, fixing a sub-composition
selection that previously diverged between callers.

Extract shared start-trim delta helpers used by both single-clip and group resize, and
remove the never-called refreshDomEditGroupSelectionsFromPreview.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 95ded6c026 fix(studio): draw the timeline marquee in the theme accent color
The marquee and range overlays used a hardcoded blue; they now use the timeline
theme accent for the border with a translucent accent fill.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra fa3848a33d fix(studio): keep the timeline multi-selection through a group edit
setSelectedElementId no longer resets the selection set when re-selecting an element
that is already a member: DOM-to-selection sync echoes fire on every pointer move during
a group drag and were collapsing the set to the grabbed clip.

Also drops the duplicate clearSelectedElementIds action in favor of clearSelection,
which the marquee now uses to clear on an empty drag.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 36413da7f6 feat(studio): resize selected timeline clips together
Apply handle deltas through a group resize session that snapshots selected members.

Clamp one shared delta against zero start and min duration before preview and commit.

Playback-start changes are calculated per member with the existing trim formula.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 5d27af30b2 feat(studio): move selected timeline clips together
Fan selected body drags through a group move session that snapshots all selected members.

The grabbed clip snap result is applied first, then one shared delta is clamped.

Live preview patches every moved member and release persists once through batch timing.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 45d09047b7 feat(studio): add timeline marquee selection 2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 1cf601202d feat(studio): batch timeline timing commits
Persist group timing edits through one coalesced write per source file.

Keep batch timing queued behind any z-index commit for the same gesture.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 1d858f004d feat(studio): highlight timeline selection sets
Render selected styling from selectedElementIds in the timeline.

Sync the set into preview group selection boxes without collapsing the anchor.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra cca31feffe feat(studio): activate selected element set state
Use selectedElementIds as the live selection set with selectedElementId as anchor.

Keep single-click selection as a one-element set and clear both fields together.
2026-07-09 16:54:34 -04:00
Miguel Angel Simon Sierra 6080f5ad3e fix(studio): propagate z-index reorder save failures and drop dead targetTrack
handleDomZIndexReorderCommit no longer swallows per-entry save failures: it settles every
patch, and on any rejection rolls back the eager DOM z-index/position and the optimistic
store zIndex before rejecting, so a failed save cannot leave the UI showing a stacking order
that never persisted or let an ordered-after timing write proceed.

Also removes the dead targetTrack parameter threaded through the timeline edit helpers;
vertical placement is owned by the z-index intent.
2026-07-09 16:53:26 -04:00