Commit Graph
100 Commits
Author SHA1 Message Date
Miguel Ángel 574da2b215 fix(studio): off-canvas indicators track live layout instead of going stale (#2018)
The dashed indicators for elements outside the canvas stayed pinned at an element's
old position after a move or a seek: they were recomputed by an effect keyed on
compRect/activeCompositionPath, neither of which changes on an in-place soft-reload
edit or a playhead seek.

They now recompute via a MutationObserver on the preview document (coalesced to one
recompute per frame), so they follow the element live. Crop-hugging is preserved.
Adds a regression test that mutates an element in place and asserts the indicator moves.
2026-07-07 04:39:46 -04:00
Miguel Ángel 037266e72b feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling

WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload

Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
2026-07-07 04:26:56 -04:00
Miguel Ángel 5d59835446 fix(studio): static-position drag no longer freezes an element beside an animated rotation (#2016)
## What

Brief description of the change.

## Why

Why is this change needed?

## How

How was this implemented? Any notable design decisions?

## Test plan

How was this tested?

- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
2026-07-07 03:45:17 -04:00
Miguel Ángel 3d8372f880 feat(cli): associate signed-in HeyGen account with telemetry (#2020)
* feat(cli): associate signed-in HeyGen account with telemetry

Sign-in telemetry currently attributes everything to the anonymous
install id, so the sign-in funnel can be counted but a completed sign-in
can't be tied to the account it produced. This associates the two.

- On a completed sign-in, emit a PostHog `$identify` alias whose
  `$anon_distinct_id` is the install's anonymousId, so events recorded
  before sign-in stitch to the same person, and tag `auth_login_completed`
  with the account identity (the pre-plumbed `distinctId`).
- `/v3/users/me` exposes no opaque user_id, so the identity key is the
  account email, falling back to username (single `identityKey` helper).
- Both no-op under the `telemetry disable` opt-out and only fire after
  the user chooses to sign in.

Privacy disclosure updated in lockstep, since this is the first PII the
CLI attaches: the first-run telemetry notice and the telemetry section
of docs/packages/cli.mdx now state that signing in links your account
email to your usage.

Tests: identifyUser payload + no-op, completion attribution incl.
username fallback and no-identity-on-reject/empty. Verified end-to-end
against the built CLI: pre-auth events anonymous, $identify carries
$anon_distinct_id, completion carries the account email.

* docs(cli): disclose the username identity fallback

Review gating item: identityKey is `email ?? username`, but the
first-run notice and cli.mdx said only "email", so an emailless
account's username would reach PostHog undisclosed. `/v3/users/me`
treats email as optional (pickString), so the fallback is live code,
not dead — disclose it rather than assert an unverifiable email
guarantee. Both surfaces now say "email, or username if the account
has no email".

Also soften the identityKey comment: it implied username is "less
identifying", but HeyGen usernames are often email-shaped, so the note
now states username is a fallback, not a privacy win.
2026-07-07 02:23:54 -04:00
Miguel Ángel 68a8547f47 fix: align README skills table padding so oxfmt --check passes (#2014)
The media-use v2 row is the widest cell in the domain-skills table; oxfmt
re-pads every other row's trailing pipe to match. The merge added the row
without re-aligning, so oxfmt --check flagged README on CI (linux). Content
is unchanged; only trailing whitespace in 10 table rows.
2026-07-07 00:16:50 -04:00
Miguel Angel Simon Sierra ac0153cb30 feat(media-use): transcription (parakeet), transcript-cut, audio-duck editing tools 2026-07-06 23:41:35 -04:00
Miguel Angel Simon Sierra 5fe957363d feat(media-use): v2 media OS core (resolve cascade, providers, local generation, telemetry) + retire hyperframes-media 2026-07-06 23:41:05 -04:00
Miguel Angel Simon Sierra 676f461f6a feat(studio): non-destructive crop + cross-project asset view 2026-07-06 23:40:32 -04:00
Miguel Angel Simon Sierra 8c3590a90e feat(cli): parakeet ASR engine for transcribe (--engine) + HYPERFRAMES_PYTHON override 2026-07-06 23:34:32 -04:00
Miguel Ángel a2a80d5a5c fix(producer): mix muted browser media as silent for preview-render parity (#1969) 2026-07-06 23:33:50 -04:00
Miguel Ángel 9fe06f30da feat(cli): forward feedback submissions to the backend feedback endpoint (#2003)
* feat(cli): forward feedback submissions to backend endpoint

* fix(cli): truncate feedback fields to backend caps + ack before forwarding

Addresses PR review (via):
- Truncate comment (2000) / cli_version (100) / env (500) to the backend DTO
  caps before POSTing, so a pasted stack trace is forwarded truncated instead
  of rejected with a 422 the best-effort path swallows silently.
- Print "Thanks for the feedback!" before the best-effort forward so the ack
  isn't blocked behind the (bounded) network call.

* fix(cli): type feedback fetch mock
2026-07-06 20:44:42 -04:00
Miguel Ángel 1a3330a972 fix(compiler): don't scan media tags inside comments/scripts; DOM-check auto-start (#1938) (#1940)
The timing compiler scanned raw HTML with tag regexes that weren't
comment-aware, so a comment or script merely mentioning `<video>`/`<audio>`
was rewritten as a real element — injecting id/data-start/data-hf-auto-start
into the comment text. That phantom attribute then tripped the probe stage's
substring check (`html.includes("data-hf-auto-start")`), launching an
unnecessary browser probe on every render with an unexplained empty reasons list.

- Mask comments, <script>, and <style> regions before the tag scan, then
  restore them verbatim (compileTimingAttrs, extractResolvedMedia).
- Replace the probe's substring match with a DOM query
  (video[data-hf-auto-start]) and add "auto-start video(s)" to the reasons list.
2026-07-06 18:19:35 -04:00
Miguel Ángel afa7f292fb feat(lint): warn when <html dir> can blank render output (#1893)
* feat(lint): flag dir="rtl" on <html> as a confirmed silent render failure

Two independent reports diagnosed the same exact bug: dir="rtl" (or any
non-ltr value) on <html> renders correctly in preview/snapshot but
produces a fully blank/black video from render, with no other
lint/validate/inspect check catching it - output file size (far smaller
than expected) was the only tell for both reporters. Both independently
confirmed the same fix: drop dir from <html>, keep lang, and scope
direction: rtl to individual text-containing elements via CSS instead.

Could not empirically verify the render pipeline's own root cause in this
session (headless Chrome screenshot capture is unreliable in this
sandboxed environment - even a baseline, non-RTL capture timed out), so
this ships the safe, already-confirmed advisory rather than guessing at a
runtime fix. Both reporters explicitly asked for exactly this: "deserves
a lint rule or render-time warning."

* fix(lint): only flag valid non-ltr html dir values
2026-07-06 18:16:40 -04:00
Miguel Ángel 769dc702c1 feat(cli): emit sign-in lifecycle telemetry (#2000)
* feat(cli): emit sign-in lifecycle telemetry

The CLI tracks command and render lifecycles but emits nothing for
`auth login`, so sign-in outcomes are invisible on the observability
dashboards — a completed sign-in, an abandoned browser flow, and a
rejected key all look identical (absent). This leaves a blind spot in
the same funnel the render events already cover.

Add three events mirroring the existing `trackX` pattern:
  - auth_login_started    (method: oauth | api_key)
  - auth_login_completed  (method)
  - auth_login_failed     (method, reason)

`reason` is a fixed low-cardinality enum (flow_error / no_credential /
rejected / invalid_input). No token, key, identity, email, or free text
is ever attached — consistent with the existing anonymous telemetry and
the `telemetry disable` opt-out. Wired into both the OAuth and
--api-key paths in `auth login`, with unit coverage for the new events.

* fix(cli): close sign-in telemetry funnel dropout gaps

Follow-up so `started` reconciles to `completed + failed` on the common
abandonment paths, which the first cut missed:

- Interactive prompt cancel (Ctrl-C) now surfaces as a throw that the
  single catch in the api-key path records as `aborted`, instead of a
  bare exit with no event.
- A stdin read that times out in non-TTY `--api-key` mode now records
  `aborted` before the error propagates, rather than exiting silently.
- OAuth split: a timed-out browser callback (user closed the tab) is
  tagged `flow_timeout`, separated from real `flow_error` (IdP/network),
  since the walk-away timeout is the dominant non-error dropout.

Also pre-plumb an optional `distinctId` on the three trackers, mirroring
trackRenderComplete/trackRenderError. Unused today; it lets a later
identity-level attribution be a one-line callsite change rather than a
signature sweep. Coverage added for the new reasons and forwarding.
2026-07-06 16:59:47 -04:00
Miguel Ángel dfa6fedbcd fix(hyperframes-media): surface a clear error when npx can't be resolved on Windows (#1961)
On Windows, resolveSpawnCommand routes `npx` through node + npm's
npx-cli.js (avoiding the un-spawnable npx.cmd), locating that CLI via
npm_execpath. When the script is run directly with `node audio.mjs`
instead of through npm/npx, npm_execpath is unset, so resolution returns
null and spawnP short-circuited to `{status:-1}` — silently. With
stdio:"ignore" hiding everything, callers just reported "TTS failed -
omitted" for every single line, giving no hint that the real cause was
an unresolvable npx. Debugging required reading the source.

Fix: when spawnP hits that null-resolution path, emit a clear one-time
diagnostic naming npm_execpath and the remedy (run via npx/npm, or export
npm_execpath) before returning {status:-1}. One-shot latch so a batch of
lines logs it once, not per line. Behavior is otherwise unchanged — still
returns {status:-1} and spawns nothing.

Test: new tts.spawn.test.mjs case — two consecutive win32 npx calls with
npm_execpath unset both return {status:-1}, nothing is spawned, and the
diagnostic (mentioning npm_execpath) is emitted exactly once. Existing
spawn tests unchanged (7/7 pass).
2026-07-05 21:29:04 -04:00
Miguel Ángel c2862eae1b docs: update README.md 2026-07-05 20:57:24 -04:00
Miguel Ángel e8d19b1b43 chore: release v0.7.36 2026-07-05 19:25:42 +00:00
Miguel Ángel 98b539df72 fix(cli): prefer real ffmpeg exe over cmd shim (#1958) 2026-07-05 12:24:50 -07:00
Miguel Ángel 2f55ea678a chore: release v0.7.35 2026-07-05 18:48:24 +00:00
Miguel Ángel f999b40d73 fix(lambda): define dirname in handler bundle 2026-07-05 11:46:48 -07:00
Miguel Ángel b7dcb9e2a3 fix(engine): harden ffmpeg binary resolution 2026-07-05 11:46:38 -07:00
Miguel Ángel b34e623bf8 fix(hyperframes-media): sfx offline path skips (loudly) a missing bundled file 2026-07-05 11:46:30 -07:00
Miguel Ángel 114d31919e chore: release v0.7.34 (#1954) 2026-07-05 10:55:11 -07:00
Miguel Ángel 56d4a7032b fix(engine): preserve DOM mask visibility state (#1953) 2026-07-05 07:24:26 -07:00
Miguel Ángel 78cca797f1 chore: release v0.7.33 2026-07-04 22:33:27 +00:00
Miguel Ángel bb066077b4 fix(producer): avoid reviving hidden DOM in HDR layers (#1935)
* fix(producer): avoid reviving hidden DOM in HDR layers

* fix(producer): filter transition HDR DOM masks

* fix(producer): keep hidden timed descendants masked
2026-07-04 15:30:56 -07:00
Miguel Ángel 16fe1368ff chore: release v0.7.32 2026-07-04 21:23:19 +00:00
Miguel Ángel 942080fa8e fix(hyperframes-media): install MusicGen deps via python3 -m pip, not bare pip (#1894)
pipInstall() spawned a bare "pip" binary. Many Homebrew/system Python
installs expose only python3/pip3 on PATH, so the spawn silently ENOENTs
and the documented "auto-installed on demand" local MusicGen path never
actually installs anything - the failure is invisible since spawnSync's
status just comes back non-zero like any other install failure.

Switched to `python3 -m pip install`, matching this same file's own
pyOk() convention of always invoking python3 explicitly. This also
closes a second latent bug: a bare pip/pip3 could resolve to a different
Python installation than the python3 binary pyOk() probes against if
more than one is on PATH, so `-m pip` guarantees the install lands in the
exact interpreter being checked.

Manually verified `python3 -m pip --version` succeeds in this
environment. No automated test added - this is a literal command-array
swap with no new branching logic, and spawnSync is a named import from
node:child_process with no clean mocking path available without a
larger refactor disproportionate to the fix's size.
2026-07-04 14:18:56 -07:00
Miguel Ángel 3388635f1d fix(hyperframes-media): resolve python3 -> python/py on Windows (#1922)
The audio engine shells out to `python3` for ElevenLabs TTS
(tts.mjs) and the local Lyria/MusicGen BGM paths (bgm.mjs). `python3`
is correct on macOS/Linux, but a standard python.org install on
Windows only creates `python.exe` plus the `py` launcher -- there is
no `python3.exe` (only the Microsoft Store build adds one). So every
`spawn("python3", ...)`/`spawnSync("python3", ...)` ENOENTs on a normal
Windows Python setup, silently disabling all Python-backed audio
features until the user hand-creates a `python3.exe` shim (reported: a
user copied python.exe to python3.exe to work around it, and separately
another had to target a python3 stub specifically).

Fix: a shared lib/python.mjs resolver probes the platform's candidates
in order and returns the argv prefix that actually launches Python 3 --
`["python3"]` / `["python"]` / `["py", "-3"]` on win32, `["python3"]`
then `["python"]` elsewhere -- resolved once per process. All direct
`python3` spawn sites in tts.mjs (elevenlabs probe + synth) and bgm.mjs
(pyOk probe, Lyria recipe, MusicGen script) now route through it. On
macOS/Linux `python3` still wins first, so behavior there is unchanged;
if nothing probes OK the resolver falls back to `python3` so the spawn
fails loudly exactly as before, never worse.

Scope: only the direct python3 invocations. bgm.mjs's pipInstall()
still shells `pip` -- switching that to `<python> -m pip` is the
separate concern of the open PR #1894 (draft); noting the overlap so
the two don't collide. Windows-specific whisper.cpp-vs-openai-whisper
detection and the npm_execpath/npx spawn issue from the same report are
distinct root causes, not addressed here.

Test: python.test.mjs (node:test) covers every platform/probe branch
with an injected probe -- no real interpreter spawned: non-win32 picks
python3; win32 prefers python3, falls back to python, then to `py -3`;
the py launcher is probed as `py -3 --version`; nothing-runs falls back
to the canonical python3; and pythonInvocation keeps the launcher's -3
ahead of caller args. Existing tts.spawn.test.mjs still passes (6/6).
2026-07-04 14:08:29 -07:00
Miguel Ángel 3f49f107eb fix(cli): validate seeks the runtime player directly, not raw timelines (#1895)
* fix(cli): validate seeks the runtime player directly, not raw timelines

validate's seekTo() only checked for window.__hf.seek (a bridge object
the producer's render-pipeline file server injects) before falling
back to grabbing window.__timelines and calling .seek() on each raw
GSAP timeline directly. validate serves compositions through a plain
static file server that never injects that bridge, so this fallback
ran on every single validate invocation.

Seeking a raw timeline moves the animation state but skips the
runtime's own [data-start]/[data-duration] visibility sync
(syncMediaForCurrentState in packages/core/src/runtime/init.ts), which
is what sets an off-window clip's inline visibility/display styles.
Skipping it left elements outside their timeline window looking fully
visible to any check that reads computed style afterward at that seek
time.

This surfaced as validate's WCAG contrast audit
(contrast-audit.browser.js) flagging text in off-window clips against
whatever background happened to be behind them, since its own
visibility filtering trusts the runtime to have already hidden them.

Fix: prefer window.__player.renderSeek, which the composition runtime
exposes directly on every page load (no bridge required) and which
does run the visibility sync, before falling back to the __hf/raw
timeline paths. No changes needed to contrast-audit.browser.js itself
since its existing visibility check now sees correct computed style.

No new test added: seekTo's branch selection runs entirely inside a
page.evaluate() callback, which Puppeteer serializes via .toString()
for the browser context, so it can't import and call a project-local
window.__player stub from a jsdom/vitest test without testing a copy
of the logic rather than the shipped code. Verified instead by reading
the runtime chain end-to-end: window.__player.renderSeek is always set
by packages/core/src/runtime/init.ts's createPlayerApiCompat, calls
through to player.renderSeek, which calls syncMediaForCurrentState().

* fix(cli): wait for runtime seek target in validate
2026-07-04 14:08:26 -07:00
Miguel Ángel 0338be97fd fix(cli): validate navigation timeout honors --timeout, hints on CDN scripts (#1929)
`validate` navigated the page with a hardcoded 10s timeout that ignored
the --timeout option. A composition that loads GSAP (or any library)
from a CDN <script> in <head> blocks `domcontentloaded` until that
script finishes downloading; on a slow network that exceeds 10s and
validate fails with an opaque "Navigation timeout of 10000ms exceeded"
— even though the full render (much larger budget) rides it out fine,
and even though --timeout (the documented "wait longer for slow loads"
knob) had no effect on navigation. The only recourse was to change the
composition (vendor the script locally).

Reported precisely, with the exact error and the observation that
render's 60s budget masks it while validate's 10s trips.

Fix:
- resolveNavigationTimeoutMs(optTimeout) = max(10s floor, --timeout), so
  --timeout now also extends the navigation budget. Default behavior is
  unchanged: the default --timeout (3000) stays clamped to the 10s floor.
- navigationTimeoutHint() replaces Puppeteer's opaque timeout error with
  an actionable message naming the likely cause (a blocking CDN <script>)
  and the two fixes (vendor locally / raise --timeout). Any non-timeout
  error is rethrown unchanged.
- --timeout help text updated to note it also governs navigation.

Both helpers are pure and exported; validateInBrowser wires them around
the single page.goto. No behavior change for compositions that navigate
within 10s.

Test: resolveNavigationTimeoutMs (floor kept for unset/small/zero,
raised past the floor) and navigationTimeoutHint (rewrites a nav-timeout
error with CDN + --timeout guidance; returns null for other errors so
the caller rethrows as-is). validate suite 14 tests pass.
2026-07-04 14:08:22 -07:00
Miguel Ángel 8bc1e5d603 fix(video-workflows): pad the frame's own duration to match the transition tail (#1889)
* fix(video-workflows): pad the frame's own duration to match the transition tail

transitions.mjs extends the index.html WRAPPER's data-duration to cover an
outgoing transition's tail, but the frame's own internal composition file
kept its shorter, content-only data-duration (authored per frame-worker.md's
"duration is fixed upstream" instruction). The render engine clip-gates a
sub-composition's visible content at its own declared duration, so content
vanished abruptly at content-end instead of fading through the wrapper's
extended fade-out tween.

A user root-caused and verified this themselves: padding the frame's own
duration to match the wrapper fixed it, project-wide, across every
non-final frame. transitions.mjs already computes the correct padded
duration for the wrapper - it now writes the same value into the matching
frame's own file at inject time.

Extracted to a shared lib/pad-frame-duration.mjs (mirroring the existing
lib/transition-registry.mjs convention) since transitions.mjs's own
top-level CLI dispatch runs on import, making it untestable directly.
Duplicated identically across pr-to-video, faceless-explainer, and
product-launch-video, whose transitions.mjs copies are otherwise
byte-identical (confirmed via diff) - one root cause, one fix, applied
everywhere it lives.

* fix(skills): avoid duration helper file race
2026-07-04 14:08:18 -07:00
Miguel Ángel 8a3227f548 fix(engine): write the audio mix filter graph to a file, not the command line (#1890)
* fix(engine): write the audio mix filter graph to a file, not the command line

mixAudioTracks built the ffmpeg -filter_complex argument as one inline
string scaling linearly with track count. Reported in the wild at 146
timed audio clips: the resulting command line exceeded the OS length
limit and spawn failed with ENAMETOOLONG, dropping audio entirely until
the user manually consolidated clips to reduce the count.

FFmpeg supports -filter_complex_script specifically for this - the same
filter graph read from a file instead of inlined as an argument. The -i
pairs for each track still scale with count but stay short and fixed-size
each, so the one component that actually grew unbounded (the filter
string) no longer sits on the command line at all. The temp file is
cleaned up immediately after ffmpeg exits, matching the existing sibling
temp-file convention in audioVolumeEnvelope.ts.

Verified end-to-end against a real ffmpeg binary (not just mocked): a
two-track mix produced correct output audio with no leftover temp files.

* fix(engine): create audio filter scripts safely
2026-07-04 14:08:15 -07:00
Miguel Ángel 78069da140 fix(cli): purge stale/partial browser installs instead of wedging retries (#1913)
* fix(cli): purge stale/partial browser installs instead of wedging retries

Two independent reports of the same failure: a `chrome-headless-shell`
zip extraction gets interrupted (Windows AV lock, sleep/wake, ctrl-C)
and leaves only the alphabetically-early files (ABOUT/LICENSE) in the
target directory, no executable. Every subsequent `browser ensure` (or
implicit re-download from `findBrowser`/`ensureBrowser`) sees the
directory already exists and hands it straight to @puppeteer/browsers'
install(), which throws "folder exists but the executable is missing"
without re-extracting -- permanently wedging the machine until someone
manually deletes the directory. `--force` didn't help because it was a
phantom flag: `browser.ts` never declared it, so it silently did
nothing (mentioned only in an error-message string).

Root cause: `findFromCache()` already detects this exact case (dir
exists, exe missing) and returns it as `staleHyperframesCachePath`, but
`findBrowser()`/`ensureBrowser()` fed that straight into a re-download
without ever deleting the stale directory first, so install() hit the
same "exists" branch every time.

Fix:
- `findFromCache()` also returns `staleInstallPath` (InstalledBrowser's
  `.path` -- the actual install-folder root, not the missing
  executablePath) for the stale case.
- Both `findBrowser()` and `ensureBrowser()` now purge that directory
  (`rmSync`, inside the existing `withInstallLock` mutex from #1866 so
  a purge can't race a concurrent installer) before retrying, so
  install() actually re-extracts instead of erroring.
- Wired up a real `--force` flag on `hyperframes browser ensure`: it
  purges the whole HF-managed cache (reusing the already-tested
  `clearBrowser()`) and skips every cache/system shortcut, so it always
  gets a fresh download regardless of what's currently on disk --
  matching what the existing (previously false) help text already
  claimed it did.

Not fixed here (separate root cause, flagged for later): neither
report's machine had a usable auto-detected system Chrome fallback on
Windows -- `SYSTEM_CHROME_PATHS` only lists macOS/Linux paths, so
`findFromSystem()` can never succeed on win32. Both reporters worked
around this manually via HYPERFRAMES_BROWSER_PATH, which still works
fine; adding real Windows system-Chrome detection is a distinct,
larger change.

Test: extended manager.test.ts's existing stale-cache-redownload test
to include a populated stale install directory and assert it's gone
before the mocked install() is called (was previously only asserting
the redownload happened, not that the fix's purge step ran). Added a
new test for `ensureBrowser({force: true})` purging the cache and
bypassing a healthy cache/system-Chrome shortcut. Also fixed the shared
fs mock's `rmSync` to actually simulate recursive deletion (drop
nested tracked paths too), which the new tests need and the old ones
never exercised. Full CLI suite (1222 tests) passes.

* fix(cli): serialize force browser cache purge
2026-07-04 14:08:12 -07:00
Miguel Ángel f7c9c35d8b fix(core): route window.__hyperframes to the scoped variant in sub-comps (#1931)
Sub-composition scripts run inside a wrapper that passes the SCOPED
__hyperframes (per-instance getVariables) as a bare script param, while
`window` is a Proxy. That proxy intercepted only __timelines, so
`window.__hyperframes` fell through to the HOST page's base
__hyperframes — whose getVariables reads the host's variables, not this
instance's. So the two documented spellings diverged: the bare
`__hyperframes.getVariables()` param returned the correct per-instance
values, but `window.__hyperframes.getVariables()` returned the wrong
(host / empty) ones, silently rendering every reused instance with the
first instance's content (or defaults).

docs/concepts/variables.mdx already promises both forms "work in both
top-level and sub-composition scripts ... each instance sees its own
resolved values" — the runtime just didn't honor it. Reported directly
(a user lost significant debugging time across three parametrized
sub-comps before discovering the bare param was the only form that
worked), and matches an earlier deferred finding that getVariables()
returns {} for reused sub-comp instances.

Fix: the scoped `window` proxy now returns the scoped __hyperframes for
`prop === "__hyperframes"`, so window.__hyperframes.getVariables() and
the bare param resolve identically to this composition's own variables.
The scoped variant is Object.assign({}, base, { getVariables }), so all
other __hyperframes members still pass through to the base unchanged.

Test: two new executed-wrapper cases (new Function(...)(fakeWindow)) —
window.__hyperframes.getVariables() now returns the per-comp variables
instead of the TOP-LEVEL-LEAK host value, and a non-getVariables member
(fitTextFontSize) still reaches the base. Full core suite (1092) passes.
2026-07-04 14:08:08 -07:00
Miguel Ángel 08c0a03510 test(studio): agent-browser e2e smoke for the design panel (#1912)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.

* fix(studio): surface persist failures with toast and guarded revert

- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted

* fix(studio): revert data-attribute and html-attribute commits on persist failure

commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.

* fix(studio): close coupled persist-hook review findings

Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.

* test(studio): cover persist-failure hook behavior

Regression tests for the persist failure paths: unresolvable targets,
no-op warns, rejected requests, revert races, structural-edit refusal,
and read/write failure toasts.

* test(studio): cover attribute-commit revert on persist failure

Regression tests for the data-attribute and html-attribute revert paths
added in #1910: unresolvable target, rejected request, success (no revert),
and a stale-failure-vs-newer-success race guarded by the per-attribute
version counter.

* test(studio): cover the patch-rejection and text-commit revert fixes

Adds the two persist-hook cases R2 flagged as untested: the
!patchResponse.ok HTTP-error path (previously only exercised via a
network-throw, which bypassed this branch) and handleDomTextCommit's
server-failure path. Also strengthens the prepareContent-write-failure
test to assert the already-persisted base patch is recorded, not
reverted, matching the coupled persist-hook fix.

* test(studio): agent-browser e2e smoke for the design panel

Standalone script driving selection plus one input per panel section
against a running preview, asserting disk persistence and reload survival.

* fix(studio): close smoke-test quality nits, add fault-injection coverage

Closes the R2/R3 findings on the design-panel e2e smoke script:

- Section lookup no longer matches h3 display text plus a manual tree
  walk (breaks on wording tweaks). Section now carries a stable
  data-panel-section attribute; the script queries by it directly.
- Fields are located by their sibling label (or, where none exists,
  by being the section's only input of that type) instead of by
  guessing the fixture's current value ahead of time.
- Fixed sleep(1400/2000/6000) waits replaced with polling on the
  actual condition (selection registered, section rendered, patch
  round-tripped, app booted). This surfaced a real bug while
  verifying: computing click coordinates right after a commit reused
  a stale preview-frame position from before the property panel's
  reflow, silently clicking the wrong spot — now waits for the
  frame's rect to stabilize first. Also found and fixed a disk-write
  race on the first commit of a run (patch fetch resolves before the
  server's file write lands).
- FAIL now dumps window.__patchLog for diagnosability.
- Added a fault-injection cell: the server rejects a patch and the
  panel must toast the rejection without persisting it or clobbering
  the prior committed value.

Verified by actually running the script with agent-browser against a
live preview (previously never exercised this way) — all 14 checks
pass across repeated clean runs.
2026-07-03 18:50:23 -07:00
Miguel Ángel 64688ddaf2 test(studio): cover persist-failure hook behavior (#1911)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.

* fix(studio): surface persist failures with toast and guarded revert

- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted

* fix(studio): revert data-attribute and html-attribute commits on persist failure

commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.

* fix(studio): close coupled persist-hook review findings

Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.

* test(studio): cover persist-failure hook behavior

Regression tests for the persist failure paths: unresolvable targets,
no-op warns, rejected requests, revert races, structural-edit refusal,
and read/write failure toasts.

* test(studio): cover attribute-commit revert on persist failure

Regression tests for the data-attribute and html-attribute revert paths
added in #1910: unresolvable target, rejected request, success (no revert),
and a stale-failure-vs-newer-success race guarded by the per-attribute
version counter.

* test(studio): cover the patch-rejection and text-commit revert fixes

Adds the two persist-hook cases R2 flagged as untested: the
!patchResponse.ok HTTP-error path (previously only exercised via a
network-throw, which bypassed this branch) and handleDomTextCommit's
server-failure path. Also strengthens the prepareContent-write-failure
test to assert the already-persisted base patch is recorded, not
reverted, matching the coupled persist-hook fix.
2026-07-03 18:25:16 -07:00
Miguel Ángel e6e0d97cc5 fix(studio): surface persist failures with toast and guarded revert (#1910)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.

* fix(studio): surface persist failures with toast and guarded revert

- matched:false and persist errors toast, warn structurally, and revert the optimistic write
- reverts guarded by a per-property version counter so stale failures never stomp newer edits
- structural text-field edits refuse persist instead of writing escaped markup
- multi-field child edits persist via per-child ops; shared commit runner extracted

* fix(studio): revert data-attribute and html-attribute commits on persist failure

commitDataAttribute and handleDomHtmlAttributeCommit toasted on failure but
never reverted the optimistic attribute write, leaving the preview showing an
edit that never reached disk (the exact bug this PR closes for style commits).
Extracted into useDomEditAttributeCommits.ts (useDomEditTextCommits.ts was at
the file-size cap) and routed through runDomEditCommit with a per-target+
attribute version guard, mirroring handleDomStyleCommit.

* fix(studio): close coupled persist-hook review findings

Three findings from R2 review that must land together: a patch-rejection
toast doubled up with the generic persist-failure toast (StudioSaveHttpError
had no alreadyToasted marker), a failed prepareContent write (e.g. font-face
injection) reverted and re-toasted a change the server had already
persisted, and text-commit shouldRevert only rolled back on one narrow
error type instead of any persist failure.
2026-07-03 18:22:22 -07:00
Miguel Ángel 22942280b6 fix(studio): per-child patch op builders and persist-seam harness (#1909)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
2026-07-03 18:14:17 -07:00
Miguel Ángel 23f67da5e2 fix(studio-server): child-scoped patch operations with batch abort (#1908)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)
2026-07-03 17:53:17 -07:00
Miguel Ángel 02e9d6142d fix(studio): make canvas selection hit intended elements (#1907)
* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing
2026-07-03 17:41:01 -07:00
Miguel Ángel 4d199c0f3a test(studio): add design-panel QA fixture and triage matrix (#1906)
Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.
2026-07-03 17:40:38 -07:00
Miguel Ángel 1a7002f208 perf(engine): superset extraction for overlapping trims of one source (#1885)
* perf(engine): superset extraction for overlapping trims of one source

Cache-missing trims of the same source that are frame-aligned and
overlapping decode their union window in ONE ffmpeg pass; each trim's
frames are materialized by hardlinking the superset frames with
renumbered names (copy fallback on EXDEV). Byte-identical to per-trim
extraction on CFR sources (verified by content hash in the A/B run),
~2x less decode+encode work for typical overlapping trims, and
sparse-keyframe sources pay the keyframe seek once instead of once per
trim. Disjoint or misaligned trims keep the direct path; any union
failure falls back to per-trim extraction.

Also: warm renders (zero cache misses) skip the extraction-cache GC
sweep instead of paying a full cache size scan.

* fix(engine): superset review hardening - clustering, abort, cache-fs temp, gc staleness

- Partition each source's trims into overlap-connected components before
  the union check, so one disjoint outlier no longer collapses the whole
  bucket to direct extraction (pinned by a 3-of-4-overlap test).
- On abort, the superset fallback no longer re-runs every member through
  direct extraction (N doomed ffmpeg spawns); the cancellation surfaces
  per member instead.
- The superset temp dir moves onto the cache filesystem when the cache
  is active so member hardlinks into partial dirs cannot EXDEV-copy and
  silently multiply disk usage; its .partial- name puts crashed
  leftovers under the GC's aged-partial sweep.
- GC staleness fallback: a .hf-last-gc marker is stamped per sweep and
  all-hit renders sweep anyway once it is older than 24h, so 100%-warm
  workloads still reclaim space (pinned by a stale-marker test).
2026-07-03 15:09:39 -07:00
Miguel Ángel 48f158a0c2 perf(engine): one-pass SDR-to-HDR extraction with cache-key transform (#1902)
* perf(engine): one-pass SDR-to-HDR extraction with cache-key transform

Mixed-HDR compositions converted each SDR source with a full libx264
re-encode (convertSdrToHdr) before extraction. The BT.709 to BT.2020
colorspace remap now runs as a filter inside the extraction pass
itself; convertSdrToHdr and the _hdr_normalized intermediate are
deleted. Same shape as the earlier one-pass VFR change.

Also fixes a cache-poisoning bug this exposed: the HDR preflight
rewrote entry.videoPath AFTER the cache-key snapshot, so a mixed-HDR
render cached converted frames under the plain source key and a later
SDR render of the same trim would have served HDR-tinted frames. The
cache key now carries an optional transform discriminator; keys
without a transform stay byte-compatible with existing entries.

* fix(engine): attribute SDR-to-HDR extract failures, pin filter-order intent

Review hardening for one-pass SDR-to-HDR:

- ffmpeg failures now carry an 'SDR→HDR conversion failed (colorspace
  filter in extract pass)' prefix when the remap is in the chain, so a
  filter-less ffmpeg build fails loudly with attribution instead of a
  generic extract error.
- Comments pin the fps-before-colorspace ordering intent and mark
  sdrToHdrTransfers as the canonical read for both the cache key and
  extraction options.
- Cross-render cache-poisoning regression test now compares frame
  BYTES across the cache boundary: mixed-HDR render then plain-SDR
  render of the same trim must produce different pixels, and a repeat
  plain render must hit the plain entry with byte-identical frames.
2026-07-03 15:08:51 -07:00
Miguel Ángel 34590649a0 perf(engine): extraction cache on by default with atomic publish and LRU gc (#1901)
* perf(engine): extraction cache on by default with atomic publish and LRU gc

Warm re-renders now skip source-video frame extraction entirely
(video_extract 400ms -> 13ms on a 4-video composition; outputs are
pixel-identical, PSNR inf). What made default-on safe:

- Atomic entry publish: frames extract into a unique .partial-<pid>-<uuid>
  dir, the completion sentinel is written there, and the dir is renamed
  into the final key atomically. Concurrent renders sharing a cache can
  duplicate work but can never serve a torn entry (previously documented
  as single-writer only).
- Size-capped LRU gc: best-effort sweep after extraction evicts
  oldest-used entries past a 2 GiB default budget
  (HYPERFRAMES_EXTRACT_CACHE_MAX_MB) and clears crashed writers'
  partials. Entries younger than 60 min are never evicted so live
  renders keep their frames.
- Default cache dir: <tmpdir>/hyperframes-extract-cache-<uid>. Opt out
  with HYPERFRAMES_EXTRACT_CACHE_DIR=off (or none/false/0); a
  non-writable dir degrades to uncached with a single warning instead
  of failing the render.

* fix(engine): harden extraction cache publish and surface cache ops signals

Review hardening for the default-on extraction cache:

- Bypass the cache for HDR-converted intermediates: the key snapshot
  describes the original source, so publishing converted frames under
  it would poison later plain-SDR renders of the same trim. (The
  follow-up transform-keyed change re-enables caching for these.)
- publishCacheEntry TOCTOU: adopt a concurrent writer's completed
  entry both before removing an apparently-stale dir and after a
  failed retry rename, so a winner's publish is never destroyed or
  reported as a failure.
- Observability for the failure paths: cachePublishFailures,
  cacheGcEvictions, cacheGcBytesFreed, and cacheAgedPartialsCleared on
  ExtractionPhaseBreakdown; gcExtractionCache now returns sweep stats.

* fix(engine): sweep superseded cache generations in gc

After a SCHEMA_PREFIX bump, old-generation entries (hfcache-v2-*)
no longer matched the sweep's prefix filter and would orphan their
disk forever. The gc now matches any hfcache-v* generation; superseded
entries never receive sentinel touches, so the LRU evicts them first.
2026-07-03 13:41:30 -07:00
Miguel Ángel 8d64d48e4a perf(engine): dedupe identical extractions within one render (#1900)
* perf(engine): write PNG frames at compression_level 1

Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.

* perf(engine): one-pass VFR extraction with -fps_mode cfr

VFR sources (screen recordings, phone videos) were re-encoded to CFR
with libx264 and then extracted in a second ffmpeg pass. Extraction now
runs a single pass with -fps_mode cfr -r <fps>. Same frame counts on
the VFR regression fixtures (120/120 mid-seek, 297-303 full file), one
less x264 generation of quality loss, ~3.4x faster on VFR inputs.
convertVfrToCfr and the _vfr_normalized intermediate are deleted.

The full-VFR test's byte-identical duplicate-frame cap is retired with
cause: the fixture has no source frames for 40% of its timeline, so
held frames are correct; the two-pass path only scored under it because
x264 encoder noise made frozen frames hash differently. The freeze
regression (missing frames) stays pinned by the frame-count windows.

* docs(engine): pin vfrPreflightMs definition change after one-pass VFR

vfrPreflightMs used to time a per-source VFR-to-CFR re-encode; it now
times only the cached classification probe and collapses to ~0. Call
that out on ExtractionPhaseBreakdown so dashboards keyed on the old
threshold semantics migrate to vfrPreflightCount / extractMs.

* fix(engine): bump extraction cache schema to v3 for one-pass VFR frames

One-pass VFR extraction changes frame CONTENTS for VFR sources while
the cache key tuple (path, mtime, size, trim, fps, format) is
unchanged, so warm v2 entries holding two-pass frames would keep being
served across the deploy boundary. Bumping the schema prefix makes v2
entries inert; affected sources re-extract once.

* perf(engine): dedupe identical extractions within one render

N <video> elements sharing (resolved path, mediaStart, duration, fps,
format) extracted N times; they now share one extraction via an
in-flight promise map keyed on that tuple. Duplicate elements receive
the shared frame set under their own videoId. This also removes a race
where two identical clips on a cache miss wrote the same
extraction-cache entry dir concurrently. 3x duplicated 60s 1080p video:
4426ms to 1521ms in the A/B benchmark, one frame set on disk.

* fix(engine): attribute shared-extraction failures to the dedupe leader

When a deduped extraction fails, every follower reported the leader's
error verbatim under its own videoId, reading as N independent
failures in traces. Follower errors now carry a
'[shared extraction, leader <id>]' prefix so the fan-out is traceable
to one root failure.
2026-07-03 13:39:29 -07:00
Miguel Ángel 7860583341 perf(engine): one-pass VFR extraction with -fps_mode cfr (#1899)
* perf(engine): write PNG frames at compression_level 1

Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.

* perf(engine): one-pass VFR extraction with -fps_mode cfr

VFR sources (screen recordings, phone videos) were re-encoded to CFR
with libx264 and then extracted in a second ffmpeg pass. Extraction now
runs a single pass with -fps_mode cfr -r <fps>. Same frame counts on
the VFR regression fixtures (120/120 mid-seek, 297-303 full file), one
less x264 generation of quality loss, ~3.4x faster on VFR inputs.
convertVfrToCfr and the _vfr_normalized intermediate are deleted.

The full-VFR test's byte-identical duplicate-frame cap is retired with
cause: the fixture has no source frames for 40% of its timeline, so
held frames are correct; the two-pass path only scored under it because
x264 encoder noise made frozen frames hash differently. The freeze
regression (missing frames) stays pinned by the frame-count windows.

* docs(engine): pin vfrPreflightMs definition change after one-pass VFR

vfrPreflightMs used to time a per-source VFR-to-CFR re-encode; it now
times only the cached classification probe and collapses to ~0. Call
that out on ExtractionPhaseBreakdown so dashboards keyed on the old
threshold semantics migrate to vfrPreflightCount / extractMs.

* fix(engine): bump extraction cache schema to v3 for one-pass VFR frames

One-pass VFR extraction changes frame CONTENTS for VFR sources while
the cache key tuple (path, mtime, size, trim, fps, format) is
unchanged, so warm v2 entries holding two-pass frames would keep being
served across the deploy boundary. Bumping the schema prefix makes v2
entries inert; affected sources re-extract once.
2026-07-03 13:39:15 -07:00
Miguel Ángel 557a270271 perf(engine): write PNG frames at compression_level 1 (#1898)
Extracted video frames are render-scoped temp files read once during
capture, so zlib effort above level 1 buys nothing. Measured 3.3x
faster on 60s of 1080p H.264 to PNG (11.4s to 3.5s) and 5.4x on a 20s
vp9-alpha webm (4.3s to 0.79s), for ~14% larger temp files.
2026-07-03 13:39:02 -07:00
Miguel Ángel 2dfae0c8b2 chore: release v0.7.28 2026-07-03 20:00:49 +00:00
Miguel Ángel df221c1fd6 fix(engine): scale static-dedup verification density with run length (#1903)
* fix(engine): scale static-dedup verification density with run length

Reported symptom: a 10-scene template composition (shared card layout,
per-scene text/progress-bar content) rendered scene 1 correctly, but
every scene after that had its text/progress-bar card missing from
the final MP4 -- even though snapshot and validate showed correct
per-scene content when seeking directly to those timestamps. Setting
HF_STATIC_DEDUP=false fixed every scene. Render log showed a large,
mostly-reusable static-frame run engaging (2430 frames, 34% reusable).

verifyStaticFramesSafe already does a real, pixel-exact comparison
(anchor vs. candidate screenshot) before trusting a predicted-static
run -- the reuse mechanism itself is correct and already regression-
locked (frameCapture-staticDedupIndex.test.ts). The gap was sample
density: interior checks per run were capped at a flat
min(sampleCount, 8) points, so the stride between checks grew with
the run's span. A 2000+ frame run (plausible for a 10-scene comp
where computeStaticFrameSet's GSAP-tween-only interval walk can't see
whatever mechanism swaps each scene's text) could space checks ~285
frames apart, letting a real content change hide between two verified
points and get the whole run wrongly trusted as static.

Fix: extract the point-selection into a pure, exported
computeStaticVerificationPoints(a, b, sampleCount), and bound the
STRIDE by sampleCount (HF_STATIC_DEDUP_SAMPLES) instead of just the
point count, so density scales with run length. Short/typical runs
are unaffected (the two formulas agree there); long runs get
proportionally denser checks. The existing hardCap safety valve is
untouched -- if this makes verification too expensive for a
pathological composition, dedup still disarms entirely rather than
trusting a sparsely-checked set.

Test: new frameCapture-staticDedupVerifyDensity.test.ts asserts the
max gap between consecutive verification points never exceeds
sampleCount on long runs (would fail pre-fix at span=2000/10000),
matches the prior stride on short runs, and always includes both
run endpoints. Full engine suite (845 tests) passes.

* fix(engine): decouple verification density scaling from sampleCount polarity

Addresses review feedback on the static-dedup density fix (PR #1903):

1. The prior revision bounded the interior-check STRIDE by sampleCount
   directly, which inverted HF_STATIC_DEDUP_SAMPLES' polarity: raising
   it widened the allowed gap between checks instead of narrowing it,
   and the "raise HF_STATIC_DEDUP_SAMPLES to verify more" log guidance
   became backwards for exactly the long runs it's meant to help.

   Fix: introduce a fixed STATIC_VERIFY_REFERENCE_STRIDE (24 frames,
   independent of sampleCount) that drives the length-scaling behavior
   -- this alone fixes the original bug (long runs going nearly
   unverified) regardless of how sampleCount is configured. sampleCount
   is now purely a per-run point-count FLOOR: raising it only ever
   increases density, restoring correct, monotonic polarity.

2. hardCap wasn't re-tuned for the new cost model. The old flat 8-point
   cap cost ~8 checks/run; the new density costs ~span/24 checks/run --
   ~103 for the reported 2430-frame run, ~417 for a 10k-frame run.
   Sizing the budget only off sampleCount (which no longer drives
   density for long runs) would make a genuinely-static long
   composition spuriously disarm under the new, more thorough checking.
   hardCap now also scales with the total predicted-static frame count,
   with a 3x margin over the expected minimum verification cost.
   Softened the budget-exhausted log message accordingly -- it no
   longer prescribes raising sampleCount, which would often just add
   cost without proportionally raising the now length-driven budget.

3. The 5 existing tests only asserted sample-point geometry (gaps,
   endpoints, stride shape), not the actual point of the fix -- that a
   real content change hiding between the OLD sample gaps now gets
   caught. Added a behavior-level test: mocks pageScreenshotCapture to
   simulate a transient content change at a frame the pre-fix formula
   would have skipped (reconstructed locally in the test, commented as
   historical-only) but the new formula samples, and asserts the real
   verifyStaticFramesSafe (now exported) detects it via the real
   computeStaticVerificationPoints -- not a reimplementation. Also
   added a direct polarity regression test (raising sampleCount past
   the length-scaled floor must strictly tighten the gap) and reworded
   the short-run test to reflect the corrected formula.

Full engine suite (847 tests) passes.
2026-07-03 12:30:38 -07:00
Miguel Ángel 7e8a1466c3 fix: producer render diverges from preview for sub-composition root styling (#1886)
Fixes #1847

The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview.

Changes:
- Wire flattenInnerRoot into the producer's sub-composition inliner (packages/producer/src/services/htmlCompiler.ts) so its render-time DOM shape matches the preview bundler's.
- Rewrite a bare root [data-composition-id="X"] box selector to a :has()/:not() pair that lands on exactly one of the host or the flattened wrapper (packages/core/src/compiler/compositionScoping.ts), avoiding double-applying additive properties like padding.
- Restore the composition's own id onto the flattened wrapper when the host has no id of its own, an "anonymous" host (packages/core/src/compiler/inlineSubCompositions.ts).
- Fix the runtime's startResolver to find a composition's start time through the post-inlining data-composition-file marker, not just data-composition-src or data-composition-id (packages/core/src/runtime/startResolver.ts).

Also adds regression coverage for the literal issue #1847 repro (a class, not just an id, on the authored root, styled via a descendant selector), a test proving the runtime compositionLoader's anonymous-host path doesn't share this bug, and fixes stale test documentation and a misattributed code comment surfaced during review.

Verified: 29-fixture Docker regression sweep on linux/amd64 (matching CI) run 3x clean, 967/967 core unit tests, full CI green.
2026-07-03 12:08:17 -07:00
Miguel Ángel 3b67918242 fix(hyperframes-media): npx spawn without shell:true fails silently on Windows (#1845)
* fix(hyperframes-media): npx spawn without shell:true fails silently on Windows

Two independent user reports of Kokoro TTS silently failing on Windows,
both naming the same site: lib/tts.mjs synthesizeOne() spawns "npx" via
plain spawn(cmd, args). On Windows npx resolves to npx.cmd, which Node's
spawn() cannot exec without shell:true — it fails ENOENT, and spawnP's
"error" listener turns that into a plain ok:false ("TTS failed") with no
indication of the real cause.

Scope the fix to the npx call specifically (python3/ffmpeg are real
binaries and don't need it), with the platform/spawn function injectable
so the win32 branch is testable without mocking node:child_process (its
ESM exports are non-configurable, so mock.method can't patch it) or the
real process.platform.

* fix(hyperframes-media): avoid shell true for windows npx
2026-07-02 18:04:14 -07:00
Miguel Ángel e2b43583fc fix(hyperframes-media): fall back to ffmpeg when ffprobe is missing (#1877)
* fix(hyperframes-media): fall back to ffmpeg when ffprobe is missing

ffprobeDuration() returned NaN whenever the ffprobe spawn failed for any
reason, conflating "ffprobe binary not installed" with "file is corrupt".
Some ffmpeg-only distributions (common in curated Windows installs) ship
ffmpeg.exe without ffprobe.exe, so every TTS line hit the missing-binary
case and audio.mjs read the NaN as a bad WAV, silently dropping an already
successfully synthesized line. Now falls back to parsing ffmpeg's own
`Duration:` stderr banner when ffprobe specifically ENOENTs, and only
returns NaN when the file itself can't be probed by either tool.

* fix(hyperframes-media): update skills manifest for ffprobe fallback
2026-07-02 17:57:24 -07:00
Miguel Ángel 2e9a33ca71 fix(hyperframes-media): cap TTS synthesis concurrency instead of firing every line at once (#1862)
Two independent post-release feedback reports of the same mechanism from
two different skills (both delegate to this one shared engine): audio.mjs
fired every line's Kokoro TTS + whisper-transcribe subprocess concurrently
via a bare Promise.all with no cap.

- One OOM'd 12/13 lines on a resource-constrained laptop (32GB total, ~7GB
  free), requiring a manual patch to a sequential for-loop.
- The other saw 7/8 lines fail on first run, then pass on retry once the
  model was cached — concurrent cold-start model loads overwhelming the
  machine, not a real synthesis failure.

Kokoro/Whisper each load their own local model per subprocess, so firing
every line at once multiplies that cost by the line count. Extracted the
concurrency cap into lib/concurrency.mjs (audio.mjs is a script — it runs
CLI/exit side effects on import, so it can't be unit-tested directly;
the cap is small enough to pull out and test in isolation). Default 4,
overridable via HYPERFRAMES_TTS_CONCURRENCY, floored at 1 (matching one
report's own manual workaround).

hyperframes-media/scripts/audio.mjs is the single canonical engine per its
own header comment; product-launch-video, faceless-explainer, and
pr-to-video each carry a thin wrapper that spawns this file as a
subprocess (confirmed via their DEFAULT_ENGINE path), so this one fix
covers all four skills without touching the other three.

Tests: 4 new cases for mapWithConcurrency (order preserved regardless of
completion order, cap actually enforced, limit > item count doesn't hang,
empty input). Full skills test suite (514 tests) shows no new failures —
the 444 pre-existing failures are environment-dependent and reproduce
identically on unmodified main.
2026-07-02 17:46:18 -07:00
Miguel Ángel f40dbd86cf fix(producer): surface the reason when audio mixing fails instead of silently shipping video-only (#1854)
At least 4 independent post-release feedback reports of a render completing
successfully (exit 0) with audio elements correctly authored and detected at
compile time (audioCount > 0), but the final MP4 having no audio track —
discovered only via ffprobe or manual playback, with the CLI giving no
indication anything went wrong. Users worked around it by muxing the
generated audio in manually with ffmpeg.

Root cause: runAudioStage sets hasAudio from processCompositionAudio's
success flag, but discarded its error field — the actual reason a per-element
audio prep step or the final mix failed (source not found, extract failed,
ffmpeg error) was computed and then thrown away. A real audio-mix failure was
therefore indistinguishable from "no audio was authored": both just produced
hasAudio: false with zero diagnostic output.

Thread the mixer's error through as audioError (only set when audios.length
> 0 but the mix failed) and log.warn it from both call sites (the main
render path in renderOrchestrator.ts and the distributed plan() path) so a
real failure is loud instead of silently downgrading to a video-only render.

Tests: 4 new cases for runAudioStage (mixer error surfaced, generic fallback
message when the mixer doesn't provide one, no audioError on success, no
audioError when there's no audio to mix). renderOrchestrator.test.ts (68
tests) unaffected. plan.test.ts's one failure (an audio-bearing planHash
determinism test timing out at 30s) is pre-existing — reproduces identically
on unmodified main with these changes stashed.
2026-07-02 17:45:26 -07:00
Miguel Ángel 638c33bc01 fix(cli): lock chrome-headless-shell install against concurrent extraction races (#1866)
* fix(cli): lock chrome-headless-shell install against concurrent extraction races

A detailed post-release feedback report of `render` producing a fully
black 15s MP4 despite lint/validate/inspect/snapshot all passing and
Studio preview playing correctly. Root cause traced by the reporter:
chrome-headless-shell had been manually re-extracted after `browser
ensure`'s own download got stuck mid-extraction when two concurrent
invocations raced on the same cache dir. The manual extraction lost a
macOS Gatekeeper/quarantine or GPU/Metal entitlement bit that a clean
install sets, so headless GPU frame capture silently returned all-black
frames — invisible to every existing health check, since they only
confirm the binary *exists*, not that it captures real pixels.
`--no-browser-gpu` fixed it completely, confirming the GPU-capture path
specifically. A related, vaguer report of the same race the prior loop
run ("'browser ensure' hung mid-extraction after a race from two
concurrent invocations") was deferred pending a clearer repro; this
report supplied one.

@puppeteer/browsers' install() has no concurrency guard of its own —
confirmed by reading its source: two concurrent installs for the same
browser/buildId both proceed straight to download+unpack with no
existing-install check, no lock. Two ensureBrowser()/findBrowser() calls
that both miss the cache at the same time (the common case on a fresh
machine, or right after `browser clear`) race on the same extract target.

Fix: mkdirSync as an atomic cross-process mutex around the download —
recursive:false makes it throw EEXIST when another process already holds
it (that's load-bearing: recursive:true would silently no-op instead).
Zero new dependencies. A concurrent caller polls until the lock releases,
then re-checks the cache before deciding whether to download at all — the
common case (loser waits, then reuses the winner's completed install)
never re-downloads. A lock held past a generous timeout is reclaimed
rather than left to wedge every future render if the holder crashed
mid-extraction. Applied to both call sites that reach the racy
downloadBrowser() (ensureBrowser's two paths, and findBrowser's stale-
cache re-download — the file already carries a code-duplication
suppression between these two near-identical functions).

Not doing (out of scope for this fix): the reporter's second suggestion,
a deeper `doctor` check that actually captures a test frame rather than
checking binary existence. That's a real gap but a separate, larger
feature — this fix prevents the corruption that caused it, which matters
more than detecting it after the fact.

Tests: two new cases (lock releases after a successful download; a lock
held past its timeout is reclaimed rather than hanging — exercised via
withInstallLock's injectable timeoutMs/pollMs with tiny real waits,
avoiding fake-timer mocking through the full async ensureBrowser call
graph). All 13 tests in manager.test.ts, 22 across packages/cli/src/browser,
and the full CLI suite (1115 tests) pass.

* test(cli): isolate browser install lock test from system chrome

* fix(cli): guard stale browser lock reclaim
2026-07-02 17:45:23 -07:00
Miguel Ángel b087f1e3c0 fix(cli): validate stops misreporting slow-loading media as unreadable (#1849)
Two independent post-release feedback reports of validate warning about
audio duration despite an explicit, correct data-duration slot, one of
them naming a timeout explicitly.

Root cause: auditClipDurations reads each <video>/<audio> element's
intrinsic .duration via a single page.evaluate() snapshot taken after a
flat, unconditional page-settle sleep (opts.timeout ?? 3000ms, shared with
other audits). Per the HTML spec, HTMLMediaElement.duration is NaN until
metadata loads. A slow-loading audio file (large narration WAV, remote
source) can still be mid-fetch when that sleep elapses — el.duration is
NaN at that exact instant, which the audit permanently records as
"could not read the duration" even though the render pipeline (which
properly awaits media readiness) handles the same file fine.

Fix: race each not-yet-ready element's loadedmetadata/error event against
a deadline instead of taking one fixed-time snapshot. Elements already
ready resolve immediately (no added latency in the common case); only
genuinely slow elements get a real second chance before the warning fires.

The race/cleanup wiring lives twice by necessity — once inline inside the
page.evaluate() closure (Puppeteer serializes and re-runs that closure in
an isolated browser realm with no access to this module), and once as the
exported, duck-typed raceMediaReady for a real, deterministic unit test
via Node's built-in EventTarget (no browser or DOM library needed). The
comment on raceMediaReady flags that both copies must move together.
2026-07-02 17:45:19 -07:00
Miguel Ángel eef4690752 fix(engine): name the fix in the ffmpeg encode-timeout error message (#1858)
Two independent post-release feedback reports of hitting
ffmpegEncodeTimeout (600000ms default) on long or high-frame-count
renders, both resolved by setting FFMPEG_ENCODE_TIMEOUT_MS to a higher
value and/or PRODUCER_ENABLE_CHUNKED_ENCODE=true — env vars that already
exist and already solve this, but that neither user found from the error
message itself.

appendEncodeTimeoutMessage only stated what happened ("FFmpeg killed after
exceeding ffmpegEncodeTimeout"), not what to do about it. Name both
existing knobs in the message so the fix is immediately visible at the
point of failure instead of requiring a source dive.

One function, six call sites, all fixed at once. Existing tests assert
with toContain, so the appended text doesn't break them; added two
assertions confirming both env var names appear in the message.
2026-07-02 17:45:16 -07:00
Miguel Ángel c7b34d5c65 fix(lint): stop scene-exit hard-kill rules from contradicting gsap_animates_clip_element (#1846)
Two independent post-release feedback reports of the same contradiction:
scene_layer_missing_visibility_kill / gsap_exit_missing_hard_kill tell you to
add `tl.set(selector, { visibility: "hidden" }, t)` on an exiting scene
element, but when that element is also class="clip", the exact tl.set they
recommend is then flagged by gsap_animates_clip_element (the framework
already owns visibility/display on clip elements). One report worked around
it by wrapping the scene's content in an inner non-clip div and asked that
the fix hint mention that pattern.

Both rules now detect when the exiting/flagged selector is a clip element
(scene_layer_missing_visibility_kill checks the tag's class list directly;
gsap_exit_missing_hard_kill reuses the clipIds/clipClasses maps already built
in its enclosing rule) and, only in that case, point at the inner-wrapper
pattern instead of a tl.set on the clip element itself. Non-clip targets are
unaffected — same fix hint as before.
2026-07-02 17:45:12 -07:00
Miguel Ángel 8ee4b7dfda fix(lint): stop a leading <svg> defs block from being mistaken for the composition root (#1867)
Two independent post-release feedback reports of the same mechanism: a
leading <svg> block (icon/gradient/filter <defs>, referenced by url(#id)
elsewhere in the document) placed before the real [data-composition-id]
root manufactures root_missing_composition_id + root_missing_dimensions
on an otherwise-correct composition. Moving the <svg> after the root
cleared both findings for each reporter.

findRootTag returned the first body child that wasn't script/style/meta/
link/title, unconditionally — <svg> was never in that skip list, so a
leading defs-only <svg> got treated as the root.

Fix: skip a leading <svg> when it carries none of the composition markers
itself (data-composition-id/data-width/data-height), so an intentionally
SVG-rooted composition is still eligible as the root. The first attempt at
this only skipped the <svg> open tag, which surfaced a second bug:
extractOpenTags is a flat, nesting-unaware scan, so the very next tag it
returns after skipping <svg> is the svg's own nested child (<defs>,
<filter>, ...), not the sibling after </svg>. Track the svg's closing tag
position and skip every tag before it, not just the <svg> tag itself.

Tests: skips a leading svg defs block (no false root findings); still
treats an <svg> as the root when data-composition-id/data-width/
data-height are declared directly on it. Full lint suite (308 tests) passes.
2026-07-02 17:45:09 -07:00
Miguel Ángel d2f1adc2af fix(lint): recognize computed-key window.__timelines registrations (#1874)
WINDOW_TIMELINE_ASSIGN_PATTERN only matched window.__timelines["literal"]
or window.__timelines.prop, so registrations via a computed key like
window.__timelines[spec.id] (used by the code-particle-assemble and
code-3d-extrude registry blocks) went undetected. That made
gsap_timeline_not_registered false-fire on correctly registered timelines,
and let root_composition_missing_duration_source wrongly demand an
explicit data-duration on compositions that already have one.
2026-07-02 17:45:05 -07:00
Miguel Ángel b7eb0dfb5a fix(lint): mention src: local() for system fonts in font_family_without_font_face (#1859)
Two independent post-release feedback reports of this rule hard-erroring
on OS system fonts (Hiragino Sans, Microsoft YaHei) that have no
downloadable file. Both asked for the same thing, in slightly different
words: a documented way to satisfy the check for a font that's genuinely
OS-bundled, not missing.

That way already exists and already works — extractFontFaceFamilies only
looks at the font-family declaration inside @font-face, never the src
value, so `@font-face { font-family: 'X'; src: local('X'); }` already
passes. One report found this themselves; the other didn't. The gap was
discoverability: the fixHint only described bundling a real font file, so
nobody would think to try `local()` unless they already knew about it.

Considered and rejected a broader fix: adding CJK system-font names to the
shared FONT_ALIAS_MAP (the mechanism that already exempts Latin system
fonts like Segoe UI/Verdana by aliasing them to a bundled fallback font).
That map has no CJK-equivalent bundled font to alias to (only Japanese has
one, noto-sans-jp) — aliasing "Microsoft YaHei" (Simplified Chinese) to a
Japanese font would silently swap in the wrong glyph shapes for shared Han
characters, and would specifically break distributed/Lambda rendering
(where system-font capture is disabled, per system_font_will_alias's own
comment) by removing the warning that currently prompts a real fix. The
local() message fix has none of that risk: it changes no detection logic,
only points at an already-correct existing escape hatch.

Tests: local() font-face no longer flags (proves the advice is accurate,
not just documented); fixHint contains "local(". 308 lint tests pass.
2026-07-02 17:45:01 -07:00
Miguel Ángel 65b2093396 chore: release v0.7.26 (#1863) 2026-07-02 00:36:07 -07:00
Miguel Ángel e10e61ceb2 fix(producer): normalize system-primary font stacks (#1857) 2026-07-02 00:06:30 -07:00
Miguel Ángel 9ebb29354b Merge pull request #1826 from heygen-com/fix/studio-recent-issues
fix(studio): resolve timeline keyframe, click-selection, and nested-video sync regressions
2026-07-01 22:01:41 -07:00
Miguel Angel Simon Sierra dff3634ea8 fix(studio): don't crash resizing an element whose keyframes were removed
commitWholePropertyOffset reduced the tween's keyframe list to find the
"nearest" stop without an initial value. When a to()/from() tween had been
collapsed to a zero-duration immediateRender hold (what removeAllKeyframes
leaves behind), synthesizeFlatTweenKeyframes correctly treats it as a
static hold and returns null, leaving an empty keyframe list — so the
reduce threw "Reduce of empty array with no initial value". Reachable by
resizing such an element with auto-keyframe recording off.

With no keyframe shape to preserve, persist the flat value directly via an
update-properties mutation instead.
2026-07-01 21:55:31 -07:00
Miguel Angel Simon Sierra 70606f840d fix(studio): re-sync soft-reloaded timelines to the studio's own scrub time
Dragging a motion-path keyframe node committed correctly, but the soft
reload that refreshes the preview re-seeked the freshly rebuilt GSAP
timeline using the iframe's raw __player.getTime(), which can lag the
studio's authoritative currentTime right after a keyframe drag parks the
playhead. The stale seek left the element (and its selection/motion-path
overlay) rendered at an unrelated position after the edit.

applySoftReload now takes the caller's currentTime instead of trusting the
iframe's own clock, and the re-seek runs before __hfForceTimelineRebind so
its internal force-render picks up the correct time.
2026-07-01 21:34:38 -07:00
Miguel Ángel 2186d3b72e chore: release v0.7.25 (#1852) 2026-07-01 20:02:43 -07:00
Miguel Ángel a908af11a8 feat(cli): keyframes command (surface GSAP/CSS/Anime keyframes + 3D onion-skin --shot) (#1603)
Renames the motion-surfacing tool from `hyperframes keyframes` to `hyperframes motion`,
renames the implementation from keyframes*.ts to motion*.ts (keeping the keyframe data
model name where still accurate), and renames the shipped skill from
hyperframes-keyframes to hyperframes-motion. Expands the skill from a command
reference into a full motion-design workflow: reading motion, 3D angle verification,
layered GSAP motion, one-shot reference reproduction, diagnostic checks, and
eval-derived craft guidance.
2026-07-01 19:51:55 -07:00
Miguel Angel Simon Sierra ae50327bdb style(studio): make the auto-keyframe toggle icon a diamond with a record dot
Reuses the same diamond outline as the Add-keyframe icon next to it, with a
small dot inside carrying the on/off state (filled = auto-recording, hollow
= manual edits won't be keyframed) — pairs the two icons visually instead of
an unrelated circle/slash glyph.
2026-07-01 19:41:10 -07:00
Miguel Ángel 145c71e837 fix(engine): parallelize forced screenshot workers (#1848) 2026-07-01 19:34:22 -07:00
Miguel Angel Simon Sierra 636d5dd6c5 feat(studio): add a global toggle to stop manual edits from auto-recording keyframes
Adds a control-bar toggle (next to the Add Keyframe diamond) that, when off,
makes a manual drag/resize/rotate/panel edit on an already-keyframed element
shift the whole tween by the edit's delta instead of inserting or updating a
keyframe at the playhead. The animation's shape is preserved, just moved.

Wired into every path that can auto-record a keyframe:
- canvas drag-to-move (tryGsapDragIntercept, reuses the existing Alt-drag
  "shift whole path" behavior)
- canvas resize/rotate (tryGsapResizeIntercept, tryGsapRotationIntercept)
- design-panel property edits (useAnimatedPropertyCommit)
- motion-path keyframe-node dragging (MotionPathOverlay), the path a plain
  click-drag on a keyframed element's canvas shape actually takes, since the
  element renders exactly at its current keyframe's position

The shared shift helper reuses synthesizeFlatTweenKeyframes for materializing
a flat tween instead of hand-rolling it, and lives in its own file
(gsapWholePropertyOffsetCommit.ts) to keep gsapDragCommit.ts under the
600-line cap, mirroring the existing gsapDragPositionCommit.ts split.

Fixes #1808
2026-07-01 19:26:03 -07:00
Miguel Angel Simon Sierra 1b8b2ac425 fix(studio): fix array-form keyframe writes, diamond click-deselect, and nested video sync
- fs.watch's async 'error' event had no listener, crashing the preview
  server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
  required object-form keyframes: {"0%": {...}}, silently no-opping on
  array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
  to the ancestor clip's onClick, which toggles selection off when the
  clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
  covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
  duration:0 + immediateRender static hold (what remove-all-keyframes
  produces) as non-animated, so it kept showing a phantom diamond after
  Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
  data-start discarded the host composition's inherited start offset,
  so a video nested inside a sub-composition played from the root
  timeline's time instead of holding until its parent scene began

Fixes #1838
2026-07-01 18:07:44 -07:00
Miguel Ángel b33d54f54b fix(engine): retry probe on pollHfReady zero-duration timeout (#1824)
Renders were failing outright with "[FrameCapture] Composition has zero
duration. Runtime ready: false, ..." whenever window.__renderReady didn't
flip true within playerReadyTimeout (45s) — most often under host
contention (e.g. several renders running concurrently), never from a
defect in the composition itself. Confirmed by re-running an affected
composition standalone: it succeeded immediately (initMs ~3.5-4.4s vs.
the 45s timeout it hit under concurrent load).

The probe stage already retries once with a fresh browser session for
exactly this class of "succeeds on retry" infra flakiness (frame
detachment, disconnects, navigation timeouts, launch failures), but
isTransientBrowserError didn't recognize this message, so it fell
through to an immediate, unretried failure.

Match "Composition has zero duration ... Runtime ready: false" as
transient. Left the "Runtime ready: true" case (pollHfReady's fast-fail:
no GSAP timeline and no data-duration) unmatched — that's a genuine
authoring bug, not a timing fluke, and should keep failing fast.
2026-06-30 22:50:06 -07:00
Miguel Ángel 060889f84c fix(skills): make hyperframes package version resolution non-fatal and overridable (#1825)
* fix(skills): make hyperframes package version resolution non-fatal and overridable

Global skill installs (e.g. ~/.claude/skills) have no hyperframes
package.json in the loader's ancestor chain, so readBundledHyperframesVersion
returns null and hyperframesPackageSpec threw. Because the spec is passed
eagerly as an argument to importPackagesOrBootstrap, it threw even when
@hyperframes/producer was already installed and no bootstrap was needed.

Resolution order is now: HYPERFRAMES_SKILL_PKG_VERSION env override first,
then the bundled/in-repo version, then a non-throwing @latest fallback that
warns to stderr. @latest satisfies the pinned-spec guard so bootstrap still
installs, and already-installed packages import fine; the eager argument is
now harmless. In-repo resolution is unchanged (same pinned version).

Applied to both package-loader.mjs copies (animation + creative) and
documented the env var in animation-map.mjs and contrast-report.mjs usage.
Adds a focused test covering override wins, in-repo pin, and unresolvable
@latest fallback.

* test(skills): cover creative package loader fallback
2026-06-30 20:49:16 -07:00
Miguel Ángel 534d6177b6 docs: clarify transparent WebM shows as yuv420p under ffprobe (alpha is a VP9 side-channel) (#1823)
* docs: note that ffprobe reports transparent WebM as yuv420p (alpha is a VP9 side-channel)

* docs: clarify WebM alpha verification
2026-06-30 20:49:13 -07:00
Miguel Ángel 0d202ea779 fix(cli): keep doctor resilient to a corrupt browser cache (#1822)
* fix(cli): keep doctor resilient to a corrupt browser cache

A partial or corrupt browser cache (a stub file where a version directory
is expected, a missing executable, or malformed metadata) makes
getInstalledBrowsers throw ENOTDIR. That throw propagated up through
findBrowser -> checkChrome -> runEnvironmentChecks, and since doctor.run
calls runEnvironmentChecks before any try/catch or the --json output, the
command crashed with exit 1.

doctor --json is documented to exit 0 even when checks fail, so it must
report a corrupt cache as "Chrome not found", not crash on it.

- checkChrome now catches any error from findBrowser and converts it to the
  existing ok:false "Chrome not found" outcome with the browser ensure hint,
  so runEnvironmentChecks never throws for a missing or corrupt browser.
- findFromCache treats a throwing getInstalledBrowsers as "no cached
  browser", letting resolution fall through to system/download instead of
  crashing every caller (render included), not just doctor.

A healthy browser still reports ok:true. Adds a preflight test asserting an
ok:false Chrome outcome when discovery throws, instead of propagating.

* fix(cli): warn on corrupt browser cache fallback
2026-06-30 20:49:10 -07:00
Miguel Ángel a7d0ab2d61 fix(cli): exclude clip-path-hidden text from inspect layout and contrast audits (#1821)
* fix(cli): exclude clip-path-hidden text from inspect layout and contrast audits

A clip-path can shrink an element's painted region to nothing (a typewriter
span pre-reveal at clip-path: inset(0 100% 0 0), or circle(0px)) while its
layout box, opacity, visibility and display all still read as present. Such
an element paints zero pixels, so the layout audit flagged the visible block
beneath it as a content_overlap, and the contrast auditor measured it as a
meaningless background-on-background ratio (~1:1) and reported a WCAG failure.

Both auditors already filtered opacity:0, visibility:hidden and display:none,
but neither accounted for clip-path. Add a shared check: when a non-none
clip-path is in effect on the element or an ancestor, probe a grid of points
across the element's box with elementFromPoint; if none resolve to the element
or a descendant, it is clipped to nothing and is skipped. The probe runs only
when a clip-path is present, so a genuinely occluded (but unclipped) element is
still measured and still flagged.

Wired at the in-page collection chokepoint so it covers content_overlap,
text_occluded and the contrast auditor consistently. Genuine overlaps between
visible elements remain flagged; data-layout-allow-overlap and
data-layout-ignore are honored unchanged. The two audit scripts and the
layout-audit test are added to the fallow ignore lists: their pre-existing
IIFE-level complexity and per-rule test scaffold re-flag under the line-shift
fingerprint when the small probe helpers are inserted.

* test(cli): cover clip-path audit edge cases

* fix(cli): satisfy clip audit test types
2026-06-30 20:49:07 -07:00
Miguel Ángel d1038918bb fix(lint): catch visible markup comments (#1819)
* fix(lint): catch visible markup comments

* test(lint): cover visible comment exemptions

* fix(lint): harden visible comment scan
2026-06-30 20:49:04 -07:00
Miguel Ángel 9b311588be fix(studio): resolve recent timeline regressions 2026-07-01 03:18:45 +00:00
Miguel Ángel 856bb0980f feat(cli): add --public flag to publish (#1815)
Opt-in --public flag on `hyperframes publish` sends is_public to the
publish endpoints (staged complete body and direct multipart form) so a
claimed project's studio session can be created public instead of the
default private. Absent the flag the request shape is unchanged.
2026-06-30 11:54:10 -07:00
Miguel Ángel bdd0084c2a docs: root composition duration is compile-time, not script/--variables parameterizable (#1818)
* docs: clarify root composition duration is compile-time, not script/--variables parameterizable

* chore: regenerate skills-manifest for hyperframes-core docs change
2026-06-30 11:53:19 -07:00
Miguel Ángel 010df6a0a4 feat(cli): file a GitHub issue with a published repro from feedback (#1816)
Add an opt-in --file-issue flag to hyperframes feedback. When set, after
sending the usual feedback the CLI publishes a minimal repro of the project
to a public URL (consent-gated, mirroring publish --yes) and opens a
pre-filled GitHub bug issue draft containing the rating, comment, public
repro link, and environment summary. The user reviews and submits the issue
under their own account; there is no token, backend, or gh invocation. New
--dir selects the project to publish; --yes skips the consent prompt for
scripts. URL/body building is extracted into pure, unit-tested helpers.
2026-06-30 11:48:22 -07:00
Miguel Ángel 27a7f37494 fix(lint): recognize Three.js loaded via ESM URL/path imports (#1805)
The missing_three_script rule only treated a bare import from 'three' as
loading Three.js, so ESM imports whose specifier is a URL or path (e.g.
.../+esm CDN builds, esm.sh/three, unpkg three.module.js, or a local
three.module.js) were not recognized. Compositions using THREE. with such
an import got a false blocking error, pushing authors onto the deprecated
UMD global build.

Generalize the module-import detection to count any import/from whose
specifier contains "three" (case-insensitive), matching the existing
loose /three/i treatment of <script src>. Bare 'three', importmap, and
<script src> paths are unchanged; the specifier must still contain
"three", so unrelated imports do not satisfy it.
2026-06-30 11:31:42 -07:00
Miguel Ángel 11432ea8ce fix(lint): stop font_family_without_font_face flagging system-ui stacks and var() (#1796)
The font_family_without_font_face rule raised a blocking error on two
legitimate, very common cases:

1. The `-apple-system, BlinkMacSystemFont` system-ui stack. These two
   tokens are the cross-browser incantation for the platform UI font
   (synonyms of `system-ui`); they name no font file, so demanding an
   @font-face for them is wrong. They appear in almost every CSS reset.

2. `font-family: var(--x)` indirection. The shared family extractor took
   the literal `var(--heading)` as a font name. The linter cannot
   statically resolve a custom property, so it must not flag it.

Fix at the root: add the two system-ui synonyms to GENERIC_FAMILIES, and
skip any parenthesised (function) token in the shared family extractor so
both this rule and system_font_will_alias stop misreading var(). A real
undeclared font sitting in the same stack is still flagged.
2026-06-30 11:31:36 -07:00
Miguel Ángel 3a2f052889 fix(engine): pad odd output dimensions up to even for H.264/H.265 encode (#1802)
* fix(engine): pad odd output dimensions up to even for H.264/H.265 encode

A composition with an odd data-width or data-height (e.g. a custom 3:1
canvas at 1080x723) failed to encode to MP4. libx264/libx265 with 4:2:0
chroma subsampling (yuv420p, yuv420p10le) require both dimensions to be
even and abort before writing a packet:

  [libx264] height not divisible by 2 (1080x723)
  Error while opening encoder ... Invalid argument

Both the streaming encoder and the chunk encoder built the software
range-conversion filter ("scale=in_range=pc:out_range=tv") with no
even-dimension enforcement, so any odd-sized canvas reached libx264
unmodified and the whole render failed.

Add a shared withEvenDimensionPad helper that appends
pad=ceil(iw/2)*2:ceil(ih/2)*2 to the filter chain only for 4:2:0 pixel
formats. The pad rounds each odd dimension up by one pixel (a no-op when
already even) without scaling, so content is never resampled. Formats
that accept odd dimensions (ProRes 4444 yuva444p10le, VP9 yuva420p) are
excluded, so transparent/alpha output is untouched.

* fix(engine): extend even-dimension pad to GPU 4:2:0 encode paths

The odd-dimension pad added for libx264/libx265 only covered the software
encoder branches. nvenc, videotoolbox, qsv, and amf feed software frames
straight to the hardware encoder with no -vf chain, so an odd-sized 4:2:0
canvas on --gpu (or an auto-selected hardware encoder) reproduced the same
"height not divisible by 2" abort before any packet was written.

Add the even-dimension pad to the software-side -vf chain for those four
GPU paths in both the chunk and streaming encoders, reusing the shared
withEvenDimensionPad helper (the pad runs on CPU before the encode). vaapi
is left as-is: its existing format=nv12,hwupload conversion already aligns
odd dimensions before upload, so it is not double-padded. ProRes 4444 and
VP9 alpha stay untouched, exactly as the software fix excludes them.

nvenc/videotoolbox/qsv/amf arg construction is logic-tested (the pad filter
is asserted on the built arg list for 8-bit and 10-bit 4:2:0, with alpha
ProRes asserted padless); runtime hardware encode is not exercised here.
2026-06-30 10:55:22 -07:00
Miguel Ángel c01d1ae03d fix(examples): host the kinetic-type A-roll so the video actually renders (#1799)
* fix(examples): host the kinetic-type A-roll so the video actually renders

The A-roll <video> was authored inside the main-graphics sub-composition
template. The runtime only seeks and decodes media that is a direct child of
the host root, so the footage was never driven by the live runtime and renders
blank in Studio preview and the player (the producer's compile step happened to
hoist it, masking the contract violation in server renders). The example also
failed lint with eight errors.

Root cause and fixes:
- Move the <video> out of compositions/main-graphics.html and into index.html
  as a direct child of #root, with data-start/data-duration/data-track-index
  and class="clip". Its per-scene opacity reveal now runs on the main timeline
  at global time, since a sub-composition timeline cannot reach host elements.
- Make the sub-composition a transparent overlay shell (background: transparent)
  and stack it above the host video so the type renders over the footage.
- Drop the crossorigin attribute (plain displayed media never needs it) and mark
  the audible footage with data-has-audio="true".
- Replace the three CSS translateY values that GSAP would overwrite with
  fromTo tweens (carson-line-1, wes-text-top, wes-text-bottom).
- Swap the unbundled Libre Baskerville for the bundled Playfair Display serif so
  the renderer can supply the Wes Anderson typography, and drop the redundant
  -apple-system token from the body font stack.

lint reports 0 errors, validate passes, and frame extraction confirms the A-roll
composites correctly under every type scene.

* docs(examples): note the A-roll reveal-time coupling to the sub-composition
2026-06-30 10:55:14 -07:00
Miguel Ángel 036e3660cd fix(lint): stop overlapping_gsap_tweens flagging distinct unresolved targets (#1798)
* fix(lint): stop overlapping_gsap_tweens flagging distinct unresolved targets

The GSAP parser assigns the sentinel `__unresolved__` to any tween whose
target it cannot statically resolve to a concrete element (a computed
variable, a helper call, etc.). The overlap check compared tweens by that
target string, so two tweens aimed at completely different elements via
unresolvable selectors (e.g. `#s0 .hl .w` and `#s1 .hl .w` produced by a
helper) both collapsed to `__unresolved__` and were reported as
overlapping, a false positive.

An unresolved target is an unknown element: two of them are not provably
the same element, so an overlap between them cannot be asserted. Skip
overlap analysis when the target is the sentinel. Genuine overlaps on a
resolved element are still flagged.

* fix(lint): guard gsap_exit_missing_hard_kill against the unresolved-target sentinel

The overlap rule already skips tweens whose target collapses to the
__unresolved__ sentinel, but the sibling exit rule in the same file did not.
A scene-boundary exit on an unresolved target could emit a finding like
GSAP exit on "__unresolved__" ... with a meaningless tl.set("__unresolved__", ...)
fix hint. An unresolved target is an unknown element: you cannot assert a
missing hard kill on it, so skip the window early in the loop, mirroring the
overlap rule. Exits on resolved selectors are still flagged.
2026-06-30 10:55:07 -07:00
Miguel Ángel a5c2636e8c fix(cli): never print "[object Object]" from validate/inspect errors (#1810)
* fix(cli): use normalizeErrorMessage so validate/inspect never print "[object Object]"

The validate and inspect (layout) commands formatted thrown values with
`err instanceof Error ? err.message : String(err)`. When a browser/CDP/
Puppeteer protocol error or a structured page error reaches the formatter
as a plain object without a string `message`, `String(obj)` yields the
useless literal "[object Object]", hiding the real cause.

Route those paths through the existing shared `normalizeErrorMessage`
helper, which returns an Error's message, a string as-is, an object's
`.message` when present, or a compact JSON serialization otherwise (with
a key-list and String fallback for circular/opaque objects). Also fold
the duplicated local `errorMessage` helpers in batchRender and preview
into the same shared helper.

Covered by added assertions in errorMessage.test.ts for the no-message
object and Puppeteer-style protocol-error object cases.

* fix(cli): route remaining browser/process error sites through normalizeErrorMessage

The validate/inspect fix routed only those two commands through the shared
normalizeErrorMessage helper. The same err instanceof Error ? err.message :
String(err) pattern survived in the other commands that drive a headless
browser or an external process (ffmpeg, Docker, CDP) or surface a network
API error, so a thrown structured object without a string message would
still render as the useless literal [object Object].

Route those sites through the shared helper:
  snapshot.ts (the closest sibling to validate/inspect, same bug class),
  render.ts (Chrome launch + Docker build), capture/index.ts and
  commands/capture.ts (page-driven extraction), auth/browser.ts,
  browser/manager.ts (Puppeteer browser resolution), and the cloud/lambda
  paths (cloud/render.ts, cloudrun.ts, lambda/render-batch.ts,
  lambda/policies.ts, cloud/detectAspectRatio.ts) that surface API/network
  error objects.

Only the message-deriving expression changes; control flow and error
propagation are untouched. capture/index.ts keeps appending the stack for
real Errors and only routes the non-Error branch. Adds a helper test for a
structured CDP-style error object (code + nested data, no message).
2026-06-30 10:47:54 -07:00
Miguel Ángel 23adfdc496 fix(cli): skip AI skills install when git is unavailable (#1803)
* fix(cli): skip AI skills install when git is unavailable

init and `skills update` route through installAllSkills, which shells out
to `npx skills add`. That CLI clones the repo with git, so on a machine
without git the clone aborts mid-run and dumps a noisy multi-line
`spawn git ENOENT` / "Installation failed" / "Canceled" block. init still
exited 0 and scaffolded the project, but the output read like a hard
failure (and surfaced as exit 1 on some platforms).

Detect git up front alongside the existing npx check via a small
table-driven preflight: best-effort callers (init) print one calm line
and continue; strict callers (`skills update`) throw so the
check-or-update recovery contract still fails loudly. The skills
freshness check already degrades gracefully without git, so the happy
path is unchanged.

* feat(cli): record a diagnostic event when a skills install is skipped for a missing prerequisite

When init's best-effort skills install bails because git (or npx) is absent
from PATH, the skip was silent, so the rare boxes that hit it (fresh Windows
without git) were invisible. Emit one low-cardinality event (reason:
git_missing / npx_missing) on the best-effort skip path only, never on the
happy path or the strict throw. Reuses the existing typed-event pattern, and
trackEvent's opt-out gate already applies.
2026-06-30 10:47:47 -07:00
Miguel Ángel db61509ddc fix(cli): omit render duration when feedback command has none (#1797)
The standalone `feedback` command runs separately from `render`, so it
has no access to the prior render's elapsed time, yet it always passed
renderDurationMs: 0 to the feedback analytics event. Since that path is
the one used in practice (the auto-prompt returns early for agent and
non-interactive runtimes), nearly every feedback event recorded a render
duration of exactly 0, which is misleading rather than absent.

Make renderDurationMs optional and only include render_duration_ms in the
event when a real value is supplied. The standalone command no longer
passes a duration; the auto-prompt path still forwards the real elapsed
time.
2026-06-30 10:43:34 -07:00
Miguel Ángel e7939ccd53 fix(cli): show output video length in render summary, not render time (#1812)
The render-complete summary printed `<fileSize> · <time> · completed`
where `<time>` was the wall-clock render duration. Presented as a bare
middle value, users read it as the video length and compared it to
ffprobe, repeatedly reporting a "wrong duration".

Show the actual output video length (from the perf summary's
compositionDurationSeconds, which equals the rendered frame span) as the
primary figure and label the render time explicitly:
`<fileSize> · <videoLength> video · rendered in <renderTime>`.

png-sequence (directory) output has no single muxed video, so it shows a
frame count instead; when neither is known the summary falls back to
render time only. Docker renders run the producer in a child process
with no perf summary threaded back, so they show render time only rather
than a misleading number.
2026-06-30 10:43:27 -07:00
Miguel Ángel 466ee08ffa fix(cli): serve project media with HTTP Range so validate reads WAV duration (#1811)
* fix(cli): serve project media with HTTP Range so validate reads WAV duration

The local static server used by validate/snapshot/layout answered every
asset request with a plain 200 and no Accept-Ranges header. Chromium treats
such resources as non-seekable, and for WAV that makes the media element
report `.duration` as Infinity no matter how long it buffers (readyState
reaches HAVE_ENOUGH_DATA but duration never resolves). The duration audit in
validate then emitted a spurious "Could not read the duration of N media
element(s) within the validate timeout" warning for a perfectly valid local
WAV, and a longer --timeout never helped because the value is never going to
arrive. MP3/MP4 carry duration in their container metadata so they were
unaffected.

Serve files with Range support (206 + Content-Range, plus Accept-Ranges on
the full 200) so the element is seekable. WAV duration now resolves, the
false warning is gone, and the genuine "media shorter than its slot" check
works for WAV for the first time.

* perf(cli): stream Range responses instead of buffering the whole file

serveFileWithRange read the entire asset with readFileSync and then sliced
it, so a 1KB Range of a 50MB MP4 still allocated the full 50MB per request.
Switch to statSync for the total size and createReadStream(filePath, { start,
end }) piped to the response, reading only the requested window. Behavior is
unchanged: 206 + Content-Range + Content-Length for a satisfiable range, 416
for an unsatisfiable one, Accept-Ranges advertised on every response, and a
plain 200 full-body stream when there is no Range header. writeHead is
deferred to the stream's open event so a failed open still answers 500, and
the fd closes on end/error. This benefits MP4 seek too, not just WAV duration.

Extend staticProjectServer.test.ts with an 8MB-file case that pulls a 4-byte
slice from deep inside and asserts the streamed bytes, Content-Range, and
Content-Length are correct.
2026-06-30 10:43:20 -07:00
Miguel Angel Simon Sierra 812a5d4246 chore: release v0.7.21 2026-06-29 21:13:39 -07:00
Miguel ÁngelandClaude Opus 4.8 1faeba4aa5 feat(lint): error on crossorigin on media (breaks preview) (#1793)
`crossorigin` on <video>/<audio> forces a CORS-checked fetch. The
server-side renderer downloads media directly (no CORS) so renders
always work, but Studio preview runs in the browser — a media host that
omits Access-Control-Allow-Origin silently fails the load, so the media
shows blank/black in preview while the render looks fine, hiding the bug.

Plain displayed media never needs crossorigin; it's only required to read
pixels/samples back (canvas/WebGL texture, WebAudio createMediaElementSource)
and only when the host is known CORS-enabled. New rule
media_crossorigin_breaks_preview flags it as an error with that guidance.


Claude-Session: https://claude.ai/code/session_01NsmfF5FzhqXY6hZ8buXgUE

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:57:31 -07:00
Miguel Ángel 04700ea9c5 docs(skills): instruct agents to run hyperframes feedback after render (#1785)
The `hyperframes feedback` CLI command and the post-render agent hint
already exist, but no skill told agents to act on it — so the feedback
channel is starved (agents see a dim stdout line and move on). Add the
instruction to the three paths agents actually read in the hyperframes-cli
skill: workflow step 8, an Agent Conventions bullet, and a dedicated
`feedback` subsection in the preview-render reference.
2026-06-29 19:10:51 -07:00
Miguel Ángel 0dfedd111c fix(core): root-cause id-less media wash in timingCompiler getAttr, drop the band-aid (#1792)
* fix(core): root-cause the id-less media wash in getAttr, drop the band-aid

The blank-wash/dropped-audio fix in #1790 added assignMissingMediaIds in the
producer to stamp ids onto id-less timed media. That was a band-aid: the real
cause is timingCompiler's getAttr, whose regex had no name boundary at all, so
getAttr(tag, "id") matched the trailing id="…" inside data-hf-id="…". compileTag
saw a phantom id and skipped its existing hf-video-N/hf-audio-N injection,
leaving the element with no real el.id — which the render pipeline keys off of.

Fix getAttr with the same (?<![\w-]) lookbehind used for the lint readAttr fix.
compileTag's auto-id injection now fires for data-hf-id-only media, in both the
main composition and sub-compositions (parseSubCompositions runs the same
compileTimingAttrs pass), so assignMissingMediaIds is removed entirely.

Extends the regression fixture with a standalone id-less <audio> (the dropped-
audio side, previously untested) and raises minAudioCorrelation to 0.9. Adds a
timingCompiler test for the data-hf-id/id boundary.

* test(producer): use seeded pink noise (not a pure sine) for fixture audio

A continuous sine anti-aligns under the audio cross-correlation (correlation
-1.0 from a sub-period offset). Broadband seeded noise correlates robustly.

* test: cover audio-side id injection via unit test; keep render fixture video-only

The audio render-baseline used synthetic sine/noise, which anti-aligns under
the harness audio cross-correlation (deterministic -1.0). Real audio fixtures
are unaffected. Cover the audio side of the boundary fix with a deterministic
timingCompiler unit test (id-less <audio> gets hf-audio-N) instead, and keep
the render fixture video-only.

* test(producer): regenerate baseline under the root fix (hf-video-N from compileTag)
2026-06-29 19:07:36 -07:00
Miguel Angel Simon Sierra faea9f2267 chore: release v0.7.20 2026-06-29 18:34:29 -07:00
Miguel Ángel 74f9c31b3f fix(producer,lint): id-less media renders blank wash instead of footage (#1790)
* fix(producer,lint): id-less media renders blank wash instead of footage

A timed <video>/<audio> identified only by a Studio-stamped `data-hf-id`
(no real `id`) rendered as a flat white/grey wash with dropped audio, and
lint stayed silent so it surfaced only at render.

Root cause, two layers:

- lint `readAttr(tag, "id")` used a `\b` boundary, which treats the hyphen
  in `data-hf-id="…"` as a word break — so reading "id" matched the trailing
  `id="…"` inside `data-hf-id` and returned a phantom id. `media_missing_id`
  therefore never fired for media carrying only a data-hf-id. Switched to a
  `(?<![\w-])` lookbehind so a short name can't match the tail of a longer
  hyphenated attribute (also fixes "width" matching `data-width`, etc.).

- the render pipeline identifies media by the real `el.id`: frame extraction
  keys injected stills as `__render_frame_<id>__`, the runtime frame-swap
  matches on `el.id`, and the audio mixer selects `audio[id][src]`. An empty
  `el.id` meant injected frames/audio never matched. compileForRender now
  assigns a stable positional id to every id-less timed media element before
  any stage parses or serves the HTML.

Adds a producer regression fixture (video with data-hf-id, no id) and a lint
test covering the data-hf-id/id collision. Baseline mp4 generated separately.

* test(producer): baseline for video-hfid-no-id regression fixture

Golden compiled.html + output.mp4 (generated on linux/amd64 in the
Dockerfile.test image). Compare-mode passes: compilation, visual (0 failed
frames), and audio (correlation 1.000). A regression to the blank-wash
behaviour fails the visual check.
2026-06-29 18:28:24 -07:00
Miguel Ángel c9613cd826 fix(producer): keep video captures viewport-bound on software (#1788) 2026-06-29 15:29:19 -07:00