Commit Graph
522 Commits
Author SHA1 Message Date
Miguel Ángel 939b026cfe perf(native-renderer): raw render pipeline + render-only benchmark
Separate rendering from encoding for maximum render throughput:
- render_animated_raw_then_encode: paint→I420→file, then FFmpeg batch
- render_only benchmark: pure paint+convert speed without any encode

Benchmark (macOS, 30 frames, 1080p, CPU raster):
  render only (paint+I420):       153ms (5.1ms/frame)
  raw render + FFmpeg batch:      308ms (10.3ms/frame)
  native openh264 in-process:     331ms (11.0ms/frame)
  FFmpeg VideoToolbox pipe:       348ms (11.6ms/frame)

Render-only is 2.7-7.8x faster than Chrome CDP (14-40ms).
2026-04-25 21:53:16 -04:00
Miguel Ángel 743cfb2003 feat(native-renderer): ffmpeg-free pipeline — openh264 + minimp4 in-process
Rust-native encoding: Skia paint → dcv BGRA→I420 → openh264 H.264 → minimp4 MP4.
No subprocess, no pipe, no FFmpeg for encoding. Inspired by MediaBunny's
browser-side approach (WebCodecs + MP4 muxer) but in Rust.

Benchmark (macOS, 30 frames, 1080p):
  native (openh264+minimp4): 331ms (11.0ms/frame)
  ffmpeg (VideoToolbox):     348ms (11.6ms/frame)
  ffmpeg (JPEG pipe):        555ms (18.5ms/frame)

The native path is ~5% faster than FFmpeg VideoToolbox on macOS.
On Linux without hardware encoders, the gap should be larger since
we skip FFmpeg subprocess startup + pipe serialization entirely.
2026-04-25 21:48:40 -04:00
Miguel Ángel a385abf942 feat(native-renderer): linux docker CI — 51 tests pass, cpu raster + libx264
Cargo feature split: metal-gpu (macOS), vulkan-gpu (Linux), no-default (CPU).
Docker builds with --no-default-features for pure CPU raster path.
All GPU code gated behind feature flags — no compilation errors on Linux.
Encoder detection probes actual encoder functionality (not just ffmpeg list).

Tested: docker run hyperframes-native:test → 51/51 pass on Linux ARM64.
2026-04-25 21:19:35 -04:00
Miguel Ángel f190820e3d feat(native-renderer): linux docker test image + dockerignore
Dockerfile.test for CI: builds Skia from source on Linux ARM64/x86,
runs all 49 Rust tests with CPU raster + FFmpeg libx264 fallback.
Includes ninja, python3, clang for Skia source build, plus fonts
and FFmpeg for rendering tests.

Supports: `docker run hyperframes-native:test` (tests)
          `docker run hyperframes-native:test bench` (benchmarks)
2026-04-25 21:06:29 -04:00
Miguel Ángel 81df8fcd23 feat(native-renderer): cross-platform GPU — vulkan on linux, metal on macos
- add Skia Vulkan backend for Linux GPU instances (A10G, T4, L4)
- new_gpu_or_raster() auto-detects best GPU backend, falls back to CPU
- remove macOS-only guards from pipeline, benchmarks, and tests
- render_animated_gpu works on all platforms (Vulkan/Metal/raster fallback)
- flush_and_submit is now cross-platform
- all 49 tests pass

Production path: Linux NVIDIA → Skia Vulkan paint → NVENC hw encode
Docker CI path:  Linux no GPU → Skia CPU raster → FFmpeg libx264
Local dev path:  macOS → Skia Metal paint → VideoToolbox hw encode
2026-04-25 20:49:19 -04:00
Miguel Ángel 5953f14fe2 perf(native-renderer): bgra readback + nv12 videotoolbox encoding
- read_pixels_bgra() skips GPU-side BGRA→RGBA conversion (1.17ms vs 2.25ms)
- raw_pixel_encoder_args uses BGRA input matching Metal's native format
- VideoToolbox encodes via NV12 (media engine converts, not CPU)
- E2E: 334ms/30frames (11.2ms/frame), down from 365ms (12.1ms/frame)
2026-04-25 20:39:34 -04:00
Miguel Ángel 02634d0e2d fix(native-renderer): tighten auto fallback for unsafe animated media 2026-04-25 19:52:43 -04:00
Miguel Ángel efbbc04055 feat(native-renderer): harden hybrid auto backend proof 2026-04-25 19:42:17 -04:00
Miguel Ángel a89f9c45e1 feat(native-renderer): wire native CLI media path 2026-04-25 17:59:11 -04:00
Miguel Ángel ab642674bb feat(native-renderer): complete supported native proof path 2026-04-25 17:01:38 -04:00
Miguel Ángel 36f79043fa feat(cli): add render backend selection 2026-04-25 16:02:36 -04:00
Miguel Ángel ff15d3431e feat(native-renderer): add hardware encode path 2026-04-25 16:02:31 -04:00
Miguel Ángel 47087132fe feat(native-renderer): metal GPU surface — 29x faster than CPU raster
Skia Metal backend on Apple Silicon:
  GPU paint:              1.02ms/frame (vs 29.9ms CPU raster)
  GPU paint + readback:   5.57ms/frame (vs 29.9ms CPU raster)

