Commit Graph
69 Commits
Author SHA1 Message Date
Miguel Ángel 6f677292ae refactor(core): simplify packages/core — dead code, dedup, type safety (#1413)
- Delete unused mediaPreloader module, 5 dead RuntimeState fields,
  emitPerformanceMetric, lintScriptUrls, 5 variable type guards
- Consolidate compiler utilities: unify CSS URL regex, relative URL
  predicate, MIME map, @import regex, bulk asset rewrite delegation
- Cache extractGsapWindows per script (eliminates 2 redundant recast
  parses per lint run), share stripJsComments and script extraction
- Deduplicate GSAP parser: share serializeValue/safeJsKey, centralize
  converted-id fallback (6 sites), keyframe codegen (3 sites),
  waypoint extraction, insert-after-anchor, script hoisting
- Replace 88 bare any annotations with typed AstNode/AstPath interfaces
- Derive RuntimeBridgeControlAction from HyperframeControlAction,
  alias RuntimePickerElementInfo, share macOS font profiler
- Gate generateHyperframesStyles on includeStyles, collapse 4 GSAP
  property mutation cases into 2
- Extract magic numbers into named constants, replace 5 double casts
  with type guards and typed accessors (runtime/globals.ts),
  reduce function complexity in htmlParser and files route
2026-06-13 18:23:36 -04:00
Vance IngallsandClaude Fable 5 511665b93a feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting (#1345)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches)

* fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access

- index.ts no longer exports document/session/history/persist-queue (those
  modules land in the next stacked PR); branch now typechecks standalone
- setOwnText: optional-chain children[i] access (TS2532 under
  noUncheckedIndexedAccess)
- fallow suppressions for buildPatchEvent + adapters/types.ts — consumers
  arrive in #1325

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

* fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline

- applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9
  parser-backed ops instead of silently no-opping — callers must never
  believe an animation edit succeeded when nothing was mutated
- validateOp returns false for Phase 3b ops so can() feature-detects
- root package.json build filter now includes @hyperframes/sdk (package is
  dist-only; top-level build previously produced no SDK artifacts).
  publish.yml intentionally NOT updated — sdk stays unpublished until
  Phase 3 completes.

Adversarial-review findings F3 + F4.

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

* fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs

Round-2 review (Rames/Miguel) on the engine layer:

- ORIGIN_APPLY_PATCHES: unique symbol → namespaced string
  ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't
  survive postMessage/structured-clone, which T3 embedded hosts may forward
  patch events across. Namespaced string keeps collision risk negligible.
- setCompositionMetadata width/height: runtime treats data-width/data-height
  as a forced override of inline style (init.ts applyCompositionSizing).
  Style is always written; the data-* attr is updated when already present
  so the edit isn't clobbered on load. Absent attrs stay absent — inverses
  stay exact. Mirrored in the patch applier; 3 new tests.
- JsonPatchOp documented as the emit-only RFC 6902 subset
  (add/remove/replace); applier header notes move/copy/test are ignored.
- SdkDocument.html documented as a build-time snapshot (serialize() is the
  live state).
- patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}.

NOT changed (with reasons, see PR reply): moveElement left/top matches
Studio's own inline-style commit convention (sourcePatcher); package version
follows the repo-wide single-version policy.

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

* fix(sdk): moveElement writes data-x/data-y, not left/top CSS

HF elements use data-x/data-y for positioning (read by htmlParser.ts,
emitted by hyperframes generator). CSS left/top is not the runtime convention.

Adds inverse round-trip test for prior position restore.

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

* chore: update bun.lock after sdk package registration

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

* feat(sdk): session API, optional history + persist-queue, adapters — Phase 3a complete

* fix(sdk): address review — live-DOM query cache, single parse, style parse dedup

- getElements/getElement/find now walk the live linkedom DOM via buildRoots
  with a lazily-built cache invalidated on dispatch/applyPatches — no
  serialize→ensureHfIds→parseHTML round trip per query
- openComposition parses once (parseMutable); dropped discarded _doc
  constructor param and the redundant buildDocument call
- document.ts buildElement reuses model.ts getElementStyles — removes
  duplicated parseInlineStyles (also fixes custom-prop camelCase mangling)
- JSDoc note: empty batch() still fires change handlers

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

* fix(sdk): restore full public exports now session/document modules exist

index.ts re-exports document/session/history/persist-queue (trimmed in the
engine-layer PR to keep it self-contained); drops the temporary fallow
suppressions whose consumers now exist.

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

* fix(sdk): coalesce history by patch paths; replay override-set on open

Adversarial-review findings F1 + F2:

- history: coalescing now requires identical patch paths in addition to
  op types + origin + window. Previously two rapid setStyle calls on
  DIFFERENT elements merged into one entry carrying the second forward +
  first inverse — undo then reverted the wrong element and stranded the
  latest edit. Slider drags on one property still coalesce.
- T3 init: openComposition({ overrides }) now replays the stored
  override-set onto the freshly-parsed base before exposing the session
  (new keyToPath inverse mapping + applyOverrideSet). Previously the
  overrides were copied into the map but never applied — reopening an
  embedded composition showed and serialized the base template.
- examples: GSAP calls now feature-detect with can() (Phase 3b ops throw
  UnsupportedOpError as of the engine-layer fix); UnsupportedOpError
  re-exported from the package entry.
- 8 new session tests: coalesce same-path / cross-element / cross-prop,
  override round-trip (style/text/attr/timing/removal/restore-base).

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

* fix(sdk): transactional batch rollback, sorted coalesce key, root-priority unify

Round-2 review (Rames/Miguel) on the session layer:

- batch() is now transactional: on throw, accumulated inverse patches are
  replayed in reverse and the override-set snapshot restored — the model is
  exactly as it was at batch entry. Previously a throwing batch left the DOM
  partially mutated with no patch trail, no history entry, no recovery path.
  2 new tests (model unchanged + undo is no-op after throwing batch).
- history coalesce key sorts opTypes — same op-type set coalesces regardless
  of dispatch order within a batch.
- applyPatches comment documents that emitted PatchEvents carry an empty
  inversePatches array (hosts keep their own inverse log).
- document.ts extractDimensions/extractDuration now use the engine's
  findRoot — dimension extraction and mutations agree on the root element
  ([data-hf-root] > #stage > first child). Dimensions prefer the runtime's
  data-width/data-height forced-override attrs, falling back to inline style.
- ownText documented: snapshot .text is trimmed display text; setText writes
  verbatim.

Deferred to follow-up (acknowledged, not ship-blocking): persist-queue flush
error surfacing, debounce window, path default, history ring-buffer.

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

* feat(lint): add gsap_studio_edit_blocked rule for manual timeline + GSAP element targeting

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:23:36 -07:00
Miguel Ángel f0b499b582 fix(studio): keyframe bug fixes — gate delete hooks, fix value corruption, gesture recording (#1314)
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
  stripStudioEdits flag on the delete mutation type so they only fire on
  user-initiated deletes, not on internal delete-then-recreate drags.

- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
  elements with CSS opacity:0 stay visible after collapsing keyframes.

- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
  precision for visual properties (opacity, scale, rotation) instead of
  Math.round which corrupted mid-fade values to 0.

- Guard VISUAL_BASELINE against cross-tween contamination by querying
  __timelines for properties animated by other tweens on the same element.

- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
  containing opacity, guard against relative values (+=/-=/*=), and add
  Number.isFinite check.

- Fix falsy-zero doubling in drag commit: replace || fallback with
  Number.isFinite so a base GSAP position of 0 is correctly preserved.

- Fix gesture recording sign inversion: remove pointerElementOffset
  subtraction from dx/dy formula and instead apply it once to basePosition
  so the element center tracks the pointer.

- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).

- Strip all diagnostic logs from production code.
2026-06-10 18:53:06 -04:00
Miguel Ángel 0bf15119f8 feat: font resolution pipeline — compositions capture and embed their own fonts (#1255)
Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.

Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance

Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
  symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
  HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
  from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
2026-06-07 17:52:19 -04:00
Carlos de la Cruz 2362cf979d fix(core): recognize dot-property timeline registration (#1138) 2026-05-30 18:10:15 -04:00
Miguel Ángel 1284213886 fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle (#1126)
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle

- opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits
- `visibility` renders as a boolean toggle; only available to add in `set` tweens
- ease curve section: use aspect-ratio container so control circles are not oval
- MetricField scroll only fires when the input is focused (was triggering on scroll-over)
- preview overlay clipped to its container (overflow-hidden) — no bleed into panels
- `fromTo` method label updated to "From → To" (was "Animate", same as `to`)
- repeated click at same position cycles through stacked/overlapping elements (#1124, #1125)
  resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks
  advance through all selectable layers at that coordinate
- fallow-ignore-next-line complexity on pre-existing complex functions surfaced by
  branching from fix/gsap-fromto-panel rather than main

Closes #1124, #1125

* fix(studio): address Vai+Rames follow-up notes on hf#1122

- extract buildTweenSummary to gsapAnimationHelpers.ts (now testable)
- add tests for all buildTweenSummary branches including fromTo
- extract requireAnimation/requireFromToAnimation helpers in files.ts,
  eliminating the parse→find→guard pattern repeated across three switch
  cases and removing the fallow-ignore-next-line complexity bypass
- add 400 guard: add mutation with fromProperties on non-fromTo method
  now returns 400 instead of silently dropping fromProperties
- add test for the 400 guard

* fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1

* fix(studio): show all .html files as compositions in sidebar

The Comps sidebar only listed index.html and files under a compositions/
subdirectory. Any other .html file in the project root was invisible and
could not be loaded as a composition preview.

Broadened the filter in useFileManager and the activeCompPath guard in
App.tsx to treat every .html file as a selectable composition.

Also excluded App.tsx from the filesize pre-commit check — the file is
already 652 lines (decomposition tracked in PR #724).

* fix(studio): detect compositions by data-composition-id, not path convention

The previous approach filtered compositions by path convention (index.html
or compositions/ subdirectory). Any .html file outside that convention was
invisible in the Comps sidebar.

The server now scans each .html file for data-composition-id and returns
a compositions[] field in the project API response. The client uses this
server-provided list instead of filtering locally. This means any .html
file that is a real HyperFrames composition shows up regardless of where
it lives in the project tree.

* fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview

Updated the property panel button label from "Ask agent" to "Copy prompt
to AI agent". Updated the modal title to match. Added a collapsible
"Context included in prompt" details section to the modal that shows the
element metadata that will be included when copying.

* fix(studio): wire contextPreview to agent modal

Passes composition path, source file, selector, tag, and text content
to the AskAgentModal so the context preview section is visible.

* fix(core): seek timeline to current time after initial bind

When bindRootTimelineIfAvailable captured a GSAP timeline for the first
time, it paused it but never seeked to state.currentTime. This left
fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0)
even after the user scrubbed past the tween's end. The polling rebind
path already seeked to previousTime — the initial bind was the only path
that skipped it.

* feat(core): add gsap_timeline_not_registered lint rule

Warns when a composition creates gsap.timeline() but never registers it
in window.__timelines. Without registration, the runtime cannot discover
the timeline, and animations will not play during preview or render.

Skips the warning for sub-compositions (template-based) which inherit
the parent's timeline context.

* fix(studio): address hf#1126 review feedback

- Extract buildAgentContextPreview into domEditingAgentPrompt.ts and
  import it in App.tsx, removing the inline computation that pushed
  App.tsx past the 600-line CI gate
- Switch isCompositionFile from sync readFileSync to async readFile with
  Promise.all, and use a regex test instead of string includes
- Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts
  into gsapAnimationConstants.ts (single source of truth)
- Add regression test for the totalTime initial-bind seek fix in
  init.test.ts — verifies the captured timeline receives a totalTime
  call on initial bind

* refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption

Extracted inspector state, studio context construction, and drag overlay
into useStudioContextValue.ts. Deduplicated block handler args via a
shared blockCtx memo. App.tsx drops from 657 to 588 lines.

Removed the App.tsx exemption from lefthook.yml — the file now passes
the 600-line gate without special-casing. Added domEditing.ts barrel to
fallowrc ignoreExports (re-exports not traceable by static analysis).
2026-05-29 22:18:27 -04:00
Miguel Ángel 789d1d4775 fix(studio): cover GSAP editor target-resolution limitations (#1116)
Follow-up to #1115. Makes the Design-panel editor recognise every target
shape real compositions use. The panel stays behind STUDIO_GSAP_PANEL_ENABLED
(default off) — no flag change here.

- Array targets: tl.to([a, b], {...}) resolves to a CSS group selector
  (".a, .b"). The source array is never rewritten — the joined string is for
  display/matching only; edits still touch just the vars object.

- Chained calls: tl.to(a, ...).to(b, ...) — the matcher now walks the member
  chain to its timeline root, so every link is captured (previously only the
  first). Deletion is chain-aware: it splices out the single targeted link and
  re-points the chain instead of dropping the whole statement.

- gsap.utils.toArray("sel") resolves like querySelectorAll, inline or via a
  variable binding.

- Lexical scoping: element-variable resolution is now per-scope (walks the
  enclosing function/program chain) instead of a flat map. Fixes silent
  wrong-resolution when two IIFEs reuse a variable name, and unlocks
  multi-scene files. (Addresses review: flat-binding-scope.)

- forEach/map callback params (items.forEach(el => tl.to(el, …))) and items[i]
  indexing resolve to the collection's selector, so loop-generated tweens are
  editable.

- Panel matching: an element matches a tween when its id/selector is any member
  of a comma-group target, so either element of an array/toArray tween surfaces
  the shared animation.

- Review items: mutation parse failures now console.warn instead of swallowing
  silently; buildTweenStatementCode no longer emits duration on `set`; the
  id-only serialize-side filter is renamed getAnimationsForElementId to
  disambiguate from the panel's id-or-selector matcher; added fromTo round-trip
  and variable-target overlap-lint tests.

Genuinely runtime-only targets (template-literal selectors, unbounded loops)
still skip gracefully — they can't be resolved or matched statically.
2026-05-28 20:57:59 -04:00
Miguel Ángel 4de054e7d4 fix(studio): make GSAP tween editing work on real compositions (#1115)
The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.

Three coordinated fixes make it work end to end:

- Parser read: resolve querySelector / querySelectorAll / getElementById
  variable targets (and inline lookup calls) back to their CSS selector,
  so variable-targeted tweens are recognized.

- Parser write: replace the full re-serialize (preamble + tweens +
  postamble) with in-place recast AST mutation. Edits now touch only the
  targeted tween's vars/position node and reprint, preserving every
  surrounding statement — gsap.set calls, element declarations, the IIFE
  wrapper, comments and formatting. Previously the first edit would
  discard all of that.

- Linter: build overlap/clip windows directly from the parser's
  structured animations instead of a regex walk paired positionally with
  the parsed list. The old pairing skipped variable targets and would
  drift once the parser started returning them. Removes the now-dead
  regex meta helpers.

- studio-api: extractGsapScriptBlock now searches inside <template>
  content (sub-compositions wrap markup + the GSAP script in a template,
  which linkedom's querySelectorAll doesn't descend into), and the
  frontend matches tweens to the selected element by id OR selector
  rather than id only (class-targeted elements have no id).

Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
2026-05-28 20:54:44 -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 4c358e6dcc Merge pull request #1001 from heygen-com/fix/sub-comp-t0-baseline-regen
test(producer): regenerate sub-comp-t0 baseline + add gsap_from_opacity_noop lint rule
2026-05-21 21:42:37 +02:00
Miguel Ángel ee8dacab1e fix(lint): exclude gsap.fromTo() from gsap_from_opacity_noop rule
gsap.fromTo(target, fromVars, toVars) animates to toVars, not to
the current CSS value — so fromTo({opacity:0}, {opacity:1}) with
CSS opacity:0 is a legitimate 0→1 fade-in, not a noop. The rule
was false-positiving on these calls with error severity, which
would block the render pipeline.

Drop the fromTo branch from the trigger guard and add a test case
that proves fromTo does not fire.
2026-05-21 14:10:57 -04:00
ukimsanov 2c9544f6d4 feat(lint): font loading + invalid capture path composition rules
Two new composition lint rules catching failure modes that recurred
across the 11-round website-to-video eval. Both ship with vitest
coverage; total lint suite goes from 148 to 151 tests.

**`fonts.ts` (new) — two warnings**

- `google_fonts_import`: composition loads fonts from
  `fonts.googleapis.com` via `<link>` or `@import url(...)`. External
  font requests fail in sandboxed/offline renders and add latency.
  Fix hint points to root-relative `capture/assets/fonts/...woff2`
  with a local `@font-face` declaration.
- `font_family_without_font_face`: CSS uses a font-family that
  isn't declared with `@font-face` and isn't in the auto-bundled
  font set (Inter, JetBrains Mono, etc.). Text would silently fall
  back to system-ui — the visual fidelity loss the eval kept hitting.
  Fix hint points to the captured woff2 files.

**`composition.ts` invalid_capture_path (new) — one error**

Sub-compositions live in `compositions/` but get served with the
project root as their base URL. `<img src="../capture/...">` works
on disk but 404s in Studio and renders. Errors with a fix hint
saying replace `../capture/` with root-relative `capture/`.
Three vitest cases: `<img>` triggers, multi-occurrence url()s are
counted, root-relative paths stay clean. Registry source files and
installed blocks are exempted.

**Wiring**

`hyperframeLinter.ts` runs the new fonts rules alongside the existing
rule set; the composition rule was added inline so it picks up
automatically.
2026-05-21 11:08:38 -07:00
Miguel ÁngelandClaude Sonnet 4.6 9e51f5cfaa feat(lint): add gsap_from_opacity_noop rule — catch invisible elements
Detects when an element has CSS `opacity: 0` (inline or style block) AND
is targeted by gsap.from({opacity: 0}). Since from() animates FROM the
specified value TO the CSS value, this produces a 0→0 animation where
the element never becomes visible.

Root cause of all-black renders from the product-launch-video skill:
every text element had opacity:0 in CSS + gsap.from({opacity:0}),
making all text permanently invisible despite the timeline "working."

Fires as error (not warning) to block the render pipeline. Includes
actionable fix hint. 4 test cases: inline style, style block, clean
code (no false positive), and gsap.to() exit (no false positive).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:58:44 +00:00
James 7e0a447325 refactor: drop unused exports detected by fallow auto-fix
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).

Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.

Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.

Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
  from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
  fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
  which fallow's static analysis doesn't follow)

Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
2026-05-19 00:51:56 +00:00
Miguel Ángel 9f18425eae fix(lint): detect Three.js loaded via importmap or ES module import
The missing_three_script rule only checked <script src> attributes
for Three.js. Now also detects:
- importmap entries defining "three"
- ES module import/from statements referencing "three"

Closes #931
2026-05-18 11:06:51 -04:00
Miguel Ángel 4b9abaaf6f fix: deterministic stagger, lint importmap/module, cache-bust previews
- Replace from:"random" with from:"center" stagger in us-map,
  world-map, spain-map — random stagger is non-deterministic across
  parallel render workers, causing visual jumps at chunk boundaries.

- Exempt type="importmap" and type="module" inline scripts from the
  invalid_inline_script_syntax lint rule. The rule used new Function()
  to parse, which rejects import statements and JSON import maps.
  Closes #929.

- Cache-bust all map MDX preview video URLs after re-rendering with
  the deterministic stagger fix.
2026-05-18 01:18:24 -04:00
Miguel Ángel 225010800a feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation (#829)
* feat(studio): add pasteboard background to preview viewport

Adds bg-neutral-800 to the preview viewport so the area outside the
canvas is visually distinct from the composition content — consistent
with professional video editors (Premiere, DaVinci, Figma).

* feat(studio): pasteboard background and canvas outline around preview

- NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard
  color surrounding the canvas — distinct from the app chrome (#0a0a0a)
- Player wrapper: drop bg-black so the pasteboard shows around the canvas
  (loading overlays still cover the area with bg-black during load)
- Player: set host background to transparent via inline style (overrides
  :host { background: #000 } in shadow DOM), and inject a style rule into
  the open shadow root so .hfp-container has overflow:visible and the
  canvas iframe gets a thin white ring + soft drop-shadow — making the
  canvas boundary legible against the pasteboard

* feat(studio): disable manual positioning JSON by default, add toggle

Manual edits were always stored in `.hyperframes/studio-manual-edits.json`,
making it hard to share source without the sidecar file and easy to
accidentally reposition elements via drag.

Changes:
- `enabled` field added to `StudioManualEditManifest` (defaults to `false`
  when absent — existing projects are unaffected until they opt in)
- Drag handles, resize, and rotation handles are hidden when disabled
- Layout X/Y/W/H/R fields in the Design panel are read-only when disabled
- "Manual positioning" toggle added at the bottom of the Design panel,
  visible whether or not an element is selected
- Toggle state is persisted to `.hyperframes/studio-manual-edits.json`
  so each project can opt in independently
- `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard
  cap (env off → feature off regardless of project setting)

* feat(studio): enable manual positioning by default (opt-out)

* feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle

* feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle

Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style
persistence baked directly into the HTML source. Drag/resize/rotation values
are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`,
`--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via
`persistDomEditOperations` — no re-apply step needed on load.

Key changes:
- `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the
  property/attribute from the HTML tag instead of setting it
- `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live
  element state into `PatchOperation[]` for HTML source writes; add
  `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers)
- `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target
  resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure
- `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest
  state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs
  seek hooks via `reapplyPositionEditsAfterSeek`
- `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with
  direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh)
- `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled`
  gates to just `canApplyManualOffset` — every draggable element is always draggable
- `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props
- `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and
  `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`,
  `roundRotationAngle`, and snapshot/CSS-property types

* fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test

* fix(studio): strip GSAP-cached translate from transform on path offset apply

* fix(studio): remove Reset edits button from design panel

* feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh

- Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads
  via the refresh-key path instead of directly touching the iframe.
- Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers;
  HTML is now the source of truth so no stale-ref refresh is needed.
- Add `manualEditsRenderScript` helper; export via studio-api and apply
  it in `htmlCompiler` during HTML compilation.

* fix(studio): prevent root composition from being selected; correct overlay drift on resize

- Guard `getDomLayerPatchTarget` against elements with `data-composition-id`
  so the root composition div is never returned as a visual selection target.
- Apply the same guard to the raw `elementFromPoint` fallback in
  `getPreviewTargetFromPointer`, which was the actual escape path.
- Thread `iframeRef` into gesture handler opts; after applying draft
  dimensions during resize, re-read the element BCR via `toOverlayRect`
  and update the overlay box position to compensate for visual drift on
  elements with centered transform-origin (e.g. GSAP scale tweens).

* fix(studio): correct resize overlay for scaled elements; block invisible element selection

- Resize: use BCR from `toOverlayRect` for both position and size after
  applying draft dimensions — GSAP scale makes visual size diverge from
  raw CSS size, BCR is the only accurate source during a gesture.
- Click selection: add `isElementComputedVisible` guard to the
  `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements
  cannot be selected even though the browser hit-test returns them.

* fix(studio): reload preview on external file changes via SSE/HMR

Share the app-level domEditSaveTimestampRef with useManifestPersistence
so the SSE/HMR handler can suppress echoes from all studio saves (code
tab, timeline, DOM edits), then call reloadPreview() for non-motion
external changes that aren't echoes of our own saves.

* fix(studio): suppress post-resize click to keep selection on resized element

* fix(studio): serve registry blocks without index.html in preview

Blocks ship as {id}.html + assets/ with no index.html. The preview
route hard-coded index.html so these projects returned 404 and their
assets (e.g. korea-map.png, map-nyc-paris.png) were never served.

Add resolveProjectMainHtml() that falls back to {id}.html, thread the
resolved compositionPath through transformPreviewHtml and
injectStudioPreviewAugmentations, and update listProjects() in the
vite adapter to surface block directories in the project list.

* fix(render): preserve studio drag/resize/rotation offsets in rendered video

Three issues caused studio-edited positions to be lost during rendering:

1. The seek-reapply script used setInterval to wrap window.__hf.seek, but
   Puppeteer's page.evaluate() calls don't yield the event loop for
   macrotasks — the interval never fired, so reapplyAll() never ran after
   GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek
   property, wrapping it synchronously the instant the bridge assigns it.

2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from <video> to proxy <img>
   during render) included "transform" but not "translate", "rotate", or
   "scale" — the CSS Transforms Level 2 individual properties used by
   studio drag/resize/rotation. The proxy was positioned at offsetLeft/
   offsetTop without the translate offset.

3. getViewportMatrix (HDR compositor) only read cs.transform, missing
   individual transform properties entirely. Added composeIndividualTransforms
   to build the translate × rotate × scale matrix and compose it before
   the legacy transform matrix.

* fix(studio): select elements with pointer-events: none in preview

Compositions often set pointer-events: none on scenes, avatar wrappers,
and decorative layers. elementsFromPoint() skips these elements entirely,
making them unselectable in the Studio. Fix: temporarily inject a
* { pointer-events: auto !important } stylesheet during hit-testing, then
remove it immediately after.

Also adds a pointer_events_none lint rule (info severity, visible with
--verbose) so authors know which selectors may affect Studio selection.
2026-05-15 06:43:00 +02: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
Vance Ingalls edac92b431 docs: add texture mask text catalog entry (#650)
* feat(registry): add texture mask PNGs for texture-mask-text component

* feat(registry): add texture-mask-text CSS snippet

* feat(registry): add registry-item.json for texture-mask-text

* feat(registry): add texture-mask-text demo composition

* feat(registry): register texture-mask-text component in manifest

* style: format texture-mask-text files with oxfmt

* fix: set mask-image directly on texture classes instead of via CSS custom property

url() inside CSS custom properties doesn't resolve correctly with mask-image
in some browsers. Move mask-image declarations to each texture class directly.

* docs: add texture mask text catalog entry

* test: lint texture mask text usage

* fix: harden texture mask text docs and lint

* fix: stabilize texture mask asset paths

* fix: address texture catalog review feedback

* fix: harden texture mask text instructions

* docs: remove texture catalog intro copy

* docs: use canonical texture preview URL

* docs: use cdn texture mask assets

* fix: escape catalog frontmatter safely

* test: stabilize windows render cli test

* test: pin texture catalog instructions
2026-05-07 00:23:36 -07:00
Miguel ÁngelandClaude Opus 4.6 ea90bbe076 fix(runtime): clear play guard after hard seek to prevent audio desync on scrub (#639)
* fix(runtime): clear play guard after hard seek to prevent audio desync on scrub

When scrubbing the timeline during playback, syncRuntimeMedia detects
the offset jump and hard-seeks the media element. But the in-flight
play() guard (playRequested WeakSet) from the previous play() call
prevented the next sync tick from re-issuing play() — leaving the
element paused at the new position for 50-150ms while the GSAP timeline
continued advancing. This caused audible audio desync after every scrub.

Fix: clear playRequested on the element after a hard seek so the very
next sync tick can re-issue play().

Also adds a lint rule (video_audio_double_source) that catches
compositions where an unmuted <video> and a separate <audio> point to
the same source — a pattern that causes double playback at runtime.

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

* fix(runtime): detect failed seeks past MP3 buffer and force full fetch

Root cause: streaming MP3 with preload="metadata" only buffers the first
~15 seconds. Seeking past the buffered range silently fails — currentTime
stays at 0 while the timeline advances, causing permanent audio desync
that only a page refresh fixes.

Three changes:
1. Move preload="auto" enforcement to run for ALL active elements on
   every sync tick (not just during play). This catches elements whose
   preload was overridden after init.ts set it.
2. After a hard seek, check if currentTime actually reached the target.
   If not (drift > 0.5s), call load() once to force the browser to
   fully fetch the media and build a complete seek index.
3. Clear the load-retry guard when the clip leaves its active window
   so re-entry can retry if needed.

Reproduced on hyperframes.dev Hermes launch video: vo.mp3 buffered to
15.96s, seeking to 20s failed silently. bg-music.wav (fully buffered)
was unaffected.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 07:53:23 +02:00
Rames JussoandClaude Opus 4.7 8700826518 fix(lint): rephrase too-large composition warnings to give actionable reasoning
Both `composition_file_too_large` and `timeline_track_too_dense` previously
said "Agents produce better results when large scenes are split into smaller
sub-compositions." The audience-flavored framing ("Agents produce better
results") doesn't tell a reader (agent or human) WHY smaller is better.

Reframe to concrete properties of smaller compositions: easier to read,
iterate on, and diff. The fixHint already covers the inspect/revise/validate
detail; the message now leads with a tight reason.

Per Abhay in #C0ACCNHLG3U:
> "an agent reading 'Agents produce better results' sounds weird. We should
>  give the agent an actual reason why smaller is better for them."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:07:49 +00:00
JamesandClaude Opus 4.7 09da5db436 refactor(core): apply /simplify findings on validation PR
- core.types.ts: export COMPOSITION_VARIABLE_TYPES, a runtime tuple of
  every CompositionVariableType variant guarded by `as const satisfies
  readonly CompositionVariableType[]`. Adding a new variant to the union
  without also adding it to the tuple becomes a compile error rather
  than silent drift in callers that maintain their own list.
- composition.ts (lint rule): the local
  `new Set(["string","number","color","boolean","enum"])` now derives
  from COMPOSITION_VARIABLE_TYPES instead of duplicating the list.
- index.ts: export COMPOSITION_VARIABLE_TYPES alongside the rest of the
  variable type guards.

Reuse + efficiency reviews otherwise clean. The other reuse finding
(loadProjectHtml helper to dedupe readFileSync + ensureDOMParser across
3 callers) is real but reaches files outside this PR's scope; it's a
better fit as a follow-up cleanup once the variable-feature stack lands.

All 48 composition lint tests + 49 core suite tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:06:10 +00:00
JamesandClaude Opus 4.7 c1b6efd9c5 feat(core,cli): variable schema validation + lint rules
Two lint rules + render-time validation built on top of the existing
data-composition-variables schema.

Lint rules (packages/core/src/lint/rules/composition.ts):
- invalid_variable_values_json — host's data-variable-values must parse as
  a JSON object. Today the runtime swallows parse failures silently and
  falls back to declared defaults, masking typos.
- invalid_composition_variables_declaration — root <html>'s
  data-composition-variables must parse as an array of objects with
  `id` (string), `type` (one of string/number/color/boolean/enum), `label`
  (string), and `default`. Per-entry findings report which fields are
  missing or invalid.

Both rules read attributes via a new `readJsonAttr` helper in lint/utils.ts.
The existing `readAttr` regex `["']([^"']+)["']` truncates JSON-in-attribute
values at the first internal quote (e.g. `data-variable-values='{"x":"y"}'`
captures only `{`); `readJsonAttr` alternates double-vs-single-quoted
branches with quote-specific char classes so JSON values round-trip cleanly.
A second helper `findHtmlTag` returns the actual <html> open tag (where
data-composition-variables lives) — distinct from `findRootTag` which
returns the first in-body composition element.

Render-time validation (packages/core/src/runtime/validateVariables.ts):
- validateVariables(values, declarations) returns a structured array of
  issues: undeclared keys, type mismatches, enum-out-of-range values.
  Pure / sync; works in any environment.
- formatVariableValidationIssue(issue) renders a one-line user-facing
  string for CLI output.
- Both exported from @hyperframes/core for studio/tooling reuse.

CLI integration (packages/cli/src/commands/render.ts):
- New --strict-variables flag. Default behavior: print warnings and
  continue. With --strict-variables: print warnings then exit 1.
- New `validateVariablesAgainstProject(indexPath, values)` helper:
  reads the project's index.html, runs extractCompositionMetadata to
  pull the declared schema, validates the CLI's --variables payload
  against it. ensureDOMParser polyfill for Node-side parsing (same
  pattern as compositions.ts).

Tests:
- 11 new validateVariables unit tests covering happy path, undeclared
  keys, type mismatches (string/number/boolean/color/enum), enum range,
  multiple-issue aggregation, and formatter output.
- 11 new composition.test.ts cases for both lint rules: parse errors,
  shape errors, per-entry validation, unknown types, missing fields,
  positive cases.
- 5 new render.test.ts cases for validateVariablesAgainstProject:
  no-declarations, happy path, undeclared, type-mismatch, missing-file.
- All 646 core tests + 213 cli tests still green.

Docs:
- docs/packages/cli.mdx — added --strict-variables flag row.

This is PR 3 of a 4-PR stack. PR 4 ships skill/scaffold distribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:05:33 +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 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 39b3997c78 fix(studio): warn on anonymous timeline clips (#533)
## Problem

Studio timeline editing still had two rough edges that made the latest alpha feel less polished when testing it like a video editor would:

- Timeline clips for anonymous DOM nodes could surface internal fallback identities like `__node__index_*`, which made the timeline look broken instead of authored.
- Elements without a stable `id` could still appear in the timeline and canvas editor, but authors did not get direct lint guidance that those elements are weaker targets for Studio and agent edits.

## What this fixes

- Adds a non-blocking `studio_missing_editable_id` lint warning for timeline-visible elements that do not have an `id`.
- Makes the warning point to the exact element and recommend stable, human-readable ids such as `hero-title` or `scene-1-card`.
- Stops using synthetic node-index ids as runtime clip identity for anonymous DOM nodes.
- Gives anonymous clips readable labels from authored metadata, composition ids, DOM ids, class names, asset filenames, text content, or a simple ordinal fallback.
- Keeps those labels display-only in Studio and uses key-first identity for matching, dragging, resizing, and manifest merge preservation.
- Covers the duplicate-label case where two anonymous clips both render as `Card` but still stay separate timeline entries.

## Root cause

The runtime manifest used synthetic node-index ids as both identity and display fallback for timeline nodes that had no stable author-provided id. Studio then treated those internal values as user-facing clip names.

The first pass improved the display label, but it also risked using that friendly label as internal identity. Two anonymous clips with the same label could then collapse into the same logical timeline element. The fix separates display labels from internal identity and prefers the timeline key whenever Studio needs to match an element.

The linter also had correctness checks for render and runtime behavior, but it did not teach authors when a timeline-visible element would be harder for Studio and agents to patch reliably. That left missing ids as a silent authoring quality issue instead of actionable guidance.

## Verification

### Local checks

- `bun run --cwd packages/core test -- src/lint/rules/core.test.ts src/runtime/timeline.test.ts` -> 41 tests pass
- `bun run --cwd packages/studio test -- src/player/hooks/useTimelinePlayer.test.ts src/player/components/timelineTheme.test.ts` -> 23 tests pass
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/studio build` -> passes with the existing Vite chunk-size warning
- `bunx oxlint $(git diff --name-only origin/main...HEAD)` -> 0 warnings, 0 errors
- `bunx oxfmt --check $(git diff --name-only origin/main...HEAD)`
- `git diff --check origin/main...HEAD`

### Browser verification

- Created a scratch project at `/tmp/hf-pr533-conflict-verify` with two timed anonymous `.card` clips that both label as `Card`.
- Started the local Studio dev server for `pr-533-conflict-verify`.
- Used `agent-browser` to verify the timeline renders two separate `Card` clips instead of collapsing duplicate anonymous labels.
- Used `agent-browser` to open the Studio lint modal and verify it shows human-readable missing-id warnings, not internal node-index labels.
- Used `agent-browser` to click Play after the lint pass and confirm the timeline remains usable.
- Recorded the tested Studio flow with `agent-browser`.

## Notes

- Rebased onto current `main`; conflict resolution preserved both the newer mainline Studio shortcut/lint behavior and this PR's anonymous-clip identity split.
- GitHub Actions are running on the rebased head.
- Scratch verification files are intentionally not committed.
- Local screenshots and recording from this rebase pass are under `.codex-artifacts/pr-533-conflict-rebase-2026-04-29/`.
2026-04-30 07:07:15 +02:00
Miguel Ángel 403c00eeae fix: warn on self-scoped composition selectors (#562) 2026-04-29 18:13:51 +02:00
Miguel Ángel 328bba0384 fix: make registry blocks pass lint errors (#564) 2026-04-29 17:53:26 +02:00
Miguel Ángel e14ff9b485 feat(lint): warn on dense compositions 2026-04-28 15:45:32 -04:00
Miguel Ángel 84d58011f1 ci: address CLI smoke review feedback 2026-04-28 11:02:27 -04:00
Miguel Ángel 9c7983211a fix: validate CLI smoke paths and warn on oversized compositions 2026-04-28 10:57:50 -04:00
Miguel Ángel c32db3ea0a fix: isolate studio sub-composition previews 2026-04-27 15:51:53 -04:00
Mu-Tsun Tsai 9b72a87c17 fix(lint): remove root_composition_missing_data_duration (#490)
* fix(lint): remove root_composition_missing_data_duration

Lint cannot statically observe the runtime's true Infinity-emission
condition: it requires a finite GSAP timeline duration AND a finite
media/sub-comp window AND timeline > floor + 1, none of which are
visible to the static linter. The looping shapes that drive the
condition are already covered by `gsap_infinite_repeat` and
`gsap_repeat_ceil_overshoot` (both from #243), which point at the
real authoring mistake — flagging the missing duration separately
was a noisy proxy for the same signal.

Per #490 review discussion, deprecate the static rule and let those
two GSAP rules carry the pre-render coverage. If perfect precision
on `durationInFrames = Infinity` is needed, that belongs on the
runtime/render path where `shouldEmitNonDeterministicInf` is
actually known.

Add regression tests pinning the removal: a docs-compliant root
without `data-duration` no longer warns, and the canonical
loop-inflated shape now surfaces only via `gsap_infinite_repeat`
instead of two duplicate findings.

* docs(skills): update step-6 build checklist after rule removal

Drops the `root_composition_missing_data_duration` reference now that
the rule is gone. Keeps the authoring recommendation (and explains the
runtime Infinity case) but points authors at the GSAP rules that
actually carry the lint signal: `gsap_infinite_repeat` and
`gsap_repeat_ceil_overshoot`.
2026-04-25 17:52:12 +02:00
Miguel Ángel fbec7bb1c4 fix(core): warn on GSAP boundary exits without hard kill (#474)
Fixes #473.

## Problem

The HyperFrames skill now tells agents to add deterministic `tl.set()` hard-kills after elements fade out at beat / scene boundaries, but the linter did not enforce that rule outside the narrow caption-specific check.

That made the rule easy for sub-agents to ignore: an element could fade to `opacity: 0` exactly as the next clip starts, with no explicit hidden-state set at the boundary. During non-linear seeking or frame capture, that leaves the final visibility state dependent on tween interpolation instead of an authored deterministic kill.

## What this fixes

This PR adds a generalized GSAP lint warning for scene-boundary exits:

- detects GSAP `to` / `fromTo` exit tweens that end at or near a clip `data-start` boundary
- treats `opacity: 0`, `autoAlpha: 0`, `visibility: "hidden"`, and `display: "none"` as hidden exit states
- requires a matching same-selector `tl.set(...)` hidden state at the same boundary
- scopes clip-boundary matching to the timeline's registered composition so sub-composition exits do not match unrelated root boundaries
- reports `gsap_exit_missing_hard_kill` with the selector, boundary time, source snippet, and a fix hint that preserves the authored hidden property when possible
- keeps valid compositions quiet when the boundary hard-kill already exists

## Why

Clip boundaries are the exact points where rendered frames are most sensitive to stale DOM state. A fade-out tween describes a transition, but it does not give the linter or the authoring model an explicit deterministic state to land on when seeking around the boundary.

The existing caption rule proved the class of bug was worth catching, but it only applied to caption-loop patterns. The issue in #473 is broader: any element inside a timed composition can exit at a scene boundary and need the same deterministic cleanup.

## Root cause

The GSAP lint rule parser already calculated tween windows and clip metadata existed in the lint context, but no rule connected those two facts:

- clip `data-start` values were not used as scene-boundary checkpoints for GSAP exits
- parsed GSAP windows tracked property names, but not enough property values to tell whether a tween ended in a hidden state
- hard-kill detection only existed as a caption-specific regex, so normal scene elements were missed

This PR extends the existing GSAP window metadata with parsed property values, then checks hidden-state exits against same-composition clip start boundaries and same-selector `tl.set` calls.

## Verification

### Local checks

- `bun run --filter @hyperframes/core test src/lint/rules/gsap.test.ts`
- `bunx oxlint packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts`
- `bunx oxfmt --check packages/core/src/lint/rules/gsap.ts packages/core/src/lint/rules/gsap.test.ts`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/core test`
- `bun run --filter @hyperframes/core build`

### CLI verification

Verified against local fixtures where `#headline` exits at the next clip boundary without a hard kill:

- opacity fixture reports `gsap_exit_missing_hard_kill` for `#headline` at `3.00s`
- autoAlpha fixture reports the same warning and suggests `tl.set("#headline", { autoAlpha: 0 }, 3.00)`
- sub-composition regression test confirms a `sub` timeline exit no longer matches an unrelated root composition boundary

### Browser verification

Verified the Studio lint flow with `agent-browser` against the autoAlpha fixture:

- opened Studio at `http://127.0.0.1:43174/#project/issue-473-autoalpha`
- clicked the real `Lint` button
- confirmed the lint modal shows the new warning and the property-preserving `{ autoAlpha: 0 }` fix hint
- saved local proof artifacts under `qa-artifacts/issue-473/`

## Notes

- the `tmp/issue-473-*` fixtures and `qa-artifacts/issue-473` browser proof are local-only and are not part of this PR
- this intentionally stays heuristic-based: it warns near clip start boundaries instead of trying to build a full GSAP execution model
- expression-valued GSAP props and deeper regex-parser limitations remain outside this PR's scope; those are parser-hardening work, not required for the bug in #473
2026-04-24 18:07:02 +02:00
Miguel Ángel 95bf333895 fix: stabilize apple master timeline and playback (#419)
## Summary
- preserve authored non-root composition timing before runtime sanitization so Studio can build the correct master timeline for chained subcompositions
- prefer the fresh runtime source in Studio dev so local preview does not serve a stale `/api/runtime.js`
- restrict preserved authored timing inference to the Studio timeline payload instead of the general runtime resolver

## What this fixes
This PR fixes the Apple presentation class of failures where the root `index.html` / `Master` view looked correct at first and then collapsed into an incorrect short timeline.

Before this change:
- the master transport could report a short duration like `0:12` instead of the real deck length (`2:21` in the Apple project)
- composition clips bunched near the start instead of laying out sequentially across the deck
- seeking into later parts of the deck would land in the wrong place or show the wrong active composition
- local Studio debugging could be misleading because dev sometimes served a stale runtime bundle

After this change:
- the master transport reflects the authored composition-chain duration
- master clips resolve linearly across the whole deck
- late seeks land on the correct slide window
- Studio dev uses the current runtime implementation, so local preview matches the branch you are testing

## Root cause
There were two related issues:

1. Studio/master timeline inference lost authored composition timing
- missing timing attrs were treated like `0` instead of `null`
- non-root composition `data-duration` / `data-end` were stripped before Studio timing resolution could use them
- root duration inference trusted an incomplete live timeline window instead of the authored composition chain

2. Preserved authored timing leaked into the general runtime resolver
- preserving authored timing was correct for Studio timeline payload generation
- but using those preserved attrs for normal runtime playback/render resolution caused visual regressions in producer CI
- the follow-up fix keeps authored timing available only for Studio payload collection while normal runtime playback continues to resolve from the real live timeline/media state

## Why the later regression fix was needed
The initial runtime change fixed the Apple master timeline, but it also widened timing inference in the core runtime too far. That caused Dockerized producer regressions because rendered visibility started respecting preserved authored timing where it should have relied on the live resolved runtime state.

The latest commit fixes that by splitting the behavior:
- Studio timeline payload: authored timing allowed
- general runtime resolver: authored timing ignored by default

That preserves the Apple master timeline fix without changing producer render semantics.

## Verification
### Local checks
- `bunx oxlint packages/core/src/runtime/init.ts packages/core/src/runtime/startResolver.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/startResolver.test.ts packages/core/src/runtime/timeline.test.ts packages/studio/vite.config.ts packages/cli/src/server/studioServer.ts`
- `bunx oxfmt --check packages/core/src/runtime/init.ts packages/core/src/runtime/startResolver.ts packages/core/src/runtime/timeline.ts packages/core/src/runtime/startResolver.test.ts packages/core/src/runtime/timeline.test.ts packages/studio/vite.config.ts packages/cli/src/server/studioServer.ts`
- `bun run --filter @hyperframes/core typecheck`
- `bun run --filter @hyperframes/studio typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `cd packages/core && bun run test src/runtime/startResolver.test.ts src/runtime/timeline.test.ts`
- `bun test packages/cli/src/server/studioServer.test.ts --timeout 20000`

### Browser proof
Tested in Studio with `agent-browser` against the Apple presentation project.
- root/master transport now shows `0:00 / 2:21`
- master clip manifest resolves sequentially (`slide-1 -> slide-2 -> slide-3 ...`)
- seeking to `120s` lands on a late slide instead of a collapsed early timeline state
- after refreshing onto the fresh runtime source, the visible later-slide media advanced correctly in local Studio playback

### CI-equivalent regression proof on devbox
The previously failing producer regressions were rerun on devbox using the same Dockerized path GitHub Actions uses:
- `docker build -f Dockerfile.test -t hyperframes-producer:test .`
- `docker run ... hyperframes-producer:test style-1-prod style-5-prod style-9-prod style-12-prod --sequential`

Those previously failing suites all passed after the runtime split fix:
- `style-1-prod`
- `style-5-prod`
- `style-9-prod`
- `style-12-prod`

## Notes
- the Apple project volume tweak stayed local-only for testing and is not part of this PR
- this PR fixes the master/root timeline bug and the runtime regression it introduced; it does not add general subtimeline authoring support
2026-04-23 04:05:11 +02:00
Miguel Ángel d740f5ce42 fix: nested GSAP sub-composition lint and render handling (#405)
## Summary

- allow nested sub-composition files to inherit GSAP from their host without tripping `missing_gsap_script`
- keep nested render seeks stable for sub-compositions without regressing producer baselines
- stop producer render-hint detection from treating the compiler's own nested mount retry wrapper as user-authored `requestAnimationFrame()` usage

## Root Cause

- the core linter treated template-based nested compositions like standalone root compositions, so it incorrectly required a local GSAP loader even when the host composition already provided GSAP
- producer `detectRenderModeHints()` runs before CDN scripts are inlined, so nested GSAP exports were never failing because of the GSAP payload itself
- the nested-only false positive came from the compiler-generated mount bootstrap that waits for the inlined sub-composition root with `requestAnimationFrame()` before running the hoisted inline script
- preview and export seek paths also needed to stay split so the nested timeline re-arm behavior that stabilizes scrubbing does not collapse render baselines

## What Changed

- lint: keep the nested GSAP false-positive fix and regression coverage for template sub-compositions
- runtime: keep the render-seek behavior that preserves nested child offsets during export without changing preview scrubbing behavior
- producer: mark compiler-owned mount bootstrap blocks and strip only those blocks before scanning inline scripts for raw `requestAnimationFrame()`
- producer tests now cover both cases: compiler-generated wrappers are ignored, but real user-authored nested `requestAnimationFrame()` still opts into screenshot mode

## Validation

- `bun test packages/core/src/lint/rules/gsap.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxfmt packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxlint packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bun run --filter @hyperframes/producer test --sequential chat style-11-prod`
  - `style-11-prod` passed locally
  - `chat` still shows local-only visual drift on this macOS/ARM workstation, but the render metadata now reports `renderModeHints.recommendScreenshot=false`, which is the concrete acceptance condition for `#402`
- Docker CI-image repro is blocked locally by OrbStack x86/arm64 loader mismatch, so final regression confirmation is deferred to GitHub Actions

Closes #392
Closes #402
2026-04-22 16:10:38 +02:00
Vance Ingalls 00af29c169 fix(cli): forward --hdr through Docker render + HDR docs (#346)
## Summary

This PR ended up covering the full HDR Docker/docs follow-through plus the producer/engine work needed to make HDR still images render and regress correctly in CI.

The branch now does four things:

- forwards `--hdr` through the Docker render path in the CLI
- adds and expands HDR documentation across the docs site
- adds first-class HDR still-image support to the engine/producer pipeline
- adds targeted HDR regression coverage, including a CI-safe fallback for PNG HDR metadata detection when `ffprobe` does not expose PNG color tags

## What changed

### CLI and docs

- `hyperframes render --docker --hdr` now preserves `--hdr` when invoking the in-container CLI
- added a dedicated HDR guide and linked it from CLI, producer, engine, rendering, and common-mistakes docs
- documented HDR constraints and verification flow: HDR source requirements, MP4/H.265 Main10 output, PQ/HLG handling, Docker usage, and common SDR fallback causes

### Engine and producer HDR image support

- added `ImageElement` support to the engine composition model and parsing path
- threaded image elements through producer compilation and orchestration
- probed image sources for HDR color spaces so image-only compositions can trigger HDR output without requiring an HDR video source
- included HDR image start times in stacking queries so the layered compositor can place images correctly in z-order
- integrated HDR image compositing into the layered HDR render loop alongside native HDR video layers and SDR DOM overlays
- forced screenshot mode for HDR layered compositing where required to keep DOM/HDR layer composition deterministic
- skipped readiness waiting for natively extracted HDR videos in the engine path where it was unnecessary and could block layered HDR flows

### HDR metadata robustness

- added a fallback in `extractVideoMetadata()` to read PNG `cICP` metadata directly when `ffprobe` omits color-space fields for PNGs
- this specifically fixes CI/Docker detection for the `hdr-image-only` fixture, where the render was falling back to SDR because the PNG was not being recognized as BT.2020 PQ

### Regression coverage and fixture cleanup

- added `hdr-image-only`, a regression fixture that validates HDR still-image rendering end to end
- added `hdr-pq`, a focused HDR PQ regression fixture for the video path
- updated regression CI to run an `hdr` shard with `--sequential hdr-pq hdr-image-only`
- removed the older larger `hdr-regression/*` fixture set in favor of the smaller targeted regressions used by CI
- added the necessary fixture generation/readme material and checked-in golden outputs for the new HDR tests

## Why

The original PR description only covered the CLI flag forwarding and docs work. Since then, the branch also picked up the missing runtime support needed for HDR still images and the regression coverage to keep that path from breaking.

The practical issue this closes is:

- local host runs could pass while CI failed `hdr-image-only`
- the failure was a full-frame visual mismatch caused by SDR fallback, not unstable rendering
- root cause was PNG HDR metadata not being surfaced by `ffprobe` in the CI Docker environment
- parsing the PNG `cICP` chunk directly makes HDR detection deterministic across environments

## Test plan

### Local targeted checks

```bash
bunx oxlint packages/engine/src/utils/ffprobe.ts packages/engine/src/utils/ffprobe.test.ts
bunx oxfmt packages/engine/src/utils/ffprobe.ts packages/engine/src/utils/ffprobe.test.ts
bun --cwd packages/engine test src/utils/ffprobe.test.ts src/utils/hdr.test.ts
```

### Producer regression runs on host

```bash
bun run --cwd packages/core build:hyperframes-runtime:modular
bun --cwd packages/producer test -- --sequential --exclude-tags slow,render-compat,hdr
bun --cwd packages/producer test -- --sequential hdr-pq hdr-image-only
```

Observed result:
- `fast` shard: 7 passed, 0 failed
- `hdr` shard: 2 passed, 0 failed

### CI-equivalent Docker verification

```bash
docker build -f Dockerfile.test -t hyperframes-producer:test .

docker run --rm \
  --security-opt seccomp=unconfined \
  --shm-size=4g \
  -v "$PWD/packages/producer/tests:/app/packages/producer/tests" \
  hyperframes-producer:test \
  --sequential hdr-pq hdr-image-only
```

Observed result:
- `hdr-image-only`: passed
- `hdr-pq`: passed
- shard summary: 2 passed, 0 failed

### Specific regression fixed

Before the PNG `cICP` fallback, the Docker/CI run failed `hdr-image-only` with:

- missing `"[Render] HDR source detected — output: PQ ..."` log line
- full-frame visual mismatch across all 100 checkpoints
- PSNR ~17 on every frame, indicating a consistent SDR-vs-HDR pipeline mismatch

After the fallback, the same Docker path recognizes the PNG as HDR and the shard passes.
2026-04-20 12:16:24 -07:00
Vance IngallsandClaude Opus 4.6 99a903be2f feat(hdr): layered HDR compositing, shader transitions, and HDR image support (#268)
* feat(hdr): shader transitions, --hdr flag, and SDR rendering fixes

- 15 GLSL→TypeScript shader transitions on rgb48le buffers
- Dual-scene compositing with scene detection via window.__hf.transitions
- --hdr flag gates ffprobe probing (zero overhead on SDR compositions)
- Cross-transfer conversion (PQ↔HLG) via OOTF-corrected composite LUT
- Buffer.from() copy in writeFrame() fixes streaming encoder race condition
- SDR rendering fixes (three stacked bugs)
- Object.assign fix for window.__hf preservation

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

* fix: tighten shader smoke thresholds + assert .scene contract

- Tighten the all-transitions smoke test thresholds: at progress=0 we now
  require the center pixel R-channel > 35000 (was > 25000) and at
  progress=1 < 15000 (was < 25000). The old midpoint of 25000 sat exactly
  halfway between the test from-pixel (40000) and to-pixel (10000), so a
  half-blended transition would silently pass.
- Add a runtime assertion in HyperShader.init() that every scene id
  resolves to a DOM element with the .scene class. Without this, missing
  ids silently no-op when textures + querySelectorAll(.scene) run later.

Addresses deferred review feedback from PR #268.

* fix(hdr): restore VIRTUAL_TIME_SHIM and applyRenderModeHints in renderOrchestrator

Commit c6b4619c ("feat(hdr): shader transitions, --hdr flag, and SDR
rendering fixes") accidentally removed two pieces of the deterministic
rendering pipeline:

1. The `VIRTUAL_TIME_SHIM` injected via `createFileServer.preHeadScripts`,
   which freezes `Date.now()` and `requestAnimationFrame` so RAF-driven
   animations advance only when `window.__hf.seek(t)` is called.
2. The `applyRenderModeHints` function and its post-`compileForRender`
   call site, which auto-forces screenshot capture mode for compositions
   the compiler flagged as needing it (RAF, iframes, etc.).

Without (1), RAF animations advanced by wall-clock between the main-loop
seek and the per-DOM-layer seek inside `compositeToBuffer`, producing the
sawtooth PSNR pattern on `raf-ball-render-compat` (high PSNR at integer
seconds, ~24 dB everywhere else). Without (2), `iframe-render-compat`
lost its automatic fallback to screenshot mode and the child-document
motion stopped being captured.

Both helpers are still produced by `htmlCompiler` and exercised by
`renderOrchestrator.test.ts` — the orchestrator just stopped calling
them. Restored:

- Re-import `VIRTUAL_TIME_SHIM` from `./fileServer.js`
- Pass `preHeadScripts: [VIRTUAL_TIME_SHIM]` to both `createFileServer`
  call sites (probe + main render)
- Re-add `applyRenderModeHints` (matching the test expectations) and
  call it immediately after `compileForRender`
- Persist `renderModeHints` in `summary.json` and the
  "Compiled composition metadata" log line

Fixes the `iframe-render-compat` and `raf-ball-render-compat` regression
failures on `feat/hdr-layered-compositing`.

Made-with: Cursor

* test(engine): expand sampleRgb48le coverage + audit Uint16Array alignment

Adds:
- 8 new sampleRgb48le bilinear-interpolation tests covering boundary
  pixels, sub-pixel weights, edge clamping, and odd-byte-offset Buffers.
- uint16-alignment-audit.test.ts documenting the alignment requirement
  for Uint16Array views over Buffer slices vs. readUInt16LE/writeUInt16LE.

Background: ~105 hot-loop sites in shader transitions still use
readUInt16LE/writeUInt16LE. Switching to Uint16Array views would cut
overhead but requires guaranteed even byteOffsets — these tests document
the contract before any future refactor lands.

* fix(engine,producer): mask DOM layers during HDR layered compositing

The HDR layered compositor blits z-ordered layers over a shared canvas. DOM
layers used a full-page screenshot from `captureAlphaPng`, which captures
*every* painted pixel on the page — root background, sibling-scene content,
overlay UI elements that aren't part of the current layer. Those opaque
pixels were then blitted over the canvas, overwriting any HDR content
composited beneath in earlier layers.

The previous workaround toggled `display:none` on hide ids via
`hideVideoElements`/`showVideoElements`. That correctly hid native videos
but did nothing about the root composition's background or about overlay
elements that the layer grouping considered part of a different layer.

This commit replaces the workaround with a precise CSS mask installed
before each DOM screenshot:

1. `applyDomLayerMask` injects a stylesheet that hides every `body *` and
   re-shows the layer's elements (and their descendants and their injected
   `__render_frame_*` siblings) with `visibility: visible !important`. CSS
   visibility is *not* multiplicative through descendants — a child with
   `visibility: visible` overrides an ancestor's `visibility: hidden`, so
   deeply nested layer content still paints even though every intermediate
   ancestor is hidden by the mass-hide rule.
2. Non-layer data-start ids are inline-hidden with
   `visibility: hidden !important`. Inline `!important` beats stylesheet
   `!important`, so this overrides the show rule for elements that fall
   under a show selector but should NOT paint — most importantly HDR
   videos and other-layer SDR videos that live as descendants of `#root`.
3. `removeDomLayerMask` tears the stylesheet down and clears the inline
   `visibility`/`opacity` properties so subsequent video frame injection
   gets a clean slate.

Crucially the mask only sets `visibility`, never `opacity`. CSS opacity
*is* multiplicative — `opacity: 0` on `#root` would zero out every
descendant including layer videos, even with `visibility: visible`. We
also extend `initTransparentBackground` to force the composition root
(`[data-composition-id]`) transparent in addition to `html`/`body`,
because compositions almost always set `#root { background: ... }` and
that background paints across the whole viewport otherwise.

Both compositing paths use the new helpers:
- The per-layer DOM branch (`compositeToBuffer`) for normal frames.
- The transition path (single DOM screenshot per scene) so transition
  frames also get a clean per-scene capture.

Adds extensive `KEEP_TEMP=1`-gated diagnostics to `compositeToBuffer`:
per-layer pixel-add accounting, dumps of every captured DOM PNG, and a
periodic raw `rgb48le` snapshot of the composite buffer. These were
essential to diagnosing the root-overwrite bug and stay zero-cost in
normal renders. Also stops the workDir / per-video frame-dir cleanup
when `KEEP_TEMP=1` so the dumps survive past frame N.

Made-with: Cursor

* fix(engine): preserve GSAP-applied opacity across DOM-layer captures

SDR clips inside an HDR composition were rendering at full opacity even
when the user had animated their wrapper opacity (e.g. fade-in or
yoyo). Two bugs in the per-layer screenshot path conspired to drop the
GSAP-applied opacity on the floor:

1. removeDomLayerMask was unconditionally calling
   `el.style.removeProperty("opacity")` on every wrapper after each
   layer capture. applyDomLayerMask only ever sets `visibility`, so the
   only inline opacity present is the value GSAP wrote. Stripping it
   between layer captures means that on the next capture (at the same
   timestamp), GSAP's `totalTime(t, false)` no-ops because the timeline
   is already at that time — the opacity is never restored, and the
   wrapper renders fully opaque.

2. injectVideoFramesBatch was reading the source <video>'s computed
   opacity via `parseFloat(computedStyle.opacity) || 1` and copying it
   onto the injected <img>. Because syncVideoFrameVisibility forces the
   <video> to `opacity: 0 !important` to hide it during capture, the
   computed value is always 0, which `|| 1` then silently flips to
   full opacity. The <img> is a sibling of the <video> inside the same
   wrapper, so it should inherit opacity from the wrapper directly
   instead of having a value hard-set on it.

Fix both: drop the opacity removal in removeDomLayerMask, skip opacity
when copying visual properties from <video> to <img>, and explicitly
clear any stale inline opacity on the <img> so it inherits from the
wrapper that GSAP is animating.

Made-with: Cursor

* fix(producer): correct hdrLayerStartTimes typo to hdrVideoStartTimes

The diagnostic logging block in executeRenderJob's HDR layer composite
path referenced an undeclared `hdrLayerStartTimes` map. The correct
variable, declared and populated earlier in the same function, is
`hdrVideoStartTimes`. The typo was introduced alongside the DOM-layer
masking work and broke the producer build/typecheck on CI.

Made-with: Cursor

* fix(engine): restore video opacity copy to injected frame img

Commit 188ebcca removed the opacity copy from `injectVideoFramesBatch` on
the assumption that the <img> sibling would inherit GSAP's opacity from
a shared wrapper. That breaks any composition where GSAP animates opacity
directly on the <video> element itself: the <img> has no animated
ancestor and renders at full opacity throughout any fade, even when the
user's intent is partial or zero opacity.

The CI `style-7-prod` and `style-8-prod` regressions caught this:
the <video id="aroll"> fade-in from 3.0-3.5s rendered as a hard cut
because the <img> inherited opacity 1 regardless of GSAP's tween.

Restore the old explicit copy from `computedStyle.opacity` to the
<img>'s inline opacity, with the `|| 1` fallback intentionally
preserved. The fallback is load-bearing: GSAP's seek does not re-apply
tweens that have already completed, so post-fade frames read opacity 0
from the stale `opacity: 0 !important` we apply to hide the native
<video>. The `|| 1` recovers the tween's end-state opacity 1 for
those frames, matching the final on-screen intent and the existing
baseline renders.

Handles both DOM shapes:
- GSAP on wrapper: video's own computed opacity is 1, img set to 1,
  wrapper's opacity applies via stacking as before.
- GSAP on <video>: video's computed opacity is the tween value, copied
  to img directly since they are siblings.

Fixes:
- style-7-prod: 0 failed frames (was 2 @ t=3.17, 3.33)
- style-8-prod: 0 failed frames (was 2 @ t=3.05, 3.24)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:00:58 -07:00
Ular KimsanovandClaude Opus 4.6 87f4c77e2f feat: website capture pipeline + 7-step video production skill (#284)
* feat(cli): add website capture with AI-powered DESIGN.md generation

Adds `hyperframes capture <url>` command that extracts a complete design
system from any website, producing AI-agent-ready output:

- Full-page screenshot (lazy-load aware, nav at top)
- AI-generated DESIGN.md via Claude API (colors, typography, elevation,
  components, do's/don'ts) with programmatic asset catalog (136+ assets
  with HTML context annotations like img[src], css url(), link[rel=preload])
- CSS-purged compositions (87% size reduction via PurgeCSS)
- HTML-prettified compositions (one-tag-per-line for AI readability)
- CLAUDE.md + .cursorrules auto-generated for AI agent instructions
- Asset deduplication (srcset variants) and tracking pixel filtering

* feat(cli): add gemini 3.1 pro, playwright screenshots, replica refinement

- switch to gemini 3.1 pro (gemini-3.1-pro-preview) with claude fallback
- playwright for full-page screenshots (fixes puppeteer gradient/fixed bugs)
- replica refinement loop: generate, screenshot, compare, fix
- extract inline svgs (50 max, 10kb each) to assets/svgs/
- extract visible text in dom order for content accuracy
- detect js libraries (gsap, three.js, scrolltrigger) via globals
- improved asset catalog grouping and naming
- reverse-engineered aura system prompt documentation
- comprehensive session handoff doc

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

* docs: update session handoff with slack research findings

- key finding: team already wants DESIGN.md integration (James, Bin, Vance)
- skills quality matters enormously - must invoke /hyperframes-compose
- eval infrastructure exists (Abhay's dashboards, Teodora's 78-criteria guide)
- templates at templates/ need study before finalizing skill
- session handoff updated with critical next steps

* refactor(cli): simplify capture pipeline, remove replica generator

* feat(capture): add Lottie detection and WebGL shader extraction

Captures Lottie animations via network interception and WebGL shader
source via gl.shaderSource hooking during site crawl. Updates
website-to-hyperframes skill with asset planning guidance, Lottie/shader
reading instructions, and stronger creative direction for scene planning.

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

* refactor(capture): clean pipeline + shader-first creative workflow

Capture pipeline:
- Remove dead deps (puppeteer-extra, stealth plugin, duplicate devDeps)
- Remove duplicate generateAgentPrompt() call (first lied about DESIGN.md)
- Remove dead canvas-to-image code in htmlExtractor (post canvas removal)
- Parallelize image downloads (batches of 5 via Promise.allSettled)
- Fix pre-existing TS error (match[1] guard in font downloader)
- Default capture output to captures/<hostname>

Skill creative overhaul:
- Add shader transition selection to creative director step (Step 4)
- Add shader wiring instructions to engineer step (Step 5)
- Replace 4-line energy modifiers with visual vocabulary table
- Strip rigid scene-by-scene templates from video-recipes.md
- Strip example fill data from scene plan tables
- Add "read transition refs before planning" instruction
- Add creative ambition language ("how the hell did they make this")

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

* docs: add skill architecture redesign spec

Comprehensive redesign of website-to-hyperframes skill and capture
pipeline based on code review findings and Claude Code architecture
research. Key changes: remove AI auto-generation, restructure skill
into phases, embed shader boilerplate in scaffold, fix color format.

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

* docs: add implementation plan for skill architecture redesign

13-task plan covering: capture pipeline cleanup (remove AI generation,
fix colors to HEX, add asset descriptions, shader-ready scaffold),
skill restructuring (4 phases with artifact gates), and compose skill
Visual Identity Gate upgrade.

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

* refactor(capture): remove AI auto-generation and SDK dependencies

* fix(capture): convert extracted colors to HEX format

* refactor(capture): remove AI key path, add asset descriptions generator

* refactor(capture): update agent prompt, remove hasDesignMd, add asset descriptions

* feat(capture): pre-wire shader transitions in index.html scaffold

* chore: remove duplicate visual-styles.md (canonical is in hyperframes/)

* refactor(skill): rewrite website-to-hyperframes as phase-based orchestrator

* feat(skill): add Phase 1 understand reference

* feat(skill): add Phase 2 design reference with full DESIGN.md schema

* feat(skill): add Phase 3 creative direction reference

* feat(skill): add Phase 4 build reference with inline shader example

* feat(skill): upgrade Visual Identity Gate to produce full DESIGN.md

* docs: update CLAUDE.md skill references for phase-based workflow

* fix: address code review findings

- Remove orphaned `false` argument in generateAgentPrompt call (critical:
  was shifting hasLottie, hasShaders, catalogedAssets parameters)
- Add HSL color handling in rgbToHex via temp element resolution
- Remove build artifact commit section from phase-4-build.md
- Fix __GSAP_TIMELINE reference to __timelines

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

* fix(capture): regex double-escape + simplify scaffold + fix asset descriptions

- Double-escape regex in tokenExtractor template literal (\s→\\s, \d→\\d, \(→\\()
  so browser receives valid regex patterns via page.evaluate()
- Simplify index.html scaffold: scene slots + audio + timeline + comment pointing
  to shader-setup.md reference (no broken inline shader boilerplate)
- Fix asset descriptions: use CatalogedAsset.contexts/notes instead of
  nonexistent htmlContext field

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

* fix: code review — 16 bugs, 7-step skill rewrite, cleanup

Code fixes:
- snapshot.ts: path traversal guard, browser leak (try/finally), div-by-zero
  for --frames 1, port bind error handling, rAF-based render settle
- index.ts: remove invalid thinkingConfig for gemini-2.5-flash, fix Gemini
  batch/rate-limit comments, fix video preview viewport y-coordinate
- tokenExtractor.ts: remove dead seen[si] dedup code
- gsap.ts: index ALL classes for inline-style transform conflict detection

Skill architecture rewrite (4-phase → 7-step):
- Replace phase-1 through phase-4 with step-1 through step-7
- Add techniques.md (10 visual techniques with code patterns)
- Fix /hyperframes-compose → /hyperframes (skill doesn't exist)
- Fix captures/arc-browser reference → shader-setup.md (file doesn't exist)
- Fix step-7 hardcoded captures/stripe path
- Document Gemini API free/paid rate limits in step-1

Cleanup:
- CLAUDE.md: restore from Stripe-capture overwrite, update 4-phase → 7-step
- .gitignore: add PR #267 skills (hyperframes-animation-map, hyperframes-contrast)
- Delete old phase-*.md, animation-recreation.md, tts-integration.md

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

* chore: remove dev artifacts, research docs, wrong lockfiles

Remove files that shouldn't ship in this PR:
- docs/research/ (aura analysis, prompt catalogs)
- docs/session-*.md, docs/SESSION-HANDOFF.md (dev notes)
- docs/superpowers/ planning and spec docs
- pnpm-lock.yaml at root and cli (repo uses bun, not pnpm)

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

* fix(CLAUDE.md): align with main — slim format, add website-to-hyperframes mention

Main PR #283 removed the full skills table from CLAUDE.md and moved it
to AGENTS.md. Align with that decision: use main's slim dev-focused
format, fix pnpm→bun references, add one-line /website-to-hyperframes
pointer.

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

* fix(cli): add capture command to help groups

The capture command was registered in cli.ts but missing from
the help groups, so it wouldn't appear in `hyperframes --help`.

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

* style: format skill reference files (oxfmt)

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

* fix: regenerate bun.lock after rebase

The lockfile was stale after rebasing onto main — bun install
--frozen-lockfile failed in CI because new dependencies (google/genai,
patchright, purgecss) weren't reflected in the lockfile.

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

* fix: address PR review comments + improve capture quality

Review fixes (16 comments from jrusso1020 + vanceingalls):
- screenshotCapture: remove Playwright dep, use Puppeteer for all screenshots
- screenshotCapture: dynamic screenshot count based on page height (30% overlap)
- snapshot.ts: fix duration() function-vs-property bug, cross-platform path guard
- htmlExtractor: fix code injection via parameterized evaluate
- index.ts: video preview re-measures position after scroll, .env file loading
- capture.ts: BLOCKED.md on timeout failures
- gsap.ts: 5 inline-style lint tests added (all pass)
- Remove Playwright, patchright deps; @google/genai to optionalDependencies
- Gitignore: generic patterns instead of 20 hardcoded directories
- Remove asset-sourcing.md, video-recipes.md (unused, duplicated guidance)

Capture quality improvements (tested on 10+ websites):
- Color extraction: canvas-based oklch/lab resolver, pixel sampling via
  elementFromPoint, broad sweep for accent colors, gradient/shadow extraction
- Section detection: broadened selectors for div-based layouts, height cap
  to skip page-level wrappers, parent bg walkup for dark sites
- Font downloads: cap 6 per family / 30 total (Cal.com: 306→30)
- CTA detection: text pattern matching + nav context filtering
- Heading text: innerText with whitespace normalization
- Gemini captioning: maxOutputTokens 100→300, .env auto-loading
- .env.example updated with GEMINI_API_KEY docs
- TTS ranking: Kokoro first with Python 3.10+ note

* fix: address PR review comments + improve capture quality

Review round 2 fixes (jrusso1020 + vanceingalls):
- verify/index.ts: add path traversal guard (relative + isAbsolute)
- verify/index.ts: fix sections[i] undefined typecheck error (CI green)
- index.ts: escape Lottie JSON with \u003c to prevent </script> breakout
- step-4-storyboard: fix technique count contradiction (2-3 per beat, not
  across whole video)
- step-6-build: perspective tilt uses gsap.set() instead of CSS transform
  (avoids GSAP overwrite conflict)
- step-1-capture: reorder — command first, Gemini note after (zero-config
  is the default path, API key is optional enhancement)
- step-7-validate: add tsx fallback for snapshot command
- step-3-script: vary hook patterns, don't default to number every time
- assetDownloader: exempt SVGs from 10KB minimum filter (company logos
  like Hubspot/Intel/DHL are 2-6KB; HeyGen capture: 13→75 assets)

Note: adm-zip was NOT removed (reviewer #3) — it's still in
packages/cli/package.json:30. The root package.json had patchright
and purgecss removed, not adm-zip.

Note: ANTHROPIC_API_KEY not restored in .env.example — grep confirms
zero references in the entire codebase. The @anthropic-ai/sdk dependency
was removed earlier in this branch.

* refactor(capture): split index.ts (1175 to 566 lines) into modules

Mechanical extraction, zero logic changes.

New files:
- mediaCapture.ts (345 lines): Lottie preview, video manifest/screenshots
- contentExtractor.ts (314 lines): library detection, text, Gemini, asset descriptions
- scaffolding.ts (135 lines): .env loading, project scaffold generation

Also fixes false-positive BLOCKED.md with structural Cloudflare detection.
Tested on 20 websites, pre/post output identical.

* chore(capture): remove --split flow (splitter, verify, cssPurger, purgecss)

The --split feature auto-generates compositions from captured HTML — a
different approach from the /website-to-hyperframes skill workflow where
agents build compositions from scratch using the storyboard.

No skill file, no step reference, and no test session ever used --split.
Removes 923 lines of unused code + purgecss dependency.

Backed up to ~/Desktop/capture-split-backup/ for reference.

* fix(security): add ssrf protection, lottie injection fix, oom guard

- assetDownloader: add isPrivateUrl() guard blocking private IP ranges
  (127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x), cloud metadata
  endpoints, localhost, and non-HTTP schemes
- mediaCapture: fix Lottie JSON injection by loading shell HTML first
  then passing animation data via parameterized page.evaluate()
- index.ts: check Content-Length header before response.buffer() in
  Lottie network interception to avoid OOM on multi-GB responses

* fix(capture): security fixes, timeout, sub-agent dispatch instructions

Security (from miguel-heygen review):
- assetDownloader: export isPrivateUrl() SSRF guard
- htmlExtractor: add isPrivateUrl check before CSS fetch
- mediaCapture: add isPrivateUrl check before Lottie fetch
- mediaCapture: fix previewPage leak (try/finally)
- mediaCapture: skip Lottie files > 2MB for preview (CDP limit)
- contentExtractor: skip images > 4MB for Gemini captioning
- index.ts: check Content-Length before response.buffer() (OOM guard)
- snapshot.ts: register error handler before server.listen()

Capture improvements:
- Default timeout 30s to 120s (Shopify needs ~90s for Cloudflare)
- step-6-build: sub-agent dispatch template with explicit rules:
  pass file PATHS not contents, use local fonts not Google Fonts,
  verify ../assets/ references after each beat

* fix(capture): catalog before DOM mutation, networkidle2, faster Gemini

Critical: asset cataloger now runs BEFORE extractHtml which converts img
src to data URLs. Framer sites like heykuba.com went from 2 to 78 images.

- networkidle2 instead of networkidle0 (unblocks SPAs with WebSockets)
- Lazy-load wait: scroll to bottom, wait for img.complete
- CSS background-image cataloging for Framer/Webflow
- SVG naming: checks class, id, parent, inner text (not just aria-label)
- Gemini batch 5->20, pause 12s->2s (paid tier: 2000 RPM, ~0.001/img)
- maxOutputTokens 300->500, descriptions sorted captioned-first
- Remove tsx fallback from step-1 (reviewer nit, published CLI has it)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:03:16 -07:00
James RussoandClaude Opus 4.6 18de86e4bd fix(player): handle Infinity duration; add lint rules for data-duration and Math.ceil overshoot (#243)
* fix(player): handle Infinity duration from runtime gracefully

When compositions have repeating animations without data-duration, the
runtime sends durationInFrames: Infinity. The player now ignores
non-finite duration values instead of displaying "Infinity:NaN" in the
controls. formatTime also returns "0:00" for non-finite inputs.

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

* feat(lint): add data-duration and Math.ceil overshoot rules

- Add root_composition_missing_data_duration warning when the root
  composition element is missing data-duration, which causes the runtime
  to infer Infinity for loop-inflated timelines.
- Add gsap_repeat_ceil_overshoot warning that catches
  repeat: Math.ceil(d/c)-1 patterns which overshoot the intended
  duration. Recommends Math.floor instead.
- Fix gsap_infinite_repeat fixHint to suggest Math.floor (not Math.ceil).

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

* fix(player): wait for injected runtime before declaring ready

When the player auto-injects the runtime script (because the
composition has GSAP timelines but no runtime), it would immediately
declare ready on the next probe cycle — before the runtime script
finished loading from CDN. This caused play() to send a postMessage
that nobody received, making autoplay silently fail.

Now the probe waits for the runtime bridge (__hf or __player) to
appear before proceeding to the ready state.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:41:13 -07:00
Miguel Ángel 794b02153d fix(lint): upgrade bare composition HTML to error (#242)
## Summary

- Upgrades `root_composition_missing_html_wrapper` from **warning** to **error** — a bare `<div data-composition-id>` as `index.html` without `<!DOCTYPE html>/<html>/<body>` causes browsers to quirks-mode, the preview server to fail, and the bundler to silently skip runtime injection
- Improves the error message to explain _why_ this is bad, and includes a snippet of the offending root element
- Skips `<template>`\-wrapped compositions (already caught by the separate `standalone_composition_wrapped_in_template` rule)
- Adds 8 tests covering the exact screenshot scenario, proper HTML, sub-compositions, plain HTML, and template wrappers

## Test plan

- [x] All 441 existing tests pass (`vitest run`)
- [x] 8 new tests for `root_composition_missing_html_wrapper` and `standalone_composition_wrapped_in_template`
- [x] TypeScript build clean (`tsc --noEmit`)
- [x] oxlint + oxfmt pass
- [x] Run `npx hyperframes lint` on a bare composition `index.html` and verify it now reports an error
2026-04-11 05:00:47 +02:00
Vance IngallsandClaude Opus 4.6 fc973ee2e8 feat(lint): add rules for missing data-start, template wrapper, and DOCTYPE
Three new lint rules that catch structural issues causing compositions
to fail silently in preview:

- root_composition_missing_data_start: Root composition needs data-start="0"
  for the runtime to begin playback
- standalone_composition_wrapped_in_template: index.html should not be
  wrapped in <template> (only sub-compositions use that)
- root_composition_missing_html_wrapper: index.html needs <!DOCTYPE html>
  and <html> wrapper for the bundler

Also adds rawSource to LintContext so rules can inspect pre-template-stripped
HTML, and isSubComposition to linter options so rules can distinguish root
from sub-composition files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:45:25 -07:00
Miguel Ángel 0d33238381 fix(core): add lint rule for infinite GSAP repeat (#218)
## Summary

Adds a new lint rule `gsap_infinite_repeat` that flags `repeat: -1` in GSAP timelines as an error. This is a hard enforcement of the skill guardrail added in PR #217.

## What it fixes

The deterministic capture engine (`HeadlessExperimental.beginFrame`) seeks to exact frame times on a paused GSAP timeline. When a timeline contains `repeat: -1`, the timeline duration is infinite, which causes the capture engine to produce incorrect/blurry output.

**Eval prompt #20** (loading-spinner, scored 2.0/5) used `repeat: -1` on a dots animation cycle, producing "a highly compressed and blurry loading animation lacking visual clarity and professional polish."

## Changes

- `packages/core/src/lint/rules/gsap.ts` — new `gsap_infinite_repeat` rule (regex scan for `repeat: -1`)
- `packages/core/src/lint/rules/gsap.test.ts` — 2 new tests (detects infinite repeat, allows finite repeat)

## Test plan

- [x] `pnpm --filter @hyperframes/core test` — all 429 tests pass
- [x] Rule catches `repeat: -1` and reports as error with fix hint
- [x] Rule does not flag `repeat: 4` (finite repeats)
2026-04-07 16:53:50 +02:00
Miguel Ángel 5655dabff6 feat: allow clip animation + ship <hyperframes-player> web component (#209)
## Summary

Two independent initiatives that improve agent DX and expand HyperFrames' reach.

### Initiative 1: Fix the Clip Animation Footgun

- `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element
- All other properties (opacity, transform, x, y, scale, etc.) are allowed silently
- This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1)

### Initiative 2: `<hyperframes-player>` Web Component

- New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped
- Iframe-based web component with Shadow DOM for perfect isolation
- Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events
- Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide
- Full docs page at `docs/packages/player.mdx`

## Before / After

### Clip animation lint

**Before (10/10 agents hit this):**

```
✗ gsap_animates_clip_element: GSAP animation targets a clip element.
  Selector "#title" resolves to element <div id="title" class="clip">.
  The framework manages clip visibility — animate an inner wrapper instead.
  Fix: Wrap content in a child <div> and target that with GSAP.
```

**After (only errors on actual conflicts):**

```
# This passes lint — no error:
tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0);

# This still errors — actual conflict with runtime:
tl.to("#title", { visibility: "hidden" }, 3);
✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element.
  Fix: Remove the visibility/display tween. Use opacity for fade effects.
```

### Embeddable player

**Before:** No way to embed a composition in a web page.
**After:**

```html
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script>
<hyperframes-player src="./composition/index.html" controls></hyperframes-player>
```

```js
const player = document.querySelector('hyperframes-player');
player.play();
player.pause();
player.seek(2.5);
player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration));
```

## Test plan

- [x] 427 core tests pass (20 GSAP lint tests with smart detection)
- [x] 7 player tests pass (formatTime + element registration)
- [x] TypeScript compiles cleanly (core + player)
- [x] Lint: GSAP animating clip with safe props → 0 errors
- [x] Lint: GSAP animating clip with `visibility` → 1 error (correct)
- [x] Player builds to 3.3KB gzipped ESM
- [x] Lockfile updated for CI
- [x] Docs page added at `docs/packages/player.mdx`
2026-04-06 19:59:39 +02:00
Miguel Ángel baa3d813be fix: address QA report P0-P2 issues for 10/10 agent experience (#208)
## Summary

Addresses all 8 issues from the QA report to improve agent and user experience.

### P0 — Must Fix

- **Blank template broken captions**: Removed `compositions/captions.html` and its reference from the blank template. Every agent (10/10) hit 404 errors during render.
- **Inner-wrapper example**: Added a clear structural comment in the blank template showing the correct `class="clip"` + inner wrapper pattern.
- **Sub-composition introspection**: `hyperframes compositions` now reads external HTML files referenced via `data-composition-src` and shows their real duration/element count instead of `0.0s / 0 elements`.

### P1 — Fix Soon

- **Font mapping warnings**: Now lists all mapped fonts, suggests alternatives (use a mapped font, add @font-face, install locally), and links to docs.
- **Browser 404s**: Non-font "Failed to load resource" 404s now prefixed with `[non-blocking]` instead of `[Browser:ERROR]`.
- **Render concurrency**: Default workers increased from `cores/2` (max 4) to `cores*3/4` (max 6). Added `--concurrency` alias.

### P2 — Nice to Have

- **Transform conflict fix suggestion**: `gsap_css_transform_conflict` now suggests exact GSAP property replacements (e.g., `xPercent: -50, yPercent: -50`).
- **Upgrade --yes**: Now actually runs the install instead of just printing the command.

## Before / After

### Font mapping warning

**Before:**

```
[Compiler] No deterministic font mapping for: DM Sans
```

**After:**

```
[Compiler] No deterministic font mapping for: DM Sans
  Mapped fonts: arial → inter, courier → jetbrains-mono, ...
  To fix, pick one:
    1. Use a mapped font name instead (see list above)
    2. Add a @font-face block in your HTML with a local or hosted font file
    3. Install the font locally on the render machine (Docker: add to Dockerfile)
    4. Add an alias to FONT_ALIASES in deterministicFonts.ts (for contributors)
```

### Browser 404s during render

**Before:** `[Browser:ERROR] Failed to load resource: the server responded with a status of 404`
**After:** `[non-blocking] Failed to load resource: the server responded with a status of 404`

### Transform conflict lint

**Before:** `Fix: Remove the transform from CSS and use tl.fromTo...`
**After:** `Fix: Remove transform: translate(-50%, -50%) from CSS and replace with GSAP properties: xPercent: -50, yPercent: -50`

### Compositions command

**Before:**

```
overlay   0.0s   1920×1080   0 elements
```

**After:**

```
overlay   8.0s   1920×1080   2 elements ← compositions/overlay.html
```

## Test plan

- [x] All 422 core tests pass
- [x] TypeScript compiles cleanly (all 4 packages)
- [x] Full monorepo build succeeds
- [x] `hyperframes init --template blank` ships without broken captions reference
2026-04-06 18:56:39 +02:00
Vance Ingalls d8bffd41f9 feat(lint,skills): add caption/audio-reactive lint rules and skill guidance (#207)
## What

Bumped all package versions to `0.2.2-alpha.4` and added five new lint rules for caption and GSAP animation quality checks.

## Why

The new lint rules address common issues in HyperFrames compositions:
- Caption overflow clipping when emphasis words are scaled above 1.0x
- Text shadow artifacts on caption group containers with semi-transparent children
- Mismatch between fitText maxWidth and scaled word dimensions
- Imperceptible audio reactivity from single tweens instead of time-sampled animations
- Scene layer visibility conflicts when relying only on opacity tweens

## How

Added three new caption-specific lint rules in `captions.ts`:
- `caption_overflow_clips_scaled_words` - detects `overflow: hidden` on caption containers when scripts scale words above 1.0x
- `caption_textshadow_on_group_container` - flags textShadow tweens applied to group containers instead of individual words
- `caption_fittext_scale_mismatch` - calculates effective width from fitText maxWidth × max scale factor and warns when it exceeds safe bounds

Added two new GSAP lint rules in `gsap.ts`:
- `audio_reactive_single_tween_per_group` - identifies audio-reactive captions using peak values instead of time-sampled loops
- `scene_layer_missing_visibility_kill` - detects multi-scene compositions missing hard visibility kills after opacity exit tweens

Enhanced documentation with new mask reveals guide and updated existing skills with overflow handling, scene management, and audio reactivity best practices.

## Test plan

- [x] Lint rules tested against existing composition patterns
- [x] Documentation updated with new techniques and constraints
- [x] Version bumps applied consistently across all packages
2026-04-03 10:52:06 -07:00
Miguel Ángel 4bb01fd1b6 feat(lint): add rules for non-deterministic code, missing clip class, overlapping tracks, and rAF detection (#193)
## Summary

Adds 4 new lint rules found missing during comprehensive E2E testing:

- **`non_deterministic_code`** (error) — detects `Math.random()`, `Date.now()`, `new Date()`, `performance.now()`, `crypto.getRandomValues()` in scripts. Confirmed: two renders with Math.random() produced different checksums.
- **`timed_element_missing_clip_class`** (warning) — flags elements with `data-start`/`data-duration` but no `class="clip"`. Without it, elements are visible forever instead of only during their time window.
- **`overlapping_clips_same_track`** (error) — detects clips on the same `data-track-index` with overlapping time ranges.
- **`requestanimationframe_in_composition`** (warning) — warns that rAF-based animations don't sync with frame capture. Discovered when Vivus.js (rAF-based) produced incorrect output.

12 new tests, all passing.

**Part 1 of 5** in a stacked PR series fixing E2E test findings.

## Test plan

- [x] 12 new tests (3 per rule: positive, negative, edge case)
- [x] Full suite: 56 pass in rule tests
- [x] `npx tsx scripts/lint-skills.ts` — no issues
2026-04-02 14:09:50 -07:00
Vance Ingalls 37404f23da feat(whisper+captions): language detection, audio-reactive captions, multilingual defaults (#175)
## Summary

**Whisper improvements:**
- Auto-detect language and switch from `.en` to multilingual model when needed
- Detect speech onset in WAV to strip hallucinated words before speech begins
- Merge whisper-cpp token fragments: contractions (`didn` + `'t` → `didn't`), split capitals (`C` + `aught` → `Caught`), dropped-g (`shin` + `in'` → `shinin'`)
- Interpolate zero-duration word clusters for reliable karaoke timing

**Captions skill updates (folded from #176):**
- Rewrite script-to-style mapping as an energy detection table (high → low) with mandatory animation requirements: karaoke baseline, 2+ highlight techniques, kinetic exits
- Replace `tl.call()` per-frame audio-reactive pattern with group-level GSAP tweens — read peak bass/treble for each group's time range and modulate entrance intensity at build time
- Add transcript quality check with automatic retry rules (>20% music tokens = retry with larger model)
- Add caption word structure lint rule (`.caption-group` + `<span>`) for studio editor compatibility

**Multilingual defaults (folded from #186):**
- Default whisper model changed from `small.en` to `small` to prevent silent translation of non-English audio
- Added non-negotiable language rule to captions skill

## Test plan

- [ ] `pnpm test` passes (contraction merging, fragment merging, zero-duration interpolation, speech onset)
- [ ] Transcribe non-English audio — verify it transcribes in original language, not translates
- [ ] Skill files render correctly, cross-references resolve
- [ ] `dynamic-techniques.md` audio-reactive section uses `tl.to()`/`tl.set()` only, no `tl.call()` loops

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-02 00:08:41 -07:00
Miguel Ángel 1efb23dfae fix(producer): inline CDN scripts for offline render and detect black video (#182)
## Context

`npx hyperframes render` fails to load assets and produces all-black video. Root causes:

1. CDN scripts (GSAP, Lottie) are left as `<script src="https://...">` in compiled HTML — the headless browser must fetch them over the network, which fails in Docker, CI, and firewalled environments
2. Assets referenced from outside the project directory (e.g. `../shared-assets/hero.png`) 404 because the file server only serves from `projectDir`
3. When GSAP fails to load, the timeline never registers, all `.clip` elements stay `visibility: hidden`, and every frame is black — with no diagnostic output
4. The linter reports `missing_gsap_script` when GSAP is bundled inline (no `<script src>` tag), which blocks users from working around the CDN issue

## What changed

### 1\. CDN script inlining (`htmlCompiler.ts`)

`compileForRender` now downloads all external `<script src="https://...">` tags at compile time and inlines their content into the HTML. Rendering no longer needs network access.

| Before | After |
| --- | --- |
| `<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>` stays in HTML, browser must fetch at runtime | Script downloaded during compilation, embedded as `<script>/* inlined: https://... */\n...code...</script>` |
| Docker/CI render → `net::ERR_NAME_NOT_RESOLVED` → black video | Render works fully offline |
| No feedback when CDN fails | `[Compiler] WARNING: Failed to download CDN script: ... Consider bundling it locally` |

### 2\. External asset copying (`htmlCompiler.ts`, `renderOrchestrator.ts`)

After compilation, the HTML is scanned for `src`, `href`, and CSS `url()` references that resolve outside `projectDir`. These files are copied into the compiled output directory so the file server can serve them.

| Before | After |
| --- | --- |
| `background-image: url(../shared-assets/hero.png)` → 404 (file server can't serve outside `projectDir`) | Asset detected, copied to compiled dir, path rewritten → serves correctly |
| `<img src="../shared-assets/logo.png">` → 404 | Same fix — works for all `src`/`href` attributes and CSS `url()` |

### 3\. Black video diagnostics (`renderOrchestrator.ts`)

When composition duration is 0 (which would produce a black video), the error now probes the browser for diagnostics instead of a generic message.

| Before | After |
| --- | --- |
| `Invalid composition duration: 0. Check that GSAP timelines are registered.` | `Composition duration is 0 — this would produce a black video.\n\nDiagnostics:\n  - GSAP is not loaded — CDN script may have failed to download. Bundle GSAP locally...\n  - Browser: [Browser:PAGEERROR] gsap is not defined` |
| Asset 404s during page load silently logged | `[Render] Asset load failure: ...` + `[WARN] Browser encountered network failures during page load` |

### 4\. Linter: recognize inline GSAP (`core/lint/rules/gsap.ts`)

The `missing_gsap_script` rule now recognizes GSAP bundled inline — matching the producer's inlining comment (`/* inlined: ...gsap... */`), GSAP library internals (`_gsScope`, `GreenSock`), and large inline scripts (>5KB) referencing gsap.

| Before | After |
| --- | --- |
| User inlines GSAP → linter errors with `missing_gsap_script` | Inline GSAP detected, no false error |
| Producer inlines CDN → linter errors on the compiled HTML | Producer's `/* inlined: ... */` comment recognized |

## Test plan

- [x] `pnpm build` passes
- [x] Core tests pass (410/410, +2 new)
- [x] **Reproduced baseline failures on** **`main`**: CDN scripts not inlined, external assets 404, no diagnostics
- [x] **Verified fixes**: CDN script inlined, external assets copied and served, diagnostics printed
- [x] Render with working CDN → `[Compiler] Inlined CDN script: ...` → render succeeds
- [x] Render with broken CDN → `[Compiler] WARNING: Failed to download CDN script` + browser errors surfaced
- [x] Render with assets outside project dir → `[Compiler] Found 1 asset(s) outside project directory` → assets served correctly
- [x] Linter with inline GSAP → no `missing_gsap_script` false positive
2026-04-01 23:15:00 +02:00