Commit Graph
2254 Commits
Author SHA1 Message Date
Ular Kimsanov c1aa35d069 Merge pull request #1987 from heygen-com/feat/studio-color-grading-inspector
feat(studio): add color grading inspector
2026-07-06 15:24:50 -07: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
ukimsanov 04f96da169 fix(studio): share safe media path cleanup 2026-07-06 14:35:41 -07:00
ukimsanov e0090efbf6 fix(studio): avoid regex in media asset path cleanup 2026-07-06 14:15:30 -07: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
ukimsanov 30a944de34 fix(studio): split color grading inspector files 2026-07-06 13:56:37 -07:00
ukimsanov 139cf568b4 feat(studio): add color grading inspector 2026-07-06 13:40:20 -07:00
Ular Kimsanov a4989dbe75 Merge pull request #1986 from heygen-com/feat/studio-media-processing-routes
feat(studio-server): add media processing routes
2026-07-06 13:39:01 -07:00
ukimsanov 413ee07da5 fix(studio-server): share background removal job runner 2026-07-06 13:27:45 -07:00
ukimsanov a3bf7eb995 feat(studio-server): add media processing routes 2026-07-06 13:25:04 -07:00
Ular Kimsanov 2757d32092 Merge pull request #1985 from heygen-com/feat/studio-color-grading-runtime
feat(runtime): render media color grading shaders
2026-07-06 13:18:06 -07:00
ukimsanov 644ae384a5 fix(runtime): satisfy color grading audit 2026-07-06 12:49:34 -07:00
ukimsanov 870964b0cf feat(runtime): render media color grading shaders 2026-07-06 12:44:43 -07:00
Ular Kimsanov e01ec16194 Merge pull request #1984 from heygen-com/feat/studio-color-grading-core
feat(core): add media color grading contract
2026-07-06 11:30:31 -07:00
ukimsanov 7c14438496 feat(core): add media color grading contract 2026-07-06 02:36:17 -07:00
Vance IngallsandClaude Fable 5 3a717fa719 fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session (#1981)
* fix(parsers,sdk,studio-server,studio): unify hf-id space across preview, disk, and SDK session

Root-causes the setTiming element_not_found resolver-shadow divergence class:
timeline edits carry hf-ids read from the live preview DOM, but the preview
minted ids AFTER rewriting attributes (and never persisted them for sub-comps),
while the SDK session mints from the raw file — content-keyed minting then
yields different ids for the same element. Template-based comps were worse:
the SDK excluded the whole <template> subtree, so the session had zero
elements and every edit diverged.

- parsers: ensureHfIds now descends into <template> subtrees (linkedom's
  querySelectorAll does not), minting and pinning inner ids
- sdk: buildRoots/buildElement treat <template> as a transparent container,
  and resolution (resolveScoped, animation-id map) searches template subtrees
  via querySelectorAllDeep — template comps now model, resolve, and edit
- studio-server: the sub-comp preview route persists hf-ids to the raw file
  BEFORE the rewrite pipeline (mirrors the main route), pinning one id space
  across served DOM, disk, and SDK session
- studio: resolver-shadow skips structurally-empty sessions (no event, no
  attempt) and tags fail-open emissions with sourceReadFailed so read errors
  are distinguishable from unwired readers in telemetry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(parsers,sdk,studio-server,studio): scope template descent, guard persist route

Addresses the 10 verified findings from the PR #1981 review:

- Restrict template transparency to COMPOSITION templates
  (<template data-composition-id>) everywhere — ensureHfIds, SDK
  buildChildren, querySelectorAllDeep. A plain <template> (runtime
  clone-source) keeps its old fully-excluded behavior: stamping its
  interior would duplicate one persisted id across every runtime clone,
  and modeling it would show phantom timeline clips.
- Guard the sub-comp persist: only .html files (the wildcard route can
  serve any project path — stamping an SVG corrupted it on disk),
  try/catch the read (file-removed race becomes 404, not 500), salt the
  etag (v2) so pre-fix cached clients don't 304 past the id pin, and
  thread the stamped content into buildSubCompositionHtml so served ids
  match the mint even when the disk write is skipped.
- Rewrite querySelectorAllDeep as a document-order DOM walk — appending
  template matches after top-level matches made duplicate-id tiebreaks
  disagree with the preview's unwrapped DOM (wrong-element edits).
- Recurse sourceMutation.querySelectorAllWithTemplates so server-side
  ops resolve ids at any template depth, matching SDK resolution.
- Replace the empty-session silent skip with ONE tagged session_empty
  event per session — silence would blind the tripwire to exactly the
  modeling-gap class that exposed the template bug. Attempts stay
  uncounted (an unmodelable comp can't cut over).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): close TOCTOU in sub-comp hf-id persist (CodeQL js/file-system-race)

Replace the route-level stat/read/persist sequence with stampFileHfIds:
validation (fstat), read, mint, and write-back all go through ONE open
file descriptor (O_NOFOLLOW where supported), so the path cannot be
swapped between validation and write. Falls back to read-only stamping
when the file isn't writable — content-keyed minting means the SDK
derives the same ids from the same bytes even without the disk write.

Addresses miguel-heygen's blocking review on PR #1981.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio-server): linear-time template-attr match (CodeQL js/polynomial-redos)

promoteTemplateCompositionId's single-pattern regex backtracked
polynomially on crafted input. Two-step match: grab each <template>
open tag linearly, then find data-composition-id within that short
tag text. Same semantics (first template carrying the attr wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:16:07 -07:00
Vance IngallsandClaude Fable 5 e9076324e7 feat(cli): figma import telemetry — subcommand labels, typed error codes, figma_import event (#1979)
Closes the observability gaps on the figma integration:
- withFigmaErrors takes a command label (figma:asset|tokens|component) and
  reports the failure inline before its process.exit — the top-level
  trackCommandFailures wrapper never sees self-exiting commands, so typed
  codes (NO_TOKEN, BAD_TOKEN, FORBIDDEN, RATE_LIMITED) were invisible.
  FigmaClientError codes surface as the error name for dashboarding the
  first-run funnel (NO_TOKEN -> later success = onboarding conversion).
- new figma_import event per import: phase, duration, reused (dedup
  effectiveness), tokens variables-vs-styles mode + entry count
  (Enterprise gating rate), unresolved-binding + rasterized-node counts
  (fidelity degradation). No fileKeys, node ids, names, or descriptions.
- /figma skill fires the events beacon (figma-motion / figma-shaders /
  figma-storyboard) for the MCP phases that never touch the CLI.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:27:58 -07:00
James RussoandClaude Opus 4.8 232d591479 feat(cli/telemetry): surface unrecognized agents in the agent_runtime=null bucket (#1978)
* feat(cli/telemetry): surface unrecognized agents in the agent_runtime=null bucket

agent_runtime is a closed allowlist: an agent we have no rule for collapses
to null with no trace of what it was, so ~18% of CLI users are unattributable
and new agents stay invisible until reverse-engineered by hand.

Add detectAgentHints(), a self-populating residual signal computed only for
the null bucket (gated off classified events):
- agent_hint: value of AGENT / AI_AGENT (the emerging self-identification
  convention; Crush and Goose set AGENT=<name>) — names agents the allowlist
  misses.
- term_program: raw TERM_PROGRAM (editor name) — catches the IDE-terminal
  class the same way the cursor/windsurf rules do.
- agent_env_hints: sorted, comma-joined "agent-ish" env-var KEY names present
  but matched by no vendor rule — a fingerprint that clusters by agent.

Privacy stays consistent with the existing "never read secret-shaped values"
stance: agent_env_hints emits key names only; the three value-reads are vars
whose sole purpose is non-secret identification, each passed through a strict
short-slug allowlist so anything long/spaced/secret-shaped is dropped.

Breaking down agent_hint / agent_env_hints filtered to agent_runtime IS NULL
AND is_tty=false gives a ranked leaderboard of new agents to promote into
VENDOR_RULES.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli/telemetry): guard agent_hint/term_program against short credential-shaped values

Review feedback (Magi, #1978): the short-slug allowlist in sanitizeHint() still
accepted short credential-shaped values (AGENT=sk-ant-api03,
AGENT=AKIAIOSFODNN7EXAMPLE, AGENT=github_pat_abc), so the "never emit a secret"
claim wasn't actually enforced — only overlong values were dropped.

Add a credential-shape guard on top of the slug allowlist:
- known token/credential prefixes (sk-, ghp_, github_pat_, akia, ya29, ...)
- any unbroken alphanumeric run >= 16 chars (key bodies, hex, base64-ish),
  while agent names segment on _/-/. and keep each run short.

Replace the single overlong-value test with the short credential shapes from the
review (parametrized) plus a positive case (gemini_managed_agent) proving real
multi-segment names still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:31:13 -07:00
James 9de41ce316 chore: release v0.7.37 v0.7.37 2026-07-06 05:59:36 +01:00
James RussoandClaude Opus 4.8 2b1109db4c test(lambda): behavioral guard for handler CJS banner shims (#1977)
Miguel's fix (f999b40d) added the require/__filename/__dirname shims to the
Lambda handler bundle after #1932 crashed every render at import with
"__dirname is not defined in ES module scope" (wawoff2's emscripten build
reads __dirname at module scope; it's inlined via producer -> fontCompression).

The accompanying test only grepped build-zip.ts for the banner literals, so it
passes even if the shim is renamed, reordered into a broken form, or if a new
inlined CJS dep needs a global the banner doesn't provide.

Replace it with a behavioral test: extract the banner to _handlerBanner.ts
(build-zip.ts self-executes on import, so it can't be imported directly),
bundle a fixture that touches __dirname/__filename/require with the real
banner, and import the output under real Node -- not the bun test runtime,
which defines __dirname in ESM and would mask a missing shim. The import
faithfully reproduces Lambda's Node ESM environment and fails with the exact
#1932 error when any shim is dropped.

Handler bundle output is unchanged (identical banner string).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:51:08 -07:00
Ular Kimsanov 94996d8da9 Merge pull request #1976 from heygen-com/docs-audit-fixes
docs: fix 53 inaccuracies across all documentation
2026-07-05 21:34:05 -07:00
ukimsanov 52d586dd31 docs: fix 53 inaccuracies across all documentation
Exhaustive audit of every MDX file in docs/ against skill references
and package source code. Every API signature, default value, flag,
and technical claim verified against ground truth.
2026-07-05 21:26:41 -07:00
Vance Ingalls c5d725abd0 fix(sdk,studio): resolve animation ids from parsed script, not just DOM-matched elements (#1957)
recordAnimationResolverParity reported a false animation_not_found divergence
for any tween whose selector doesn't currently CSS-match a live DOM element,
because it only checked el.animationIds (DOM-gated). The real server-side op
it shadows resolves purely from the parsed script. Adds
Composition.getAllAnimationIds() as a DOM-independent id set and checks it
too, matching the server's actual resolution behavior.
2026-07-05 20:11:49 -07: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 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 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) v0.7.34 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 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 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
Vance Ingalls af5f3e5fab chore: release v0.7.31 v0.7.31 2026-07-03 23:07:38 -07:00
Vance IngallsandClaude Fable 5 3900caaaa9 feat(core,cli): media-use interop — shared index.md regen + description/entity on figma imports (#1927)
Post-release review of media-use ↔ figma coupling (spec §13.1):
- figma asset imports now regenerate .media/index.md, the agent-readable
  inventory media-use maintains — format locked byte-identical via a
  cross-runner parity test against media-use's own index-gen.mjs
- figma asset --description/--entity land in the manifest record, the
  index table, and <img alt>; component rasterize auto-describes with
  the node name. Named brand marks become visible to media-use's
  resolve --entity lookups.
- spec §13.1 records the review verdict (loose coupling correct) and
  the follow-up queue (shared media-ledger module, global cache for
  figma assets, media-use version-keyed idempotency)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:56:43 -07:00
Vance Ingalls cf594403ef chore: release v0.7.30 v0.7.30 2026-07-03 21:47:20 -07:00
Vance IngallsandClaude Sonnet 5 fe821ff8bc feat(studio): add resolver-shadow attempt counter for soak-gate denominator (#1926)
* feat(studio): add resolver-shadow attempt counter for soak-gate denominator

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio): harden attempt-counter exception safety and tab-hide flush ordering

PR review feedback (4 reviewers): recordAttempt() sat outside the try/catch
in all three emit functions, so a throw inside it (e.g. setInterval/
addEventListener failing in a non-standard environment) would break the
"never throws" contract. Also, the new visibilitychange listener races
studioTelemetry.ts's own tab-hide handler — whichever fires first can beacon
the queue before or after this module's rollup lands in it, silently
dropping the attempt count for short sessions closed before the 5-minute
timer fires.

Fixes: move recordAttempt() inside each function's try block; export
flushViaBeacon() from studioTelemetry.ts and call it explicitly after
queuing the rollup, so delivery no longer depends on listener registration
order; capture the visibilitychange handler by reference so
__resetAttemptSchedulingForTests() actually removes it instead of leaking a
duplicate on re-arm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 21:45:23 -07:00
Vance IngallsandClaude Fable 5 74faa4b2a4 fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate (#1875)
* fix(sdk): moveElement survives GSAP animation per-axis via runtime delta translate

A committed moveElement wrote data-x/data-y but nothing rendered them:
hosts shimmed CSS translate, which GSAP folds into the cached transform
at first parse and then discards on the animated axis at every seek —
dragging an animated element kept only the un-animated axis.

Spike-proven on GSAP 3.15: a translate set AFTER GSAP's first parse is
never read, folded, or cleared across seeks and composes natively with
the animated transform. So:

- moveElement captures the pre-edit baseline once (data-hf-edit-base-x/y)
- the runtime (new core runtime/positionEdits.ts, applied at timeline
  bind — after GSAP parse) renders translate = (data-x − base), a pure
  delta that composes with GSAP tweens, tl.set positions, and CSS alike
- applyDraft now drives the drag preview through the same translate
  channel (the --hf-studio-dx/dy vars had no consumer outside authored
  Studio bridges), and commitPreview mirrors the committed move onto
  the live element so it holds without an srcdoc reload

Acceptance: packages/engine/scripts/test-runtime-position-edits-browser.ts
(real Chrome + GSAP + runtime IIFE, no Studio shell) — X-animated,
Y-animated, and static elements hold both edited axes across the full
seek range. New subpath export @hyperframes/core/runtime/position-edits.

Known limitation (documented): a tween created lazily at runtime that
first-parses a marked element after apply folds the edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): harden position-edit rendering and the drag draft channel

Fixes six issues from adversarial review of the moveElement stack:

- Runtime: apply position edits at init as well as at timeline bind, so
  committed moves render in compositions with no usable GSAP timeline
  (CSS/WAAPI-animated or fully static) — previously the apply was
  unreachable outside the boundDuration > 0 bind branch and the edit
  silently vanished from reloads and renders.
- Runtime: guard bind-path re-apply against post-fold double-apply — if
  the previously written translate was consumed externally (a lazily
  created tween folding it into GSAP's cached transform), skip instead
  of re-setting it on top ({force} escape hatch for editor commits).
- Adapter: stop writing the --hf-studio-dx/dy custom properties during
  drags — compositions with the documented var-consuming drag-bridge
  CSS moved by twice the pointer delta (var transform + new inline
  translate). The inline translate is now the only draft channel;
  deltas accumulate in adapter fields. Docs updated to match.
- Adapter: switching applyDraft to a new id reverts the abandoned
  element's draft translate instead of leaving it displaced with no op.
- Adapter: cancelPreview restores the raw inline translate (removing it
  when there was none), so a stylesheet-authored translate is never
  promoted to a permanent inline style.
- Adapter: commitPreview reverts the draft and clears state when
  dispatch throws, instead of leaving the element shifted by an
  uncommitted draft.

Cleanups: reuse readCurrentTranslate from the core module (was a
verbatim copy), drop the dead __hfApplyPositionEdits window hook.
Browser acceptance test now also covers the GSAP-free composition path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): prime GSAP transform cache before position-edit apply; add fold-loss telemetry

Addresses PR #1875 review feedback (Rames, Miga):

- Prime the element's GSAP transform parse (gsap.getProperty) before the
  first translate apply — positioned tl.set()s and tweens that first
  RENDER after the apply now reuse the cache instead of folding the edit.
  This closes the lazy-first-parse fold-loss for any page where GSAP is
  loaded at apply time; the residual limitation is GSAP itself loading
  after the apply. Proven by the extended browser acceptance test.
- Emit position_edit_fold_skipped analytics at the fold-guard skip site
  so the residual degradation is observable instead of silent.
- Browser acceptance test: add a both-axis-animated element (the shape
  that originated the per-axis loss) and a positioned tl.set() element,
  asserted across the full seek range.
- Simplify the num() null guard (review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 20:44:27 -07:00