Replace bgm-to-video, bgm-to-video-new, bgm-to-video-refactor, and the
standalone beat-sync/montage skills with a single music-to-video skill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gsap_css_transform_conflict existed but missed the most common real-world
shape (a label centered with CSS translateX(-50%) plus a GSAP xPercent that
stacks to -100% in the capture path), for three independent reasons:
- selector matching was exact-string, so a scoped/grouped GSAP selector
("#root .label, #root .sub") never matched a CSS class rule (.label)
- the acorn parser only captures timeline-rooted calls (tl.to/tl.set), so a
standalone gsap.set("#root .label", { xPercent: -50 }) was invisible to it
- lintProject read compositions/ non-recursively, so per-frame compositions
in compositions/frames/*.html were never linted at all
Fix: token-decompose grouped/descendant/compound selectors and match by
id/class against CSS transform rules; additionally scan standalone gsap.*
transform calls; and recurse into compositions/ subdirectories so frame
sub-compositions are linted.
Adds unit tests (grouped gsap.set repro, descendant tl.to, negative case) and
an end-to-end lintProject test that writes compositions/frames/04-*.html and
asserts the conflict is reported there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the music-to-video skill: turns a music/BGM track into a kinetic
typography video. Includes the director/builder/music-reader/finalize
agents, reference contracts, beatgrid analysis script, motion-primitive
library, and starter templates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a routing rule to the /hyperframes entry skill: after picking a
workflow, if its skill isn't available to the agent, tell the user to
install it rather than silently falling back to a guess. Covers the
targeted install (`--skill <name>`) and the install-everything one-shot
(`--all`), then re-read the workflow's skill and continue.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What
Two small render-side improvements for video-heavy compositions:
1. **`packages/core/src/runtime/media.ts`** — gate the per-tick `el.currentTime = relTime` set + the `el.load()` drift-recovery retry on the *absence* of a `<img id="__render_frame_<id>__">` sibling (i.e., we're in render mode + this video's visual is bypassed by frame injection + its audio is mixed by ffmpeg from source files).
2. **`packages/engine/src/services/videoFrameInjector.ts`** — probe `window.__hfReseekGpu` and `window.__hf.colorGrading.redraw` once at the first injector call; cache the booleans; skip the per-frame `page.evaluate` round-trips when neither capability is registered.
## Why
#### media.ts
During render the runtime calls `el.currentTime = relTime` on every active video per sync tick. For frame-injected videos that's pure waste:
- The visual comes from the `<img id="__render_frame_<id>__">` sibling injected by the producer's `videoFrameInjector` — the `<video>` element is `visibility: hidden`.
- Audio is mixed by ffmpeg from the source files in `runAudioStage` (separate stage) — it never goes through the in-browser audio pipeline during render.
So every per-tick seek just kicks Chrome's media pipeline (buffering checks, range fetches, decoder state changes) for no visible or audible benefit. On a 30 × 32 MB synth comp, that's ~2,400 wasted seeks per render — and the cost wasn't on the JS critical path, so it didn't show up in `avgBeforeCapture` directly. It bled into the BeginFrame compositor's per-frame screenshot time.
Preview is unaffected: the injection sibling only exists during render. In preview `hasInjectionSibling` is always false → existing seek path runs unchanged.
#### videoFrameInjector.ts
The injector hook ran `__hfReseekGpu` and `redrawRuntimeColorGrading` via `page.evaluate` on every render frame. For comps that don't register either capability (the common case — anything without WebGL/WebGPU video sub-comps or a color-grading layer), each was a no-op page-side function preceded by a ~CDP-round-trip-worth of overhead. Probing once and caching `false` eliminates that for the rest of the render.
## How was this validated
Stress shape: `synth-30-heavy` — 30 × 32 MB MP4 / 3 s each, sequenced end-to-end over a 90 s timeline (`data-composition-id` root + per-video `<video id="vid-NN" data-start data-duration data-track-index>`). Host: 8-core / 30 GB Linux.
N=3 baseline against stock `origin/main` (post-#1630), N=3 with-fix on the same machine, same corpus, fresh worker pool each run. Phase timings via `[Render:trace]` JSON; per-frame sub-breakdown via a one-line `[CapturePerf]` stderr emit (kept locally, not in this PR — `dedupPerfs` already carries the data, this branch surfaces it).
| | Baseline N=3 | With-fix N=3 | Δ |
|---|---|---|---|
| wall mean | 119.5 s ± 1.4 s | **117.3 s ± 0.9 s** | **-2.2 s (-1.8%)** |
| avg screenshot / frame | 50.0 ms | **49.0 ms** | -2.0% |
| avg beforeCapture / frame | 13.0 ms | **12.1 ms** | -7.0% |
| avg total / frame | 66.0 ms | 63.9 ms | -3.2% |
| output md5 | `5a22be64...` | identical ×3 | ✓ |
The 1 ms screenshot drop is the load-bearing signal: it confirms the kicked Chrome media-pipeline work *was* bleeding into BeginFrame compositor time, even though it wasn't on the JS critical path. Per-frame budget improved 2.1 ms × 2700 / 3 workers ≈ 1.9 s of `capture_disk` savings, which matches the observed wall delta.
This stacks cleanly with #1630 (which removed the injector's fileServer contention). #1630 moved the injector's PNG fetches off the fileServer's hot path; this PR keeps Chrome's media pipeline quiet during render so the BeginFrame compositor runs unhindered.
## Test plan
- [x] Local-CLI render on `synth-30-heavy` × N=3 baseline + N=3 with-fix; wall, per-frame, md5 captured (above).
- [x] Lint / format / typecheck via lefthook pre-commit (`oxlint`, `oxfmt`, `fallow audit`, `tsc --noEmit` across `@hyperframes/core` + `@hyperframes/engine` + `@hyperframes/producer`).
- [ ] *Real-world video-heavy comp validation* — would love a Magi / Miga eye on a HF-heygen-stripe-shape or a Rahino-shape comp to confirm there's no audible artifact on unmuted videos. The change shouldn't affect them — in render mode the audio path is ffmpeg, not the in-browser pipeline — but a sanity-check render is cheap.
## Scope notes
- *Not addressed in this PR*: the user-facing request for an upfront-extract concurrency cap (`Promise.all` in `extractAllVideoFrames` is currently unbounded across all videos). Filing as a follow-up PR — different layer of the pipeline, different user surface (CLI flag), worth keeping separate for review.
- *Edge case*: in the calibration test-frame phase, the injection sibling may not yet exist when drift recovery first checks a video at the very start of its active window. The gate correctly defaults to "no sibling → run the seek" in that case, which is the existing behavior.
_Authored by Jerrai (Rames team)._
* fix(compiler): skip CSS var() in font resolver — fixes FONT_FETCH_FAILED on distributed renders
The font scanner treated `var(--ui-font)` as a literal font family name,
causing fail-closed distributed renders to throw FONT_FETCH_FAILED for
any composition using CSS custom properties in font-family declarations.
CSS var() expressions resolve at browser paint time, not at compile time.
The regex-based font scanner cannot resolve them statically — skip them
and let headless Chrome handle variable substitution during render.
Closes#1654
— Miga
* test(regression): add distributed css-var-fonts fixture
Regression test for compositions that use CSS custom properties in
font-family declarations. Exercises the var() skip guard in
extractRequestedFontFamilies() under the distributed renderer's
fail-closed font resolution path.
Baseline needs to be generated on first CI run with --update.
— Miga
* fix(compiler): address review feedback — mixed declaration test + validator TODO
Add unit test verifying concrete fonts alongside var() in mixed
declarations still get resolved (non-aggression pin).
Add TODO(#1654) in validateNoSystemFonts for the var()-as-primary gap
flagged by both reviewers.
— Miga
* fix(test): correct stale 4xx fail-closed test expectations
The 4xx tests expected no throw, but that was the contract before #1255
added the system font capture path (Path 3). Post-#1255, a font that
gets 4xx from Google Fonts AND isn't a bundled alias AND has no system
font IS genuinely unresolvable — fail-closed mode should throw.
The 4xx distinction still matters at the fetch level (no retry, treated
as deterministic "not served"), but at the final unresolved check, a
completely unresolvable font must throw regardless of the HTTP status
that caused the Google Fonts path to return empty.
Updated tests to match the actual contract: 4xx + unresolvable = throw.
Also set allowSystemFontCapture: false to match how distributed renders
(plan.ts:799) actually call the function.
— Miga
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Probes the rendered output for video and audio stream durations after
render and fails the test if they differ by more than 0.5s. Catches
mux-level truncation regressions like the ffmpeg -shortest bug (#1648)
where one stream gets silently cut short.
Runs on all non-png-sequence fixtures with audio — no new meta.json
field needed since this is a universal invariant, not a per-fixture
threshold.
FFmpeg 6.0 (bundled by ffmpeg-static) has a regression where -shortest
combined with -c:v copy over-truncates the video stream while leaving
audio untouched. The flag is also redundant — the audio mixer already
pads/caps all tracks to totalDuration via apad=whole_dur and -t.
Closes#1648
- include target in useGsapAnimationsForElement fetch key so a selection
change triggers a re-fetch even at the same cache version
- add gsapCacheVersion to useDomEditPreviewSync deps so the selection
re-syncs after every soft reload
- trim manualEditsDom.ts to 600 LOC (filesize compliance)
Fixes#1645
* fix(producer): inline base64 frames in injector to unblock video-heavy renders
The URL-served frame path (PR #596) hands each injected `<img>` a fileServer URL
instead of a base64 data URI, on the theory that shipping a short URL through
`page.evaluate` beats shipping a multi-MB base64 string per frame. That holds
when the fileServer is otherwise idle.
But on video-heavy compositions, the same fileServer also serves every
`<video>.src`. The runtime's drift-recovery branch (`runtime/media.ts:294-302`)
issues `el.load()` on the underlying `<video>` during seeks, kicking off
full-file downloads that occupy the fileServer's single Node event loop (it
uses `readFileSync` and offers no `Accept-Ranges`). The injector's
`<img>.decode()` then queues behind those video fetches and is never serviced
before puppeteer's protocol timeout fires, surfacing as
`Runtime.callFunctionOn timed out` in `capture_streaming`.
Reproducer (30 × 32 MB videos / 90 s comp / 8-core / 30 GB host):
baseline (broken corpus) 537 s render fails
baseline (corpus-fixed) 428 s render fails
this fix (drop frameSrcResolver) 121 s render succeeds, 69 MB MP4
Control corpus (30 × 1.6 MB / 60 s) shows no regression: 137 s with this
change vs ~135 s on \`main\`. The \`createCompiledFrameSrcResolver\` builder and
the \`frameSrcResolver\` option stay in the codebase, just unused for now —
re-enabling them behind a proper gate ("only use URL-served frames when the
page has zero fileServer-bound \`<video>.src\` traffic") is a follow-up. The
cache memory ceiling (\`frameDataUriCacheBytesLimitMb\`, default 1500 MB above
8 GB hosts) already bounds the cost of base64 inlining.
— Jerrai
* refactor(producer): drop unused frameSrcResolver builder import in render orchestrator
Followup to the previous commit. The void-call and the
`createCompiledFrameSrcResolver` import in `renderOrchestrator.ts` were left
behind as a no-op breadcrumb for the future gating PR. Code review (PR #1630)
correctly flagged this as dead code — the builder is a pure factory with no
side effects, so calling it and discarding the result is just wasted CPU.
Remove both and explain in the in-source comment where the builder still
lives, so the gating PR knows where to re-import from.
— Jerrai
* fix(core): register sub-composition timelines after async build + lint rule
When a composition builds its GSAP timeline inside document.fonts.ready (or any
async callback), registering window.__timelines[id] BEFORE the build leaves an
EMPTY timeline registered. The runtime's sub-composition readiness gate treats
"key present" as "ready" and nests the child once — an empty timeline gets
nested empty and is never re-nested, so the frame renders blank when used as a
sub-composition.
- registry/blocks/code-{diff,highlight,morph,scroll,typing}: register the
timeline AFTER the fonts.ready build completes, then call
window.__hfForceTimelineRebind() to re-nest now that it is populated.
- core lint: add rule gsap_timeline_registered_before_async_build to flag the
early-registration anti-pattern, with tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): import commitGsapPositionFromDrag from its actual module
The function was split out into gsapDragPositionCommit.ts in #1605, but the
test kept importing it from ./gsapDragCommit, which no longer exports it —
yielding 'is not a function' at runtime. Import from the correct module.
Inherited main breakage (same fix as #1631/#1635); fixes the Test CI check on
this branch independently of merge order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): escape digit-leading id selectors in standalone sub-composition preview
A CSS identifier cannot start with a digit, so an authored rule like
`#01-wall-pushes-back { ... }` is an invalid selector and the browser drops
the whole rule — taking the root's size/background with it. A full
composition masks this (the host stretches/paints the frame), but a
standalone preview has no host, so the root collapses to height:0 +
transparent and renders blank.
extractFullDocumentParts now rewrites `#<digit-leading-id>` selectors to
their escaped valid form (`#\30 1-...`, still matching the element id),
scoped to ids actually present and matched only as `#id` not followed by an
ident char so hex colors are never touched. Also harden the <template>
inner-HTML extraction to use the DOM instead of a greedy regex. Tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit e43b377fc0)
* feat(cli): surface captured video clips in asset descriptions
generateAssetDescriptions now reads extracted/video-manifest.json and emits
each downloaded clip first, tagged [video], with its DOM heading/caption and
dimensions — motion clips are usually the strongest hero material and
downstream planners key off the [video] marker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): import commitGsapPositionFromDrag from its actual module
The function was split out into gsapDragPositionCommit.ts in #1605, but
the test kept importing it from ./gsapDragCommit, which no longer exports
it — yielding `is not a function` at runtime. Import from the correct
module to match the production import in gsapRuntimeBridge.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(core): address review nits on standalone sub-composition preview
Review follow-ups (#1631), all non-blocking polish:
- contentExtractor: use path.basename() instead of localPath.split('/').pop()
so video filenames resolve correctly on Windows-style paths too.
- subComposition: document that only the leading digit needs CSS escaping
(CSS Syntax L3 §4.3.11) on escapeLeadingDigitIdent.
- tests: pin three previously-uncovered paths — multiple digit-leading ids in
one composition, a digit-leading id inside compound/combinator selectors, and
the promoteTemplateCompositionId no-op when the <template> has no id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(product-launch-video): restructure onto script-driven architecture
Move product-launch-video onto the shared script-driven authoring flow:
build-frame remixes a hyperframes-creative preset onto brand tokens, audio
routes through the shared hyperframes-media engine, per-preset caption skins,
and every frame is authored as a directed shot. Removes the old bespoke
scripts (captions/validate/prep/hoist/…) in favour of the shared lib.
assemble-index.mjs keeps upstream #1629's blank/partial scene-file guard
(reject an empty or markup-less scene file at assembly, before emitting
data-composition-src, and re-dispatch) carried onto the restructured reader.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(pr-to-video): restructure onto script-driven architecture
Move pr-to-video onto the shared script-driven authoring flow: ingest.mjs
folds the gh PR artifacts into the synthetic capture package the shared
backend (build-frame / captions / assemble-index) reads, add the mechanism
beat, route audio through hyperframes-media, and remix a hyperframes-creative
preset onto brand tokens via the shared lib.
- Fix skill name: pr-to-video-refactor -> pr-to-video (match directory).
- Drop a stale faceless-explainer-refactor reference in an ingest.mjs comment.
- assemble-index.mjs keeps upstream #1629's blank/partial scene-file guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(faceless-explainer): restructure onto script-driven architecture
Move faceless-explainer onto the shared script-driven authoring flow:
every visual is invented (typography / abstract graphics / diagram / data-viz)
and authored through the shared backend (build-frame remixes a
hyperframes-creative preset onto tokens, audio via hyperframes-media,
assemble-index builds the standalone index.html) using the shared lib.
- Fix skill name: faceless-explainer-refactor -> faceless-explainer (match directory).
- assemble-index.mjs keeps upstream #1629's blank/partial scene-file guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(skills): refresh test-skills-fresh.sh workflow roster
Update the install-and-verify harness to the current surface: 10 workflows
(adds website-to-video, embedded-captions, graphic-overlays, slideshow;
drops the removed footage-recut) and refreshed example prompts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(product-launch-video): oxfmt storyboard.mjs
Run oxfmt over lib/storyboard.mjs — formatting only, no logic change.
Fixes the Format / Preflight CI check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(studio): import commitGsapPositionFromDrag from its actual module
The function was split out into gsapDragPositionCommit.ts in #1605, but the
test kept importing it from ./gsapDragCommit, which no longer exports it —
yielding 'is not a function' at runtime. Import from the correct module.
Inherited main breakage (same fix as #1631); fixes the Test CI check on this
branch independently of merge order.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(hyperframes): refine router skill metadata tags
Update the entry router's metadata tags (video / animation / router focus);
oxfmt collapses the now-shorter metadata to a single line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): tighten caption comment-strip + document audio --only merge
Review follow-ups (#1635):
- captions.mjs (x3): the HTML-comment strip used a single global replace, which
CodeQL flags as incomplete multi-character sanitization (a nested/partial pair
can re-form a marker the single pass misses). Strip in a fixpoint loop instead.
Input is preset-library content, not user-controlled, so this is lint-
cleanliness, not XSS defense.
- audio.mjs (x3): document that fetch-sfx (--only sfx) MERGES into the neutral
audio_engine_meta.json sidecar — the engine reads prev and recomputes only the
sfx section, so voices/bgm from the generate pass are preserved (review Q).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): remove existsSync->write TOCTOU in workflow scripts
Clears the 9 js/file-system-race CodeQL alerts (captions/audio/transitions x3).
Each was an existsSync precheck followed by a later write of the same path:
- captions.mjs: caption-overrides shim -> atomic writeFileSync({ flag: 'wx' }).
- audio.mjs (sync-durations) + transitions.mjs (inject): drop the existsSync
precheck and read directly, surfacing the same friendly error from a try/catch
on readFileSync — no check->write gap.
Behavior is unchanged (same error messages); these are local single-process
deterministic scripts so the race was never a real risk, but this clears the gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): paint root composition ground color in assemble-index
Per-frame roots carry data-start/data-duration and get clip-gated against the
global timeline at render, so only the first frame's window overlaps global 0 —
a frame's own full-bleed background can't serve as the video ground, and every
frame after the first renders on the bare body color (black). Paint the ground
on the always-present root composition using the project's frame.md canvas color
(the same role the caption skin maps to --cap-canvas); fall back to the body
letterbox color when frame.md is absent or has no resolvable ground.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(hyperframes): drop router-tag edit (moved to the foundation PR)
The entry SKILL.md is rewritten wholesale by the frame-presets/media foundation
PR (#1632); editing it here too guaranteed a merge conflict. Restore this file
to main and let the router-tag tweak live with the rewrite in #1632, so the two
PRs no longer both touch it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(hyperframes-creative): add frame-preset library
Add a library of ready-made visual frame presets (claude, biennale-yellow,
blockframe, blue-professional, bold-poster, broadside, capsule, cartesian,
cobalt-grid, coral, creative-mode, daisy-days, editorial-forest, …), each with
a FRAME.md spec, a frame-showcase.html, and a per-preset caption-skin.html.
Registered in the creative design-spec so workflows can remix a preset onto
brand tokens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(hyperframes-media): shared TTS/BGM/SFX audio engine
Add a shared audio engine under hyperframes-media (scripts/audio.mjs + lib/
tts.mjs, bgm.mjs, sfx.mjs, heygen.mjs) plus a bundled SFX pack and manifest.
Workflows resolve this engine by path (../../hyperframes-media/scripts/
audio.mjs) for text-to-speech, background music, and sound effects, so audio
is authored once and reused across skills instead of duplicated per workflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(skills): gate render on user review; refresh router, core, general-video
- hyperframes-cli: render is now user-gated — preview opens Studio (the timeline
editor where the user can hand-edit anything, not just watch); never
auto-render once checks pass, pause at preview and render only after approval.
- hyperframes (router): tighten the entry SKILL.md description + routing.
- hyperframes-core: rewrite SKILL.md and add script-format.md + storyboard-format.md
references for the script-driven authoring architecture.
- general-video: tidy the fallback-workflow description and routing table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(hyperframes-creative): reformat frame-preset showcase HTML
Run the HTML formatter over the frame-showcase.html files (indentation,
self-closing void tags, one CSS declaration per line). Formatting only — no
content or markup changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(hyperframes-media): correct wait-bgm field mapping and guard credential parse
Two correctness fixes from review (#1632):
- wait-bgm.mjs read audioMeta.bgm_path / audioMeta.bgm_enabled, but audio.mjs
writes the path nested as bgm.path and the flag as bgm_pending. The detached
generate path (Lyria/MusicGen) therefore always saw an empty path and exited
status: disabled, silently dropping the music track even while generation was
running. Read audioMeta.bgm?.path and gate on bgm_pending.
- heygenCredential() had an unguarded JSON.parse despite documenting that it
never throws — a malformed ~/.heygen credentials file crashed the engine at
startup instead of degrading to no-credential. Wrap the parse and return null.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(hyperframes): add router tag to entry skill metadata
Fold the router metadata tag into the foundation rewrite of the entry SKILL.md.
This file is owned by this PR (the full router rewrite); keeping the tag tweak
here — instead of a separate edit on the pre-rewrite version in another PR —
avoids a guaranteed merge conflict between the two.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(producer): shim __filename/__dirname in the CJS banner
Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.
* chore(producer): use a template literal for the CJS banner (review nit)
* feat(core): add GSAP keyframe + motion-path source mutations
Array-form keyframe removal in both the recast and acorn writers, plus
update/add/remove-motion-path-point and add-motion-path. Exclude _auto and
data from tween property-group classification.
* fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks
- Regression test for the `data` GSAP-key exclusion (parallel to _auto).
- splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the
from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests.
- addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated.
- Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring
removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug —
documented as it.skip with a ready assertion (acorn cutover follow-up).
* feat(core): route motion-path mutations through studio-api + fix clip stamping
Wire the new mutations into the file save route. Only authored clips suppress
descendant stamping, so auto-stamped animated scenes can inline-expand.
Hide in-flow timed clips with `display:none` only when they are LEAF clips (no
nested timed clips). `display:none` on a container removes its whole subtree,
hiding descendants that are still inside their own visibility window — e.g. an
in-flow composition root whose effective window clamps to the timeline end would
black out a child video that should still show (the hdr-hlg regression).
Containers keep `visibility:hidden`, which a visible descendant can override; only
leaves leave the flow, which is all the split-overlap case needs.
* feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule
A position or rotation add/set mutation makes the GSAP timeline the single source of
truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS
var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both
channels, and the add-strip fires for the position AND rotation property groups.
Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio
cannot save drag/resize edits to elements in a registered timeline — the exact premise
the single-source work inverts (the timeline is now the edit target). Removed the rule,
its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests.
* fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel
- HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript
authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta,
shift-positions, scale-positions, split-animations. (add stays out: flat tweens
only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.)
- init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change;
visible/hidden branches both go through isTimedClipInFlow (was .get() by accident).
- keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set
strips the stale --hf-studio-rotation channel.
* feat(studio): GSAP runtime read layer + shared helpers
* fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests
- useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold
(warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once.
- Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent.
- parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior
ease is stripped without shifting the other keyframes' percentages.
* feat(studio): GSAP drag/commit/bridge editing infra
* fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore
BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the
commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the
server save, fixing both consumers (useEnableKeyframes + useGestureCommit's
showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation
return type widened void→Promise<void> (fire-and-forget consumers ignore it).
- stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live).
- commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek,
so a failed commit leaves the dropped pose, not a cleared element.
* feat(studio): motion-path geometry + commit helpers
* docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics
* feat(studio): on-canvas motion-path overlay
* fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold
* feat(studio): keyframes flag, gesture recording + timeline/selection refinements
* fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens
- useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's
empty parse isn't overridden by a stale selectedGsapAnimations cache.
- useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick).
- useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent).
- playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV).
- useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per
group, so a mixed gesture no longer yields an untagged legacy tween.
* feat(studio): single-source manual offset + rotation via the GSAP timeline
Dragging or rotating an element writes into the GSAP timeline (the single source of
truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static
elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and
the live preview moves via gsap.set so what you see equals what is written and renders.
Removes the dual-channel CSS-var/transform reconciliation behind the
fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position
and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/
keyframe commit, dropped the handleDom*Commit CSS fallbacks).
Subcompositions edit the same single-source way, which surfaced and fixes:
- resolve a subcomp element's source file via the composition-id map (the runtime drops
the source linkage when inlining the subcomposition);
- a selected element's selection box AND motion path use basic visibility, not the
occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover);
- soft reload rebuilds ONLY the committed composition's timeline, leaving other
compositions' timelines intact (no cross-composition revert);
- read keyframes from the element's OWN composition timeline (scan all timelines, not
the first unstable key);
- delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.
* fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures
- DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only
position/rotation channel; the false branch silently killed drag+rotate (and let GSAP
elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests.
- gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller
full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script.
- gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops
partial reads (no phantom identity) and re-applies the drag override in finally.
- MotionPathOverlay: park-timer cleanup keyed on animId change.
- useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review).
* feat(studio): patchRuntimeTweenInPlace — update a tween's values in place
Defensive runtime helper: locate the element's tween in window.__timelines via the
shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek
the playhead — without re-running the whole composition. Returns false (caller falls
back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed
keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant,
flicker-free manual edits.
* fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches
- resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose
vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write
{x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props.
- patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch],
instead of silently dropping the computed expression.
* feat(studio): instantPatch fast path in runCommit
A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on
success the preview updates in place with NO reload (instant), on false it falls back
to the existing soft reload. Extracts the preview-sync tail into a testable
applyPreviewSync helper. No behavior change when instantPatch is absent.
* feat(studio): route static position/rotation set drags through instantPatch
Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).
* fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure
- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)
* feat(studio): no full iframe remount for soft-reloadable edits
A softReload edit (and the SDK single-script refresh) no longer escalates to a full
reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set
already shows the value, and a remount is the worst flash + re-inlines subcomps
(reverting their keyframes). verifyTimelinesPopulated now checks the expected target
keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty.
Full reload stays only for the structural (no-softReload) and ambiguous-script paths.
* feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash
ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout
onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the
timeline. So when a user adds a motion path to a composition that didn't originally
use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN
async path (the flash). Idempotent + defensive; the existing async fallback stays for
genuine cold-start/CDN-failure.
* fix(studio): don't re-save + reload when source editor syncs externally
The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged —
including the programmatic dispatch that syncs external content (e.g. a manual-edit
commit writing the source back into the open editor). That made the editor re-save the
file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe
edit — defeating the in-place instant patch and causing the flash. Annotate the
programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save.
* fix(core): inject MotionPathPlugin into preview when a composition uses motionPath
A studio-created motion path writes a gsap motionPath tween into the single-source
timeline, but the preview HTML only loaded gsap core — so the first render threw
"Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject
MotionPathPlugin right after the composition's gsap script, version-matched to it.
* fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact)
* fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard
BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' |
'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full
reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script,
or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the
TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome
(origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted.
- SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an
in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused).
- Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append).
* fix(core): align __clipTree and __clipManifest ids via stableClipId
Timeline inline expansion was dead for nested children inside index.html:
the tree keyed id-less elements by a synthetic __clip-N while the manifest
keyed them null, so parent<->child never joined. Both now resolve identity
through stableClipId (id || data-hf-id), which every generated element has.
* fix(core): strip baked runtime + tag comp root in preview assembly
Comps that ship a baked inline runtime were double-loaded (preview injects
its own) and the baked copy failed to parse inline (Unexpected token '<').
Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also
tag the comp root with data-composition-file so the studio resolves a comp's
top-level elements to the right source file instead of defaulting to
index.html (which made the GSAP panel parse the wrong, multi-timeline file).
* feat(studio): set motion-path destination from a toolbar toggle
Replaces the double-click-on-canvas UX (which painted text over the preview)
with a 'Set motion destination' toggle next to Snap/Grid, shown only when the
selected element can take a path. While armed, one canvas press places the
destination. Also removes the dead TimelinePropertyRows component.
* fix(studio): center timeline keyframe diamonds on their percentage
Dropped clampDiamondLeft, which forced boundary keyframes fully inside the
clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's
midpoint now sits exactly on its % (the clip is overflow-visible).
* fix(studio): resize static elements via tl.set, not a single-stop keyframes tween
Resizing an element with no size animation wrote keyframes:{ <playhead%>:
{width,height} } — one mid-point stop GSAP can't interpolate, so it rendered
NaN/0 dimensions at every other frame and the element vanished (worst off 0%).
Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize
now writes tl.set({width,height}), held at all frames; re-resizing updates it
in place.
* fix(studio): negative-cache failed media probes
Only successful probes were cached, so CORS/404 cross-origin media was
re-probed every rAF-driven timeline re-derive, flooding the console. Remember
failed URLs and skip them.
* fix(studio): type window.setTimeout handle as number
ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is
present and clashes with the DOM number the call returns. Type it number.
* fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps
- Fix soft-reload clearProps destroying element inline styles — save cssText,
clear, restore, strip only transform
- Fix resize no-op on re-resize: delete+add instead of two update-property
- Route set tweens through static resize path (convertToKeyframes skips sets)
- Re-fetch animation ID before drag commit to prevent stale-ID duplicates
- Guard editDebugLog for Node test environments
- Fix NLELayout setState-during-render (move reset to useEffect)
- Stop SnapToolbar pointer events propagating to canvas deselect handler
- Enable click-to-add waypoints on cubic motion paths
- Add whole-path drag offset (Alt+drag shifts all keyframes together)
- Add Canvas shortcuts section to ShortcutsPanel
- Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance)
- Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
* chore(producer): shim __filename/__dirname in the CJS banner
Bundled CJS deps like wawoff2 call __dirname; without the shim they throw
"__dirname is not defined in ES module" at render time. Also ignore .zed/.
* chore(producer): use a template literal for the CJS banner (review nit)
transcribe hard-failed with cli_error whenever whisper-cpp was absent. On
Linux/Docker/CI (no Homebrew, no compiler toolchain) that is unavoidable, so it
drove ~30k cli_error/day that are really "install the prerequisite" rather than
bugs — and buried genuine transcription failures in the command-error budget.
ensureWhisper now throws a typed WhisperUnavailableError when no binary exists
and none can be built. The transcribe command reports that on a dedicated
transcribe_unavailable metric instead of cli_error, and a new --optional flag
lets pipelines skip captions and exit 0. Real transcription crashes still fail
as cli_error. init and the skill pipelines already continue without captions.
Also removes a stale doc reference to a `transcribe --provider groq` flag that
does not exist.
A scene worker that errors or is interrupted mid-write leaves an empty (or
markup-less) compositions/<scene>.html. existsSync passed, so assemble-index
emitted a data-composition-src pointing at it and the failure surfaced much
later as the render-compile error "Composition HTML is empty or could not be
parsed: compositions/scene-*.html" — the #1 render_error, ~4.7k users/day and
climbing.
All three assemblers (product-launch-video, faceless-explainer, pr-to-video)
now validate scene-file content (non-empty + contains markup) right where they
already read it for the duration cross-check, and die with an actionable
"re-dispatch that scene worker" message before the broken project can reach a
user's render.
Page-side compositing (default on) silently dropped HyperShader.init shader
transitions in the engine render. The compositor clones the from/to scenes to
feed drawElementImage, but cloneNode copies the GSAP opacity-fade, and Chrome
won't paint hidden elements, so drawElementImage throws "No cached paint record"
and the shader degrades to a hard cut. Force the clones visible before capture,
as the html2canvas path already does via forceSceneVisibleInClone.
Also fixes the final scene's content dropping in the last beat: the core clip
runtime hides it shortly before the composition ends, and page-side screenshots
the live page (the layered path survives via forceVisible per-scene capture).
Un-hide the settled scene on non-transition frames.
Page-side only; retains the ~6.5x page-side speedup.
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The runtime plays audio two ways — a Web Audio transport (sample-accurate) and
the HTMLMediaElement as a fallback — and mutes the elements when Web Audio takes
over so they don't double-play. That mute gate was global: it muted every element
the moment ANY Web Audio source was active (webAudio.isActive()). A track Web
Audio had not claimed yet (its larger buffer decodes slower) was muted on the
fallback AND not playing on Web Audio = silent, while the other tracks played.
With TTS narration + BGM + SFX, the narration (largest buffer) lost the decode
race and dropped out intermittently, with every file fully loaded.
Make the mute per-element: an element is muted only when its own Web Audio source
is live, or the user / parent-proxy force-mute is set. A track Web Audio has not
claimed stays audible on the HTMLMedia fallback until the transport takes it over
— which also lets narration start immediately on cold play instead of waiting for
its buffer to decode.
Also in this change:
- Don't permanently blacklist a transient fetch failure in the Web Audio decoder
(_failedSrcs was never cleared); only blacklist genuinely undecodable bytes, so
a late-arriving asset (404 then available) self-heals on the next play.
- Stop re-issuing play() every tick on an errored / no-source element.
core runs its suite via `vitest run` (include: src/**/*.test.ts), so the bun:test
import broke vitest collection ("Module bun:test has been externalized"), turning
core CI red on main and on every open PR (which build against the merge ref).
vitest's describe/it/expect are API-compatible — no test changes needed.
Extract cssAttrSelector to packages/core/src/utils/cssSelector.ts and
use it (or CSS.escape for browser-side code) at all 12 sites that
previously interpolated raw user-authored values into querySelector
attribute selectors. A " in a composition ID, script src, or
data-start value would produce a malformed selector that throws.
Node-side (core compiler/parser): uses the shared cssAttrSelector.
Browser-side (runtime, studio): uses native CSS.escape().
Supersedes #1568 which fixed only the 3 bundler sites.