Commit Graph
1138 Commits
Author SHA1 Message Date
Miguel Ángel 1bdb2d4ec0 feat(studio): runtime-synced design panel + 3D props + split polish (#1188)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* ci: trigger regression run

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* feat(studio): add 'delete all keyframes' to diamond context menu

* ci: trigger regression run

* ci: trigger regression run

* ci: trigger regression run

* fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard

- Fix overlay bounding box jump: reapplyBoxSizes now skips elements whose
  width/height are animated by GSAP (gsapAnimatesProperty check prevents
  studio CSS from overwriting GSAP interpolated values)
- Delete All Keyframes removes entire animation (handleGsapDeleteAnimation)
  with fallback to first animation when no keyframed anim exists
- Wire split clip through App → StudioPreviewArea → NLELayout → Timeline
  (onSplitElement prop, toolbar button, S hotkey, clip context menu)
- Add onContextMenu to TimelineClip for right-click clip context menu

* fix(studio): block split on sub-compositions

Sub-compositions (data-composition-src) cannot be meaningfully split —
the clone would load the same source and fight for the same timeline.
Block with a specific toast message and disable in the context menu.

* fix(studio): no toast when split is unavailable for compositions

* fix(studio): remove defensive toast from split handler — UI gates are sufficient

* fix(studio): hide split button for sub-compositions, use scissors icon

* ci: trigger regression run

* feat(studio): runtime-synced design panel values + 3D transform properties

The Layout section (X, Y, W, H, R) now reads GSAP-interpolated values
from the runtime via gsap.getProperty() at the current seek time. When
an element has GSAP animations, the fields reflect the actual interpolated
position/size/rotation instead of the CSS defaults.

Also adds 3D transform properties to SUPPORTED_PROPS: z, rotationX,
rotationY, rotationZ, perspective, transformOrigin.

* fix(studio): read ALL animated properties from runtime, not just hardcoded 7

* fix(studio): stronger clip selection border + wider keyframe playhead tolerance

- Selected clip: full accent border (was 38% opacity), subtle glow shadow
- Keyframe diamond at playhead: tolerance 0.5% (was 0.05% — too tight at
  high zoom levels, causing diamonds to never highlight)

* fix(studio): sync DOM selection to timeline selectedElementId on cold load

* fix(studio): use Phosphor Scissors icon for split button

* fix(studio): restrict split to media elements only (video, audio, img)

* feat(studio): unified commitAnimatedProperty for all GSAP property edits

Extract useAnimatedPropertyCommit hook that handles the three-case commit
logic: keyframed → add-keyframe, flat → convert + add, no animation →
create + convert + add. Wire Z, Scale, RotX, RotY design panel fields
and 2D Layout fields through this unified pipeline.

Export readAllAnimatedProperties and readGsapProperty from the runtime
bridge so the commit helper can read all animated props for backfill.

* fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty

* chore: remove committed plan files

These design plans were accidentally committed and should not be in
the PR.
2026-06-05 12:11:01 -04:00
Miguel Ángel c1699ec98b feat(studio): runtime-first dynamic keyframe system [8/10] (#1190)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(studio): runtime-first dynamic keyframe system with auto-materialization

Read GSAP keyframe data from the live runtime instead of only the AST parser.
Dynamic keyframes (loops, variables, computed selectors) now show diamonds
on timeline clips and animation cards in the design panel.

On first edit, dynamic code is automatically materialized:
- Unresolved keyframes (keyframes: kf) replaced with static object
- Unresolved selectors (tl.to(sel, ...)) entire loop unrolled into
  individual static tl.to() calls per element

Key changes:
- Parser: hasUnresolvedKeyframes/hasUnresolvedSelector flags
- Runtime bridge: scanAllRuntimeKeyframes reads tween.vars from iframe
- Tween cache: interval-based runtime scan for dynamic animations
- materializeKeyframesInScript + unrollDynamicAnimations parser functions
- Keyframe cache dual-writes both sourceFile#id and index.html#id keys
- commitMutation updates cache from mutation response
- easeEach placement fix (inside keyframes object, not tween vars)
2026-06-05 12:08:03 -04:00
Miguel Ángel d1aad77fd7 feat(studio): split clip at playhead for media elements [7/10] (#1189)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(studio): add split clip feature with timeline context menu and hotkey

Add splitElementInHtml to core source mutation helpers — clones an element
at the split time, adjusts data-start/data-duration/data-media-start for
both halves, and inserts the clone after the original.

Wire through: split-element API endpoint, handleTimelineElementSplit in
useTimelineEditing, clip context menu (right-click → Split at Xs), toolbar
split button, and S keyboard shortcut.

Edge cases: locked/implicit clips blocked, media trim offset adjusted by
playback rate, unique ID generation with collision avoidance, undo via
edit history.
2026-06-05 12:06:00 -04:00
Miguel Ángel a5211954ea feat(studio): design panel, timeline polish, feature flag [6/6] (#1172)
* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.
2026-06-05 12:05:55 -04:00
Miguel Ángel 7a0883264d feat(studio): keyframe hooks wiring — session, cache, toolbar [5/6] (#1171)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.

* feat(studio): GSAP runtime bridge + optimistic update pattern

* feat(studio): keyframe diamonds, navigation controls, context menu

* feat(studio): keyframe hooks wiring — session, commits, cache, toolbar toggle
2026-06-05 11:51:52 -04:00
Miguel Ángel 5984c58846 feat(studio): keyframe diamonds, navigation, context menu [4/6] (#1170)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.

* feat(studio): GSAP runtime bridge + optimistic update pattern

* feat(studio): keyframe diamonds, navigation controls, context menu
2026-06-05 11:51:44 -04:00
Miguel Ángel 12e87e05b4 feat(studio): GSAP runtime bridge + optimistic updates [3/6] (#1169)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.

* feat(studio): GSAP runtime bridge + optimistic update pattern
2026-06-05 11:51:39 -04:00
Miguel Ángel aab7377400 feat(core): spring physics solver + runtime fixes [2/6] (#1168)
* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.
2026-06-05 11:51:25 -04:00
Miguel Ángel e639a1638f feat(core): GSAP keyframe parsing, mutations, and API routes (#1167) 2026-06-05 11:46:45 -04:00
Miguel Ángel 2757949912 chore: release v0.6.73 2026-06-04 22:52:23 +00:00
Miguel Ángel faa3f588fb fix(runtime): don't restart non-loop media that has naturally ended (#1203)
* fix(runtime): don't restart non-loop media that has naturally ended

When a media element's authored data-duration exceeds the actual file
length, el.ended becomes true at the file's natural end while the clip
is still considered 'active' (timeSeconds < clip.end). The runtime was
calling el.play() on the ended element every rAF tick, resetting
currentTime to 0 and causing audible stutter for the overshoot duration.

Fix: treat el.ended as inactive for non-loop clips. The element sits
silently until the composition ends. el.ended resets to false on any
seek, so scrubbing backward correctly resumes playback.

Reproducer: bg-music WAV is 60s but data-duration='68.6' (composition
duration). Last 8.6s: rapid play->clamp->end->play cycle at 60fps.

* test(runtime): add seek-recovery contract test for el.ended guard

Adds a third test case pinning the seek-recovery property called out in
the PR body: a clip that went silent at t=62 (el.ended=true) should
resume playing after a backward seek resets el.ended to false.
2026-06-04 18:50:32 -04:00
Miguel Ángel 9679503158 fix(cli): report available memory instead of free memory in doctor (#1204)
os.freemem() on macOS returns only truly free pages (~0.1 GB on a 24 GB
machine), ignoring inactive/purgeable/speculative pages the kernel
reclaims on demand. This caused a false "Low memory" warning on every
macOS machine.

Add getAvailableMemoryMb() that uses vm_stat on macOS and MemAvailable
from /proc/meminfo on Linux, falling back to os.freemem() elsewhere.

Also trim FFmpeg/FFprobe version strings to just "toolname X.Y.Z"
instead of the full copyright line.
2026-06-04 18:23:02 -04:00
Carlos Alcaraz GregorandCarlos Alcaraz 0870394d20 test(cli): cover the cloud client 401-refresh-retry decorator (#1202)
createCloudClient wraps the generated client in a Proxy that catches
HyperframesApiError(401), force-refreshes credentials, and retries
once. That auth recovery path had no tests; a regression would only
surface as cloud commands failing outright on server-side token
revocation or clock-skew rejections.

Covers: passthrough, refresh-and-retry with the new token actually
re-resolved (not a stale header replay), refresh failure surfacing
the original 401, single-retry on repeated 401, and no refresh on
non-401 or transport errors. Zero source changes.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-06-04 15:12:19 -07:00
James RussoandClaude Opus 4.7 6affe2d212 fix(cli): reject directory --composition and add --browser-timeout (#1199) (#1200)
* fix(cli): reject directory --composition and add --browser-timeout (#1199)

Two unrelated symptoms from issue #1199, fixed together:

1. `--composition .` (or any directory path) used to slip past the
   existsSync check in render.ts and explode downstream as
   `EISDIR: illegal operation on a directory, read` when the producer
   readFileSync'd the entry. The CLI now treats `.` / `""` as "omit
   the flag" (falls back to index.html) and rejects other directory
   paths with an actionable error pointing at the .html shape.

2. The 60s Puppeteer page.goto timeout in frameCapture.ts was hard-
   coded, so heavy compositions (many videos / fonts / asset requests)
   could not complete `domcontentloaded` in time. Add a configurable
   `pageNavigationTimeout` to EngineConfig (default 60_000, env
   fallback PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS) and expose it as
   `--browser-timeout <seconds>` on `hyperframes render`. The flag
   threads through both renderLocal (via resolveConfig) and the
   docker bridge (via buildDockerRunArgs).

Tests:
- render.test.ts: forwards/omits pageNavigationTimeout into resolveConfig
- dockerRunArgs.test.ts: forwards/omits --browser-timeout (seconds)

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

* fix(cli): address PR #1200 review — extract validators, tighten bounds

Addresses Vai's blockers and Miguel's nits on PR #1200:

- Vai blocker 1 (fallow CRAP) + blocker 3 (no argv tests):
  Extract --browser-timeout and --composition validators into pure
  helpers in utils/renderArgs.ts with a structured-result discriminant.
  Drops ~45 lines of inline validation from run(), reducing its CRAP
  score 1290→978 and cyclomatic 75→65. 19 new unit tests cover the
  parse branches (sub-ms, overflow, NaN, Infinity, empty, negative,
  ".", "./", whitespace, directory, missing, ../escape, sibling-prefix).

- Vai blocker 2 (sub-ms → timeout:0 = "no timeout"): reject inputs
  that round to <1 ms. Puppeteer treats page.goto({timeout:0}) as
  wait-forever, so --browser-timeout 0.0004 silently flipped the
  semantics. Now rejected with an explicit "rounds to 0 ms" error.

- Vai important 5 (1e10 accepted → setTimeout overflow): cap at
  86_400s (24h). Above Node's TIMEOUT_MAX ≈ 2^31-1 ms setTimeout
  fires immediately, the opposite of "long timeout."

- Vai important 4 (related timeouts unmentioned): CLI help and docs
  now flag PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS and the 45s
  playerReadyTimeout as the other knobs heavy compositions may need.

- Vai nit 7 (s/ms unit mismatch): help text and docs row both call
  out the SECONDS-vs-MILLISECONDS difference between flag and env.

- Vai nit 8 / Miguel nit (composition flag discoverability): the
  --composition description now says "Pass `.` (or omit the flag)
  to render the project's index.html."

- Miguel nit (dead branch): the entryFile === "" unreachable branch
  is gone. New helper uses `if (!trimmed || trimmed === ".")`.

Also adds a trailing-separator guard on the project-containment check
(sibling-prefix bypass: /proj-evil/x.html no longer slips past
startsWith('/proj')) — flagged by the code review.

The three remaining fallow complexity findings on render.ts (run,
renderDocker, trackRenderMetrics) are inherited from main; this PR
reduces run() but does not refactor it. Suppressed with
fallow-ignore-next-line markers and inline rationale.

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

* fix(cli): diverge --browser-timeout error messages per Vai nit 5

The `not-a-number` and `not-positive` branches in browserTimeoutErrorMessage
shared the generic "Must be a positive number of seconds" message even
though the discriminant carried distinct kinds. Diverge them so users see
the specific failure mode:

  --browser-timeout abc   →  "Got \"abc\", which is not a number."
  --browser-timeout -5    →  "Got \"-5\" seconds, which is not positive."

The shared hint ("pass a positive number of seconds, e.g. 180") is
preserved on both branches.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:47:10 -04:00
James 0b98565039 chore: release v0.6.72 2026-06-04 07:38:21 +00:00
James RussoandClaude Opus 4.8 72c461d86a fix(producer): localize remote <img> sources + await image readiness (#1197)
* fix(producer): localize remote <img> sources + await image readiness

Producer's frame-capture has `pollVideosReady` (waits readyState >= 2 for
every <video>) but no equivalent for <img>. Combined with htmlCompiler's
`collectExternalAssets` explicitly skipping http(s) URLs (line 805-806),
agent-pipeline-generated compositions (astral / daphne / hyperion
multi-v2 outputs with raw S3 <img src>) reach Chrome with a network
dependency that races the readiness gate AND can be evicted mid-render.
Either path produces blank-frame flicker.

Reproduction (02_kobe agent output, 42s render @ 30fps): scene_02's
remote S3 background-image painted from t=7.0s, vanished at t=10.5s
(frame size 139KB vs 700-940KB neighbors), back at t=11.0s. GSAP
timeline said opacity:1 throughout — Chrome simply didn't have the
pixels.

Two-layer fix:

1. **Producer** — `localizeRemoteImageSources` in `htmlCompiler.ts`
   mirrors the existing `localizeRemoteMediaSources` (video/audio) +
   `localizeRemoteFontFaces` pattern, reusing `downloadAndRewriteUrls`
   and the `_remote_media/` subdir. Wired into `compileForRender`
   between the media and font localize steps. Once the file is local,
   Chrome's image cache is bounded by disk reads, not S3 latency.

2. **Engine** — `pollImagesReady` + `decodeAllImages` helpers in
   `frameCapture.ts` parallel to `pollVideosReady`. Waits for every
   `<img>` (skipping data: URIs) to have `complete && naturalWidth > 0`,
   then forces GPU upload via `img.decode()`. Called from both the
   classic-xvfb path and the BeginFrame path after their respective
   video readiness checks. Defense-in-depth — Layer 1 closes the
   symptom for current+future agent-pipeline outputs; Layer 2 protects
   any future code path that leaves a remote URL in place.

Tests: 7 new cases in `htmlCompiler.test.ts` covering happy-path
rewrite, 404 fallback, dedup of duplicate URLs, non-HTTP and data:
URI passthrough, both quote styles, and the agent-pipeline shape where
`src` is not the first attribute. All pass alongside the existing 56
htmlCompiler tests.

* fix(producer): scope remote-img regex to real src; correct stale comments

Review follow-ups on the remote-<img> localization fix:

- Tighten REMOTE_IMG_TAG_RE with a (?<![\w-]) lookbehind so it matches a
  real `src` attribute only. The previous `\bsrc` also matched `data-src`
  (and `data-*-src`) lazy-loader placeholders, which would download/rewrite
  a URL the render never paints. Added a regression test; `srcset` stays
  excluded by the `\s*=` requirement.
- Fix comments that claimed frameCapture has "no pollImagesReady analog" —
  this PR adds exactly that, so the docstrings were self-contradictory.
  Reframed localization as the primary fix and pollImagesReady as the
  defense-in-depth layer, and documented the <img src>-only scope
  (srcset / <picture> / SVG <image> / CSS background-image are follow-ups).

Verified locally end-to-end on the 02_kobe repro: all 4 remote S3 <img>
URLs localize to _remote_media/, the render completes, and the frame at
t~10.5s that was a 139KB blank in the broken render now paints the trophy
background in every native-fps frame. htmlCompiler.test.ts 64 pass.

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

* fix(engine): pollImagesReady broken-image escape + skip decode on in-flight

Addresses two real bugs Magi caught in review on hf#1197:

1. pollImagesReady would spin the full pageReadyTimeout (45s default)
   for any <img> that settled with an error — Chrome marks 404 / decode
   failure / CORS rejection with (complete=true, naturalWidth=0), and
   the previous predicate `complete && naturalWidth > 0` returned false
   for those, so the poll ran to timeout. This is the HTMLImageElement
   equivalent of pollVideosReady's `ve.error` early-exit. Add a
   `complete && naturalWidth === 0` branch that treats settled-with-
   error as done — waiting won't make it load. Particularly relevant
   because localizeRemoteImageSources falls back to the original URL on
   download failure; that failed URL is now hit by a 45s stall instead
   of the broken-image marker rendering immediately.

2. decodeAllImages called img.decode() on every image, including those
   still in flight after pollImagesReady timed out. Per the WHATWG spec,
   decode() on a loading image awaits the fetch — never resolving
   until the network completes or puppeteer's evaluate timeout fires
   and throws an uncaught error that aborts the render. Pre-filter to
   only call decode() on images that successfully loaded.

Test coverage: new frameCapture-pollImagesReady.test.ts with 8 cases
covering empty docs, all-loaded, broken (complete + naturalWidth=0),
data: URI, empty src, in-flight → resolves, in-flight → timeout, and
the mixed batch. The broken-image test explicitly asserts elapsed <
500ms on a 1000ms timeout — guards against the regression Magi flagged.

* docs(engine): clarify decodeAllImages prevents init race, not eviction

Vai correctly noted that decode() forces initial GPU upload but does not
prevent Chrome from evicting decoded pixels mid-render. The producer-side
localizeRemoteImageSources is what bounds the eviction risk (local
file-server paging vs S3 re-fetch). Comment updated to reflect that split
of responsibilities.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 03:28:20 -04:00
James Russo 2be41937a9 fix(cli): support arm64 hosts for --docker render (#1196)
* fix(cli): support arm64 hosts for `--docker` render

The Docker render path pinned `--platform linux/amd64` for both build
and run, which on Apple Silicon / Graviton forced qemu emulation of
chrome-headless-shell. The emulated chrome process either SEGV'd or
hung on page navigation, producing the failures reported in #1193 /
#1194 / #1195.

Derive the platform from `process.arch` instead. On arm64 hosts:

- The image builds natively (no qemu).
- The Dockerfile skips the chrome-headless-shell install because
  Chrome for Testing only publishes a `linux64` build (verified
  against the known-good-versions manifest).
- The wrapper script leaves `PRODUCER_HEADLESS_SHELL_PATH` unset
  when no headless-shell binary is present, so the engine falls
  back to the system chromium that the Dockerfile already
  installs from apt and points at via `PUPPETEER_EXECUTABLE_PATH`.

`TARGETARCH` is forwarded as an explicit `--build-arg` instead of
relying on BuildKit's automatic platform args — the legacy
builder (and some BuildKit configs, including colima on macOS)
leaves it unset, which would silently bypass the arch conditional
in the Dockerfile.

Image tags are now suffixed with `-arm64` on arm64 hosts so amd64
and arm64 images of the same hyperframes version can coexist in
the local cache.

The arm64 path renders correctly but loses byte-for-byte parity
with amd64 (system chromium uses screenshot capture, not
HeadlessExperimental.beginFrame). The CLI prints a one-line
warning so users comparing against amd64 baselines know.

Verified on macOS 26.5 / M4 Max:

- Before: `qemu: unknown option 'type=gpu-process'` followed by a
  chrome-headless-shell SIGSEGV after ~4 minutes.
- After: 300/300 frames captured in ~18s of render time (1m18s
  wallclock including a one-time image build), MP4 produced.

Closes #1193
Closes #1194
Closes #1195

* fix(cli): address review feedback on docker arm64 fix

Follow-up to 61880cdc. Addresses one substantive review comment from
@vanceingalls and three self-review gaps.

1. Restore loud build failure on amd64 when chrome-headless-shell is
   missing (per @vanceingalls). The original Dockerfile used an `&&`
   chain that crashed the build if `find` returned empty; the new
   `if/else` wrapper silently fell through to system chromium even on
   amd64, which would mask golden-baseline regressions from a future
   @puppeteer/browsers cache layout change. The else branch now checks
   `TARGETARCH = amd64` and exits 1 with an actionable error, while
   arm64 still falls through to the system-chromium wrapper cleanly.

2. Add `HYPERFRAMES_DOCKER_PLATFORM` env override. The fix derives
   platform from `process.arch`, which silently picks the wrong arch
   in three real-world cases: x64 Node under Rosetta on Apple Silicon
   (re-triggers issue #1193), parity-regen for amd64 golden baselines
   on an arm64 host, and DOCKER_HOST pointing at a remote daemon with
   a different arch. Empty/whitespace override is a no-op (falls back
   to arch detection) so `export FOO=""` doesn't pin platform to "".

3. Fail fast when `--gpu` is requested on arm64. Docker Desktop on
   Apple Silicon doesn't implement `--gpus` passthrough; the previous
   code would crash at `docker run` with an opaque device-driver
   error. We now short-circuit with errorBox pointing at the env
   override as the workaround.

4. Close the test gap on the default-arch resolution. Every previous
   test passed `arch` explicitly; a refactor that dropped the
   `= process.arch` default would pass all tests but break every arm64
   host at runtime. Added one assertion that calls
   `resolveDockerPlatform()` with no args, plus coverage for the env
   override.

The new arm64 platform-checking logic is extracted into
`resolveDockerHostPlatform()` so `renderDocker` itself stays focused
on the build/run wiring (and below the fallow complexity gate).

Test plan:
- `bunx vitest run packages/cli/src/utils/dockerRunArgs.test.ts` — 31 passed (was 27).
- `bunx vitest run packages/cli` — 647 passed (was 643).
- E2E on macOS 26.5 / M4 Max: deleted the cached arm64 image, ran
  `--docker --quality draft --workers 1` against the blank scaffold —
  300/300 frames in 1m1s wallclock, MP4 produced.
2026-06-04 03:13:44 -04:00
Miguel Ángel c6a91ab0e4 chore: release v0.6.71 2026-06-04 03:11:40 +00:00
Miguel Ángel efb8d90f72 fix(engine): fast-fail on zero duration instead of 45s timeout (#1186)
* fix(engine): fast-fail on zero duration instead of 45s timeout

When a composition's runtime finishes initializing but reports zero
duration (no GSAP timeline and no data-duration attribute), the engine
previously polled for the full 45-second timeout before failing.

Now, after 10 seconds of polling, the engine checks whether the runtime
has finished (window.__renderReady === true) with a working seek
function but zero duration. If so, it fails immediately with a
diagnostic message explaining what's wrong and how to fix it.

This also improves the generic timeout error message to include runtime
state (whether __player exists, __hf.seek, GSAP timelines, declared
duration) so users can self-diagnose.

PostHog data: 555-1,234 occurrences/day, each wasting 45s of user time.

* fix(engine): throttle diagnostic polls and tighten zero-duration fast-fail

Two nit fixes in pollHfReady:

1. Throttle evaluateHfDiagnostic calls to once per ~1000ms after the 10s
   mark. Previously called on every 100ms loop tick, generating ~350 CDP
   round-trips per failed render. One check per second is sufficient to
   detect a permanently-zero composition.

2. Change fast-fail condition from 'duration === 0' to
   '!hasTimeline && declaredDuration <= 0'. A composition with a GSAP
   timeline but no data-duration attribute should not be fast-failed —
   GSAP sets duration synchronously before __renderReady via __timelines,
   so a non-empty __timelines is a reliable signal that duration will
   eventually be non-zero. Only compositions with NEITHER a GSAP timeline
   NOR a declared duration are permanently zero.
2026-06-03 23:06:09 -04:00
Miguel Ángel 8c6faa45b5 fix(cli): lazy-load @puppeteer/browsers to prevent debug package crash (#1185)
* fix(cli): lazy-load @puppeteer/browsers to prevent debug package crash

Convert the static `import { ... } from "@puppeteer/browsers"` in
browser/manager.ts to dynamic imports inside the async functions that
use them. This eliminates a module-load-time crash when the transitive
`debug` dependency is missing or corrupted.

Previously, every CLI command (including init, lint, docs, help) would
crash with "Cannot find package debug" if the debug package was absent —
even though only browser-related commands need @puppeteer/browsers.

Also add `debug` as a direct dependency so npm/bun always installs it
explicitly rather than relying on transitive resolution.

PostHog data: ~3,955 total-CLI-crash occurrences since May 29.

* fix(cli): simplify isLinuxArm to sync inline check and surface real load error

isLinuxArm() was async only to call detectBrowserPlatform() from
@puppeteer/browsers, but that function just checks process.platform +
process.arch under the hood. Replace with a direct inline check and make
the function sync — no behavioral change, removes an unnecessary async
boundary and an eager load of the package we're trying to lazy-load.

Also surface the real error from loadPuppeteerBrowsers() catch block instead
of hard-coding 'likely missing transitive dependency "debug"' — the actual
cause could be anything (missing package, corrupt install, wrong Node ABI).
2026-06-03 22:53:16 -04:00
Miguel Ángel cabd0616ea fix(cli): suppress EPIPE crashes in piped agent environments (#1184)
* fix(cli): suppress EPIPE crashes in piped agent environments

When the CLI runs inside a piped environment (Claude Code, Codex,
Cursor), the reader may close the pipe before we finish writing.
Node treats EPIPE on stdout/stderr as an uncaughtException, crashing
the process with a non-zero exit code.

Add stream-level EPIPE handlers on stdout/stderr at the top of the
entry point (before any output) and make the uncaughtException handler
EPIPE-aware so it exits cleanly (code 0) instead of crash-reporting.

PostHog data: ~10,300 EPIPE errors over 10 days, contributing to the
preview command's 43-59% failure rate in agent environments.

* fix(cli): set commandFailed before EPIPE exit to prevent false success telemetry

EPIPE is a pipe-reader-closed signal, not a successful run. The exit handler
uses 'code === 0 && !commandFailed' to determine success — without setting
commandFailed=true before process.exit(0), every EPIPE exit was recorded as
success:true in telemetry.

Moves the commandFailed declaration to the top of the file so the stream-error
EPIPE handlers (which must run before any writes) can reference it. Also sets
commandFailed=true in the uncaughtException EPIPE path for the same reason.
2026-06-03 22:53:08 -04:00
Miguel ÁngelandJefsky Wong 6de6ea5349 fix: delay ObjectURL revocation and silence TS5 baseUrl deprecations (#1181)
- Delay URL.revokeObjectURL() from 0ms to 1000ms in useFrameCapture so
  the browser has time to initiate the download before the blob is freed.
  A 0ms timeout fires synchronously after the current microtask queue,
  before the browser's download machinery reads the URL.

- Add ignoreDeprecations: '5.0' to cli and studio tsconfigs to silence
  TypeScript baseUrl/paths deprecation warnings without changing behavior.

Co-authored-by: Jefsky Wong <jefsky@qq.com>
2026-06-03 20:38:40 -04:00
Miguel Ángel f5d81cb5a7 chore: release v0.6.70 2026-06-03 04:41:52 +00:00
Miguel ÁngelandClaude Sonnet 4.6 1aa651bd1e test(producer): regenerate stale style-7-prod baseline in Docker (#1178)
Output diverged from the stored baseline (pre-existing drift from
Chrome/FFmpeg version differences). Rendered inside Dockerfile.test
to produce the correct reference for CI.

Full suite result after regen: 51/51 passed (0 visual, 0 audio failures).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 00:34:50 -04:00
Miguel ÁngelandClaude Sonnet 4.6 9e5adafd7a fix(engine): add --autoplay-policy=no-user-gesture-required to headless Chrome args (#1177)
GSAP's volume tween on an <audio> element causes Chrome to construct an
AudioContext. In headless Chrome, the autoplay policy blocks AudioContext
startup with "The AudioContext was not allowed to start" — the frame-capture
loop then waits for it indefinitely and deadlocks before the BeginFrame
fallback can recover. The render hangs at "Starting frame capture" with
0 output frames and times out.

Adding --autoplay-policy=no-user-gesture-required lets the AudioContext start
without a user gesture, which is safe in the headless rendering context where
no real user interaction is possible anyway.

Applied to both the main Chrome launch (browserManager) and the HDR capture
path (hdrCapture).

Fixes #1176. Reported by Abhai (Infinity agent, external).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 23:44:53 -04:00
Miguel ÁngelandClaude Sonnet 4.6 ac73cbbcd0 fix(runtime): apply parent composition offset to WebAudio scheduling for sub-comp audio (#1175)
Audio elements inside sub-compositions on the root timeline were ignoring
their host composition's data-start placement offset in the WebAudio
scheduling path (introduced in #671 / v0.5.4). All sub-comp audio was
scheduled with compositionStart equal to its local data-start (typically 0),
causing every slide's audio to fire simultaneously at global t=0 instead of
at each slide's placement time.

Root cause: two sites in the WebAudio path read rawEl.dataset.start directly
instead of accounting for the [data-composition-id] ancestor's data-start:
  1. player.play() — WebAudioTransport.schedulePlayback() compositionStart arg
  2. transportTick — TransportClock.attachAudioSource() compositionStart arg

The syncRuntimeMedia path (HTMLMediaElement fallback) was already correct
because syncMediaForCurrentState uses resolveMediaCompositionContext which
sums the host offset into the clip's start time.

Fix: add resolveGlobalAudioStart() that walks up [data-composition-id]
ancestors and sums their resolveStartForElement() offsets. Handles nested
sub-compositions. Apply it at both broken call sites.

Fixes #1174.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:58:43 -04:00
Miguel Ángel b652c0a235 fix(runtime): show elements at exact end of their duration (inclusive boundary) (#1166)
Visibility check used strict less-than (currentTime < end), hiding
elements at exactly t=duration. Changed to <= so the last frame
renders the final animation state.
2026-06-02 19:07:10 -04:00
Miguel Ángel a4706da513 fix(cli): localize external assets in publish archive (#1160)
Compositions referencing assets outside the project directory (via ../
paths) produced broken published projects — those files were never
included in the ZIP archive.

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

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

Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted
into core/compiler/assetPaths.ts — single source of truth across core,
producer, and CLI.
2026-06-01 21:06:35 -04:00
Miguel Ángel 598e3e957a chore: release v0.6.69 2026-06-01 20:14:01 -04:00
Miguel ÁngelandClaude Sonnet 4.6 b1b03782a1 fix(producer): localize remote @font-face src URLs before render (#1155)
* fix(producer): localize remote @font-face src URLs before render

Remote font URLs in @font-face blocks fail with a CORS rejection when
the renderer fetches them from http://localhost:PORT (S3 does not echo
the local origin in Access-Control-Allow-Origin). Chrome falls back to
the next font in the stack (e.g. Arial), producing wrong typography.

localizeRemoteFontFaces() scans <style> blocks, extracts HTTP url()
references inside @font-face rules, downloads them in parallel into
_remote_media/, and rewrites the CSS url() references to local paths —
the same pattern as localizeRemoteMediaSources() for <video>/<audio>.

Background url() references outside @font-face blocks are intentionally
left untouched to avoid downloading arbitrary images.

The shared download+rewrite logic is extracted into downloadAndRewriteUrls()
to eliminate duplication between the two localize functions.

Reported via the Beasty Style caption template (Komika Axis .ttf from S3
falling back to Arial on every cloud render).

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

* fix(engine): add SSRF guard to downloadToTemp (blocks private/IMDS addresses)

Customer-supplied compositions can author @font-face src URLs (and <video>/
<audio> src attrs via the existing localize path) that point to private
infrastructure. Without a guard, the producer's downloadToTemp would fetch
http://169.254.169.254/... (AWS IMDS), RFC1918, loopback, etc., save the
response to _remote_media/, and expose it via the local file server.

assertPublicHttpsUrl() rejects:
  - Non-HTTPS (http://) — all composition fetches must use HTTPS
  - 169.254.x (AWS link-local / IMDS)
  - 127.x / localhost / 0.x (loopback / unspecified)
  - 10.x, 172.16–172.31, 192.168.x (RFC1918)
  - [::1], [fc...], [fd...] (IPv6 loopback + unique-local)

The guard fires before the cache check so a blocked URL never gets into
the in-flight map. Applies to both the font-face localize path (PR #1155)
and the existing video/audio localize path (PR #1146) since both call
downloadToTemp.

Note: DNS-rebinding bypasses are not closed by this check (hostname
comparison only, no DNS resolution). Acceptable risk for current threat
model; server-side DNS validation can be layered on later.

12 unit tests covering all blocked ranges + the allowed edge cases.

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

* fix(engine): fix TypeScript strict-mode error in urlDownloader SSRF guard

m[1] from RegExp.match() is typed string | undefined; parseInt requires string.
Use nullish coalescing to satisfy tsc without changing runtime behavior —
the regex guarantees m[1] is always defined when the match succeeds.

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

* fix(engine): use vitest import in urlDownloader test

bun:test is not available in CI — the engine package runs tests via vitest.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:12:53 -04:00
Miguel Ángel bda4a32a7e chore: release v0.6.68 2026-06-01 20:05:12 -04:00
Miguel Ángel e7c874ccb3 fix(core): emit mediaVolumeEnvelope in tsc lib output (#1159)
core's tsconfig excludes src/runtime (it's the browser IIFE bundle
source), so tsc never emitted dist/runtime/mediaVolumeEnvelope.{js,d.ts}.
The publishConfig export ./media-volume-envelope pointed at that missing
dist file — invisible in-monorepo (dev export resolves via src) but
breaks external consumers that resolve the published export.

Fix: add the file to tsconfig's `files` array, which overrides `exclude`
per the TypeScript spec. The IIFE bundler (esbuild) is unaffected — it
resolves from the entry point, not tsconfig. No package.json or import
changes needed.
2026-06-01 20:03:39 -04:00
James f37e3b993e chore: release v0.6.67 2026-06-01 22:28:28 +00:00
James RussoandClaude Opus 4.8 42ad305073 feat(cli): validate cloud render aspect/composition/format before upload (#1156)
* feat(cli): validate cloud render aspect/composition/format before upload

`hyperframes cloud render` accepted inputs the render pipeline can't
satisfy and only failed server-side with a generic message. Add three
client-side, pre-upload checks:

- Missing `--composition` entry → clean "Composition not found" error
  instead of uploading a zip the render rejects opaquely.
- Explicit `--aspect-ratio` that conflicts with the composition's
  authored data-width/data-height → "Aspect ratio mismatch" error.
  Aspect ratio is derived from the composition (auto-detected for local
  dirs), so the flag is rarely needed and can't reshape — only match.
- `--resolution 4k` with `--format webm|mov` → rejected, since the alpha
  capture path can't supersample.

Replaces maybeAutoDetectAspectRatio with resolveAspectRatioForSubmit,
which folds detection + explicit-flag validation into one pass. Both new
validators are exported and unit-tested.

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

* fix(cli): reject explicit --aspect-ratio on unsupported-ratio compositions

Addresses review on #1153.

The mismatch guard only fired for `matched` compositions. For a composition
whose dims resolve to an unsupported ratio (e.g. 4:5 → detection `no-match`),
a conflicting explicit `--aspect-ratio` silently passed through and was
forwarded to the server, which rejected it later — the opposite experience
from a `matched` composition with the same wrong flag.

Extend the guard to the `no-match` case: dims are known and the ratio can
never equal a supported (16:9/9:16/1:1) explicit value, so it's a definite
conflict. Kinds with unknown dims (no-dims/no-root-div/invalid-dims/read-error)
still forward the explicit value since a conflict can't be proven. +1 test.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:09:19 -04:00
James RussoandClaude Opus 4.8 8b6d35e226 fix(producer): honor variables + outputResolution in HTTP render server (#1152)
* fix(producer): honor variables + outputResolution in HTTP render server

The producer HTTP server's parseRenderOptions read only
fps/quality/workers/gpu/debug/entryFile/format from the request body.
`variables` and `outputResolution` were silently dropped, so any caller
of the server render path (the cloud-render sidecar that
experiment-framework POSTs to) got the composition's declared variable
defaults and its intrinsic dimensions regardless of what was requested.

RenderConfig already supports both fields (the local CLI `render`
command passes them); the server just never forwarded them. Wire them
through RenderInput, parseRenderOptions, and a shared buildRenderJobConfig
used by the sync + streaming handlers. outputResolution now drives the
same resolveDeviceScaleFactor supersampling path the local CLI uses, so a
4k render against a matching-aspect composition produces true 4k.

Validation: a non-object `variables` or an unknown `outputResolution`
returns a clean 400 instead of being silently ignored. Also extracts
resolvePreparedRenderOutput + parseRenderOverrides helpers to keep both
handlers DRY.

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

* fix(producer): reject non-string + alpha-incompatible outputResolution

Addresses review on #1152.

- A non-string `outputResolution` (e.g. a JSON number) was coerced to
  `undefined` by parseRenderOverrides and silently ignored — the same
  silent-drop this validation exists to prevent. Now rejected with a 400.
- `outputResolution` + an alpha format (webm/mov) is rejected up front:
  supersampling runs through a deviceScaleFactor the alpha capture path
  can't apply, so resolveDeviceScaleFactor throws mid-render. Guarding it
  here makes the producer self-defending for every caller (not just the
  CLI / external API), and closes the 1080p-webm regression window during
  the producer-honors-outputResolution rollout.

Extracted validateOutputResolutionOverride to keep validateRenderOverrides
under the complexity gate. +2 prepareRenderBody tests.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:59:08 -04:00
Miguel Ángel a01a266efa fix(cli): mock findFFmpeg in render tests for CI without ffmpeg (#1154)
* fix(cli): pre-flight FFmpeg check and propagate failed_stage on render errors

Add an early FFmpeg availability check in renderLocal() so users get a
clear error message before the render starts instead of a cryptic ENOENT
mid-render. Also thread job.failedStage through handleRenderError into
the render_error telemetry event so we can attribute failures to a
specific pipeline stage.

* fix(cli): consolidate FFmpeg pre-flight into renderLocal()

Remove the duplicate findFFmpeg() check from run() — renderLocal()
already validates FFmpeg availability before starting. Single source of
truth.

* fix(cli): mock findFFmpeg in render tests for CI runners without ffmpeg
2026-06-01 16:55:04 -04:00
Miguel Ángel cfef6caf5f chore: release v0.6.66 2026-06-01 20:48:20 +00:00
Miguel Ángel a0a569fcce fix(studio): inject version from package.json and include in all telemetry events (#1151)
The Vite build relied on process.env.npm_package_version which is only
set when invoked through npm/bun run scripts. CI builds running vite
build directly got "dev" as the version. Read package.json directly so
the version is always correct regardless of invocation method.

Also add studio_version to the BrowserSystemMeta interface so the new
telemetry system (studio_session_start, studio_render_start, etc.)
includes the deployed version in every event.
2026-06-01 16:44:57 -04:00
Miguel Ángel 8c7068aa42 fix(studio): gracefully handle visual edits on runtime-generated elements (#1150)
* fix(studio): gracefully handle visual edits on runtime-generated elements

When the DOM patcher can't find an element in source HTML (e.g. elements
created by JavaScript at runtime like #arrows-svg, .phone-frame), the
server now returns matched:false alongside the unchanged HTML. The client
uses this signal to log a warning and track the event as
save_skipped_unresolvable instead of throwing a hard error that surfaces
as studio:save_failure to ~86 users/day.

Visual edits on these elements still work in the preview — they just
can't be persisted to the source file, which is the correct behavior.

* fix(studio): throttle save_skipped_unresolvable and add composition context

Deduplicate telemetry — fire once per selector per session instead of on
every RAF tick during drag. Add composition path to the event payload for
dashboard pivoting.
2026-06-01 16:44:53 -04:00
Miguel Ángel 5697e4adc3 fix(cli): pre-flight FFmpeg check and propagate render failure stage (#1149)
* fix(cli): pre-flight FFmpeg check and propagate failed_stage on render errors

Add an early FFmpeg availability check in renderLocal() so users get a
clear error message before the render starts instead of a cryptic ENOENT
mid-render. Also thread job.failedStage through handleRenderError into
the render_error telemetry event so we can attribute failures to a
specific pipeline stage.

* fix(cli): consolidate FFmpeg pre-flight into renderLocal()

Remove the duplicate findFFmpeg() check from run() — renderLocal()
already validates FFmpeg availability before starting. Single source of
truth.
2026-06-01 16:44:49 -04:00
kshift 00dad396db fix(producer): pass streaming encoder config (#1147) 2026-06-01 11:15:44 -04:00
Miguel Ángel 9ead3a83b5 chore: release v0.6.65 2026-06-01 13:50:51 +00:00
Miguel ÁngelandClaude Sonnet 4.6 7bbedc080a fix(producer): localize remote media sources + strip audio crossorigin (#1146)
* fix(producer): localize remote media sources + strip audio crossorigin

Two bugs affecting compositions that use remote S3 URLs for video/audio.

Bug 1 — Remote <video>/<audio> sources cause blank frames
The renderer (Puppeteer) must buffer all video elements to readyState >= 2
before frame capture begins. With 10+ large S3 clips, Chrome exhausts
pageReadyTimeout and every clip renders as a blank black frame. Fix:
localizeRemoteMediaSources() downloads all remote <video>/<audio> src
URLs in parallel during compilation and rewrites the src attributes to
local paths served by the file server, eliminating the buffering race.

Bug 2 — crossorigin on <audio> elements not stripped
htmlCompiler.ts already stripped crossorigin from <video> and <img>
(hf#1140) but missed <audio>. Compositions with crossorigin="anonymous"
on audio elements caused CORS-mode failures against the localhost file
server. Extended the strip to cover <audio>.

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

* fix(producer): basename portability + localizeRemoteMediaSources tests

Addresses Rames' review on hf#1146:

- Replace `absPath.split('/').at(-1)` with `path.basename(absPath)`. On
  Windows, path.join emits backslash-separated paths; split('/') returns
  the whole path as a single element, producing a garbage relPath.
  path.basename delegates to the OS separator on the current platform.

- Export `localizeRemoteMediaSources` for unit testing. Tests verify:
  - Successful download rewrites src to _remote_media/ path
  - Download failure preserves original URL without throwing
  - Duplicate src URL across two tags → single fetch call (dedup)
  - Local (non-HTTP) src paths are not rewritten
  - Both double-quoted and single-quoted src attributes are rewritten
  - basename extraction is correct on POSIX paths

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:48:31 -04:00
James Russo 3c7e2f3649 feat(cli): auto-detect aspect_ratio from composition dims when --aspect-ratio is omitted (#1145)
When the user runs `hyperframes cloud render` without `--aspect-ratio` and
the project source is a local directory, parse the entry HTML's root
`<div data-composition-id ...>` for `data-width` / `data-height` and pick
the supported aspect ratio that matches within ±0.05 tolerance:

- 16:9 (≈1.778) ← landscape 1920×1080, 4K 3840×2160, etc.
- 9:16 (≈0.563) ← portrait 1080×1920
- 1:1 (=1.0)    ← square 1080×1080

If the composition's ratio matches one of these, the CLI sets
`aspect_ratio` in the submit body and prints a one-line note
(`Detected aspect ratio: 9:16 (from index.html dims 1080×1920)`).

If the composition has no root div, no dims, or a ratio outside all three
tolerance bands (e.g. 4:5, 5:4, 21:9), the CLI logs a one-line warning
explaining the fallback and leaves `aspect_ratio` out of the submit body
— the server defaults to 16:9, and the user can pass `--aspect-ratio`
explicitly to override.

Explicit `--aspect-ratio` always wins. Detection is skipped for
`--asset-id` / `--url` project sources since the composition isn't on
disk; user gets a brief note in that case too.

New helper: `packages/cli/src/cloud/detectAspectRatio.ts` (pure regex
parse, no DOM library dep). 23 tests cover canonical matches, in-band
tolerance, all three non-match patterns (no root div, no dims, ratio out
of bands), and authoring edge cases (unquoted attrs, attribute order,
self-closing tags, multi-composition files).

Closes the `auto` carve-out flagged in ef#38182's deferred-scope note —
the CLI gets auto-detect without requiring a server-side zip-parse
capability (no API change).
2026-05-31 21:06:18 -04:00
James Russo 8e0b26dab6 feat(cli): split cloud render --resolution into --aspect-ratio + --resolution (#1143)
Aligns the `hyperframes cloud render` CLI with the v3 API's decomposed
shape (ef#38182). Replaces the flat 6-value `--resolution` flag with two
independent flags:

- `--resolution`: tier ∈ {1080p, 4k}; default 1080p; 4k bills at 1.5x
- `--aspect-ratio`: ratio ∈ {16:9, 9:16, 1:1}; default 16:9

Regenerates `packages/cli/src/cloud/_gen/{types,client}.ts` from the
updated `experiment-framework/openapi/external-api.json`. Threads
`aspectRatio` through `SubmitOptions` and `buildRenderBody` so it lands
in the request body as `aspect_ratio`.

Old flag values (`landscape`, `portrait-4k`, etc.) now reject at the CLI
layer via `parseEnumFlag`, matching the API surface's rejection. The
six legacy combinations map to the same effective output in the new
shape — see the migration table in ef#38182's PR body.

Deferred (will follow in a separate PR): 720p, 4:5, 5:4, and `auto`.
These need producer-side capability + controller-side composition-dim
inference; out of scope for an API/CLI shape refactor.
2026-05-31 20:41:25 -04:00
Miguel ÁngelandClaude Sonnet 4.6 5e28738566 fix(engine): correct mock call index in multi-track audioMixer test (#1144)
processCompositionAudio prepares all tracks in parallel (Promise.all),
so for N tracks the mix call lands at index N, not index 1. The 3-track
test was reading calls[1] (the second prepare call) instead of calls[3]
(the mix call), causing indexOf("-filter_complex") to return -1 and the
subsequent assertions to read the wrong args.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:24:30 -04:00
Miguel Ángel a7b874326a chore: release v0.6.64 2026-05-31 13:56:46 +00:00
Miguel Ángel fc608ad7fc fix(producer): audio drops + blank images on FFmpeg 4.x/CORS-restricted origins (#1140)
* fix(engine): remove amix normalize=0 to fix audio on FFmpeg 4.x/6.x

amix's normalize=0 option is absent from many FFmpeg builds (e.g.
FFmpeg 4.2 on Ubuntu 20.04). When the option is not recognized, FFmpeg
fails the entire filter graph initialization, processCompositionAudio
returns success:false, and the assembled video has no audio stream.

Replace normalize=0 + weights='1...' with the amix default behavior
(normalize=true, divides by track count) and multiply the master output
gain by the track count to restore the original per-track volumes.
The net volume is identical across all FFmpeg versions.

Fixes #1136-adjacent: reported as 'audio doesn't play' in rendered MP4.

* fix(producer): strip img crossorigin + fix audioExtractor normalize=0

Two follow-up fixes:

1. htmlCompiler: strip crossorigin attribute from <img> elements during
   compilation. External images (e.g. S3) with crossorigin='anonymous'
   force CORS-mode requests against the renderer's localhost file server,
   which S3 rejects → images render blank. Matches the existing video
   strip at line 261.

2. audioExtractor: same amix normalize=0 bug as audioMixer.ts. The
   audioExtractor path is used for <video data-has-audio='true'> mixing
   in the CLI's local render pipeline; on FFmpeg 4.x it would also drop
   audio silently. Fix: remove normalize=0, compensate with volume=N.

* test(engine,producer): pin amix normalize contract + img crossorigin strip

- audioMixer.test.ts: assert filter has no normalize=/weights=; add
  3-track test confirming compensatedGain = masterGain × N = 3
- htmlCompiler.test.ts: parallel tests for img and video crossorigin
  strip (covers both elements, not just video)
2026-05-31 09:55:49 -04:00
Carlos Alcaraz GregorandCarlos Alcaraz f8abff2e1c test(cli): cover cloud reportApiError hint cascade (#1131)
reportApiError centralizes the HyperframesApiError -> Error -> String
reporting cascade for the cloud subverbs, including the curated
ERROR_CODE_HINTS table and its priority order (code-specific hint >
caller suggestion > bare code label > no third line). That priority
logic was previously untested; the module comment notes a past
regression where hyperframes_render_not_found was unreachable from
get/delete.

Add errors.test.ts covering: 404 + notFound short-circuit, known-code
hint, hint-wins-over-suggestion priority, suggestion fallback, bare
code label, no-third-line, extraHints merge and override, plain Error,
and non-Error stringification. Mocks errorBox and process.exit
following the sibling cloud/parsing.test.ts pattern.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
2026-05-30 22:38:22 -04:00
Carlos de la Cruz 2362cf979d fix(core): recognize dot-property timeline registration (#1138) 2026-05-30 18:10:15 -04:00