Commit Graph
197 Commits
Author SHA1 Message Date
Miguel Ángel 3e17ef7447 fix: propagate data-timeline-locked in compiler inliners
The runtime path (compositionLoader.ts) already propagated
data-timeline-locked from inner root to host, but both compiler
inliners (bundler + producer) did not. Bundled output re-opened in
Studio would lose the lock. Now propagated in inlineSubCompositions
alongside the existing data-hf-authored-id propagation.
2026-05-20 02:40:49 -04:00
Miguel Ángel 9c159ad96d fix: escape href in compiler link dedup + add font-link extraction tests
Escape href values in querySelector calls for link dedup in both
htmlBundler.ts and htmlCompiler.ts to match the runtime path (which
uses CSS.escape). Prevents SyntaxError on hrefs containing quotes.

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

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

Font fix:
- Extract <link rel="stylesheet"> and <link rel="preconnect"> from
  sub-composition <head> alongside existing <style>/<script> extraction
- Fixes caption components (and any sub-comp using Google Fonts via
  <link> tags) losing their font-family when loaded as sub-compositions
- Applied in both runtime (compositionLoader) and compiler
  (inlineSubCompositions) paths
2026-05-20 01:55:17 -04:00
Miguel Ángel e5bad2b80a Merge pull request #978 from heygen-com/worktree-fix+studio-timeline-resize-and-perf
fix(studio): enable timeline resize for all elements, improve perf and UX
2026-05-20 03:07:08 +02:00
Miguel Ángel 20c82980d5 chore: address remaining review nits
Add mediabunny (MPL-2.0) to CREDITS.md third-party licenses section.

Add regression test for 4-5 clip compositions under the lowered lazy
threshold — verifies lazy mode activates and no spurious eviction churn
occurs when all clips fit within the promoted cap.
2026-05-19 21:06:22 -04:00
Miguel Ángel 89f9ca196d fix(studio): enable timeline resize for all elements, improve perf and UX
Enable trim-start and trim-end for all authored timeline elements (divs,
sections, compositions) — not just video/audio/img. The deterministic-window
gate was overly restrictive since all non-implicit elements have authored
data-start/data-duration that define their timeline window.

Replace iframe reload after resize/move with direct DOM attribute patching
via patchIframeDomTiming(). This eliminates playhead-jump-to-zero, visual
blinking, and race conditions from file-watcher echoes. File persistence
runs in a serialized background queue (persistTimelineEdit + enqueueEdit)
so rapid edits don't overwrite each other.

Add mediabunny-based media probe service (mediaProbe.ts) for fast metadata
extraction from file headers. Timeline elements missing sourceDuration are
enriched asynchronously without waiting for DOM loadedmetadata events.

Tune the runtime media preloader: lower lazy threshold from 6 to 3 clips,
add 3s lookbehind window for reverse scrub, adaptive promoted-clip cap.

Deduplicate getTimelineEditCapabilities — computed once in TimelineCanvas
and passed as a prop to TimelineClip instead of recomputing per clip.

