Commit Graph
60 Commits
Author SHA1 Message Date
Miguel Ángel a4706da513 fix(cli): localize external assets in publish archive (#1160)
Compositions referencing assets outside the project directory (via ../
paths) produced broken published projects — those files were never
included in the ZIP archive.

localizeExternalAssets() now scans all HTML and CSS files in the archive
for src, href, and url() references that resolve outside the project
dir. For each, it copies the file into the archive under _ext/ and
rewrites the reference to point there.

Handles: src/href attributes, <style> url(), inline style url(),
standalone CSS url(), sub-composition HTML files, deduplication of
the same asset referenced from multiple files.

Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted
into core/compiler/assetPaths.ts — single source of truth across core,
producer, and CLI.
2026-06-01 21:06:35 -04:00
Miguel Ángel fb2e21090f feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
2026-05-28 19:16:34 -04:00
Miguel Ángel 0de17a1f66 fix(core): restore IIFE with </script> escaping for composition scripts
The Function constructor (3bb0d1ef) was a security hardening to prevent
</script> injection, but it broke sub-composition DOM proxy scoping.
This restores the inline IIFE (preserving closure scope) while adding
</script> → <\/script> escaping to maintain the injection prevention.

Updates tests to match the new IIFE output shape.

Closes #1074
2026-05-25 15:44:35 -04:00
Miguel Ángel ccee8df4e1 fix(core): revert Function constructor back to inline IIFE for composition scripts
The Function constructor (3bb0d1ef) breaks sub-composition scripts that
call document.getElementById() — the constructor creates functions with
global scope, losing access to the composition-scoped DOM proxy. Native
method calls on proxy-returned elements throw "Illegal invocation".

Reverts to the inline IIFE that preserves the closure over __hfScoped*
variables. The original motivation (handling </script> in source) is
already handled by the compiler's script bundling path.

Closes #1074
2026-05-25 15:44:35 -04:00
func25 3bb0d1efd9 fix(core): wrap bundled composition scripts as string literals 2026-05-25 15:41:13 +07:00
func25 e101b47be4 fix(core): preserve sub-composition script order in preview bundles 2026-05-25 15:27:52 +07:00
func25 bbb7a4d965 fix(core): inline local sub-composition scripts in preview bundles 2026-05-25 15:12:11 +07:00
func25 d3127a7507 fix(core): preserve static methods on classes exposed through scoped window 2026-05-24 22:50:40 +07:00
Miguel Ángel 3983d4e7bf fix(core): set explicit dimensions on data-hf-inner-root wrapper
The prepareFlattenedInnerRoot function creates a wrapper div when
inlining sub-compositions. This wrapper had no width/height, which
broke CSS height:100% chains — any sub-composition using percentage
heights with flexbox centering would collapse to 0px and render
content at the top instead of centered.

Read data-width/data-height from the inner root and set matching
pixel dimensions on the wrapper's inline style. Applied in both the
compiler (server-side bundling) and the runtime (browser-side
composition loader).

Adds a producer regression test with a centered card sub-composition
that fails without this fix.
2026-05-23 14:53:16 -04:00
Lirian SuandClaude Opus 4.7 69b7965446 fix(core): preserve replace-pattern characters in bundled runtime IIFE
`injectInterceptor` used `String.prototype.replace(target, replacement)`
to inject the runtime `<script>` before `</head>`. The replacement
string is a substitution template — `$&` expands to the matched
substring, and the minified runtime IIFE contains legitimate `$&`
sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so every `$&` in
the body was silently rewritten to `</head>`, producing
`Unexpected token '<'` SyntaxErrors and breaking every timeline in
the bundle.

Switch to the function-replacer form so the runtime body is passed
through verbatim. Add a regression test that diffs the bundled
runtime body against `getHyperframeRuntimeScript()` and asserts only
one `</head>` survives in the document — the test exercises the
`<head>`-present injection path (the only branch that uses the
substitution template; the no-`<head>` fallback uses slice+concat
and was unaffected).

Only the bundler is affected — `producer/fileServer.ts` already uses
the function form via `injectScriptsIntoHtml` in
`htmlDocument.ts`, so render output was correct. Snapshot, preview,
studio, layout, and validate all consume `bundleToSingleHtml` and
were broken before this fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:06:11 +08:00
Miguel Ángel be9b61a8c9 Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css
fix(studio): server-side DOM patching, render CSS scoping, and resilience
2026-05-21 05:53:32 +02:00
James b7bd956583 fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome 2026-05-20 18:44:41 -04:00
Miguel Ángel 45999226a3 fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.

Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.

Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types

Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test

GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile

Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame

Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
  toolbar actions, navigation, and render starts
2026-05-20 17:07:31 -04:00
Miguel Ángel 3e17ef7447 fix: propagate data-timeline-locked in compiler inliners
The runtime path (compositionLoader.ts) already propagated
data-timeline-locked from inner root to host, but both compiler
inliners (bundler + producer) did not. Bundled output re-opened in
Studio would lose the lock. Now propagated in inlineSubCompositions
alongside the existing data-hf-authored-id propagation.
2026-05-20 02:40:49 -04:00
Miguel Ángel 9c159ad96d fix: escape href in compiler link dedup + add font-link extraction tests
Escape href values in querySelector calls for link dedup in both
htmlBundler.ts and htmlCompiler.ts to match the runtime path (which
uses CSS.escape). Prevents SyntaxError on hrefs containing quotes.

Add two tests for inlineSubCompositions font-link extraction:
- Verifies <link> elements are extracted with original rel + crossorigin
- Verifies dedup across multiple sub-compositions sharing the same font
2026-05-20 02:36:58 -04:00
Miguel Ángel 5d501a1628 fix: preserve original rel attribute on sub-composition link extraction
Store {href, rel, crossorigin} from source <link> elements instead of
re-deriving rel from a URL substring heuristic. Fixes preview-vs-render
parity: a stylesheet link whose href lacks ".css" or "css2?" was
emitted as preconnect in the compiled output, silently dropping the font.

Also documents that caption components ship with transparent backgrounds
intentionally — users add contrast layers in the host composition.
2026-05-20 02:33:40 -04:00
Miguel Ángel 471b4efb4b feat: data-timeline-locked + fix font loss in sub-compositions
Timeline locking:
- Add data-timeline-locked attribute support — fully disables move,
  trim-start, and trim-end in Studio for clips that carry this attr
- Runtime propagates the attribute from inner composition root to host
  element so component authors control it from their HTML
- All 15 caption components in the registry now carry the attribute

Font fix:
- Extract <link rel="stylesheet"> and <link rel="preconnect"> from
  sub-composition <head> alongside existing <style>/<script> extraction
- Fixes caption components (and any sub-comp using Google Fonts via
  <link> tags) losing their font-family when loaded as sub-compositions
- Applied in both runtime (compositionLoader) and compiler
  (inlineSubCompositions) paths
2026-05-20 01:55:17 -04:00
Miguel Ángel 6652fae243 fix(#969): producer path propagates data-hf-authored-id to host element
When the producer inlines a sub-composition with compId match, it takes
innerRoot.innerHTML which strips the wrapper div and its id attribute.
CSS/GSAP selectors rewritten from #ID to [data-hf-authored-id=ID] then
match nothing.

Fix: after innerHTML injection, copy the inner root's id as
data-hf-authored-id on the host element. This makes #ID selectors work
identically in both preview (bundler) and render (producer) paths.

Closes #969
2026-05-19 16:07:24 -04:00
Miguel Ángel 26450c1a27 refactor: address review — rename activateSiblingTimelines, opts arg, FIXME tracking
- Rename activateNestedChildTimelines → activateSiblingTimelines (matches player.ts)
- Use tl.play() instead of tl.paused(false) for consistency
- Convert positional activateChildren boolean to { activateChildren } opts
- Add FIXME(#969) to divergence test with tracking issue link
- Add [id="intro"] no-rewrite boundary test
- Add comment about deliberate no-restore behavior in render-seek path
2026-05-19 16:02:33 -04:00
Miguel Ángel e2f7f6a58a fix: add regression fixtures with golden baselines + address review
- Create sub-comp-t0 and sub-comp-id-selector as proper regression tests
  under packages/producer/tests/ with golden MP4 baselines
- Add both to shard-7 in regression.yml
- Add clarifying comment on activateNestedChildTimelines scope
- Confirm test fixture network safety in comment
2026-05-19 15:59:09 -04:00
Miguel Ángel 60cdf8c66b test: add regression fixture for sub-comp #ID selector scoping divergence
The producer inlining path strips the inner root element (taking innerHTML
when compId matches), losing the id attribute. The bundler path preserves it
via flattenInnerRoot + data-hf-authored-root-id. This causes #ID selectors
in sub-comp CSS and GSAP to fail silently during render while working in
preview.

Adds a minimal fixture with a sub-comp using #intro scope to catch this
divergence in future compiler changes.
2026-05-19 15:38:59 -04:00
Miguel Ángel 0f1c64dcae fix: address review feedback — observability, dedup, query-strip, catalog
Review items addressed:

1. Mirror video-failure warning in beginFrame path (was screenshot-only)
2. Fix resolveProjectRelativeSrc escape-fallback to use query-stripped
   cleanSrc instead of raw src for the normalize/strip arm
3. Export prepareFlattenedInnerRoot from @hyperframes/core/compiler and
   consume in the producer instead of duplicating the implementation
4. Use typed Window cast instead of (window as any) for __hfForceTimelineRebind
5. Regenerate docs/public/catalog-index.json with all 6 map blocks
6. Restore Maps nav group in docs.json (catalog generator had merged
   them into Data)
2026-05-18 01:41:15 -04:00
Miguel Ángel 72506f3b4a fix(core): address #922 review — posix paths, diamond imports, comment safety
Fixes from review #4306329284 on hf#922:

- Normalize path.relative() output with .split(sep).join("/") so
  rebased url() paths use forward slashes on Windows, matching the
  posix-path convention in rewriteSubCompPaths.ts.

- Return empty string (not the original @import statement) when the
  visited set detects a diamond import. Previously the stale @import
  leaked through and caused a 404 after bundling.

- Strip CSS block comments before @import matching so commented-out
  imports (/* @import url(...) */) are not resolved. Comments are
  restored after processing via placeholder substitution.
2026-05-17 17:35:31 -04:00
Miguel Ángel 1ac70e8968 Merge pull request #922 from heygen-com/fix/css-import-inlining
fix(core): resolve CSS @import and rebase url() paths during bundling
2026-05-17 23:28:36 +02:00
Miguel Ángel 998343fbb3 fix(core): rebase url() paths when inlining CSS from subdirectories
When CSS files in subdirectories are inlined into the bundle's <style>
block, their url() references (fonts, images, cursors) break because
they resolve relative to the HTML document root instead of the CSS
file's original directory.

Rebase all relative url() paths to the project root during CSS
inlining, for both <link>-referenced stylesheets and @import-resolved
content. Uses a placeholder approach to avoid double-rebasing when
nested @import chains each carry their own url() references.

Preserves absolute URLs, data URIs, query strings, and hash fragments.
2026-05-17 17:22:46 -04:00
Miguel Ángel 59ee7c2e6a fix(core): resolve CSS @import when inlining stylesheets into bundle
The bundler inlines local CSS files by reading their content and
concatenating into a <style> block. @import statements inside those
files were left unresolved — their paths were relative to the original
CSS file location, but after inlining they resolve against the HTML
document, causing 404s for tokens, fonts, and variables.

Recursively resolve relative @import statements during CSS inlining,
with circular-import protection and @media wrapping for conditional
imports. Absolute URLs (CDN, Google Fonts) are preserved as-is.
2026-05-17 17:12:08 -04:00
Miguel ÁngelandClaude Sonnet 4.6 754b0edc05 fix: handle linkedom fragment parsing in shared inlineSubCompositions
When linkedom parses a fragment like `<div data-composition-id="X">...
</div>`, the div becomes the documentElement and body is empty.
contentDoc.body?.innerHTML returns "" losing the composition wrapper.

Fall back to contentDoc.documentElement?.outerHTML when body content
is empty, preserving composition IDs for sub-compositions where the
host data-composition-id differs from the inner root's.

Fixes style-1-prod regression (captions sub-comp has host id
"captions-comp" but inner root id "captions").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:51:31 +00:00
Miguel ÁngelandClaude Sonnet 4.6 581e7a7eda refactor: extract shared inlineSubCompositions from bundler and producer
Both the core bundler (htmlBundler.ts) and the producer (htmlCompiler.ts)
had parallel ~200-line implementations of sub-composition inlining. This
divergence caused bug #911 (producer didn't set data-composition-file).

Extract the shared logic into core/compiler/inlineSubCompositions.ts:
- Single function handles: template/body extraction, CSS/script scoping,
  asset path rewriting, data-composition-file attribution, content injection
- Callers provide environment-specific callbacks (HTML resolution, parsing,
  variable handling, inner root flattening)
- Core bundler passes its advanced features (runtime IDs, variables,
  inline style rewriting, inner root flattening)
- Producer passes a simpler resolver (map + filesystem fallback) and
  adds pixel sizing post-hoc

Net: -215 lines, one source of truth for sub-comp inlining.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:28:20 +00:00
Phuong Le be33afb361 fix(core): align sub-composition scoping across runtime and bundler (#897) 2026-05-16 18:25:24 +02:00
f84cc492de perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review)

Two correctness fixes from PR #821 self-review:

1. Cache priority order. Previous order was hyperframes-managed cache →
   puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which
   lags 17+ releases behind upstream; if a user separately installed a
   newer chrome-headless-shell via @puppeteer/browsers install, the CLI
   would silently hand engine the older HF-cache binary while engine's
   own resolveHeadlessShellPath would have picked the newer one. Flip
   the priority so puppeteer cache wins, matching engine semantics.

2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()`
   over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would
   return `linux-99...` first because character '9' outranks '1'. Parse
   each name into integer segments and compare them numerically.

Tests: add both-caches-populated and linux-148-beats-linux-99 cases.

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

* perf(engine): page-side compositing for shader transitions (opt-in spike)

Add an opt-in `--page-side-compositing` flag (CLI) backed by a new engine
config field `enablePageSideCompositing` and env var `HF_PAGE_SIDE_COMPOSITING`.
When set, SDR shader-transition compositions skip the Node-side layered blend
(the hf#677 chain) and instead run the shader inside Chrome via a page-side
WebGL canvas; the engine then captures ONE opaque RGB frame per output frame
via the existing streaming capture path.

This is the strongest non-beginFrame perf lever for Mac users, who cannot
take the beginFrame `~5×` path (Chromium structural limit, crbug.com/40656275).
Stacks on top of the hf#677 1.95× baseline.

Default OFF — existing fixture pins (byte-exact MP4 output) are preserved.
Opt-in path is intentionally PSNR-pinned, not byte-equal (WebGL is f32; Node
is f64). HDR content forces the existing layered path regardless.

Implementation:
- engine: new `EngineConfig.enablePageSideCompositing` (default false).
- producer/fileServer: new `HF_PAGE_SIDE_COMPOSITING_STUB` early-page script
  injected into the served HTML head when the flag is on.
- producer/renderOrchestrator: when the flag + no HDR + no png-sequence,
  route SDR transitions through the streaming path instead of the layered
  HDR stage.
- shader-transitions: new `engineModePageComposite.ts` installs a fullscreen
  WebGL compositor overlay and wraps `window.__hf.seek` so each seek inside
  a transition window captures both scenes via the Chromium
  `drawElementImage` API to GL textures, runs the fragment shader, and
  displays the composited result on the overlay canvas. The engine takes
  one screenshot per frame and sees the composited overlay.
- cli: new `--page-side-compositing` flag sets `HF_PAGE_SIDE_COMPOSITING=true`
  before producer load.
- scripts/page-side-compositing-smoke: bundled-CLI smoke that renders a
  representative fixture with and without the flag, validates the canary
  strings are in the shipped bundles, and writes a wall-time pair.

Determinism trade documented in the engine config doc-comment. The smoke
script enforces the bundled-CLI validation discipline from prior perf work
(see internal feedback note `validate_bundled_cli_not_dev_path`).

Runtime requirement: Chromium's `CanvasDrawElement` feature (already
enabled by the engine's `--enable-features=CanvasDrawElement` launch flag).
When the runtime feature is unavailable, the page-side installer logs a
warning and falls back to opacity-flip mode — the engine still takes the
streaming path; the transition window degrades to a hard scene swap. Vance
will validate on Mac Chrome where the feature is supported.

Co-Authored-By: Vai <vai@heygen.com>

* fix(shader-transitions): use html2canvas for page-side compositor capture

The original drawElementImage approach fails in engine render mode because
the virtual-time shim prevents Chromium from generating paint records for
cloned elements. drawElementImage requires a cached paint record from the
browser's compositor — clones created at capture time never receive one
because (a) shimmed rAFs deadlock inside the seek wrapper, (b) original
rAFs don't produce real paints under virtual-time control, and
(c) layoutsubtree canvases don't apply CSS stylesheet rules to children.

Switch scene capture to html2canvas (foreignObjectRendering: false), the
same JS-based renderer already used by the preview-mode fallback path in
capture.ts. html2canvas reads computed styles and renders via its own
canvas drawing pipeline with no dependency on the browser paint cycle.

Also fixes:
- Engine seek must return the result so Puppeteer awaits async seek
  promises (frameCapture.ts).
- GSAP opacity cache: compositor must restore scene opacity before seek,
  not after — GSAP caches inline values and skips re-writes.
- Support check gates on WebGL availability, not drawElementImage.

Perf: 15-scene shader-perf fixture (28s, 14 transitions, 30fps)
  Baseline (Node-side layered): 137s
  Page-side (html2canvas+WebGL): 33s → 4.1× speedup

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

* refactor(shader-transitions): simplify review fixes for page-side compositor

- Use uploadTexture (zeroes canvas backing store after upload) to prevent
  ~2.2GB transient memory pressure across 280 html2canvas calls per render
- Add ignoreElements + stabilizeTransformedBoxShadows to html2canvas call,
  matching the preview-path capture.ts behavior
- Parallelize from/to scene captures with Promise.all
- Wrap post-capture render in try/finally so opacity is always restored
- Fix WebGL context leak in isPageSideCompositingSupported probe
- Remove dead ResolvedTransition.index field
- Export stabilizeTransformedBoxShadows from capture.ts

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

* fix(producer): unify page-side compositing gating and Docker forwarding

Addresses three issues from staff review:

1. ignoreElements filter stripped all in-scene canvases (Chart.js, D3,
   p5.js) — narrowed to data-no-capture only since the compositor canvas
   is a body sibling never in the scene subtree.

2. Docker mode silently dropped --page-side-compositing — thread
   pageSideCompositing through DockerRenderOptions/buildDockerRunArgs
   with regression tests.

3. Fragmented gating across 4 independent sites could disagree:
   - Stub injection gated only on cfg flag (leaked into HDR/alpha)
   - Probe-created fileServer never got the stub
   - needsAlpha (WebM/MOV) not excluded from the gate
   - WebGL-unavailable fallback claimed layered path would run but
     orchestrator had already disabled it

   Fix: compute stub injection at the same site as the layered-bypass
   decision (after hasHdrContent is known), using addPreHeadScript on
   the already-running fileServer. Single predicate now gates both
   decisions, including !needsAlpha.

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

* perf(engine): two-phase drawElementImage capture for page-side compositing

Replace html2canvas with native drawElementImage for scene capture in
the page-side compositor. drawElementImage reads from the browser's own
paint cache, giving pixel-identical output to the preview path.

The blocker was that cloned elements inside layoutsubtree canvases have
no cached paint record under virtual time — the compositor only paints
when explicitly triggered. Fix: split the seek+composite into two phases
with an engine-forced paint between them.

Phase 1 (seek wrapper, page-side):
  - GSAP seek positions the timeline
  - Clone FROM/TO scenes into visible layoutsubtree staging canvases
  - Set window.__hf_page_composite_pending flag

Engine paint force (frameCapture.ts):
  - Detect pending flag after seek returns
  - Fire micro Page.captureScreenshot (1x1 clip) via CDP to force the
    browser compositor to paint all visible elements including staging
    canvas children

Phase 2 (page.evaluate, page-side):
  - drawElementImage reads the now-valid paint records
  - Upload textures to WebGL, run shader, show GL overlay

Key insight: staging canvases must be visible (not opacity:0) for the
browser to paint their children. They sit at z-index:-9998, behind
the main DOM and covered by the GL overlay during transitions.

Perf: 15-scene fixture (28s, 14 transitions, 30fps):
  Baseline (Node-side layered): 137s
  html2canvas + WebGL:           33s (3.7×)
  drawElementImage + WebGL:      21s (6.6×)

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

* perf(engine): optimize two-phase compositor hot path

- uploadTextureSource instead of uploadTexture: eliminates ~2.3GB of
  canvas buffer alloc/dealloc churn (persistent staging canvases don't
  need the one-shot zeroing behavior)
- Fold hasPending check into seek page.evaluate: eliminates one CDP
  round-trip per frame (~700 unnecessary IPC calls on non-transition
  frames)
- Fix renderShader error handling: on failure, leave source scenes
  visible as fallback instead of hiding both scenes + GL overlay
  (which produced black frames)
- Move mutable state declarations above resolveComposite to prevent
  TDZ risk on refactor

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

* fix(engine): staff review — staging cleanup, pending flag, beginFrame guard

- Clear staging canvas children when leaving transition window (prevents
  visible clone bleed-through on transparent compositions)
- Clear __hf_page_composite_pending on all resolveComposite exit paths
- Guard micro-screenshot paint force against beginFrame mode (CDP
  Page.captureScreenshot conflicts with beginFrame compositor control)
- Update CLI flag description: document video/canvas limitation, remove
  stale PSNR claim

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

* feat(engine): default-on page-side compositing for SDR shader transitions

Page-side compositing is now enabled by default for SDR shader-transition
renders without video content. The 6.6× speedup applies automatically —
no flag needed.

Auto-disables when:
- HDR content detected
- Alpha output (WebM/MOV/PNG-sequence)
- Composition contains <video> elements (cloneNode loses playback state)
- beginFrame capture mode (Linux headless)

Use --no-page-side-compositing to force the Node-side layered path.

Changes:
- Engine config: enablePageSideCompositing defaults to true
- CLI: flag default flipped to true; --no-page-side-compositing disables
- Orchestrator: added composition.videos.length === 0 gate
- Docker: forwards --no-page-side-compositing when explicitly disabled
- Config tests updated for new default

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

* feat(engine): support video elements on page-side compositing fast path

Three-phase capture protocol lets shader transitions render video scenes
without falling back to the slow Node-side layered pipeline:

1. Seek → compositor records transition metadata, sets pending flag
2. onBeforeCapture → video frame injector updates <img> replacements
3. prepare → cloneNode picks up current video frames, img.decode() awaits
4. micro-screenshot → forces browser to paint cloned elements
5. resolve → drawElementImage reads paint records, shader composites

Key changes:
- Remove `composition.videos.length === 0` gate from orchestrator
- Split compositor resolve into prepare (clone) + resolve (shader)
- Move onBeforeCapture before compositor prepare in frameCapture.ts
- Await img.decode() on cloned data-URI images to prevent stale frames
- Stop manipulating scene opacity in compositor (GL canvas overlay suffices)
- Add gsap.set declaration for shader-transitions ambient types
- Add video_missing_timing_attrs lint rule for <video> without id/data-start/data-end

Performance: compositions with video now render at 7.5s (6 workers) instead
of 2m38s on the layered path.

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

* fix(core): auto-inject data-start on video/audio so frame extraction works without explicit attrs

The timing compiler now injects data-start="0" on <video> and <audio>
elements that lack it. This makes discoverMediaFromBrowser() find the
element (it queries video[data-start]), so the frame extraction pipeline
activates automatically. Videos "just work" without requiring authors to
add data-start, data-end, or id attributes.

Also removes the video_missing_timing_attrs lint rule — the compiler
handles the missing attributes automatically, so the lint rule would
only false-positive.

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

* feat(core): add data-hf-auto-start sentinel on auto-injected video timing

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

* feat(producer): add discoverVideoVisibilityFromTimeline for runtime video discovery

Seeks the GSAP timeline in Puppeteer to discover when each video's parent
scene is visible (opacity > 0). Uses coarse sampling at 100ms steps followed
by binary search refinement to frame-level precision (1/60s). Only processes
videos with the data-hf-auto-start sentinel so author-specified timing is
never overridden.

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

* feat(producer): integrate runtime video visibility discovery into probe stage

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

* fix(producer): trigger browser probe for auto-start videos, remove debug logging

The probe stage was skipping browser launch when composition duration was
already known, which meant discoverVideoVisibilityFromTimeline never ran.
Now needsBrowser also checks for data-hf-auto-start sentinel in compiled HTML.

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

* fix(scripts): use mkdtempSync for smoke test work directory

Replaces hardcoded /tmp/hf-page-side-smoke with a unique temp directory
via mkdtempSync to resolve CodeQL "insecure temporary file" alert.

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

* style: format smoke test script

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Vai <vai@heygen.com>
2026-05-15 16:23:12 -07:00
38efe168e2 refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)
* feat(studio): add manual DOM editing inspector (#466)

* fix: stabilize studio preview and runtime sync

* fix: pass selector through timeline thumbnails

* feat: add studio timeline editing

* fix: disambiguate timeline edit targets

* fix: stop timeline auto-scroll in fit mode

* feat: use percentage-based timeline zoom

* fix: sync timeline playhead on zoom changes

* fix: reset timeline scroll when returning to fit

* feat(studio): add manual DOM editing inspector

* docs: update studio manual dom editing guide

* feat(studio): add image asset picker for fills

* feat(studio): add inline image uploads for fills

* fix(studio): use real file input for image fill uploads

* fix(studio): restore toast plumbing after rebase

* fix(studio): explain in-app upload limitation

* fix(studio): reuse asset-tab upload pattern in fills

* feat(studio): refine manual design inspector

* fix(studio): polish manual design inspector

* fix(studio): keep color picker in viewport

* fix(studio): clarify color picker selection

* docs: update manual DOM editing guide

* fix(studio): keep gradient color picker open

* fix(studio): scope text color to text layers

* fix(studio): add agent fallback for immovable layers

* fix(studio): address manual editing review feedback

* fix(studio): make local font selection reliable

* fix(studio): improve dom picking and thumbnails

* fix(studio): copy absolute paths in agent prompts

* fix(studio): prevent timeline track cutoff

* fix: copy Studio agent prompts in Safari

* fix(studio): hold canvas movement from inspector

* feat(studio): add persistent undo redo (#537)

Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.

* fix: align Studio capture with preview (#595)

Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404.

While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview.

- Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction.
- Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode.
- Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages.
- Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds.
- Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing.

Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched.

The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time.

The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`.

- `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts`
- `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts`
- `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/core build:hyperframes-runtime`
- `bun run --cwd packages/core typecheck`
- `git diff --check`

Pre-commit also reran lint, format, and typecheck successfully for the committed files.

Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened:

```text
http://127.0.0.1:5197/#project/Notion%20Showcase
```

Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`.

After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared.

Mean pixel diffs for preview vs capture were:

- `0s`: `0.0`
- `2s`: `0.8641`
- `10s`: `0.3496`
- `18s`: `0.2309`

The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions.

- Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed.
- The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed.
- Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused.

* feat: persist studio manual edits via manifest

* fix(studio): stabilize manual edit manifest rendering

* fix(studio): allow master canvas layer selection

* fix(studio): scale master edits in source coordinates

* fix(studio): reapply manual edits during playback

* fix(studio): keep rotation edit base stable

* feat(studio): highlight hovered canvas target

* fix(studio): drag hovered canvas targets immediately

* fix(studio): rotate manual edits around center

* fix(studio): keep rotate handle aligned while dragging

* fix(studio): allow small rotation adjustments

* fix(studio): match rotate handle size to resize handle

* fix(studio): connect rotate handle line to selection

* feat(studio): reset selected manual edits

* fix(studio): route inspector geometry through manual edits

* feat: add studio group repositioning

* fix: preserve studio group selections

* fix: seed additive studio selection groups

* fix: select studio groups on pointerdown

* fix: harden studio group overlay events

* fix: address studio manual edit review feedback

* fix: apply nested manual edits in drilled previews

* fix: commit drag offsets from gesture math

* fix: persist manual preview edits on refresh

* fix: harden manual edit refresh apply

* fix: share manual edit render runtime

* chore: release v0.5.0-alpha.15

* feat(core): add studio animation preview APIs

* feat(studio): add alpha editor layer inspector

* chore: release v0.6.0-alpha.1

* feat(studio): enable inspector panels by default

* fix(studio): keep motion panel opt-in

* chore: release v0.6.0-alpha.2

* feat: auto-open timeline clip layers

* feat: show composition loading in studio

* feat: disable Studio timeline while composition loads

* chore: ignore .claude directory

* chore: release v0.6.0-alpha.3

* feat(studio): simplify inspector selection ux

* fix(studio): keep notion preview playback moving

* fix(studio): handle raster inspector clicks

* fix(studio): stale selection, rotation control, design panel polish

Fixes and improvements based on power-user testing feedback:

1. Fix stale selection after style edits — handleDomStyleCommit now
   calls refreshDomEditSelectionFromPreview after persisting, matching
   every other commit handler. Without this, the PropertyPanel showed
   frozen computedStyles after color/radius/shadow edits, making it
   look like editing "didn't work." Also adds error handling around
   the persist call.

2. Add rotation field to the Design panel Layout section — reads the
   current rotation angle from the manual edit manifest and commits
   via the existing handleDomRotationCommit handler.

3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now
   defaults to true so the Motion tab is discoverable without env vars.

4. Color controls only when element has color — fill color section now
   only shows when the element has an explicit non-transparent
   background-color. Text color shows only when the element has a
   color style. Prevents showing color pickers on elements where
   color edits have no visible effect.

5. Exclude canvas from selection — added "canvas" to
   DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the
   preview or listed in the layer panel.

6. Multi-selection feedback — shows "N elements selected" with
   guidance instead of the generic empty state when multiple elements
   are selected.

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

* fix(studio): prevent browser launch timeout from crashing dev server

The shared Puppeteer browser pool in getSharedBrowser() could throw a
30s TimeoutError during launch. This error propagated as an uncaught
rejection and killed the vite process, even though generateThumbnail
had its own try/catch — the browser launch promise rejected outside
that scope. Now getSharedBrowser itself catches launch failures and
returns null, so thumbnails degrade gracefully instead of crashing.

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

* fix(studio): revert motion panel default to false

Motion panel stays opt-in via env var per product direction. Only
the Design panel is enabled by default.

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

* fix(studio): prevent read-only property crash in manual edit wrappers

The seek/play/applyAfter wrapper functions in manualEdits.ts crashed
with "Cannot set property X which has only a getter" when the player
or timeline objects define seek/play as getter-only properties. This
prevented ALL manual edits (position, rotation, size) from persisting
to disk — the error thrown during applyCurrentStudioManualEditsToPreview
aborted the save queue.

Wrapped all three property assignments in try/catch so wrapping
gracefully degrades when the target object is non-configurable.

Verified: position edit (X=42px) now persists to
.hyperframes/studio-manual-edits.json and survives page refresh.

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

* fix: alpha preview e2e fixes — exports, init templates, EPIPE crash

Three bugs found via automated e2e testing of the v0.6.0-alpha preview:

1. core: add missing package.json export specifiers for
   studio-api/manual-edits-render-script and
   studio-api/studio-motion-render-script — the alpha.3 npm publish
   failed because the studio build could not resolve these sub-paths.

2. cli: fix init --example creating empty projects — tsup leaves empty
   template directories in dist/ during the build, causing
   existsSync(templateDir) to return true and skip the remote fetch
   fallback. Now checks for index.html inside the dir instead.

3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg
   stdin/stdout had no error handlers, so a write after the ffmpeg
   process exits throws an uncaught error that crashes the process.

Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky).

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

* fix(studio): thumbnail crash, feature defaults, multi-select UX, fps selector

Power-user audit fixes for the alpha studio:

- vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer
  TimeoutError doesn't crash the entire vite dev server as an uncaught
  rejection. Close the page on error to prevent browser session leaks.

- manualEditingAvailability.ts: enable motion panel and manual canvas
  drag editing by default (were both false, undiscoverable without
  knowing the env vars).

- PropertyPanel.tsx: show "N elements selected" feedback when multiple
  elements are selected instead of the generic "Select an element"
  empty state.

- RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render
  export bar instead of hardcoding 30fps. Pass the user's choice
  through to startRender.

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

* chore: release v0.6.0-alpha.4

* fix(runtime): update clock duration when root timeline is late-bound

Compositions with external sub-compositions (like apple-presentation
with 7 slides) load child compositions via fetch(). The root GSAP
timeline is only bound after all external compositions finish loading,
but the TransportClock duration was only set during initial setup.

When bindRootTimelineIfAvailable runs after the external compositions
load, it captures the root timeline but never updates the clock.
player.getDuration() continues returning 0, so the player's probe
interval never fires the 'ready' event, and the Studio shows "Loading
composition" indefinitely.

Now bindRootTimelineIfAvailable updates clock.setDuration when the
root timeline is late-bound. Guarded with try/catch for the early call
site where clock is not yet initialized (temporal dead zone).

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

* fix(studio): block element selection while composition is loading

Prevent users from selecting elements in the preview while the
composition is still loading (showing "Loading composition" overlay).
Selection and hover highlighting are suppressed until the player fires
the ready event.

Also reverts motion panel and manual drag editing defaults to false —
these were accidentally set to true during the PR #693 merge.

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

* chore: release v0.6.0-alpha.5

* chore: release v0.6.0-alpha.6

* fix(runtime): remove per-tick timeline.pause() that causes audio stutter

The seekRuntimeTimeline helper added timeline.pause() before every
totalTime() seek. During transport-driven playback, this runs 60 times
per second, causing GSAP to cascade pause events to media elements on
every frame. The result: audio plays/stops/plays/stops in a stutter
pattern.

The captured root timeline is already paused once in player.play() —
the TransportClock drives it via totalTime(t) which keeps it paused.
The extra per-tick pause() was redundant for the root timeline but
actively harmful for media sync.

Fix: restore the original inline seek for the captured timeline
(totalTime without pause), keep seekRuntimeTimeline with pause() only
for standalone child timelines where explicit pause control is needed.

Also fixes rebase artifact: missing PropertyPanel props in App.tsx.

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

* chore: release v0.6.0-alpha.7

* fix(studio): restore text field handlers lost in rebase

Restores handleDomAddTextField and handleDomRemoveTextField that were
dropped when resolving App.tsx conflicts during the main→next rebase.

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

* chore: release v0.6.0-alpha.8

* fix(runtime): comprehensive audio stutter fix

Three changes that together caused audio play/stop/play/stop stutter
during transport-driven playback:

1. seekRuntimeTimeline called timeline.pause() before every totalTime()
   seek, 60x per second. GSAP cascades pause to media elements on every
   frame. Fix: restore original inline seek for the captured timeline
   (totalTime without pause). The timeline is already paused once in
   player.play(). seekRuntimeTimeline with pause() remains only for
   standalone child timelines.

2. player.play() removed the !tl guard, allowing play without a
   captured timeline. But getSafeTimelineDurationSeconds(null) returns
   0, so the clock has no duration → immediately reaches end → stops →
   restarts. Fix: when no timeline provides duration, fall back to the
   root composition element's data-duration attribute.

3. Audio source attachment added networkState guard that could cause
   the clock to flicker between audio-source and monotonic timing
   on transient media states. Fix: keep !rawEl.error guard (prevents
   errored audio from freezing the clock) but drop the networkState
   check.

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

* fix(runtime): skip drift corrections on playing video elements

Seeking a playing video resets the browser's decoder pipeline, causing
a ~150ms freeze while it re-buffers. During that freeze the monotonic
clock advances, drift grows, and strict sync fires another seek —
creating a perpetual stutter loop (176 seek events / 8s observed on
the apple-presentation composition).

Skip strict and force drift corrections for playing video elements;
only hard sync (>0.5s catastrophic drift) warrants the decoder-reset
cost. Audio elements are unaffected and retain the full correction
tiers.

Also propagate the asset-loading overlay state to the timeline so
controls are disabled during "Preparing preview assets", matching the
existing behavior for the initial composition loading overlay.

* chore: release v0.6.0-alpha.9

* feat(studio): consolidate keyboard shortcuts into single handler

Move all window-level keyboard shortcuts from 4 separate files into
one `handleAppKeyDown` listener in App.tsx:

- Shift+T: toggle timeline (was App.tsx, separate useMountEffect)
- Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect)
- Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect)
- Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx)
- Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx)
- Delete/Backspace: remove selected element (was Timeline.tsx)

LeftSidebar exposes a ref handle for tab switching. Timeline watches
selectedElement becoming null to clean up popover/range UI state.
History hotkey kept as named function for iframe forwarding.

Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain
in their component hooks — tightly coupled to component state.

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

* fix(studio): sidebar tab overflow + hot-reload double-refresh

1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate
   on overflow, tighter padding. Fixes tabs clipping outside the rounded
   pill at narrow sidebar widths.

2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh
   path (source editor, timeline move/resize/delete, asset drop). The
   file-change watcher already checks this timestamp and suppresses
   echoed events — but source editor saves and timeline operations
   weren't setting it, causing a double refreshKey increment that could
   leave the player in a non-playable state.

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

* fix(studio): delete key removes preview-selected elements

The consolidated keyboard handler only checked selectedElementId
(timeline clips). When a user selected a child element in the
preview via the inspector, selectedElementId was null because
the element didn't correspond to a top-level timeline clip, so
Delete/Backspace did nothing.

Add handleDomEditElementDelete that removes the element referenced
by the current domEditSelection via the remove-element mutation
API. The Delete key handler now falls through from timeline
selection to DOM edit selection.

* fix(studio): remove unused deleteInFlightRef from Timeline

Leftover from moving Delete handling to the consolidated
keyboard handler in App.tsx. Also suppress pre-existing
exhaustive-deps warning on the intentional every-render
selection-change watcher.

* fix(studio): forward all keyboard shortcuts to preview iframe

The consolidated handleAppKeyDown was only added to the parent
window. When focus was inside the preview iframe (after clicking
an element), keydown events didn't reach the parent, so Delete
and other shortcuts didn't fire.

Replace the per-function iframe forwarding (handleTimelineToggleHotkey
only) with the full app-level handler via a ref-stable wrapper.
All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work
from within the preview iframe.

* fix(core): search inside <template> content when removing elements

linkedom's document.querySelectorAll does not traverse <template>
content. Elements in template-based compositions (like .title-word,
.bullet-text) were invisible to the removal logic, so delete
returned changed: false and the element survived the reload.

Fall back to template.querySelectorAll when the document-level
query returns no matches. Uses template.querySelectorAll directly
(not template.content.querySelectorAll) because removing from
the content DocumentFragment doesn't update the serialized output.

* fix(studio): suppress loading overlay on hot-reload

Only show the composition loading overlay on the first iframe load.
Hot-reloads (source editor save, timeline edits, element delete)
no longer flash the full-screen loading state.

* fix(studio): reorder design panel, fix stroke height, rename Blending

- Move Text section to the top of the panel (before Layout)
- Remove Selection Colors section
- Rename "Blending" to "Transparency"
- Fix stroke Width/Style height mismatch by making SelectField
  use inline label layout matching MetricField

* fix(studio): prevent panel scroll when wheel-adjusting metric inputs

React registers onWheel passively, so preventDefault had no effect
on the parent scroll container. Replace with a native wheel listener
(passive: false) that blocks both default scroll and propagation.

* chore: release v0.6.0-alpha.10

* chore: release v0.6.0-alpha.11

* fix(studio): clean next alpha inspector artifacts

* chore: release v0.6.0-alpha.12

* fix(studio,player,core): eliminate double audio and manifest polling loop (#722)

Three bugs that compound in Studio preview:

1. **Double audio on pause/resume**: syncRuntimeMedia played audio through
   the HTML <audio> element while WebAudioTransport simultaneously played
   the same source through AudioBufferSourceNode. Fixed by passing
   webAudio.isActive() as outputMuted so HTML elements stay muted when
   Web Audio owns playback. Also removed the priorMuted restore in
   stopAll() which raced with the next play cycle.

2. **Manifest polling loop**: applyStudioManualEditsToPreview and
   applyStudioMotionToPreview unconditionally fetched from disk on every
   call, even without forceFromDisk. The runtime posts state messages
   every frame via postMessage, triggering React re-renders that re-invoked
   these functions ~60x/second. Fixed by returning early when no disk read
   is requested, and using refs instead of callbacks in useEffect deps.

3. **Parent proxy double-play**: the player web component created parent-frame
   audio proxies even when the runtime bridge was available, causing two
   audio sources on autoplay-blocked promotion. Fixed by skipping proxy
   creation when _hasRuntimeBridge returns true, and synchronously muting
   iframe media on promotion to close the async race window.

Also fixes pre-existing ResolutionPreset type missing square variants.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): improve font picker and text property controls (#736)

- Line height and letter-spacing: convert from free-text to select with presets
- Font style: remove oblique (browser falls back to italic), keep normal/italic
- Font weight: detect available weights via document.fonts.check(), add labels
- Font source: local fonts matching Google catalog tagged as Google
- Font list: balanced per-source caps prevent any source from being cut off
- Sort order: Google fonts rank before Local so curated fonts appear first

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): inspector visibility, undo/redo blinking, and preview caching

Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0
because CSS opacity is not inherited — getComputedStyle on the child still
returns 1. Walk the ancestor chain in the picker, domEditing, and overlay
visibility checks to catch this.

Also:
- Containers with all-invisible children are no longer selectable
- Selection/hover overlay hides during playback and while loading
- Undo/redo no longer double-refreshes (echo suppression for all file writes)
- Undo/redo reloads iframe in-place instead of recreating the Player,
  preserving shader transition cache
- Preview routes return ETag + Cache-Control headers; composition HTML uses
  project signature for conditional 304, binary assets use mtime+size
- Loading overlay deferred 400ms so cached loads never flash it

* fix(studio): remove timeline inspector buttons, enable manual dragging

Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline
clips. The timeline layer inspector feature and all supporting code is removed.

Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H
fields in the design panel. Hide the Radius section when the element has no
visible background. Fix pre-existing ResolutionPreset type for square presets.

* chore: release v0.6.0-alpha.13

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

* fix(studio): add rotation field, inline element drag, fix manifest load regression (#743)

- Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel.
  Goes through manifest via handleDomRotationCommit, resettable with Reset Edits.
- Auto-promote display:inline elements to inline-block when dragged so
  translate works on inline spans.
- Fix regression from polling fix: iframe load now passes readFromDiskFirst
  to load manifest from disk, so Reset Edits finds existing entries.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(studio): decompose App.tsx monolith (4297 → 567 lines) (#741)

* refactor(studio): decompose App.tsx from 4297 to 567 lines

Break the monolithic StudioApp component into focused modules:

Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener

Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle

Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management

Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.

* feat(studio): add Layer (z-index) field to design panel

Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout
section. Available for all elements regardless of style editing
capability since z-index is fundamental to composition stacking order.

* docs: architecture spec for studio domain contexts, hook split, and file-size lint

* docs: implementation plan for studio contexts, hook split, and file-size lint

* refactor(studio): consolidate duplicate helpers in useDomEditSession

Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).

Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator

Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
  rotation, manual edits reset, motion), persist operations, element delete,
  font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
  modal, preview interaction, and commit hooks

All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.

* feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio)

Create context providers that wrap hook return values for prop-drilling
elimination. Each context destructures and reconstructs the value inside
useMemo so exhaustive-deps is satisfied and re-renders are minimized.

Not yet wired into App.tsx — that comes in a follow-up.

* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components

Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.

Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3

Net: -118 lines, 108 props removed from call sites.

* chore: upgrade to React 19

Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace.
Add resolutions/overrides in root package.json to prevent peer
dependency pins (e.g. @phosphor-icons/react) from pulling React 18.
Regenerate bun.lock.

This enables the React 19 context syntax (<Context value={...}>)
used by the new domain contexts.

* fix(studio): refresh preview after z-index change so stacking updates visually

* fix(studio): remove duplicate duration override causing oscillation

The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.

* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs

Two changes to fix duration oscillation after deleting a timeline clip:

1. Replace setRefreshKey (full Player remount) with in-place
   iframe.contentWindow.location.reload() after deleting a clip.
   The full remount triggered a chaotic re-probing cycle with multiple
   duration sources (adapter, manifest, postMessage) fighting each
   other, causing the timeline to oscillate between durations.
   In-place reload preserves the Player web component and its state.

2. Remove window.confirm dialogs from both timeline clip delete and
   DOM element delete. Undo is available so the confirmation adds
   friction without value.

* chore: gitignore docs/superpowers

* feat(studio): add favicon

* perf(studio): skip no-op state updates in timeline sync

syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.

Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.

* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules

The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:

- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
  SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers

All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.

* fix(studio): use in-place iframe reload for all timeline operations

Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.

* perf(studio): replace 5s polling loop with event-driven adapter init

The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.

Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
   reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
   signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)

This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.

* fix(studio): prevent duration oscillation after element delete

Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:

1. Clear store elements before iframe reload in handleDomEditElementDelete.
   Without this, stale pre-delete elements remain in the store and cause
   mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
   and PRESERVE modes as the element count fluctuates.

2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
   The "state" handler was calling enrichMissingCompositions every ~80ms,
   which added extra elements from GSAP timelines. These fought with the
   authoritative element list from "timeline" messages (~333ms), creating
   a feedback loop where element count oscillated and triggered alternating
   merge strategies with different durations.

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

* fix(studio): single reloadPreview as source of truth for preview refresh

Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(studio): decompose App.tsx from 4297 to 567 lines

Break the monolithic StudioApp component into focused modules:

Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener

Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle

Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management

Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.

* docs: architecture spec for studio domain contexts, hook split, and file-size lint

* docs: implementation plan for studio contexts, hook split, and file-size lint

* refactor(studio): consolidate duplicate helpers in useDomEditSession

Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).

Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator

Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
  rotation, manual edits reset, motion), persist operations, element delete,
  font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
  modal, preview interaction, and commit hooks

All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.

* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components

Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.

Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3

Net: -118 lines, 108 props removed from call sites.

* fix(studio): refresh preview after z-index change so stacking updates visually

* fix(studio): remove duplicate duration override causing oscillation

The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.

* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs

Two changes to fix duration oscillation after deleting a timeline clip:

1. Replace setRefreshKey (full Player remount) with in-place
   iframe.contentWindow.location.reload() after deleting a clip.
   The full remount triggered a chaotic re-probing cycle with multiple
   duration sources (adapter, manifest, postMessage) fighting each
   other, causing the timeline to oscillate between durations.
   In-place reload preserves the Player web component and its state.

2. Remove window.confirm dialogs from both timeline clip delete and
   DOM element delete. Undo is available so the confirmation adds
   friction without value.

* chore: gitignore docs/superpowers

* perf(studio): skip no-op state updates in timeline sync

syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.

Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.

* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules

The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:

- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
  SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers

All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.

* fix(studio): use in-place iframe reload for all timeline operations

Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.

* perf(studio): replace 5s polling loop with event-driven adapter init

The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.

Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
   reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
   signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)

This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.

* fix(studio): prevent duration oscillation after element delete

Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:

1. Clear store elements before iframe reload in handleDomEditElementDelete.
   Without this, stale pre-delete elements remain in the store and cause
   mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
   and PRESERVE modes as the element count fluctuates.

2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
   The "state" handler was calling enrichMissingCompositions every ~80ms,
   which added extra elements from GSAP timelines. These fought with the
   authoritative element list from "timeline" messages (~333ms), creating
   a feedback loop where element count oscillated and triggered alternating
   merge strategies with different durations.

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

* fix(studio): single reloadPreview as source of truth for preview refresh

Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.

* fix: resolve lint errors from rebase (unused imports, duplicate declarations)

* fix: prefix unused probeResult variable

* fix: restore renderOrchestrator.ts from origin/next (rebase conflict artifact)

* fix: resolve rebase conflicts by using main's producer and next's studio/player

* fix: restore rebase-conflicted files from origin/next

* fix: use 'load' instead of 'networkidle0' for Puppeteer waitUntil (type compatibility)

* fix: restore webAudioTransport.ts from main (test compatibility)

---------

Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 20:18:52 +02:00
Miguel Ángel ea3f7273e7 fix(core): scope duplicate getElementById lookups (#655) 2026-05-07 02:49:30 +02:00
James 3d370c4064 test(bundler): parse scripts via linkedom, not regex
Per CodeQL's `js/bad-tag-filter` recommendation, replace the regex-based
`<script>` body extraction with a `parseHTML` + `querySelectorAll`
walk. The rule explicitly says "use a parser library" — and linkedom
is already imported in this file, so the diff is small.

This eliminates the regex entirely, so the rule can no longer fire on
this site (instead of chasing whitespace / case / trailing-content
edge cases one at a time).
2026-05-06 05:06:42 +00:00
James f3f542b42f test(bundler): accept arbitrary content in script close tag
CodeQL still flagged `</script\s*>` as too narrow — the rule wants
tolerance for `</script\t\n bar>` (HTML parser treats trailing content
in a close tag as part of the tag). Switched to `</script[^>]*>` for
full coverage.

The bundler still always emits the canonical `</script>`; this is
test-side hardening, not a runtime fix.
2026-05-06 05:04:43 +00:00
James b075f90b78 test(bundler): tolerate whitespace in closing script tag
CodeQL's `js/bad-tag-filter` rule flagged `</script>` as too strict —
`</script >` (with whitespace before `>`) is valid HTML and would slip
past the matcher. Changed to `</script\s*>` for full defense-in-depth.

The bundler always emits the canonical form, so no real-traffic miss —
this is hardening the test's parse-loop, not fixing a downstream bug.

Addresses CodeQL alert on #641.
2026-05-06 04:59:41 +00:00
James 93ab216f2b test(bundler): case-insensitive script regex in ASI-guard test
CodeQL flagged the inline `<script>...</script>` regex as case-sensitive,
which would miss `<SCRIPT>` tags. The bundler always emits lowercase, so
this is a defense-in-depth fix matching the `/i` flag already used by the
sibling regexes in this file (lines 37 & 75).

Addresses CodeQL review on #641.
2026-05-06 04:46:39 +00:00
Rames JussoandClaude Opus 4.7 dfca302d37 fix(bundler): runtime mode opt-in, ASI-safe joinJsChunks, prune dead subs
Per @vai-bot's review on hf#641:

Important #1: dead `src=""` substitution sites
=============================================

Now that `bundleToSingleHtml` inlines the runtime IIFE by default, the empty
`src=""` placeholder is never emitted in the no-env-var path — the 5 downstream
substitution sites that grep for `src=""` were dead.

Two of them (studio dev server + studio vite preview) genuinely WANT the
placeholder so they can hot-reload a local /api/runtime.js endpoint without
re-inlining ~150 KB on every composition edit. Three of them (CLI validate,
snapshot, layout) were just doing the same inlining the bundler already does.

Resolution:
- Add a `runtime: "inline" | "placeholder"` option to `BundleOptions`. Default
  is "inline" (matches the self-contained-bundle promise the function name
  makes). The two studio surfaces explicitly pass `{ runtime: "placeholder" }`
  to opt in.
- studioServer.ts + studio/vite.config.ts: pass the option, keep their
  existing string-replace logic unchanged.
- validate.ts + snapshot.ts + layout.ts: delete the now-redundant runtime
  substitution code (regex never matches the new inlined-runtime shape).

Important #2: joinJsChunks ASI hazard
======================================

The new helper appended `;` to chunks not already ending in `;` and joined
on `\n`. If a chunk ended with a `// line comment`, the appended semicolon
was eaten by the comment, leaving the next chunk's first statement attached
to the previous chunk's last expression — exactly the ASI hazard the helper
exists to prevent.

Fix: append `\n;` instead of `;` for chunks not already terminated. The
newline closes the line comment, the standalone `;` becomes the statement
separator. For typical chunks (already ending in `;`), output is unchanged
— still clean `\n`-joined chunks with no bare-semicolon lines.

Also added a trailing `;` to `wrapScopedCompositionScript`'s IIFE close
(`})()` → `})();`) so composition scripts join cleanly without falling
through to the `\n;` fallback.

New test: regression guard at the chunk boundary verifies every inline
script body in the bundle parses cleanly via esbuild even when a source JS
file ends with a line comment.

Verification
============

- `bun run --filter @hyperframes/core test` — 653/653 pass
- `bun run --filter @hyperframes/cli test` — 243/243 pass
- `bun run --filter @hyperframes/{core,cli,studio} typecheck` — clean
- `bunx oxfmt --check` + `bunx oxlint` on all touched files — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:37:04 +00:00
Rames JussoandClaude Opus 4.7 af2f727b3f fix(bundler): inline runtime body, drop bare-semi joins, drop empty catch binding
Three issues in `bundleToSingleHtml` reported via Abhay's LLM-based code-validity
eval against the bundled output. Each is independently small; they share a single
PR because they're all artifacts of the bundler-output shape.

1. Empty `src=""` runtime placeholder (real bug)

`htmlBundler.ts:injectInterceptor` emitted
`<script data-hyperframes-preview-runtime="1" src=""></script>`
when no `HYPERFRAME_RUNTIME_URL` was configured. Empty `src` resolves to the
page URL itself; Chrome flags this as an infinite-fetch hazard. Three other
consumers (studioServer, validate, snapshot) post-process the placeholder to
substitute either a real URL or an inlined body — `bundleToSingleHtml` did
not, so the bundle wasn't actually self-contained despite the function name.

Fix: when no URL is configured, inline the runtime IIFE directly via
`getHyperframeRuntimeScript()`. Otherwise emit `src=…` as before.

2. Bare-semicolon lines between joined JS chunks (cosmetic)

Three sites used `chunks.join("\n;\n")` (body-script coalesce, local JS,
composition scripts) which produced a lone `;` on its own line between
chunks. Valid JS but a code smell. Replace with a `joinJsChunks()` helper
that ensures each chunk ends in `;` and joins on `\n`.

3. Empty `catch (_err) {}` in compositionScoping.ts (lint-noisy)

The `_err` underscore prefix signals "intentionally swallowed" but bundle-time
linters often don't honor that convention. Replaced with `catch { /* ... */ }`
(no binding, explanatory comment) — same behavior, no rule fires.

Tests: 2 new regression guards (runtime-not-empty-src, no-bare-semi) plus
existing tests updated to reflect the new inlined-runtime shape (the previous
"runtime block must not contain getElementById" assertion no longer holds
because the inlined body itself uses getElementById; replaced with a more
specific "author script not merged into runtime tag" check).

Issue #4 from the original report (Unterminated string at line 1111 col 18,
char 65497) was not directly reproducible after applying these fixes — esbuild
parses all 4 inline scripts in the rebundled output cleanly. The unterminated-
string symptom was likely a downstream artifact of the bare-semicolon joining
or the empty-src placeholder confusing the lint tool. If the original symptom
persists on a clean re-run against the fixed bundle, will open a follow-up PR
with a focused repro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:43:58 +00:00
JamesandClaude Opus 4.7 1da6f456b7 refactor(core): apply /simplify findings on sub-comp scoping PR
- compositionLoader.ts: drop the redundant inline `Window` cast; the
  ambient `__hfVariablesByComp?` declaration in runtime/window.d.ts
  already covers it within the same package.
- compositionScoping.test.ts: drop the `__captured: undefined as unknown`
  initializers — `Record<string, unknown>` already permits the key, the
  init was noise.

Reuse + efficiency reviews returned clean. The scoped getVariables's
per-call Object.assign({}, scoped) is consistent with the file's
existing scoped-utility conventions (gsap proxy returns fresh bound
functions per access) and acceptable since the idiomatic usage
destructures once at script init.

All 44 touched core tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:42:04 +00:00
JamesandClaude Opus 4.7 484ab54442 feat(core): scope getVariables() per sub-comp instance
Building on PR 1's getVariables() helper, this PR routes per-instance
values into the correct sub-composition. Same composition source can
now be embedded N times with different content via data-variable-values
on each host element.

How it works:

- compositionLoader, before injecting wrapped scripts, layers the host
  element's data-variable-values JSON over the sub-comp's declared
  defaults (its own data-composition-variables) and writes the merged
  object to window.__hfVariablesByComp[compositionId]. Skipped when
  both sides are empty so the table only grows for instances that
  actually carry values.
- compositionScoping's wrapper IIFE now takes a fourth parameter
  __hyperframes alongside the existing scoped document/gsap/window.
  The scoped __hyperframes shadows getVariables() to read from
  __hfVariablesByComp[__hfCompId], returning a fresh object each call
  so script mutations don't leak into the shared table.
- Top-level scripts (not wrapped by compositionScoping) keep using the
  unscoped window.__hyperframes.getVariables(), which reads
  data-composition-variables defaults plus the CLI override
  (window.__hfVariables) — same path as PR 1.
- readDeclaredDefaults is exported from getVariables.ts so the loader
  reuses the exact same defaults-extraction logic the helper uses for
  the top-level path.

Inline templates (no separate <html> document root) get host overrides
only — no declared defaults — since there's no separate <html> to read
data-composition-variables from. External sub-comps fetched via
data-composition-src get the full declared defaults + host overrides
merge.

Tests: 3 new compositionScoping tests covering scoped getVariables
invocation, missing-entry fallback, and mutation isolation. 5 new
compositionLoader tests covering merge order, declared-only path,
empty-skip, invalid-host-JSON resilience, and per-instance scoping
across two hosts sharing a source. 3 new getVariables tests covering
the newly-public readDeclaredDefaults. All 622 core tests green.

Docs: docs/concepts/compositions.mdx switched its sub-comp example from
hand-rolled JSON.parse(host.dataset.variableValues) to the new
__hyperframes.getVariables() pattern. data-attributes.mdx clarifies
per-instance scoping behavior.

This is PR 2 of a 4-PR stack. PR 3 adds schema validation + lint;
PR 4 ships skill / scaffold updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:41:26 +00:00
Miguel Ángel 26b8e2a985 Revert "feat: Persist Studio manual edits via manifest (#593)"
This reverts commit d0abe90a82.
2026-05-04 09:41:36 -07:00
Vance Ingalls d0abe90a82 feat: Persist Studio manual edits via manifest (#593)
## Summary

Studio manual geometry edits now persist as a project-local manifest instead of being baked into composition source on each gesture.

The manifest lives at:

```text
.hyperframes/studio-manual-edits.json
```

It is the source of truth for manual drag, resize, rotation, inspector geometry edits, group moves, and selected-layer reset.

## Architecture

- **Manifest-backed edits**: each edit stores a kind (`path-offset`, `box-size`, `rotation`), a source-scoped target, and the edit values.
- **Source-scoped resolution**: targets include `sourceFile`, `id`, `selector`, and `selectorIndex`, so duplicate selectors in nested compositions resolve against the owning source file.
- **Additive CSS layer**: move uses CSS `translate`, resize writes stable dimensions/flex sizing, and rotation uses CSS `rotate` over the authored base.
- **Shared replay runtime**: Studio preview, thumbnails, frame capture, producer renders, and CLI Studio renders/thumbnails all use the same core manual-edit render script.
- **Animation-safe replay**: Studio reapplies the manual layer after load, refresh, timeline seeks, player operations, playback frames, thumbnail seeks, and render seeks instead of rewriting GSAP timelines.
- **History and handoff**: the manifest is a normal project file, so undo/redo and agent edits can preserve, modify, or remove manual visual edits explicitly.

## User Impact

Users can move, resize, rotate, group-move, and reset supported layers from the canvas or inspector, then refresh, capture thumbnails/screenshots, play animated compositions, and render videos without manual edits drifting away from the edited state.

## Main Files

- `packages/studio/src/components/editor/manualEdits.ts`
- `packages/studio/src/components/editor/DomEditOverlay.tsx`
- `packages/studio/src/components/editor/PropertyPanel.tsx`
- `packages/studio/src/App.tsx`
- `packages/core/src/studio-api/helpers/manualEditsRenderScript.ts`
- `packages/studio/vite.config.ts`
- `packages/cli/src/server/studioServer.ts`
- `packages/core/src/compiler/htmlBundler.ts`
- `packages/producer/src/services/htmlCompiler.ts`
- `packages/core/src/studio-api/routes/thumbnail.ts`
- `packages/producer/src/services/fileServer.ts`
- `packages/producer/src/services/renderOrchestrator.ts`

## Test Plan

```bash
volta run --node 22.20.0 bun run build
volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/studio-api/helpers/manualEditsRenderScript.test.ts
volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck
volta run --node 22.20.0 bun run --filter @hyperframes/cli typecheck
volta run --node 22.20.0 bunx oxlint <changed files>
volta run --node 22.20.0 bunx oxfmt --check <changed files>
git diff --check
```
2026-05-03 23:06:11 -07:00
Miguel Ángel 6bcf3ceddb fix: use target receiver for scoped proxy accessors (#607)
## Problem

`wrapScopedCompositionScript` wraps composition scripts with scoped `document`, `window`, and `gsap` proxies. The current published `latest` and `alpha` packages still pass the proxy as the `Reflect.get` receiver, so browser host accessors like `document.body` can throw `TypeError: Illegal invocation`.

When that happens, the wrapper catches the error and aborts the rest of the composition script. For components that start hidden and reveal themselves through GSAP/timeline setup, that means the timeline is never registered and the render can stay visually empty.

Closes #606.

## What this fixes

- Reads scoped proxy properties with the original target as the `Reflect.get` receiver.
- Applies the same target-receiver pattern to scoped proxy setters, including remapped timeline registry writes.
- Preserves the existing behavior of binding returned methods back to the real target.
- Covers document, window, remapped timeline registry, GSAP, and GSAP utils accessors/setters in regression tests that throw unless the receiver is the original target.

## Root cause

The previous proxy traps called `Reflect.get(target, prop, receiver)`. For accessors, that invokes the getter with `this === receiver`, and in this wrapper the receiver is the proxy. Browser host getters such as `Document.prototype.body` validate their receiver and reject the proxy, which makes ordinary composition code like `document.body` fail before timeline registration can run.

## Verification

### Local checks

- Confirmed current npm state with `npm view @hyperframes/core version dist-tags versions --json` and `npm view @hyperframes/producer version dist-tags versions --json`: `latest` is `0.4.42`, `alpha` is `0.5.0-alpha.14`.
- Packed `@hyperframes/core` and `@hyperframes/producer` at both `latest` and `alpha`; all four packed artifacts still contained the bad `Reflect.get(target, prop, receiver)` / `utilsReceiver` wrapper patterns before this fix.
- `bun run --cwd packages/core test -- src/compiler/compositionScoping.test.ts`
- `bunx oxlint packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.ts`
- `bun run --cwd packages/core build`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer build`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- Confirmed the rebuilt core runtime and producer bundles no longer contain the old `Reflect.get(..., receiver)` / `Reflect.set(..., receiver)` scoped-wrapper patterns.
- `git diff --check`
- Pre-commit also reran lint, format, and typecheck successfully.

### Browser verification

Used `agent-browser` against generated local repro pages:

- `core latest 0.4.42`: `bodyRead: false`, `titleOpacity: "0"`, `timelineRegistered: false`, `errorCount: 1`
- `core alpha 0.5.0-alpha.14`: `bodyRead: false`, `titleOpacity: "0"`, `timelineRegistered: false`, `errorCount: 1`
- Patched local wrapper: `bodyRead: true`, `titleOpacity: "1"`, `timelineRegistered: true`, `errorCount: 0`

## Notes

- Browser screenshots and the `agent-browser` recordings are local-only under `tmp/issue-606/browser/`, including `issue-606-browser-proof.webm` and `issue-606-after-comment.webm`.
- No generated `dist/` artifacts are committed.
2026-05-03 19:19:53 +02:00
Miguel Ángel 15ee63c6e7 fix: harden CLI edge-case repros (#591)
## Problem

I reproduced the selected open issue batch one by one and confirmed the reports were valid. The fixes all touch the CLI/runtime capture boundary, then the follow-up regression run exposed one over-broad runtime change in sub-composition host visibility and one CI-only baseline trap.

Closes #590, #589, #588, #587, #586, and #584.

## What this fixes

### CLI/runtime edge cases

- Makes the GSAP infinite-repeat lint rule ignore JavaScript comments, so literal `repeat:-1` text in comments is not flagged.
- Lets the compositions CLI inspect `<template>` content, count visual-only template descendants, estimate simple GSAP durations, and suppress root `data-start` warnings in sub-composition lint mode.
- Preserves runtime bootstrap scripts when body scripts are coalesced, and injects the runtime into a real `<head>` when source HTML has no head.
- Keeps #589 fixed by loading and rendering template-wrapped sub-composition content, while restoring host visibility to the shorter of the authored parent clip window and the child composition live timeline.
- Resolves snapshot/validate viewport size from root `data-width` / `data-height` instead of falling back to 1920x1080.
- Skips fully off-frame text boxes during contrast sampling and bounds-checks ring samples so contrast output no longer emits `null:1` / `NaN:1`.
- Marks muted videos as `data-has-audio="false"` in the core timing compiler, which fixes the same-src muted `<video>` + separate `<audio>` StaticGuard case.
- Keeps user-authored `hf-seek` listeners reachable during capture by preventing author scripts from being merged into the runtime bootstrap path.

### Shared helper cleanup

- Removes the stale producer-local timing compiler duplicate; producer compilation now consumes the core timing compiler.
- Centralizes HTML document helpers in core: fragment parsing, embedded runtime stripping, head/body script injection, and early-head injection.
- Centralizes the CLI layout/snapshot static HTML server.
- Adds browser-safe core subpath helpers for Lottie readiness and CLI screenshot clip calculation; Studio's Vite config keeps the screenshot clip helper self-contained so clean-checkout test startup does not value-import core `.ts` source.
- Replaces the engine parity-contract copy with a core re-export.
- De-duplicates render-job cleanup and Studio static file-serving callbacks.

### Regression hardening

- Replaces the embedded-runtime script stripping regex with a script-tag scanner that handles closing tags like `</script >`.
- Escapes inline script bodies before wrapping them in `<script>` tags, so authored `</script` and `<!--` text cannot break out of the injected wrapper script.
- Shares media-duration clamping between core and producer, with a 50 ms tolerance for ffprobe precision drift between local and CI media stacks.
- Pins the affected style fixture SFX durations in source so style-1 and style-9 compile deterministically.
- Restores the `vfr-screen-recording` video golden to the CI-stable baseline; the current CI failure showed the Linux render matches the old golden, while the locally refreshed macOS golden was the mismatch.

## Root cause

The CLI paths had accumulated assumptions that held for simple direct-root landscape compositions but not for current composition patterns: DOM queries did not enter template content, snapshot/validate used a fixed viewport, runtime and author scripts shared a coalescing bucket, and timing compilation treated every video as audio-bearing unless authors manually overrode it.

The style shard failures were not product regressions. Local and CI media probing disagreed on the short SFX clip duration by about 45 ms, and the compiler was clamping authored durations to the locally probed value. The shared clamp tolerance preserves explicit author/source durations for small probe precision differences while still clamping real overflows.

The vfr fast-shard failure was a bad baseline refresh: CI actual frames matched the old `vfr-screen-recording` baseline at 40+ dB PSNR, but mismatched the macOS-refreshed golden at ~18-22 dB. The fix is to keep the Docker/Linux-stable video golden and only retain the deterministic compiled snapshot change.

The sub-composition regression came from treating a host's authored parent window as the only visibility boundary. That made settled child overlays stay visible after their own live GSAP timeline ended. The corrected runtime behavior respects both contracts: parent clips still bound where the host can appear, and the child live timeline can end the host earlier.

## Verification

### Local checks

- `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bun run --cwd packages/core test src/runtime/init.test.ts`
- `bun run --cwd packages/cli test src/commands/compositions.test.ts src/utils/compositionViewport.test.ts`
- `bun run build:hyperframes-runtime`
- `bun run --cwd packages/producer test --keep-temp --sequential style-12-prod style-5-prod`
- `bun run --cwd packages/producer test --sequential vfr-screen-recording hdr-hlg-regression style-7-prod`
- `bun run --cwd packages/core test src/compiler/htmlCompiler.test.ts src/compiler/timingCompiler.test.ts src/index.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bunx oxlint packages/core/src/compiler/timingCompiler.ts packages/core/src/compiler/htmlCompiler.ts packages/core/src/compiler/htmlCompiler.test.ts packages/core/src/compiler/index.ts packages/core/src/index.ts packages/core/src/index.test.ts packages/producer/src/services/htmlCompiler.ts`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer test --sequential style-1-prod style-9-prod`
- `bun run --filter @hyperframes/studio test` with `packages/core/dist` temporarily hidden to simulate clean-checkout config loading
- `git diff --check`

### CI artifact checks

- Inspected failed run `25225854394` job `73969147096`: style-1 failed only on `click-sfx` `1.044898` vs `1` duration/end.
- Inspected failed run `25225854394` job `73969147061`: style-9 failed only on SFX `1.044898`-based duration/end mismatches.
- Inspected failed run `25225854394` job `73969147048`: `vfr-screen-recording` compilation/audio passed, visual failed after comparing against the macOS-refreshed golden.
- Compared the first 10 uploaded CI vfr failure frames against the restored old baseline; minimum PSNR was `40.444705`, above the fixture threshold of `28`.

### Repro checks

- `bun packages/cli/src/cli.ts lint /tmp/hf-590-repro` now passes without `gsap_infinite_repeat`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-587-repro --at 0.5 --timeout 1000` now writes a 1080x1920 PNG.
- `bun packages/cli/src/cli.ts validate /tmp/hf-588-repro --timeout 500` no longer emits `null:1` / `NaN:1` contrast output.
- `bun packages/cli/src/cli.ts validate /tmp/hf-586-repro --timeout 500 --contrast false` no longer emits the muted-video StaticGuard contract error.
- `bun packages/cli/src/cli.ts compositions /tmp/hf-589-gsap-repro` now reports `foo 0.5s 1920x1080 1 element`.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-589-gsap-repro --at 0.25 --timeout 2000` captures the expected template-backed red frame.
- `bun packages/cli/src/cli.ts snapshot /tmp/hf-584-repro --at 0.5,1.5 --timeout 500` captures the expected post-seek green frame.

### Browser verification

- Refreshed the local side-by-side comparison page at `qa-artifacts/pr-591-video-compare/index.html`.
- Served the comparison page locally and used `agent-browser` to load `style-12-prod`, play both videos quickly to the failed window, pause, and inspect the side-by-side frame.
- Browser proof screenshot: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12-labeled.png`.
- Browser proof recording: `qa-artifacts/pr-591-video-compare/browser-proof/fixed-style12.webm`.
- Earlier Studio proof artifacts remain local-only: `qa-artifacts/dedupe-refactor-preview.png`, `qa-artifacts/dedupe-refactor-preview-after-play.png`, `qa-artifacts/dedupe-refactor-preview.webm`.

## Notes

- Browser proof and CI diagnostic artifacts are intentionally local-only and not committed.
- Studio's Vite config intentionally keeps the thumbnail clip helper inline because Vite/Vitest config startup runs through Node's loader before package source `.ts` imports are transformed.
- The committed PR diff changes `vfr-screen-recording/output/compiled.html` but no longer changes `vfr-screen-recording/output/output.mp4` relative to `main`.
- I attempted a local `linux/amd64` Docker validation to mirror CI, but the local Docker build was blocked by Debian package download failures. The arm64 Docker image also cannot launch the x64 Puppeteer headless shell under OrbStack. The vfr baseline decision is therefore based on the uploaded CI artifact comparison above.
- I kept this validated issue batch in one PR because the fixes overlap the same CLI/runtime capture surfaces.
2026-05-02 00:00:08 +02:00
Miguel Ángel c196b76a40 fix: isolate duplicate sub-composition instances (#561) 2026-04-29 17:53:18 +02:00
Miguel Ángel 6ea2e54fcf fix: preserve anonymous inlined composition roots 2026-04-27 18:16:01 -04:00
Miguel Ángel c58b995cb0 refactor: use postcss for composition CSS scoping 2026-04-27 16:42:42 -04:00
Miguel Ángel 3701bb9eeb fix: flatten inlined composition roots 2026-04-27 15:53:24 -04:00
Miguel Ángel c32db3ea0a fix: isolate studio sub-composition previews 2026-04-27 15:51:53 -04:00
Miguel Ángel 31e8144304 fix: render parity for transparent looped videos (#478)
## Summary
- preserve alpha for render-injected video frames by detecting alpha streams with ffprobe and extracting alpha video frames as PNG
- keep `<video loop>` semantics through static parsing, compiler duration resolution, browser media discovery, and render frame lookup
- fail embedded preview startup before opening a broken browser page when the Studio bundle is missing
- align snapshot frame injection with looped media timing and VP9 alpha extraction

## Why
The Studio preview and rendered MP4 could disagree for timed transparent looped videos. The Comfy funding composition exposed two separate parity bugs: render-injected frames needed alpha-preserving PNG extraction, and the compiler was clamping a looped `data-duration="4"` video down to the 3.125s source duration. After the first source cycle, render lookup treated the video as inactive, hid the native video, and produced the blank polygon/glow the user saw around the rounded `0:03` mark.

`hyperframes lint` and `hyperframes validate` did not catch this because they check syntax/load/console/accessibility, not preview-vs-render visual parity. This PR adds regression coverage for the compiler loop-duration path and frame lookup path.

## Verification
- `bun run --filter @hyperframes/core test -- src/compiler/timingCompiler.test.ts src/compiler/htmlCompiler.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bun run --filter @hyperframes/engine test -- videoFrameExtractor ffprobe`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run lint`
- `bun run format:check ...` on touched files
- Comfy project: `node packages/cli/dist/cli.js validate` -> no console errors, 44 text elements pass WCAG AA
- Comfy project patched render from source: `/tmp/comfy-render-compare/fixed6-comfy.mp4`, 1920x1080, 30fps, 21.8s, 654 frames
- 3.00s-3.97s render contact sheet: `/tmp/comfy-render-compare/fixed6-window-contact.png`
- targeted fixed render capture at 3.733s: `/tmp/comfy-render-compare/probe-capture-fixed/captured/frame_000112.jpg`
- agent-browser Studio proof screenshot at 3.7s: `/tmp/comfy-render-compare/agent-browser-studio-3_7-fixed.png`
- agent-browser-driven recording of 3s seek pass: `/tmp/comfy-render-compare/agent-browser-wysiwyg-3s-fixed.webm`

Note: `bun run --filter @hyperframes/cli dev -- validate` is blocked in source mode by the existing `contrast-audit.browser.js` default-export loader issue; packaged `node packages/cli/dist/cli.js validate` passes for this project.
2026-04-24 23:18:20 +02:00