29.3x speedup on pure GPU paint. 5.4x with pixel readback.
Phase 3.2 (hardware encoding) eliminates the readback cost entirely
via zero-copy IOSurface → VideoToolbox.

- RenderSurface::new_metal_gpu() creates Metal device + DirectContext
- flush_and_submit() for GPU command synchronization
- Criterion GPU benchmarks (macOS-gated)
2026-04-25 15:28:36 -04:00
Miguel Ángel 904cc42de4 feat(native-renderer): tier 2 CSS effects — box-shadow, blur, gradients
- box-shadow with offset, spread, blur radius, border-radius aware
- filter:blur via Skia ImageFilter + SaveLayerRec
- linear-gradient and radial-gradient via Skia gradient shaders
- gradient takes priority over solid background when both present
- 7 new tests (26 total), all passing
2026-04-25 15:22:46 -04:00
Miguel Ángel 5d45b8c3ed feat(native-renderer): phase 2 — effects, images, font caching
- box-shadow, filter:blur, linear/radial gradients (Skia effects)
- image loading with object-fit:cover and ImageCache
- font manager cached via thread_local (177ms → 29ms per frame)
- updated paint_element signature to accept ImageCache
2026-04-25 15:20:21 -04:00
Miguel Ángel ccf9bdb93f feat(native-renderer): pre-baked timeline extraction from Chrome
Add timeline baking module that uses Chrome CDP to evaluate a GSAP
timeline at every frame timestamp, extracting per-frame transform,
opacity, and visibility for all animated elements. The output JSON
uses snake_case field names compatible with Rust serde deserialization,
bridging GSAP animations to the native renderer without embedding V8.
2026-04-25 15:18:54 -04:00
Miguel Ángel 38cafc86c1 feat(native-renderer): phase 1 complete — Skia rendering pipeline + benchmark
Rust-based video composition renderer using Skia (Chrome's 2D engine):
- scene graph types with serde JSON parsing
- Skia raster surface with JPEG/PNG encoding
- element painter: backgrounds, border-radius, transforms, opacity, text
- static render pipeline: scene → Skia paint → FFmpeg pipe → MP4
- CDP scene extraction bridge (TypeScript)
- criterion benchmark: 1080p, 20 elements with text

Benchmark results (CPU raster, Apple Silicon):
  paint_1080p_20_elements:      29.8ms/frame
  paint_and_encode_jpeg_1080p:  35.7ms/frame

CPU raster is comparable to Chrome CDP (14-40ms). The GPU backend
(Metal/Vulkan, Phase 3) is where the 10-50x speedup materializes —
the infrastructure is proven, the bottleneck is CPU vs GPU painting.
2026-04-25 15:10:01 -04:00
Miguel Ángel a29b1f5fb9 perf(native-renderer): add skia paint benchmark at 1080p with 20 elements
Criterion benchmark with a realistic 1080p scene: 20 overlapping
card containers with text children, rounded corners, partial opacity,
and overflow clipping.

Two bench functions:
- paint_1080p_20_elements: clear + recursive paint
- paint_and_encode_jpeg_1080p: clear + paint + JPEG encode

Baseline numbers (Apple Silicon, raster CPU backend):
  paint only:         ~177 ms
  paint + JPEG encode: ~184 ms

The high paint time is dominated by FontMgr::new() being called per
text element per frame (20x per iteration). Font/typeface caching
in the paint path is the obvious next optimization target.
2026-04-25 15:07:31 -04:00
Miguel Ángel f099d76d17 feat(native-renderer): add CDP scene extraction from Chrome
Walks a Chrome page's DOM via Puppeteer and extracts a scene graph as
JSON matching the Rust parse_scene_json() format. Uses kind nested-object
structure with serde-compatible internally-tagged enum discriminator for
Container/Text/Image/Video.

Extracts bounds, computed styles (background, opacity, border-radius,
overflow, transform, visibility, font properties, color), and recurses
through children. Text detection based on text-only child nodes, image
and video detection based on element tag.

Tests verify JSON round-trip compatibility with the Rust scene types
including all element kinds, nested children, and transform serialization.
2026-04-25 15:04:57 -04:00
Miguel Ángel 49762b5a99 feat(native-renderer): static scene to MP4 render pipeline via FFmpeg
Wire Scene -> Skia paint -> JPEG encode -> FFmpeg pipe into an
end-to-end render_static function. The scene is painted once and the
JPEG frame is repeated N times via image2pipe, producing an H.264 MP4.

Includes RenderConfig/RenderResult types and two integration tests
(1s@30fps, 0.5s@24fps) that assert frame count, file existence, and
non-trivial output size.
2026-04-25 15:03:14 -04:00
Miguel Ángel 0f139b4e61 feat(native-renderer): element painting with transforms, opacity, border-radius
Adds paint_element() that recursively renders a scene graph Element onto
a Skia Canvas. Supports:
- Visibility culling (skip invisible elements)
- Position translation + CSS-like transform (rotate, scale around center)
- Layer-based opacity blending (save_layer_alpha)
- Overflow clipping with rounded rects
- Background fill (rect or rrect depending on border_radius)
- Text rendering via FontMgr default typeface
- Recursive child painting

Helper functions: to_color4f, to_sk_rect, make_rrect.

Four new tests covering background+text, border-radius+opacity pixel
verification, transforms, and invisible element skipping.
2026-04-25 15:00:12 -04:00
Miguel Ángel 8da7cb37de feat(native-renderer): add skia surface creation and pixel readback
Add RenderSurface abstraction wrapping skia_safe::Surface with:
- CPU-backed raster surface creation via surfaces::raster_n32_premul
- RGBA8888 pixel readback
- JPEG and PNG encoding via image snapshots
- Canvas access, clear, and dimension queries

All 4 paint tests pass alongside existing scene graph tests.
2026-04-25 14:56:24 -04:00
Miguel Ángel db1b3ae23c feat(native-renderer): scaffold Rust crate with scene graph types
Initialize the hyperframes-native-renderer crate with:
- Scene graph types (Scene, Element, ElementKind, Rect, Style, Color, Transform2D)
- Serde JSON serialization/deserialization with sensible defaults
- Internally-tagged ElementKind enum (Container/Text/Image/Video)
- parse_scene_json and parse_scene_file entry points
- Integration tests covering all element kinds, nesting, transforms,
  partial styles, round-trip serialization, and error handling
2026-04-25 14:53:03 -04:00
Miguel Ángel 39410ee320 fix(engine): revert streaming/multi-page/gpu defaults to safe values
Streaming encode is a regression on Linux/headless-shell (confirmed by
both local benchmarks and Rames' Linux VM A/B). The pipe becomes a
bottleneck when capture is already fast — Chrome and FFmpeg compete for
CPU instead of overlapping.

Reverts:
- enableStreamingEncode: true → false
- useMultiPageCapture: true → false (shared WebSocket serializes CDP calls)
- gpuBackend: "hardware" → "swiftshader" (needs real GPU to validate)

All three features remain available as opt-in via config/env vars.
The remaining 6 always-on optimizations (string-eval seek, FFmpeg
threading, parallel audio, hwaccel extraction, worker tuning, lower
PNG compression) are net-positive on all tested fixtures.
2026-04-25 13:57:03 -04:00
Miguel Ángel cb1642f2ed perf(engine): overhaul rendering pipeline for speed parity with Remotion
9 optimizations targeting the frame capture hot path (86% of total render time):

1. Enable streaming encode by default — overlaps capture with FFmpeg encoding,
   eliminating the encode stage as a separate step. Saves ~10% wall time.

2. Multi-page shared browser — screenshot-mode workers share a single Chrome
   process instead of launching N separate browsers. Eliminates N-1 Chrome
   startup costs (~2-3s each) and shares GPU/cache across pages.

3. Lower streaming JPEG quality (55 vs 80/95) — intermediate frames piped to
   FFmpeg are re-encoded, so quality loss is invisible in the final output.
   Smaller buffers transfer faster over CDP, cutting per-frame capture time.

4. Hardware GPU acceleration — replace forced SwiftShader (software renderer)
   with native GPU (Metal on macOS, Vulkan on Linux). CSS effects like
   box-shadow, blur, and transforms render 10-100x faster. Falls back to
   SwiftShader automatically in Docker/CI where no GPU exists.

5. Optimized seek evaluation — string eval replaces function serialization
   in the per-frame page.evaluate() call, skipping Puppeteer's serialize +
   Runtime.callFunctionOn overhead. Removes redundant __hf.seek guard that
   was already validated during session initialization.

6. FFmpeg multi-threading — adds -threads 0 across all encoding paths
   (streaming, chunk, extraction) to let FFmpeg auto-parallelize.

7. Parallel audio processing — audio extraction now runs concurrently with
   frame capture instead of blocking it. Audio uses separate FFmpeg processes
   that don't compete with Chrome for CPU.

8. Hardware-accelerated video extraction — adds -hwaccel auto for H.264
   sources and reduces PNG compression from level 6 to 1 (temp files).

9. Tuned parallelism defaults — more aggressive worker allocation (8 max,
   2 cores/worker, 24 min frames/worker) suited to modern hardware.

All 494 engine tests pass. No regressions.
2026-04-25 13:25:33 -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 45f70004a1 chore: release v0.4.26 v0.4.26 2026-04-25 11:14:36 -04:00
Miguel Ángel b92341eb98 fix(cli): publish projects through staged uploads (#491)
* fix(cli): publish projects through staged uploads

* fix(cli): honor signed upload length header
2026-04-25 17:09:16 +02:00
Miguel Ángel 8ac46b4596 chore: release v0.4.25 v0.4.25 2026-04-25 10:59:45 -04:00
Mu-Tsun Tsai 1782cebfc4 fix(cli): resolve sub-composition <audio> src relative to its own file (#489)
`lintAudioSrcNotFound` resolves every `<audio src>` against the project
root, which is correct for index.html but wrong for sub-compositions —
their srcs are written relative to the sub-composition file (e.g.
`../assets/foo.mp3`), and the bundler rewrites them at compile time.

Carry the sub-composition path alongside each html source and run
`<audio src>` strings starting with `../` through the existing
`rewriteAssetPath` helper before checking existence on disk. Mirrors the
runtime/bundler behaviour so the lint check sees the same path the
renderer will fetch.

Original (un-rewritten) src is still surfaced in the finding message so
authors can grep for it in their HTML.
2026-04-25 16:55:28 +02:00
Miguel Ángel 82fab98e69 chore: release v0.4.24 v0.4.24 2026-04-25 00:39:03 -04:00
Miguel Ángel 2f60dd3727 fix(producer): serve symlinked render assets (#486) 2026-04-25 06:36:27 +02:00
Miguel Ángel 5e1db03fe7 fix: sequence multi-file timeline drops (#487) 2026-04-25 06:22:52 +02:00
Mu-Tsun Tsai bb2e64ced1 fix(studio): pin user-agent color scheme to dark (#482)
The studio shell paints a #0a0a0a body but never declares `color-scheme`,
so browsers render native UA chrome (scrollbars, form controls, focus
rings) in the light palette and the mismatch is obvious — especially
scrollbars, which appear as light tracks sitting on top of a near-black
panel.

Studio doesn't expose a theme toggle; the UI is dark-only. Declaring
`color-scheme: dark` on `:root` lines up the browser-native surfaces
with the rest of the chrome.
2026-04-25 06:12:06 +02:00
Miguel Ángel ef45f653ff ci: guard release channel publishing (#488) 2026-04-25 06:04:10 +02:00
Mu-Tsun Tsai 28b84ad8de fix(studio): prevent NLE timeline scrollbar from clipping below viewport (#481)
* fix(studio): restore flex layout inside the NLE timeline panel

Without `flex flex-col`, the toolbar and Timeline children render as
block elements and Timeline's `h-full` takes the panel's entire height.
The toolbar still occupies its own ~45px of flow, pushing the scroll
area below the viewport and clipping the scrollbar at the bottom of the
right-side panel.

* fix(studio): pin toolbar height with flex-shrink-0 and trim comment

Wrap the toolbar slot in a flex-shrink-0 div so Timeline back-pressure
can't squeeze the toolbar below its natural height. The visible bug
doesn't manifest at current sizes, but in a flex-col container both
children are flex items and the toolbar has no shrink guard of its own.

Trim the inline comment to a single line — the rationale belongs in
the commit message, not at the call site.
2026-04-25 05:50:46 +02:00
Mu-Tsun Tsai bdc7061f98 fix(cli): shut down preview embedded-mode server on Ctrl+C (#483)
* fix(cli): shut down preview embedded-mode server on Ctrl+C

runEmbeddedMode awaited a promise that never resolved, relying on Node
to exit on SIGINT. Two things kept that from working in practice:

1. @hono/node-server's listening handle keeps the event loop alive after
   the signal fires, so the process hangs even when SIGINT does arrive.
2. On Windows, some terminals (Git Bash / MSYS) don't deliver Ctrl+C to
   the Node process as a SIGINT at all — the keystroke is eaten at the
   TTY layer.

Register a SIGINT/SIGTERM handler that closes the server explicitly and
resolves the promise, with a 2s force-exit fallback. On Windows, run a
readline interface on stdin to catch Ctrl+C at the TTY and re-emit it
as SIGINT so the same handler fires.

Print "Shutting down studio..." as soon as the signal is received —
server.close() can take a second or two to drain keep-alive connections
and an unmarked pause reads as "stuck".

Exit code 0 because a user-initiated Ctrl+C isn't an error; non-zero
codes make pnpm print ELIFECYCLE right where the user just asked the
process to stop.

* fix(cli): close readline interface in shutdown to honour exit-code intent

After the SIGINT handler removes itself, the Windows readline interface
is still alive and listening. A second Ctrl+C during the 2s grace
period would re-emit SIGINT with no registered handler, triggering
Node's default exit-130 behaviour and contradicting the explicit
exit(0) we chose for clean teardown.

Hoist the readline handle out of the win32 branch so shutdown can
close it before invoking server.close(). Also pass the signal name
to process.emit("SIGINT", "SIGINT") to match Node's ProcessEvents
overload — runtime behaviour is unchanged.
2026-04-25 05:49:26 +02:00
Mu-Tsun Tsai 6928e5ae53 fix(engine): suppress benign play()/pause() AbortError spam during render (#484)
* fix(engine): suppress benign AbortError spam from frame-capture pageerror

Frame capture pauses → seeks → screenshots → plays audio/video many times
per second. HTMLMediaElement.play() returns a promise that rejects with
AbortError whenever another pause() lands before it resolves — which it
does, every frame. The rejection is benign (output frames and mixed
audio are unaffected) but the frameCapture pageerror handler was logging
it to stderr, producing dozens of identical lines per render:

  [Browser:PAGEERROR] AbortError: The play() request was interrupted by
  a call to pause(). https://goo.gl/LdLk22

Filter out exactly this pattern before console.error — still pushed to
browserConsoleBuffer so it's available in the failure-diagnostic dump.

* fix(engine): trim play-abort filter comment and drop unnecessary regex flags

The why-it-exists explanation belongs in the commit message and PR
description, not as a 12-line comment block at the call site.

Chrome's play()/pause() AbortError message is always lowercase, so
the case-insensitive flag implies uncertainty that doesn't exist.
Replace the two /play\(\)/i and /pause\(\)/i regexes with plain
String.prototype.includes — same outcome, less ceremony.
2026-04-25 05:48:54 +02:00
Miguel Ángel b947966a8b feat(cli): add visual inspect command (#480)
* feat: add layout audit command

* feat: refine visual inspect command
2026-04-25 02:07:47 +02:00
Miguel Ángel c427619cff chore: release v0.4.23 v0.4.23 2026-04-24 17:24:53 -04:00
Ular Kimsanov 309c74e864 Merge pull request #364 from ibrews/skill-load-bearing-gsap-rules
docs(website-to-hyperframes): add load-bearing GSAP authoring rules
2026-04-24 17:21:28 -04:00
Miguel Ángel 31e8144304 fix: render parity for transparent looped videos (#478)
## Summary
- preserve alpha for render-injected video frames by detecting alpha streams with ffprobe and extracting alpha video frames as PNG
- keep `<video loop>` semantics through static parsing, compiler duration resolution, browser media discovery, and render frame lookup
- fail embedded preview startup before opening a broken browser page when the Studio bundle is missing
- align snapshot frame injection with looped media timing and VP9 alpha extraction

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

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

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

Note: `bun run --filter @hyperframes/cli dev -- validate` is blocked in source mode by the existing `contrast-audit.browser.js` default-export loader issue; packaged `node packages/cli/dist/cli.js validate` passes for this project.
2026-04-24 23:18:20 +02:00
Miguel Ángel e8c43f0889 fix: prevent nested composition videos from autoplaying on seek (#477)
## Problem

Studio seek could still wake nested composition media even when the transport itself stayed paused.

In the real repro from `apple-presentation`, scrubbing to `0:29` without pressing play lands on the `slide-translation` composition. That composition contains `Multilingual_Journey.mp4` inside the composition host. On the broken path:

- the main Studio transport remained paused
- the nested video advanced and stayed playing anyway
- the user saw autoplay-like behavior even though the only action was a seek

That was especially confusing because the seek was otherwise correct: the timeline moved to the right point, but the nested media stopped obeying the paused transport state.

## What this fixes

### Nested media now participates in runtime media sync

- the runtime media cache no longer assumes only `video[data-start]` / `audio[data-start]` are relevant
- nested media inside a composition host can now be included in the same timed-media sync pass even when the inner media element does not carry its own authored `data-start`

### Nested media timing is resolved in the host composition window

- nested media start time is resolved against the enclosing composition host instead of falling back to scene-local `0`
- nested media duration is clamped to the enclosing composition window so it stays aligned with the authored host clip timing

### Paused seeks land on the right frame and stay paused

- after seeking into a nested composition, the inner media is now seeked to the correct frame relative to the host timeline
- because it is now part of the managed media set, the runtime also keeps it paused when the transport is paused instead of letting it continue playing on its own

### Regression coverage

- adds a runtime regression test that covers a nested composition video with no local `data-start`
- the test verifies that `player.seek(29)` leaves the nested video paused while landing it at the expected `currentTime`

## Root cause

The bug came from a mismatch between deterministic timeline seeking and media ownership.

### 1. The runtime only managed media with direct timing attrs

`refreshRuntimeMediaCache()` only collected `video[data-start]` and `audio[data-start]`. That works for root-level timed media, but not for media embedded inside a composition host where timing is inherited from the host composition rather than duplicated onto the inner media node.

### 2. Nested composition seek could still advance inner media

The runtime intentionally rearms sibling timelines during deterministic seek so nested timelines land on the right local offsets. That part is necessary and correct.

But because the nested video was not part of the managed media cache, it could advance during that seek path without being brought back under the paused transport state afterward.

### 3. The runtime had no way to reconcile the two

So the system had an inconsistent split:

- timeline seek knew about the nested composition timeline
- media sync did not know about the nested media inside it

The fix closes that split by resolving nested media start/duration from the enclosing composition context and running it through the same sync logic as other managed media.

## Verification

### Local checks

- `bun run --filter @hyperframes/core typecheck`
- `bun run test -- src/runtime/init.test.ts src/runtime/media.test.ts src/runtime/player.test.ts` in `packages/core`
- `bunx oxlint packages/core/src/runtime/media.ts packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`
- `bunx oxfmt --check packages/core/src/runtime/media.ts packages/core/src/runtime/init.ts packages/core/src/runtime/init.test.ts`

### Browser verification

Verified against a repo-backed local Studio preview of `apple-presentation`:

- opened `http://127.0.0.1:3014/#project/apple-presentation`
- seeked to `0:29` without pressing play
- confirmed the visible composition switched to `slide-translation`
- confirmed `Multilingual_Journey.mp4` landed at a non-zero `currentTime` (`3.067` in the verified run)
- confirmed the nested video stayed `paused` and its `currentTime` remained stable across a follow-up check instead of autoplaying

## Notes

- the local browser proof artifacts under `qa-artifacts/autoplay-seek/` are verification-only and are not part of this PR
- this PR is intentionally scoped to nested media ownership during paused seek; it does not broaden into unrelated runtime media refactors beyond bringing inherited nested media under the existing sync contract
2026-04-24 22:10:11 +02:00
Vance Ingalls 6b21ead737 chore: release v0.4.22 v0.4.22 2026-04-24 11:43:48 -07:00
Alex Coulombe 2cb933df61 style: format 2026-04-24 14:21:34 -04:00
Miguel Ángel a47f48a17f chore: release v0.4.21 v0.4.21 2026-04-24 17:11:05 +00:00
Miguel Ángel 267ffd3fca fix(engine,producer): preserve template-wrapped sub-composition media offsets (#476)
## Problem

Template-wrapped sub-compositions could still lose correct parent timing during render in more than one place.

In the validated repros, a host sub-composition starting after the intro (and in one follow-up repro, starting at `20s` after earlier compositions) contained scene-local media inside it. On the broken paths:

- template-wrapped media could be missed during compile and scheduled at raw scene-local time
- already-correct first-pass offsets could be clobbered during `recompileWithResolutions()`
- even after those two fixes, the browser-metadata reconcile step in `executeRenderJob()` could still overwrite a compiled global `end` with a scene-local `data-end` from the inlined DOM, clipping the tail off late-start sub-composition media

## What this fixes

### Template-wrapped media discovery

- `parseVideoElements`, `parseImageElements`, and `parseAudioElements` now unwrap a single top-level `<template>` wrapper before scraping media
- the unwrap helper is DOM-based, not regex-based, so it avoids the CodeQL backtracking warning and only unwraps the exact single-wrapper shape we want
- multiple sibling templates or other top-level content are left untouched instead of being rewritten heuristically

### Offset preservation after duration resolution

- `recompileWithResolutions()` now preserves the first-pass sub-composition media arrays when the already-inlined HTML no longer contains `[data-composition-src]` hosts
- that prevents correctly offset media metadata from being overwritten by scene-local media parsed from the merged DOM

### Browser metadata reconciliation in the compiled time origin

- browser-discovered media can still report scene-local `data-start` / `data-end` from the merged DOM after inlining
- the producer now reprojects browser `end` values into the compiled element's time origin before reconciling them back into `composition.videos` / `composition.audios`
- this prevents late-start sub-composition media from getting truncated back to a scene-local end during the probe phase

### Regression coverage

- adds focused engine tests for the template unwrap helper
- adds producer regression coverage for both the initial compile path and the post-inline `recompileWithResolutions()` path
- adds producer regression coverage for late-start host compositions (`t≈20`) with scene-local media inside them
- adds producer unit coverage for the browser-end reprojection helper used by the reconcile path

## Root cause

There were three distinct renderer failures behind the bug:

### 1. Template contents were invisible to the media scrapers

`parseSubCompositions()` reads raw sub-composition HTML and applies the host offset to discovered media. But the engine media helpers were querying the parsed document directly, and linkedom follows browser semantics here: top-level `<template>` contents live in a `DocumentFragment`, so `querySelectorAll()` never saw those `<video>` / `<audio>` / `<img>` nodes.

That meant template-wrapped sub-compositions could silently produce zero discovered media during the first pass.

### 2. The duration-resolution recompile could clobber already-correct offsets

After the browser resolves composition durations, `recompileWithResolutions()` reparses the already-inlined HTML. By that point the original `[data-composition-src]` hosts are gone, so `parseSubCompositions()` legitimately returns no nested media.

The old code still rebuilt the deduped media arrays from the merged DOM, which let scene-local media parsed from the inlined HTML overwrite the correctly offset first-pass metadata.

### 3. The browser probe reconcile path mixed two timing coordinate systems

`discoverMediaFromBrowser()` reads `data-start` / `data-end` directly from the live DOM after sub-compositions are already inlined. For nested media, those attributes can still be scene-local even though the compiled metadata has already been offset into the parent host timeline.

The old reconcile path compared those values directly and overwrote `existing.end` whenever the numbers differed. For a late-start sub-composition, that could replace a correct global end like `25.5` with a scene-local end like `5.5`, cutting the clip off during render.

## Verification

### Local checks

- `bun test packages/engine/src/utils/htmlTemplate.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bunx vitest run packages/producer/src/services/renderOrchestrator.test.ts`
- `bun run --filter @hyperframes/engine test`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bunx oxlint packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxfmt --check packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts`
- `bun run build:producer`

### Render / browser verification

Verified against two local repros:

1. **Early offset repro**
   - host starts at `2s`
   - child media is scene-local `0-4s`
   - compiled render summary keeps the child video/audio at `start: 2`
   - browser verification via `agent-browser` confirmed the `2.2s` frame still shows the child clip active in the host timeline

2. **Late offset repro**
   - earlier compositions run first, then the target host starts at `20s`
   - child media starts scene-local at `1.5s` and should remain visible through `24.5s`
   - compiled render summary keeps the child video/audio at `start: 21.5`, `end: 25.5`
   - browser verification via `agent-browser` confirmed the `24.5s` frame still shows the late clip visible, which is the exact tail-clipping case the old reconcile path could break

## Notes

- the `/tmp/hf-pr475-repro` and `/tmp/hf-pr476-late-offset-repro` projects plus their browser-proof artifacts are verification-only and are not part of this PR
- this PR stays narrowly scoped to sub-composition media timing across compile, recompile, and browser probe reconciliation; it does not broaden into general sub-composition HTML normalization beyond the single-wrapper case
2026-04-24 19:00:42 +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
Alex Coulombe 924fd2f145 docs(step-6-build): sharpen Rule 2 — name from() seek-past-end reset as root cause
Per review feedback on PR #364: the 'hero vanishes' failure has two
mechanisms. Primary: second tween's immediateRender overwrites the first
at construction time. Secondary: tl.from() resets to its declared from-
state when seeked past timeline end, which the capture engine triggers.
Both are now named so the rule has precise rationale, not just a pattern
to avoid.

Ref: https://github.com/heygen-com/hyperframes/pull/364#pullrequestreview-4167523103
2026-04-24 10:56:59 -04:00
Vance Ingalls e9c56961fb docs(engine): document __name polyfill and add regression test (#385)
## Summary

Document why the `window.__name` polyfill in `frameCapture.ts` is necessary, expand the inline comment with the full per-runtime matrix, and add a regression test that surfaces transpiler behavior on the next failure.

Outcome of the Chunk 12 investigation: **keep the polyfill**.

## Why

`Chunk 12` of `plans/hdr-followups.md`. The polyfill had a vague comment and no test, so it was unclear whether it was still needed or could be deleted.

## Empirical findings

Probe in `/tmp/hf-name-probe`:

| Runtime / build | Injects `__name(fn, "name")` wrappers in `Function.prototype.toString()`? |
|-----------------|---------------------------------------------------------------------------|
| `bun` (TS loader) | No — verified for top-level and nested named functions / arrow expressions. |
| `tsx` (esbuild loader, `keepNames=true`) | **Yes** for nested named functions / arrows; observed crash mode in dev/test. |
| `tsc` (`noEmit` and emit) | No — does not inject the helper. |
| `tsup` for `@hyperframes/cli` (`noExternal: ["@hyperframes/engine"]`) | Polyfill *definition* is bundled, but `__name(...)` *call sites* are absent in `packages/cli/dist/cli.js` (grepped). |

**Root cause.** `@hyperframes/engine`'s `package.json` exports raw TypeScript (`main`/`exports` → `./src/index.ts`), so every consumer's transpiler decides whether to inject `__name`. Anything that runs through `tsx` (producer parity-harness, ad-hoc dev scripts, `bun run --filter @hyperframes/engine test` via Vitest's loader) will serialize wrapped function bodies into `page.evaluate(...)` and crash with `ReferenceError: __name is not defined`.

**Decision.** Keep the no-op `window.__name` shim. Cost is one `evaluateOnNewDocument` call. The alternative (rewriting every `page.evaluate(fn)` site to `page.addScriptTag({ content: "..." })`, like `packages/cli/src/commands/contrast-audit.browser.js` already does) is far more invasive and easy to regress.

## What changed

- Expanded the inline comment in `packages/engine/src/services/frameCapture.ts` to explain the per-runtime matrix above and point to the script-tag alternative.
- New `packages/engine/src/services/frameCapture-namePolyfill.test.ts` — a pure unit test (matches the rest of the engine package's no-browser-launch convention) that:
  1. Asserts the polyfill is wired up via `evaluateOnNewDocument` and runs before the first awaited `browser.version()` call.
  2. Probes the active Vitest transpiler for `__name(...)` injection so the next maintainer can see at a glance whether the upstream behavior has shifted.

## Test plan

- [x] `bun run --filter @hyperframes/engine test` → 408/408 pass (3 new tests in this file).
- [x] `bunx tsc --noEmit -p packages/engine` clean.
- [x] `bunx oxlint` and `bunx oxfmt --check` clean on edited files.

## Stack

Chunk 12 of `plans/hdr-followups.md`. Independent of all other chunks; closes out the investigation item.
2026-04-24 01:03:20 -07:00