Remove dead PlaybackAdapter re-export from useTimelinePlayer — all consumers
import directly from playbackTypes.
2026-05-19 20:40:39 -04:00
Miguel Ángel 8f0545738c Merge pull request #977 from func25/ceil-duration-preview
fix(core): ceil timeline payload duration to match render frames
2026-05-20 01:33:15 +02:00
func25 0fc3937809 fix(core): ceil timeline payload duration to match render frames 2026-05-20 06:04:29 +07:00
Miguel Ángel 83e01e0d44 Merge pull request #965 from heygen-com/fix/sub-comp-timeline-t0
fix: activate nested child timelines on renderSeek (sub-comp at t=0)
2026-05-19 23:11:32 +02:00
Miguel Ángel 6652fae243 fix(#969): producer path propagates data-hf-authored-id to host element
When the producer inlines a sub-composition with compId match, it takes
innerRoot.innerHTML which strips the wrapper div and its id attribute.
CSS/GSAP selectors rewritten from #ID to [data-hf-authored-id=ID] then
match nothing.

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

Closes #969
2026-05-19 16:07:24 -04:00
Miguel Ángel c44b1aaea7 chore: remove duplicate runtime fixtures, producer tests are the source of truth 2026-05-19 16:05:56 -04:00
Miguel Ángel f7d63fbebd fix(studio): address PR review — add tests, strip GSAP transform for rotation, remove dead exports
Addresses review feedback from Rames and Vai:

1. Add 7 new tests for createStudioPositionSeekReapplyScript:
   box-size reapplication, GSAP translate stripping (identity removal,
   scale+translate preservation, transform:none no-op), and rotation-
   only elements with GSAP-baked translate.

2. Add pinning test for the PiP-over-sub-composition selection bug:
   elementsFromPoint returns [pipVideo, subCompRoot, sfChromeImg] as
   siblings — assert the topmost (pipVideo) wins.

3. Apply stripGsapTranslateFromTransform to rotation-only elements
   too, not just path-offset elements. A rotation-only element with
   a GSAP-animated translate would have its position clobbered.

4. Remove dead exports: getPreviewLocalPointer,
   buildRasterClickSelectionContext, getPreviewPlayer,
   seekStudioPreview, PreviewPlayerCompat, PreviewLocalPointer from
   studioPreviewHelpers.ts. Unexport resolvePreviewLocalPointer.
2026-05-19 16:05:37 -04:00
Miguel Ángel 26450c1a27 refactor: address review — rename activateSiblingTimelines, opts arg, FIXME tracking
- Rename activateNestedChildTimelines → activateSiblingTimelines (matches player.ts)
- Use tl.play() instead of tl.paused(false) for consistency
- Convert positional activateChildren boolean to { activateChildren } opts
- Add FIXME(#969) to divergence test with tracking issue link
- Add [id="intro"] no-rewrite boundary test
- Add comment about deliberate no-restore behavior in render-seek path
2026-05-19 16:02:33 -04:00
Miguel Ángel e2f7f6a58a fix: add regression fixtures with golden baselines + address review
- Create sub-comp-t0 and sub-comp-id-selector as proper regression tests
  under packages/producer/tests/ with golden MP4 baselines
- Add both to shard-7 in regression.yml
- Add clarifying comment on activateNestedChildTimelines scope
- Confirm test fixture network safety in comment
2026-05-19 15:59:09 -04:00
Miguel Ángel 6498807e1a fix(core): strip GSAP translate from transform after reapplying manual edits
When GSAP animates an element (e.g. scale, opacity), it captures the
element's translate into its internal transform matrix (m41/m42). The
render reapply script was setting the CSS `translate` property but
leaving GSAP's translate baked into `transform`, causing the manual
edit offset to be ignored or doubled.

Port the same stripGsapTranslateFromTransform logic the studio uses:
parse the transform matrix, zero out m41/m42, and remove or rewrite
the transform property so the CSS `translate` takes effect cleanly.
2026-05-19 15:45:51 -04:00
Miguel Ángel 8fd5bf8f51 fix(studio): remove Ask agent popup, fix preview selection, fix manual edits in renderer
Three interrelated studio UX and rendering fixes:

1. Remove the "Ask agent" popup that auto-triggered when clicking large
   raster elements in the preview. The modal intercepted clicks meant
   for editable elements and blocked normal selection workflow.

2. Rewrite preview click selection to respect visual stacking order.
   The previous scoring algorithm weighted DOM depth at 10,000× per
   level, causing elements inside sub-compositions to beat visually-
   on-top elements (e.g., clicking Pip Studio selected Sf Chrome
   instead). The new algorithm trusts elementsFromPoint order and only
   prefers a deeper candidate when it is a descendant of the current
   pick — never jumping to an unrelated element painted behind it.

3. Fix manual edits (resize) not surviving video rendering. The
   producer's seek-reapply script handled translate and rotation but
   was missing box-size (width/height) reapplication after each GSAP
   seek. Also added data-hf-studio-box-size to the detection list in
   htmlCompiler so the script is injected for resize-only edits.
2026-05-19 15:45:51 -04:00
Miguel Ángel 0d12a465a3 fix: activate nested child timelines during renderSeek
The renderSeek override in init.ts called seekTimelineAndAdapters() which
only did rootTimeline.totalTime(t) without activating child timelines.
GSAP does not propagate totalTime() to internally paused children.

Also simplifies pollSubCompositionTimelines to always call rebind when
timelines are ready, removing the before/after count comparison that
could skip the rebind on fast page loads.
2026-05-19 15:42:07 -04:00
Miguel Ángel 29ad7df90c style: format fixture HTML files 2026-05-19 15:41:38 -04:00
Miguel Ángel 60cdf8c66b test: add regression fixture for sub-comp #ID selector scoping divergence
The producer inlining path strips the inner root element (taking innerHTML
when compId matches), losing the id attribute. The bundler path preserves it
via flattenInnerRoot + data-hf-authored-root-id. This causes #ID selectors
in sub-comp CSS and GSAP to fail silently during render while working in
preview.

Adds a minimal fixture with a sub-comp using #intro scope to catch this
divergence in future compiler changes.
2026-05-19 15:38:59 -04:00
James Russo 2729ee5087 refactor: delete orphan declarations flagged by fallow (#949)
* ci: run fallow audit in lefthook pre-commit

Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.

Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.

Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.

The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.

* refactor: delete orphan declarations flagged by fallow

After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.

Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.

Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.

Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.

Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.
2026-05-18 21:11:03 -07:00
Miguel Ángel 72a18a0116 Merge pull request #947 from heygen-com/feat/studio-blocks-panel
feat(studio): full Blocks panel — browse, search, add, drag-and-drop registry items
2026-05-19 03:58:25 +02:00
Miguel Ángel ffbc18ad31 feat(studio): full Blocks panel — browse, search, add, drag-and-drop registry items
Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.

Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
  Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls

Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
  overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks

Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
  Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
2026-05-18 21:15:15 -04: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
James 2087d5dab2 chore: add fallow config and fix high-signal findings
Configure fallow via .fallowrc.jsonc so its analysis reflects this repo's
real entry surface, then fix the genuine issues it found.

Fallow noise reduction (601 → 276 dead-code findings):
- Ignore docs/, test fixtures, skill test-corpora, registry/, examples/
- Declare worker entry points loaded dynamically by file path
  (pngDecodeBlitWorker.ts, shaderTransitionWorker.ts)
- Declare runtime IIFE entry (core/src/runtime/entry.ts) built outside the
  import graph by build-hyperframes-runtime-artifact.ts
- Declare bun:test files in producer + aws-lambda as test entries
- Ignore dynamically-resolved deps: tsup external (puppeteer-core, esbuild,
  giget), peer/static-file (gsap in player perf tests), workspace deps
  hoisted by bun (happy-dom, @hyperframes/*), and @fontsource/* packages
  read via readFileSync in generate-font-data.ts

Extract inline build:fonts scripts:
- packages/{cli,producer}/package.json had multi-line `node -e ...` blobs
  containing braces that fallow mis-parsed as glob alternate groups. Moved
  to dedicated build-fonts.mjs scripts.

Fix duplicate exports:
- Remove dead FileIcon alias in studio/SystemIcons.tsx (FileTreeIcons.tsx
  has the real, used one)
- Consolidate ValidationResult: drop the identical duplicate in
  gsapParser.ts; both parsers now import from core.types
- Suppress intentional namespace patterns (per-namespace ML manager
  exports; CLI per-command 'examples' convention; fileServer.ts test-only
  isPathInside which has different symlink semantics from utils/paths.ts)

Break circular dep (studio/components/editor):
- manualEditsDom.ts re-exported clearStudioPathOffset / clearStudioRotation
  / clearStudioBoxSize from manualEditsSnapshot.ts, which imports four
  helpers from manualEditsDom.ts — back-edge cycle
- Re-export moved to manualEdits.ts (the package-public barrel) where the
  rest of the snapshot re-exports already live; underlying files now form
  a clean DAG

Remove genuinely unused deps:
- studio: motion (no imports anywhere), codemirror (umbrella package; the
  @codemirror/* sub-packages are used directly)
- cli: mime-types (plus its only consumer src/utils/mime.ts, which was a
  hardcoded mime table that didn't use the package), and its now-stale
  tsup external entry

Verified: typecheck across core/cli/producer/studio is clean, oxlint
+ oxfmt pass, manualEdits.test.ts (18 tests) and core parser tests (69
tests) still pass.

Deferred follow-ups (real findings, separate PRs):
- 8 circular deps in producer/services/render/stages/ — renderOrchestrator
  ↔ captureHdr* / captureStage / extractVideosStage form a hub cycle
- ~14 unused files in producer/src/services/ that look like dead
  re-export shims to @hyperframes/engine, but aren't in the public
  exports map — need to confirm no deep-import consumers before deletion
- waveform.ts complexity hotspot
2026-05-18 18:57:21 +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 0ff6296d64 fix(core): remove late-bind polling from runtime — engine handles it
The setInterval-based late-bind polling in init.ts caused visual
regressions across all style-prod tests. Even with the sawMissing
guard, the mere presence of the interval registration altered
event loop timing enough to shift rendered frames.

The engine's pollSubCompositionTimelines + conditional
__hfForceTimelineRebind already handles async timeline detection
for renders. The runtime only needs to expose the rebind hook —
it shouldn't poll on its own.

For studio preview of async compositions, the engine's rebind
call (via __hfForceTimelineRebind) is the correct mechanism.
2026-05-18 05:50:08 -04:00
Miguel Ángel d10b2f4ded fix(core): only rebind timelines when late arrivals were actually detected
The late-bind polling was unconditionally rebinding on its first
check even when all timelines were already present, causing visual
regressions across style-prod tests. Now tracks sawMissing flag —
only rebinds if the poll previously detected missing timelines that
subsequently appeared. Compositions with synchronous timeline
registration exit the poll immediately with no side effects.
2026-05-18 05:24:33 -04:00
Miguel Ángel 04c35ce24c fix: regression fixes — conditional rebind + updated compilation baselines
1. Only call __hfForceTimelineRebind() when the timeline poll actually
   had to wait (pollDuration > 2 intervals). For compositions with
   synchronous timeline registration, the rebind was unnecessary and
   shifted render timing, causing PSNR regressions in chat and
   gsap-letters-render-compat.

2. Regenerate compiled.html baselines for missing-host-comp-id and
   overlay-montage-prod to match the new flattenInnerRoot behavior
   (data-composition-id stripped from inlined inner roots, replaced
   with data-hf-authored-id).

3. Add late-bind polling to runtime init.ts — after external
   compositions load, poll for 5s to detect async timelines that
   register after initial binding (e.g. from fetch callbacks).
2026-05-18 03:46:58 -04:00
Miguel Ángel 0f1c64dcae fix: address review feedback — observability, dedup, query-strip, catalog
Review items addressed:

1. Mirror video-failure warning in beginFrame path (was screenshot-only)
2. Fix resolveProjectRelativeSrc escape-fallback to use query-stripped
   cleanSrc instead of raw src for the normalize/strip arm
3. Export prepareFlattenedInnerRoot from @hyperframes/core/compiler and
   consume in the producer instead of duplicating the implementation
4. Use typed Window cast instead of (window as any) for __hfForceTimelineRebind
5. Regenerate docs/public/catalog-index.json with all 6 map blocks
6. Restore Maps nav group in docs.json (catalog generator had merged
   them into Data)
2026-05-18 01:41:15 -04:00
Miguel Ángel 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 2c84c9a55d fix(engine,core): wait for async timelines and force rebind before capture
Two fixes for compositions that register timelines after async data
loading (e.g. fetch for TopoJSON map data):

1. engine/frameCapture: remove the hosts.length <= 1 early return
   so the timeline readiness poll runs for ALL compositions, not just
   multi-composition galleries. Single-composition pages with async
   setup were silently skipped.

2. core/runtime/init: expose window.__hfForceTimelineRebind() which
   resets childrenBound and re-runs bindRootTimelineIfAvailable().
   The renderer calls this after all timelines are confirmed present,
   ensuring the root player discovers late-registered timelines from
   fetch callbacks.

Without these fixes, compositions using fetch() to load data at
runtime would render blank frames because the root player bound
timelines before the async setup completed, and seek() never
reached the unbound composition timeline.
2026-05-18 00:39:10 -04:00
Miguel Ángel 72506f3b4a fix(core): address #922 review — posix paths, diamond imports, comment safety
Fixes from review #4306329284 on hf#922:

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:28:20 +00:00
Miguel ÁngelandClaude Sonnet 4.6 2b46565c65 fix(runtime): hold external sub-compositions in render mode
PR #917 fixed visibility clamping for external sub-compositions in
preview mode by checking data-composition-src. However, the producer's
htmlCompiler strips that attribute during inlining without setting the
data-composition-file marker that the core bundler sets. This caused
the runtime to still clamp duration to Math.min(authored, live) in
rendered output.

Two fixes:
- Runtime: also check data-composition-file (set by the core bundler
  after inlining)
- Producer: set data-composition-file before removing
  data-composition-src, matching the core bundler's behavior

Closes #911

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:30:23 +00:00
Carlos Alcaraz Gregor cff1e76246 fix(runtime): respect keepPlaying option in player seek (#863) 2026-05-17 17:10:23 +02:00
Puneet Dixit 551607efd6 fix: hold external sub-compositions through host duration (#917) 2026-05-17 17:09:43 +02:00
Miguel Ángel 4e0034f072 fix(studio): fix capture button silent failures and broken CLI seek (#904)
* fix(studio): fix capture button silent failures and broken CLI seek

The Capture button could silently fail with no user feedback due to
several compounding issues:

- The click handler's try-catch only covered the fetch call, leaving
  waitForPendingDomEditSaves() and URL construction unprotected. Any
  error there became an unhandled promise rejection with zero UI
  feedback. Wrap the entire handler body in try-catch.

- No timeout on the fetch or save-queue drain, so a hung server or
  stuck save queue caused the button to appear permanently broken.
  Add a 30s AbortController timeout on the fetch and a 5s race
  timeout on waitForPendingDomEditSaves.

- The CLI server's thumbnail seek used `__timeline` (singular) which
  doesn't exist — the runtime registers `__timelines` (plural). Also
  used `.seek()` instead of `.pause(t)` and didn't kick the GSAP
  ticker. Align with the Vite adapter's working seek logic.

- The CLI server's getThumbnailBrowser and generateThumbnail catch
  blocks swallowed all errors silently — Chrome launch failures and
  screenshot errors were invisible. Add console.warn logging.

- Parse the JSON error body from the server so the toast shows the
  actual message ("Chrome browser may not be available") instead of
  just "Capture failed (500)".

Closes #902

* fix(cli): apply same seek fix to snapshot command, address review nits

- Fix snapshot.ts seek logic: same __timeline→__timelines + .pause(t)
  + gsap ticker kick fix as studioServer.ts (caught by Vai's review)
- Use typed Window shape in waitForFunction instead of (window as any)
- Use function-form page.evaluate for document.fonts?.ready

* fix(cli): force screenshot mode for thumbnail browser on Linux

Root cause: on Linux, acquireBrowser defaults to beginframe mode
(--enable-begin-frame-control) which makes page.screenshot() hang
indefinitely — beginframe mode expects CDP HeadlessExperimental.beginFrame
commands, not Puppeteer's Page.captureScreenshot.

Pass forceScreenshot: true and captureMode: "screenshot" so the
thumbnail browser always uses screenshot-compatible Chrome flags.

Reproduced on Linux devbox: thumbnail endpoint hung >30s with
beginframe flags; returns a valid PNG instantly in screenshot mode.
2026-05-16 22:00:08 +02:00
Phuong Le be33afb361 fix(core): align sub-composition scoping across runtime and bundler (#897) 2026-05-16 18:25:24 +02:00
terencecho a79d8acd7a fix: ship lottieReadiness + guard studio import.meta.env for non-Vite consumers (#861)
## Summary

Two small fixes that together make `@hyperframes/core` + `@hyperframes/studio` consumable from non-Vite hosts (Next.js / Turbopack, Node, etc.).

### 1. `core`: ship the missing `lottieReadiness` module

The `"./runtime/lottie-readiness"` subpath export in `@hyperframes/core` claims to ship at `./dist/runtime/adapters/lottieReadiness.js`, but that file is missing from the published 0.6.6 and 0.6.7 tarballs. Consumers that import the subpath — most notably `@hyperframes/studio`'s `Player.tsx` — fail to resolve the module and break downstream builds.

**Root cause:** `packages/core/tsconfig.json` excludes `src/runtime` (those files run in a browser context and are bundled separately into the IIFE artifact). Since nothing in the included tree imports `lottieReadiness.ts`, tsc never emits a compiled output, and the file silently goes missing from the publish.

**Fix:** `lottieReadiness.ts` is a pure helper — takes `unknown`, returns `boolean`, no DOM/`window` dependencies. It doesn't belong in `src/runtime/` in the first place; the runtime-exclude rule rightly caught it. Move it to `src/lottieReadiness.ts` so the standard library build picks it up.

The subpath export **name** stays `"./runtime/lottie-readiness"` — only the exports map's underlying file path changes — so existing consumers (studio) don't need any code change.

### 2. `studio`: guard `import.meta.env` for non-Vite hosts

`packages/studio/src/components/editor/manualEditingAvailability.ts` unconditionally reads `import.meta.env`. That's a Vite-only extension; in plain ESM hosts (Next.js / Turbopack, Node, jest in some configs) `import.meta` exists but `import.meta.env` is `undefined`. Reading any property off undefined throws at module evaluation time, so the studio fails to load the moment a non-Vite host imports anything from `@hyperframes/studio`.

Guarded the read so the module is loadable everywhere; outside Vite, every flag falls back to its declared default, preserving Vite behavior.

### Changes

**core:**
- `mv src/runtime/adapters/lottieReadiness.{ts,test.ts}` → `src/`
- Update `src/runtime/adapters/lottie.ts` re-export path
- Update `package.json` + `publishConfig.exports` to point at the new dist path (`./dist/lottieReadiness.{js,d.ts}`)

**studio:**
- One-line guard in `manualEditingAvailability.ts:30` with explanatory comment

## Test plan

- [x] `pnpm typecheck` (core, studio) — clean
- [x] `bun run build` (core) — `dist/lottieReadiness.{js,d.ts}` now present
- [x] `bunx vitest run` (core) — 862/862 passing
- [x] `bun run typecheck` (studio) — clean, resolves moved file via subpath export
- [ ] Publish 0.6.8 and verify the tarball contains `dist/lottieReadiness.js`
- [ ] Verify a non-Vite ESM consumer (e.g. a Next.js / Turbopack app) imports `@hyperframes/studio` without `import.meta.env` errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-16 06:24:30 +02:00
Miguel Ángel 3336fddd41 fix(studio): handle full HTML doc sub-compositions in preview (#885)
## Summary

- **Root cause**: `buildSubCompositionHtml` assumed all sub-compositions used `<template>` wrappers. Full HTML document blocks (like `north-korea-locked-down` and `nyc-paris-flight`) were nested as-is inside `<body>`, producing invalid HTML with nested `<html>` and `<head>` elements
- **Effect**: the composition's `<style>` tags ended up misplaced inside `<body>`, and `<img src="assets/...">` paths failed to resolve when combined with the injected `<base>` tag — resulting in missing map images in the Studio sub-composition preview
- **Fix**: detect full HTML documents and properly extract head styles/scripts and body content into separate sections, producing valid HTML where CSS lands in `<head>` and relative asset paths resolve correctly

## Test plan

- [x] New unit test: full HTML document composition produces clean output without nested `<html>` in `<body>`
- [x] Existing test: `<template>`-wrapped compositions still rewrite `../` asset paths correctly
- [x] Visual verification: captured sub-composition preview frames before/after fix — maps now render correctly for both blocks
- [x] Manual: open a project with `north-korea-locked-down` or `nyc-paris-flight` as a sub-composition in Studio, click on the sub-comp in the timeline → map should be visible
2026-05-16 06:23:55 +02:00
Phuong Le 395cbaf3f5 fix(studio): make optional file reads explicit (#883) 2026-05-16 04:42:56 +02:00
f84cc492de perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review)

Two correctness fixes from PR #821 self-review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses three issues from staff review:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style: format smoke test script

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Vai <vai@heygen.com>
2026-05-15 16:23:12 -07:00
Miguel Ángel 4fd9520a90 feat(studio): per-composition render button in compositions tab (#874)
* feat(studio): add per-composition render button in compositions tab

Thread composition path through the full render pipeline so individual
compositions can be rendered independently from the studio UI.

- Add download icon button on each comp card (visible on hover)
- Accept `composition` field in POST /projects/:id/render
- Pass composition as `entryFile` to the producer's createRenderJob
- Make the Export button in the Renders panel composition-aware
  (renders the active composition instead of always index.html)

* fix(studio): make composition render buttons always visible

The hover-only opacity made them undiscoverable.

* fix(studio): address PR review — CLI adapter, path guard, a11y, tests, settings sync

- Wire `composition` → `entryFile` in CLI studio adapter (studioServer.ts)
  so `hyperframes preview` renders the correct composition, not always index.html
- Add path-traversal guard: reject composition paths that resolve outside projectDir
- Add `aria-label` to the icon-only render button for screen readers
- Add 4 tests: forwarding, empty/missing → undefined, path-traversal → 400
- Persist render settings (format/quality/fps) to localStorage so comp card
  buttons use the same settings as the Export panel

* refactor(studio): extract render settings persistence to own module

Move getPersistedRenderSettings/persistRenderSettings out of
RenderQueue.tsx into renderSettings.ts so code-splitting the
component doesn't drag along the helper.
2026-05-15 22:55:54 +02:00
Miguel Ángel 9b23ccf665 feat(studio): html-backed motion panel (#873)
## Summary

Re-architects the studio motion panel to persist GSAP motion data directly in HTML element attributes instead of a `.hyperframes/studio-motion.json` JSON sidecar file. Same pattern as position/resize/rotation edits.

### Before
```
MotionPanel → commitStudioMotionManifestOptimistically()
  → writes .hyperframes/studio-motion.json
  → applyStudioMotionManifest(doc, manifest)
```

### After
```html
<div id="hero" data-hf-studio-motion='{"start":0.5,"duration":1,"ease":"power3.out","from":{"opacity":0,"y":40},"to":{"opacity":1,"y":0}}'>
```
```
MotionPanel → writeStudioMotionToElement(element, motion)
  → buildMotionPatches(element)
  → commitPositionPatchToHtml(selection, patches)
```

## What changed

- **studioMotionOps.ts** — Added `readStudioMotionFromElement()`, `writeStudioMotionToElement()`, `clearStudioMotionFromElement()` for attribute-based CRUD
- **studioMotion.ts** — Added `applyStudioMotionFromDom()` that reads motion from DOM attributes and builds GSAP timeline (kept `applyStudioMotionManifest` for render script compat)
- **manualEditsDom.ts** — Added `buildMotionPatches()` / `buildClearMotionPatches()`, integrated motion into `reapplyPositionEditsAfterSeek()`
- **useDomEditCommits.ts** — Rewrote `handleDomMotionCommit` / `handleDomMotionClear` to use HTML patching instead of manifest persistence
- **useManifestPersistence.ts** — Removed all motion manifest state (~200 lines): `studioMotionManifestRef`, `commitStudioMotionManifestOptimistically`, `applyStudioMotionToPreview`, motion SSE handler
- **App.tsx** — Reads motion from element attribute (`readStudioMotionFromElement`) instead of manifest ref
- **manualEditsRenderScript.ts** — Extended `studioPositionSeekReapplyRuntime` to rebuild GSAP motion timeline from `data-hf-studio-motion` attributes after each seek, including CustomEase support
- **htmlCompiler.ts** — Trigger seek-reapply script injection on `data-hf-studio-motion=` attributes

## Benefits

- No sidecar file — motion survives git, copy-paste, and manual HTML editing
- Undo/redo works via HTML source history (same as position edits)
- Renders correctly via CLI — seek-reapply script handles motion timeline rebuild
- Simpler architecture — one persistence path for all studio edits

## Test plan

- [x] `bun run build` passes
- [x] Pre-commit hooks pass (lint, format, typecheck)
- [ ] Set motion on element in Studio → `data-hf-studio-motion` attribute appears in HTML source
- [ ] Reload page → motion persists and plays correctly
- [ ] Clear motion → attribute removed, element returns to original state
- [ ] Undo/redo motion changes
- [ ] Render via CLI → motion visible in rendered video
- [ ] Seek animation → motion timeline re-syncs correctly
2026-05-15 21:45:57 +02: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