The render pre-flight check shells out to `which ffmpeg`, which only
searches the server process's PATH. When Studio is launched from a
GUI/Dock/launchd context that PATH lacks /opt/homebrew/bin, so `which`
fails even when ffmpeg is installed — and POST /render returns 503
"FFmpeg not found".
Fall back to probing well-known install dirs (Homebrew on Apple Silicon
and Intel, plus system/Linux locations) when the PATH lookup fails.
Also drop the [kf:static]/[kf:runtime] keyframe diagnostics that were
spamming the Studio console in prod, and fix two unrelated CI breakages
the branch inherited: a Windows-sensitive ffmpeg test (pin platform) and
a stale player test mock missing onRuntimeReady.
* feat(core): add param-substitution utility for GSAP timeline inlining
U1: clone + shadow-aware identifier substitution over acorn ESTree, plus
provenance tagging and a GsapProvenance type. Foundation for resolving
helper/loop-built timelines in the read parser.
* feat(core): inline helper-built and bounded-loop GSAP timelines
U2: expansion pre-pass that rewrites the analysis AST so a helper called N
times, a literal-bounds for-loop, a for-of, or a forEach over an inline array
each become concrete per-call/per-iteration tl.* statements with substituted
positions and provenance tags. Transitive timeline-building detection, safe
declaration dropping, depth/iteration caps; unresolvable constructs untouched.
* feat(core): resolve computed GSAP timelines in the read parser
U3: parseGsapScriptAcorn runs the inlining pre-pass before analysis, so
helper-built and bounded-loop timelines resolve at true positions with
motionPath arcs recognized; each tween carries provenance. Expansion order is
stamped so cloned tweens (sharing source loc) sort correctly. Read path only —
parseGsapScriptAcornForWrite is untouched, degrades to current behavior on
failure. The add-to-basket addCycle case now yields 7 resolved animations.
* feat(studio): runtime-authoritative keyframes for dynamic timelines
Phase 2 (U4-U6): the live-runtime scanner returns tween-relative keyframes
with per-tween timing and converts them to clip-relative when given clip dims,
fixing the timeline-vs-clip-relative bug; it extracts motionPath into arcPath
(shared buildArcPath) so the Arc Motion panel activates for data-driven arcs;
the cache leaves statically-unresolvable tweens to the runtime scan. Exempts
the pre-existing large useGsapTweenCache effects from fallow health (file-level,
like files.ts) rather than suppression comments.
* feat(studio): surface keyframe editability from provenance
U9: editabilityForProvenance(provenance) -> direct|unroll|override (core,
re-exported from the acorn subpath). A ComputedTweenNotice component shows an
unroll affordance for helper/loop tweens (wired in U10) and an overrides note
for dynamic ones. Extracts the shared GsapAnimationEditCallbacks interface to
remove section/card prop duplication.
* feat(core): lint understands computed timelines (acorn parser)
U7: the GSAP lint rule now loads parseGsapScriptAcorn (which inlines helpers
and bounded loops) instead of the recast parser, so overlapping_gsap_tweens and
related findings reflect true resolved positions for computed timelines — and
keeps recast out of the lint graph entirely. Literal compositions are
unchanged (parity), all 182 lint tests pass.
* docs: document the computed-timeline keyframe editing model
U8: keyframes.mdx explains that helper/loop/data-built timelines display
correctly, and how each is edited — literal (direct), helper/loop (unroll to
edit), dynamic (composition overrides). Nothing is permanently locked.
* feat: unroll computed timelines into literal tweens (U10)
Adds unrollComputedTimeline (core): serializes a parsed timeline's resolved
animations back to literal tl.* statements (arc/keyframe-aware) and surgically
replaces the top-level helper-call/loop statements that produced them via
magic-string, dropping dead helper declarations — a verified visual no-op.
Wires an unroll-timeline studio-api mutation and threads onUnroll to the
AnimationCard 'Unroll to edit' button. Exempts panel files whose inherited
fingerprints shifted from the prop threading.
* feat(runtime): declarative keyframe override layer for dynamic tweens (U11)
Adds applyKeyframeOverrides: fetches a gsap-overrides.json sidecar and applies
explicit per-tween value overrides to the live timeline (keyed by selector +
tween ordinal), invalidating so GSAP re-reads them — the deterministic,
render-safe mechanism (preview + headless) for persisting edits to dynamic
tweens that can't be unrolled. Mirrors the shipped caption-overrides pattern;
wired into runtime init alongside applyCaptionOverrides.
* refactor: drop the keyframe override layer; rely on unroll + source
Removes the gsap-overrides.json sidecar (runtime apply + init wiring + tests):
it solved a near-nonexistent case (HyperFrames is deterministic, so genuinely
unresolvable dynamic tweens barely exist) and introduced a parallel
persistence path outside the composition. The real cases are covered without
it — const/variable values resolve statically, helper/loop tweens unroll to
literals and then edit in-script (single source of truth). Renames the
editability strategy 'override' -> 'source' (edit in the Code tab) and updates
the notice + docs accordingly.
* fix(studio): drag outside tween range creates new keyframe, picks nearest tween
Fixes the GSAP drag intercept to pick the position tween closest to the
playhead (not the one with the most keyframes), and when dragging outside all
tweens' ranges, creates a brand-new keyframed tween instead of destructively
extending/replacing the nearest one. Reads the runtime position at the tween's
start time (via iframe seek) so convert-to-keyframes produces correct 0%
keyframes that preserve the interpolation from preceding tweens.
* fix(studio): drag outside tween range creates new keyframe, picks nearest tween
Also reverts all fallow health.ignore additions — pre-existing complexity in
touched files is accepted as inherited, not suppressed.
Moving sharp and onnxruntime-node to optionalDependencies (in the earlier
capture/native-module hardening) regressed `remove-background` from ~7% to
~97% failure starting at 0.6.101: the command genuinely *requires* both native
modules, but as optional deps they're skipped on most installs, so it hits the
guarded "module not available" error and fails for nearly everyone.
The capture crash that motivated the optional move is already fixed by the
lazy, guarded `await import()` in contentExtractor / inference — that holds
regardless of dependency classification. Making the modules optional was the
over-correction; the lazy import alone was sufficient. sharp ships its own
platform binaries as optional sub-deps, so it installs cleanly as a hard dep
without failing installs on unsupported platforms (it was a hard dep at 0.6.99
with remove-background at a healthy ~7%).
- Move sharp + onnxruntime-node back to `dependencies` (so they install for
everyone again). `@google/genai` stays optional — genuinely optional, lazy,
and not part of the regression.
- Keep the lazy guarded imports — they remain the crash-safety for capture.
- Add trackCommandFailure to remove-background's catch: it self-exits, so the
dispatch wrapper never saw it (the reason stream was blind). Now its failures
carry a reason, closing that command from the wrapper-blind follow-up.
remove-background tests + background-removal suite pass; tsc clean; build green.
* fix(engine): use ANGLE-EGL for Linux GPU path, bump NVENC probe size
Chrome 131+ rejects --use-gl=egl in headless shell; the GPU process
exits and the renderer silently falls back to SwiftShader. Switch to
(gl=angle, angle=gl-egl) which is on the headless-shell allowlist,
and add --ignore-gpu-blocklist + --disable-software-rasterizer so
data-center GPUs (L4/T4/A10) are not blocked.
Also bump the NVENC probe frame from 16×16 to 320×240 — NVIDIA
data-center cards require ≥257 on each dimension and reject the
smaller size with "Frame Dimension less than the minimum supported
value", causing the encoder probe to silently fall back to libx264.
Closes#1493
* fix(engine): address review feedback — probe test, observability, comments
- Export getProbeArgs and add test pinning 320×240 probe dimensions
across all 5 GPU encoder backends (nvenc/videotoolbox/vaapi/qsv/amf)
- Add driver/SKU rationale comment on the probe size constant with
context about NVIDIA data-center card behavior vs documented minimums
- Add rationale comment on --ignore-gpu-blocklist (operator opted into
hardware mode explicitly)
- Log resolved GL flags at browser launch for GPU fallback observability
Studio-triggered renders emit render_complete / render_error from the CLI
preview-server process, which stamps every event with the install's
anonymousId (client.ts drainQueueToPayload). The browser, meanwhile, fires
studio_session_start / studio_render_start under its own getAnonymousId(). So
the render outcome and the render start never share a person_id — verified in
data: of 15,125 users who started a studio render in 30d, ZERO have any
render_complete under any source, and the 898 studio-tagged completers are
disjoint server UUIDs. The studio render funnel — the product's core value
moment and strongest retention signal — is therefore unmeasurable.
Thread the browser's telemetry id through to the render-outcome events:
- client.ts: trackEvent takes an optional distinctId; drainQueueToPayload uses
`event.distinctId ?? config.anonymousId`. CLI renders unchanged.
- events.ts: trackRenderComplete/trackRenderError forward an optional distinctId.
- studioRenderTelemetry.ts: emitStudioRender* pass opts.distinctId through.
- core studio-api (types.ts + routes/render.ts): the render route reads
`telemetryDistinctId` from the request body (validated string) and passes it
to the adapter's startRender, which already forwards opts to the emitters.
- studio (useRenderQueue.ts): include getAnonymousId() as telemetryDistinctId
in the render POST — the same id studio_* events already use.
Result: studio render_complete/error now carry the browser user's id and join
studio_session_start / studio_render_start. Older clients that don't send the
field fall back to anonymousId (no regression). No new tracking surface — it's
the existing anonymous studio id.
Tests: per-event override forwarding (events), studio render distinctId
threading + older-client fallback (studioRenderTelemetry), and route body →
adapter forwarding incl. non-string rejection (core render route).
Observability showed `browser` (~75% fail, ~1.3k users/day) and `info` (~60%
fail) failing at high rates with no captured reason — only
`cli_command_result success=false`. citty's `runMain` catches a command's
thrown error and `process.exit(1)`s without re-throwing, so a thrown failure
never reached the existing `cli_error` telemetry (which only fired from the
uncaughtException / unhandledRejection handlers).
Wrap every command's `run()` at the dispatch boundary (cli.ts) so a thrown
failure reports its reason via `cli_error` (kind=command_error) before being
re-thrown unchanged — citty's print + exit-1 behavior is preserved. This
de-blinds every throw-style command at once: `browser ensure` (Chrome
download), `tts`, `inspect`, `render`, etc.
Paths that bypass the wrapper are handled inline:
- `browser` self-exits (`path` download failure, unknown subcommand) — report
inline; the ARM64 `ensure` branch previously swallowed a failed install and
returned success, now reports and exits 1.
- `resolveProject()` self-exits on InvalidProjectError (the dominant `info`
failure — run outside a project) — report inline before exit.
Hardening:
- PII: `trackCliError` now redacts error_message + stack_trace via
redactTelemetryString (matching render_* events) — CLI errors and stacks
carry absolute install paths / cache dirs / user args.
- Race: the wrapper awaits an on-demand telemetry import before re-throwing, so
a command that fails before the lazy telemetry import settles still reports
(a telemetry failure is swallowed and never masks the real error).
Pure helpers in utils/command-failure-tracking.ts with unit tests for the
throw / success / no-run / onFailure-rejection cases, the reporter wiring, and
trackCliError redaction. CommandDef<any> mirrors citty's SubCommandsDef.
Known scope: commands that print + `process.exit(1)` on their own validation
paths (tts/validate/lint argument errors) remain wrapper-blind — follow-up.
Add a "Video Components" overview to the docs site — an entry point to the
50+ catalog blocks and components: what they are, how to install and wire
them, and how to contribute a new one. Wire it into the Guides sidebar.
The 9 Code Animation blocks shipped to the registry but were referenced
in zero agent-facing skills, so agents rebuilt code scenes from scratch
instead of installing them.
- discovery.md: add a Code Animations (9) section to the block inventory
and fix the stale block count (88 -> 97)
- motion-graphics/catalog-map.md: add a code / code-reveal row to the
Director -> block map
- pr-to-video visual-design: install code-diff / code-morph /
code-highlight / code-typing / code-scroll before hand-authoring a code window
Regenerate the lockfile so it matches current package.json: reflects the
sharp / onnxruntime-node move to optionalDependencies and the workspace
version mirror. No resolved-graph change — pure metadata sync to stop the
recurring `bun install` churn in git status.
Transcribe failures are recorded as `cli_command_result success=false` but
without a reason: the command catches its own error, prints it, and
`process.exit(1)` — the message never reaches telemetry. `cli_error` was only
emitted from the uncaughtException / unhandledRejection handlers, so
self-handled command failures were invisible. That makes a high failure rate
countable but not debuggable.
Add `trackCommandFailure(command, err)` — a thin wrapper over the existing
`trackCliError({ kind: "command_error" })` that normalizes an unknown reason to
name/message/stack. It enqueues synchronously, so the process `exit` handler's
flushSync ships it alongside `cli_command_result`. Respects the telemetry
opt-out (gated in trackEvent) and reuses the existing PII redaction.
Wire it into all three of transcribe's failure exits (file-not-found,
empty-transcript import, and the transcribe() catch — ffmpeg / whisper-binary /
model-download errors). Now each failure carries its reason, so we can see how
much of the failure rate is environment vs user input.
The helper is generic — the same one-liner can be dropped into other commands'
failure paths, or centralized at the runMain boundary, as a follow-up.
When a child element inside a sub-composition is selected, the timeline
replaces the parent scene clip with the deepest-level siblings. Deselect
or selecting outside collapses back. Expanded clips are fully editable —
move, resize, delete, and split — addressed by their real DOM id with
timeline time rebased onto the sub-comp they live in.
Runtime:
- New window.__clipTree API: a read-only hierarchical ClipNode tree
(id/parentId/children + backing element) so Studio can derive
parent/child relationships for inline expansion.
Studio:
- useExpandedTimelineElements derives the expanded view from
selectedElementId + clipParentMap (pure useMemo, no useEffect).
Each child rebases onto its immediate sub-comp host (start +
sourceFile), so multi-level nesting targets the right file.
- NLELayout routes expanded-clip edits through the same handlers
top-level clips use, in local coordinates — edits save to the
sub-comp source and reflect via reloadPreview (no separate DOM-patch
path). This is the canonical update; there is no reactive observer.
- findMatchingTimelineElementId resolves sub-comp children with no
top-level element to `sourceFile#id`.
- Razor tool enabled by default; studio_razor_split analytics event
fired on single and split-all.
- O(n²) isElementGsapTargeted extracted to gsapTargetCache.ts with a
cached Set+WeakSet O(1) lookup.
Adds a Code Animations catalog section — 9 self-contained, installable blocks:
morph, snippet-flight, typing, diff, highlight, scroll (DOM/GSAP) and 3d-extrude,
shader-dissolve, particle-assemble (WebGL). Each block ships only its own effect and
renders deterministically (paused GSAP timeline seeked per frame, seeded RNG, no
render-time data fetch). Wires the catalog nav, registry.json, a new code-animation
Studio category, and preview assets.
Aimed at `npx hyperframes` users (standalone and inside monorepos), where the
native modules `sharp` and `onnxruntime-node` can't install or load.
## Native modules are now optional, and never abort the CLI
`sharp` and `onnxruntime-node` are native modules: their platform binaries ship
as optional sub-dependencies that can fail to land on end-user installs
(--omit=optional, musl/glibc, monorepo hoisting, cross-platform lockfiles,
broken npx cache). Both powered only optional commands, yet both were wired as
hard `dependencies`, so on any platform where a binary can't install the whole
CLI failed to install. Moved both to `optionalDependencies` (alongside
@google/genai) so the core CLI always installs; the native-accelerated paths
light up only when present.
Runtime handling so a missing/unloadable binary degrades instead of crashing:
- `capture` (`contentExtractor.ts`): sharp was a static top-level
`import sharp from "sharp"`, so a load failure threw on module import —
before the inner try/catch — aborting the whole command. Now a guarded lazy
`await import("sharp")` that skips SVG captioning with an actionable warning.
Marked `external` in tsup so esbuild never bundles the native module.
- `remove-background` (`inference.ts`): both `onnxruntime-node` and `sharp`
are loaded here and genuinely required. The dynamic imports are now guarded
to throw an actionable "install / reinstall with optional deps" error
(surfaced cleanly by the command's existing try/catch) instead of a raw
"Cannot find module". New tests assert createSession rejects with that
guidance — before touching the model download — when either module is
unavailable.
`contactSheet.ts` also uses sharp but is already behind a dynamic-import
boundary wrapped in try/catch, so it was never a hard-fatal path.
## inspect: content-overlap as a warning, not a blocking error
The `content_overlap` layout-audit check shipped as `severity: "error"`, and
the audit exits non-zero when `errorCount > 0`, so `inspect` failed for
compositions that intentionally layer text. Downgraded to `severity: "warning"`
so it still reports (and prints the `data-layout-allow-overlap` opt-out hint)
without breaking exit codes. Reversible.
Extend `inspect` to verify motion intent against the same seeked timeline
the renderer uses, catching render-≠-preview bugs that layout sampling can't:
entrance reveals the seek skips, broken stagger order, off-frame drift, and
frozen shots.
A `*.motion.json` sidecar next to the composition opts in (auto-discovered,
no flag, no authoring-framework changes); without one, inspect is unchanged.
inspect seeks a dense grid over the asserted selectors, builds an
element × time matrix of {rect, opacity, visible} plus per-scope liveness
signatures, and evaluates four assertions in Node:
appearsBy -> motion_appears_late
before -> motion_out_of_order
staysInFrame -> motion_off_frame
keepsMoving -> motion_frozen
A selector matching nothing is reported as motion_selector_missing rather
than silently passing. Findings reuse the LayoutIssue shape and flow through
the existing dedupe/collapse/limit/format pipeline and JSON envelope; they
are errors by default, so a failed assertion fails the run.
The motion pass runs in the same Chrome session as the layout audit (no extra
launch) and only when a sidecar is present.
Gate the runtime's per-frame transport re-seek to yield to an active Studio manual-edit drag, so GSAP x/y-controlled elements track the cursor instead of freezing until drop. Also adds the missing sdk-playground workspace member to Dockerfile.test, which unblocks the render regression suite for any runtime-touching PR.
- Add missing version field to sdk-playground/package.json (broke pnpm pack)
- Skip private packages in verify-packed-manifests (prevent recurrence)
- Pin bun v1.3.13 in Dockerfile.test (1.3.14 produces different lockfile)
Resize: proportionally scale all GSAP animation positions and durations
to fit the new clip duration via scalePositionsInScript. This preserves
clip-relative keyframe percentages — diamonds don't move during resize,
nothing disappears. Modeled after After Effects Time Stretch behavior.
Drag: shift all GSAP positions by the time delta (unchanged from before).
Diamond rendering:
- Clamp diamonds at 0%/100% so they stay fully visible at clip edges
- Filter out-of-range keyframes using predicted percentages during resize
- Clamp connection lines to clip boundaries
- PropertyRows: same edge clamping for SVG diamonds
Parser: scalePositionsInScript (proportional position + duration scaling),
shiftPositionsInScript (rigid shift), scale-positions + shift-positions
mutation types, 5 shift tests passing.
* refactor(producer): extract HDR compositor from renderOrchestrator
Move ~700 LOC of HDR compositing primitives (countNonZeroAlpha,
countNonZeroRgb48, cropRgb48le, HdrVideoFrameSource,
closeHdrVideoFrameSource, blitHdrVideoLayer, HdrImageBuffer,
blitHdrImageLayer, CompositeTransfer, shouldUseLayeredComposite,
resolveCompositeTransfer, HdrCompositeContext, compositeHdrFrame,
HdrTransitionMeta, TransitionRange) into a dedicated
hdrCompositor.ts module.
Remove backward-compat re-exports from renderOrchestrator (hdrPerf,
captureCost, shared) and rewire all import sites to the
authoritative source modules.
* refactor(producer): delete 4 re-export shim files
screenshotService.ts, videoFrameExtractor.ts, videoFrameInjector.ts,
and streamingEncoder.ts existed solely to re-export symbols from
@hyperframes/engine. No internal consumer imported from them except
index.ts → videoFrameInjector, which now imports directly from engine.
* refactor(producer): delete unused PNG decode/blit worker pool
The pool (455 LOC) and worker (127 LOC) were built speculatively for
pipelining Chrome screenshots with PNG decode/blit but were never
wired into any capture path. Zero non-test source files imported them.
Also removed the esbuild entry point from producer/build.mjs, the
tsup entry point + alpha-blit alias from cli/tsup.config.ts, and
the PNG worker bootstrap from cli/src/cli.ts.
* refactor(producer): centralize frame filename construction
Replace 4 inline padStart(6) template literals with shared helpers:
- formatCaptureFrameName(index, ext): zero-based, for internal capture
- formatExportFrameName(index, ext): zero-based input, one-based output
for user-facing png-sequence export
* perf(producer): hoist allElementIds out of compositing loop
Move fullStacking.map() from inside the per-layer iteration to before
the loop, computing the element ID list once per frame instead of once
per DOM layer per frame.
* refactor(producer): consolidate HDR timing instrumentation
* refactor(producer): remove typecasts and deduplicate HDR capture patterns
- Extract seekInjectAndQueryStacking() and seekAndInject() helpers to
deduplicate the seek+inject+query pattern across sequential loop,
hybrid loop, and per-scene transition capture (3 call sites → 1 helper)
- Fix sceneBuf as Buffer casts by properly typing the scene-capture
arrays as [Buffer, Set<string>][] instead of using as const + cast
- Replace as NonNullable<> cast on outputFormat with as const fallback
- Add explanatory comments on inherent linkedom DOM casts
* refactor(producer): name constants, type matrix, extract opacity helper
- Replace magic 0.001/0.999 with TRANSFORM_IDENTITY_EPSILON and
OPAQUE_ALPHA_THRESHOLD; replace BPP=6 with RGB48_BYTES_PER_PIXEL
- Add AffineMatrix tuple type + isAffineMatrix guard, eliminating
all 4 non-null assertions on matrix indices
- Extract resolveBlitOpacity() to replace 5 identical ternaries
- Narrow fallow-ignore-file to line-level complexity suppressions
* fix(studio): guard Zustand no-op setters and fix useConsoleErrorCapture memory leak
- Guard setIsPlaying to skip set() when value unchanged (eliminates 60
notifications/sec during reverse playback)
- Guard caption store selectGroup to bail before set() when group missing
(prevents empty Zustand notifications)
- Guard clearSelection to skip when already empty
- Fix useConsoleErrorCapture: restore original console.error, remove error
event listener, and delete __hfErrorCapture flag on cleanup
* fix(studio): delete dead files and unused exports
Remove 7 dead files (audioBeatDetection, keyframeSnapping,
timelineInspector, DopesheetStrip, StaggerControls,
TimelineLayerPanel, TimelineEditorNotice) and their test companions.
Delete unused computeFitToChildrenSize export from propertyPanelHelpers.
Fix re-export indirection: useDomEditCommits and studioMotionOps.test
now import patch builders directly from manualEditsDomPatches instead
of the re-export passthrough in manualEditsDom.
* fix(studio): eliminate effect-chain state mirroring for lint findings, hover, and GSAP fetch
Move lint findingsByElement sync from App.tsx into useLintModal where
the value is produced, removing the mirroring useEffect. Consolidate
4 hover-clearing effects in useDomSelection into 2 (one unconditional
on context change, one conditional combining caption mode, selection
match, and disconnected element checks). Fold the GSAP retry effect
into the fetch effect in useGsapTweenCache, scheduling a single retry
via setTimeout when the initial fetch returns 0 animations.
Eliminates 3 unnecessary render cycles from effect chains.
* fix(studio): memoize renderQueue, toolbar, and canvas rect to prevent re-render cascade
- Wrap renderQueue object in useMemo so StudioContext consumers don't
re-render on every App render
- Memoize timelineToolbar JSX so NLELayout memo isn't defeated
- Move canvasRect getBoundingClientRect() from render-time IIFE to a
useLayoutEffect-backed ref, eliminating layout thrashing
- Track and clear setTimeout handles in refreshPreviewDocumentVersion
to prevent stale timer accumulation on rapid calls and unmount
* refactor(studio): consolidate GSAP shared primitives — defaults, iframe access, keyframe parsing
Extract duplicated PROPERTY_DEFAULTS, IframeGsap interface, iframe
accessors (getIframeGsap, queryIframeElement), percentage keyframe
parsing, and toAbsoluteTime into a single gsapShared.ts module.
Removes ~120 lines of copy-pasted logic across 8 hook files, reducing
drift risk between the duplicate implementations.
* fix(studio): remove dead store fields, dead file, duplicate helper, and unsafe assertions
* refactor(studio): deduplicate selector helpers, rounding utils, percentage computation, and iframe access
* fix(studio): split StudioContext into Shell + Playback to prevent cascade re-renders
* refactor(studio): decompose useGsapScriptCommits into focused mutation hooks
* refactor(studio): decompose useFileManager into focused file operation hooks
Extract useFileTree (tree loading, refresh, derived assets/compositions)
and useEditorSave (debounced save with history tracking) from the 508-LOC
useFileManager. The parent hook composes both and retains file I/O,
click-to-source, upload/import, and CRUD — preserving the same public
interface so no consumers change.
* refactor(studio): decompose useDomEditCommits into focused commit hooks
Extract geometry (path offset, box size, rotation) and element lifecycle
(delete, z-index reorder) into useDomGeometryCommits and
useElementLifecycleOps. Parent keeps persistDomEditOperations as core
and composes all sub-hooks — public interface unchanged.
* refactor(studio): simplify useAppHotkeys with declarative command table
* refactor(studio): simplify useAppHotkeys with declarative command table
Replace 15 individual useRef callback refs with a single cbRef object.
Extract keydown dispatch into pure dispatchModifierKey/dispatchPlainKey
functions. Merge duplicate undo/redo logic into shared applyHistory.
Extract cross-origin listener boilerplate into safeAddListener/safeRemoveListener.
Hook body: 204 LOC (down from 445). Public API unchanged.
* fix(studio): remove unused getDomEditTargetKey import
* refactor(studio): decompose useDomEditSession into focused editing hooks
Extract GSAP-aware geometry intercepts (move/resize/rotation) and
animated property commit into useGsapAwareEditing, and selection
wiring, GSAP cache management, preview sync, and selection handlers
into useDomEditWiring. The parent remains a pure composition shell.
* style(studio): fix formatting in 5 files
* fix(studio): trim App.tsx to 598 lines (under 600 limit)
---------
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics
Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.
- Retry code-editor saves with exponential backoff instead of dropping the
edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
queue with a user-visible error state instead of burning every queued
mutation against it.
- save_failure events now carry error_message, status_code, and source on
every emission path; style/attribute DOM-edit failures that previously only
logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
undo/redo, text fields) through a safe wrapper that reports failures via
telemetry instead of unhandledrejection.
Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.
* fix(studio): narrow save retry changes for fallow
## Problem
Studio manual drag had two bad target paths in `hf-keyframes-test`:
- Drag could start from a cached hover selection instead of the selected overlay, so moving `#cta` could write an offset to the wrong element while the overlay appeared correct.
- `#stage` is the visual 1920x1080 canvas, but the composition metadata lives on `<html data-composition-id ...>`. Studio treated `#stage` as a normal movable layer, so dragging it moved the coordinate basis the overlay depends on and left Undo with no reliable saved edit to reverse.
## Fix
- Manual drag now starts only from the selected overlay bounds; canvas pointer-down no longer starts movement from `hoverSelectionRef`.
- Added a structural root-layer heuristic: a direct body child matching the composition root's declared `data-width` / `data-height` is selectable and style-editable, but cannot receive manual offset/size/rotation edits.
- Manual geometry commits now return/rethrow the save promise so failed writes roll back optimistic DOM changes instead of silently sticking.
- GSAP-targeted CSS fallback edits are blocked before applying manual geometry when the GSAP drag intercept is unavailable.
## Tests
- New regression coverage for stale-hover drag start and full-canvas root-stage capability resolution.
- Focused DOM-edit suite: 119/119 pass.
- Full Studio suite: 800 pass, 18 todo.
- Studio typecheck clean; oxlint/oxfmt clean; lefthook pre-commit clean.
- Studio build passes with the existing Vite chunk-size warning.
- Browser verified with `agent-browser`: attempted `#stage` drag writes no offset and keeps Undo disabled; `#cta` drag writes exactly one offset to `#cta`; Undo returns the fixture to zero offsets.
Local browser artifacts: `artifacts/studio-cta-drag/stage-cta-drag-fix.webm`, `artifacts/studio-cta-drag/stage-cta-drag-fix-final.png`.
The motion panel was behind a feature flag defaulting to false and never
shipped. Remove the tab button, MotionPanel component, feature flag,
and all associated wiring (EaseCurveEditor, SpringEaseEditor,
MotionPanelFields, MotionPathOverlay). The underlying motion data
infrastructure (studioMotion, studioMotionOps) remains intact for the
GSAP panel.
Co-authored-by: Miguel Ángel <miguelangelsisi098@gmail.com>
getSystemTotalMb returned os.totalmem() — the host's physical RAM — so a
4GB Docker container on a 32GB host never auto-flagged as low-memory and
the low-memory render profile didn't activate exactly where it's needed
most. Read the cgroup v2 limit (/sys/fs/cgroup/memory.max, with the v1
fallback and its no-limit sentinel handled) and use min(host, cgroup).
The probe is best-effort and non-Linux platforms never touch /sys.
Review follow-ups: worker sizing (calculateOptimalWorkers) and the
getSystemResources diagnostics previously read os.totalmem() directly
and now use getSystemTotalMb(), so container limits actually govern
parallel spawn decisions; CLI telemetry reports the effective total as
well. The cgroup probe result is cached for the process lifetime (the
limit is immutable per process) with a test reset hook; a detected limit
logs once so operators can see which source governs, and a
present-but-unreadable cgroup file warns once instead of failing
silently — absence stays silent. The root-path-vs-/proc/self/cgroup
trade-off is documented at the path constants. cli/tsconfig.json gains
the gcp-cloud-run/sdk source alias (matching the existing producer and
aws-lambda entries) so the cli typecheck resolves from source in a
fresh checkout.
Refs #1193, #1194, #1195, #1236
writeFrame returned the stdin.write boolean synchronously; when FFmpeg
encoded slower than workers captured, Node's writable buffer grew without
bound (multi-worker worst case ~80GB over a 1h render) until the kernel
OOM-killed the process. writeFrame is now async: a buffered write awaits
the drain event before resolving, so back-pressure propagates through the
frame reorder buffer to the capture loops and in-flight frames stay
bounded. Inactivity-timer semantics are preserved: no reset before drain,
so a hung FFmpeg still trips SIGTERM.
The drain wait races one-shot drain/close listeners (aborted in a
finally) rather than chaining onto the shared exit promise — V8 retains
reaction-list entries on unsettled promises, so per-frame .then chains
would accumulate ~108K closures over a 1h back-pressured render. An
exit-status re-check after listener attachment closes the
close-before-attach hang window. All five writeFrame call sites
(streaming stage and HDR loops) check the result via a shared
ensureFrameWritten guard and stop the render with a frame-indexed error
when the encoder is gone instead of discarding the boolean.
The MULTI_WORKER_MAX_DURATION_SECONDS cap can be relaxed in a follow-up
now that buffering is bounded.
Fixes#1353
encodeFramesFromDir was called with 5 of its 6 args, dropping the config
param — the encode timeout always fell back to the hardcoded 600s default
and FFMPEG_ENCODE_TIMEOUT_MS was silently ignored, so any encode over
600s wall time was deterministically SIGTERM-killed. Resolve the engine
config once in the encode stage and pass it to the non-chunked, chunked,
and GIF paths. The chunked per-chunk encodes previously had no timeout at
all; they now honor the same config value.
Review follow-ups: the chunked path's final concat spawn gains the same
config-driven timeout (it previously had none); every encode-timeout
kill now appends 'FFmpeg killed after exceeding ffmpegEncodeTimeout
(N ms)' to the failure instead of surfacing a bare exit-255; and the
orchestrator threads its already-resolved config into the encode stage
via an optional EncodeStageInput.engineConfig field (direct callers and
distributed chunks keep the producerConfig ?? resolveConfig() fallback).
The encode-timeout tests run against a mocked child_process spawn with
fake timers, removing the real-ffmpeg dependency that made the previous
default-timeout test environment-fragile in CI.
Fixes#1348
## Problem
Windows renders commonly fail with environment errors before any real work starts:
- `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists.
- `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling.
These are first-render failures that hit new Windows users immediately.
## Fix
- Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path.
- New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints.
- Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH.
- Map opaque Windows ffmpeg exit codes to actionable messages.
## Testing
- New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary.
- CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean.
- Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* fix(studio): property panel group-aware keyframe routing
Add animIdForProp helper routing keyframe diamonds to correct property-group
animation. Wire StudioPreviewArea delete/move/toggle handlers to use
propertyGroup for routing. Fix per-property epsilon in rdpSimplify.
* fix(studio): gesture recording replaces existing position keyframes
Gesture recording uses replace-with-keyframes mutation to replace existing
position-group tween. Fix N1 sign inversion and N9 wheel startPointer
with pointerElementOffset subtraction.
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* fix(studio): property panel group-aware keyframe routing
Add animIdForProp helper routing keyframe diamonds to correct property-group
animation. Wire StudioPreviewArea delete/move/toggle handlers to use
propertyGroup for routing. Fix per-property epsilon in rdpSimplify.
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
## Problem
The most common render failure in recent reports is:
```
Cannot destructure property 'firstElementChild' of 'documentElement' as it is null.
```
It appears when a `data-composition-src` file resolves to empty or unparsable HTML, and started showing up after the render pipeline change in 0.6.73.
## Root cause
When a sub-composition file is empty or unparsable, linkedom's `parseHTML` returns a document with a null `documentElement`, and the shared inliner (`packages/core/src/compiler/inlineSubCompositions.ts`) dereferences `.body`/`.head` on it, crashing inside linkedom internals with the cryptic destructure error instead of telling the user what's wrong.
## Fix
Guard the resolved sub-composition HTML and the extracted content HTML in the shared inliner: empty or unparsable input now fails with an actionable error naming the offending file.
## Testing
- New tests in core and producer reproducing the empty sub-composition case (previously crashed with the destructure error, now throws the actionable message).
- `bun run build` green, all tests pass in the changed test files.
Add VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT env var (default: false) to
gate the GSAP drag/resize/rotation intercept in useDomEditSession. When
off, dragging GSAP elements falls through to the standard CSS path
instead of committing via script mutation.
Manual dragging (STUDIO_PREVIEW_MANUAL_EDITING_ENABLED) remains on.
Wire the razor tool into Studio's timeline UI:
- B enters razor mode (crosshair cursor + red vertical guide line)
- Click any clip to split at the click position
- Shift+click splits all clips across every track at that time
- V or Escape exits razor mode
- Toolbar shows selection arrow / scissors toggle
Add useRazorSplit hook for split orchestration (HTML + GSAP mutation).
Add activeTool state to playerStore. Add preview reload after timeline
move/resize operations so the composition re-renders with updated timing.
* refactor(studio): extract shared timeline components and deduplicate code
Extract shared utilities to reduce duplication across timeline components:
- PlayheadIndicator: shared playhead rendering (was duplicated in
TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
* feat(core): GSAP-aware split engine for timeline clip splitting
Add splitAnimationsInScript to the GSAP parser — correctly re-times
animations when a timeline clip is split at an arbitrary position:
- Animations before split: kept on original, properties inherited via
tl.set inserted before other tweens for correct GSAP state recording
- Animations after split: retargeted via AST selector update
- Spanning animations: trimmed on original, continuation added for
new element with correct position and duration
- Keyframes: classified by total per-keyframe duration
- Reverse iteration prevents stale animation ID collisions
Enhance splitElementInHtml:
- CSS rule duplication via PostCSS for ID-based styles
- Server-side ID deduplication for repeated splits
- Media playback-start adjustment for video/audio
Add split-animations route to gsap-mutations endpoint.
Extract shared utilities to reduce duplication across timeline components:
- PlayheadIndicator: shared playhead rendering (was duplicated in
TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
onSetMuted/onSetMediaOutputMuted set el.muted = effective on every
<video> and <audio> element. When the bridge sent onSetMuted(false),
it unmuted avatar <video muted> elements whose baked-in lip-sync audio
should never play — causing double audio alongside the separate TTS.
Fix: el.muted = effective || el.defaultMuted.
AudioBufferSourceNode fires 'ended' when playback completes naturally,
but _activeSources was never cleaned up. This kept isActive() true
permanently, which force-muted all HTML audio elements via the
outputMuted flag in syncRuntimeMedia — causing audio to disappear
after the WebAudio buffer finished (~5s for short TTS clips).
Add onended listener that removes the source from _activeSources and
restores el.muted to its pre-WebAudio value. All side-effects are
guarded by idx !== -1 so a stale ended event after stopAll() is a
no-op and cannot clobber bridge state set between stop and the async
event delivery.
- Gate stripStudioEditsFromTarget/bakeVisibilityOnDelete behind a
stripStudioEdits flag on the delete mutation type so they only fire on
user-initiated deletes, not on internal delete-then-recreate drags.
- Add bakeVisibilityOnDelete to the remove-all-keyframes handler so
elements with CSS opacity:0 stay visible after collapsing keyframes.
- Fix integer rounding in readAllAnimatedProperties: use 3-decimal
precision for visual properties (opacity, scale, rotation) instead of
Math.round which corrupted mid-fade values to 0.
- Guard VISUAL_BASELINE against cross-tween contamination by querying
__timelines for properties animated by other tweens on the same element.
- Harden bakeVisibilityOnDelete: reverse-scan keyframes for the last one
containing opacity, guard against relative values (+=/-=/*=), and add
Number.isFinite check.
- Fix falsy-zero doubling in drag commit: replace || fallback with
Number.isFinite so a base GSAP position of 0 is correctly preserved.
- Fix gesture recording sign inversion: remove pointerElementOffset
subtraction from dx/dy formula and instead apply it once to basePosition
so the element center tracks the pointer.
- Fix TypeScript build errors in gsapSoftReload.ts (6 double-casts).
- Strip all diagnostic logs from production code.
## Summary
Fixes#1317 — systematic duplicate+skip video frames when clip `data-start` is aligned to the output frame grid.
### Root cause
`Math.floor(localTime * fps)` in `getFrameAtTime` produces off-by-one errors when the product lands exactly on an integer boundary due to IEEE 754 float noise. For example, `0.28 * 25 === 6.999999999999999` instead of `7`, causing `Math.floor` to return 6 (duplicate of previous frame) instead of 7.
### Fix
1. Add `1e-9` epsilon before flooring: `Math.floor(localTime * fps + 1e-9)` — nudges boundary values like `6.999999` to `7.000000` without affecting mid-frame values.
2. Include `mediaStart` in the frame index computation so trimmed clips (`data-media-start`) map to the correct extracted frames.
Both call sites fixed: `getFrameAtTime()` (public API) and the `FrameLookupTable.getFramesAtTime()` bulk lookup.
### Reporter's measurements (before fix)
| Case | Duplicates (of 351 frames) |
|---|---|
| Source file | 1 |
| data-start="0" | 14 |
| data-start="230.44" (production) | 127 |
| data-start="0.02" (half-frame offset workaround) | 1 |
## Test plan
- [x] 4 new regression tests for IEEE 754 boundary precision
- [x] No duplicate frames when data-start is grid-aligned (25fps)
- [x] Monotonically increasing frame indices across 100 frames
- [x] Correct frame at the `0.28 * 25` boundary (frame 7, not 6)
- [x] `mediaStart` correctly offsets frame index
- [x] Typecheck clean
* fix(producer): don't mix audio from muted videos into the render
The auto-detect audio block checked ext.metadata.hasAudio (file has
audio track) but not video.hasAudio (element declares itself audible).
A <video muted> whose source file contains audio leaked that audio
into the final render at full volume.
Add video.hasAudio guard so only audible elements contribute audio.
* test(producer): add unit tests for muted video audio guard
* fix: format
Add parser-level mutations for arc paths, keyframe add/remove/update,
convert-to-keyframes, and _auto flag for 100% keyframes. Wire route
handlers for new mutation types.
Extract readElementPlaybackRate() to eliminate clamping duplication across
media.ts, init.ts, startResolver.ts, and timeline.ts. Apply the rate
division to the two remaining sites that were missed:
- startResolver.ts: visibility loop used raw source duration, hiding
slowed-down videos mid-playback when no data-duration was set
- timeline.ts: resolveMediaElementDurationSeconds underreported the end
window sent to the renderer, affecting preview parity
Also adds direct tests for readElementPlaybackRate().
* fix(core): account for playbackRate in media duration resolution
resolveDurationSeconds computed sourceDuration as (element.duration - mediaStart)
without dividing by playbackRate. A 5s source at 0.5x should span 10s on the
timeline, but was capped at 5s — causing the video to go black once the raw
source was exhausted.
Read defaultPlaybackRate from the element (same clamping as refreshRuntimeMediaCache)
and divide sourceDuration by it so the effective timeline window matches the
actual playback speed.
* test(core): add regression test for playbackRate in resolveDurationSeconds
Pins the fix: a 5s source at 0.5x playbackRate must resolve to 10s effective
duration when resolveDurationSeconds is provided (mirroring the init.ts callback
pattern). Without the rate division, this would return 5s and clip early.
* fix(producer): revert Proxy-based wrapTimeline to plain-object approach
The `new Proxy` wrapper for GSAP timelines introduced in #1279 causes
Chrome headless to hang indefinitely during page.goto — DOMContentLoaded
never fires. The plain-object approach (explicit method allowlist) loads
in <800ms on the same composition.
The Proxy's generic get/set traps interact badly with Chrome's internal
object inspection (Symbol checks, thenable probing, DevTools serialization)
during HTML parsing, creating a permanent navigation hang. The
maybePublishRenderReady listener fix from #1279 is preserved — only the
wrapTimeline implementation is reverted.
Compositions using GSAP methods outside the allowlist (eventCallback,
labels, repeat, etc.) will see those calls silently dropped rather than
forwarded. This is the same behavior as v0.6.81 and earlier. A safer
forwarding approach can be explored separately without blocking renders.
* fix(producer): address review — stale meta.json descriptions + silently-dropped methods doc
- three-boundary: description referenced Proxy fix but the test uses
onUpdate in to() vars (allowlist path), not eventCallback
- three-boundary-deferred: same — pins Bug 2's deferred-race, not Bug 1
- Add inline doc comment listing silently-dropped GSAP methods and the
onUpdate workaround
* ci: add page.goto timing canary to CLI smoke test
Parse page.goto completion times from the render log and fail if the
slowest navigation exceeds 5s. Catches wrapTimeline regressions that
block DOMContentLoaded before the 60s timeout fires.
Refs: #1285
* fix(producer): forward all GSAP methods via dynamic enumeration at wrap time
Instead of silently dropping methods outside a static allowlist, enumerate
the real timeline's prototype chain at wrap time and generate plain-object
forwarding stubs for every method not already covered.
This achieves the same coverage as the `new Proxy` approach from #1279
without the Chrome headless navigation hang — no Proxy trap surfaces are
exposed to Chrome internals. Methods prefixed with `_` (GSAP private) are
skipped. All forwarded methods flush pending batch operations before
delegating, matching the existing allowlist behavior.
Closes#1285
* fix(producer): make proxy non-thenable + harden CI canary
- Skip `then` in forwardRemainingMethods — GSAP timelines are thenable
(tl.then resolves on completion), and forwarding it makes the proxy
thenable too: Promise.resolve(proxy) or await proxy hangs forever for
paused timelines
- Add unit test: Promise.resolve(proxy) resolves immediately, real
then() is never called
- CI canary: exit 1 (not 0) when no page.goto timing is found in logs,
so a log-format change loudly breaks CI instead of silently disabling
the canary
* fix(release): guard against non-monotonic tags in set-version
Add a pre-tag check that rejects stable releases when an existing tag
has a higher semver. This prevents tag-sorting installers (npx skills,
simple-git tags.latest) from resolving stale versions.
Closes#1282
* fix(review): add --skip-monotonicity-check escape hatch, fix formatting
Address review feedback:
- Add --skip-monotonicity-check flag for legitimate backport scenarios
- Soften error message to show both options (delete tag or skip check)
- Fix oxfmt formatting
- Add test for new flag parsing
Compositions that defer gsap.timeline() registration past DOMContentLoaded
(via setTimeout, template instantiation, or dynamic script loading) hit a
race where __renderReady stays false forever:
1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips
the hf-timelines-built listener and sets __renderReady = true
2. The deferred script runs, calls gsap.timeline().to() which sets
__hfTimelinesBuilding = true via the batching proxy
3. The deferred maybePublishRenderReady() sees building=true, sets
__renderReady = false, but never registers a listener to retry
4. __renderReady stays false, __hf.duration returns 0, pollHfReady
times out with "Composition has zero duration"
Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true,
register a one-shot hf-timelines-built listener to retry — matching the
pattern already used at init time for the synchronous batching case.
Closes#1260
Plan documents are working artifacts that shouldn't be committed.
Removes three plans that were accidentally tracked and adds
docs/plans/ to .gitignore to prevent future occurrences.
Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.
Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance
Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231)
Compositions with thousands of tl.to() calls (e.g. 8,562 in the
reported case) block Chrome's main thread synchronously during HTML
parsing, preventing DOMContentLoaded from firing before Puppeteer's
navigation timeout. This caused render jobs to hang indefinitely at
'Initializing calibration session...' with no error message.
Root cause: GSAP's timeline API is synchronous — each tl.to() call
registers a tween immediately on the main thread. A script with 8k+
calls holds the thread for seconds, starving the browser event loop and
delaying DCL past the navigation timeout window.
Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected
at the top of <head>, before GSAP or user scripts load). When GSAP
assigns itself to window.gsap, the setter intercepts the real gsap
object and wraps gsap.timeline() to return a proxy that queues tween
descriptors (to/from/fromTo/set) instead of calling them synchronously.
A requestAnimationFrame-based flush loop drains 100 tweens per frame,
yielding the main thread between batches so DCL can fire.
When the queue is drained, the stub sets window.__hfTimelinesBuilding =
false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks
this flag at DOMContentLoaded time; if building is still in progress it
defers bindRootTimelineIfAvailable() until the event fires, then sets
window.__renderReady = true as normal. pollHfReady continues to gate
on both __renderReady and window.__hf.duration > 0, so the render
pipeline does not start until the full timeline is bound.
- Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale)
- Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0))
- Determinism: 'hf-timelines-built' event guarantees sequencing
- Proxy forwards: pause/seek/totalTime/time/duration/add/paused/
timeScale/play delegate to the real timeline immediately
- No GSAP package changes; no navigation timeout increase
Fixes#1231
* style: apply oxfmt formatting to producer stub files
* fix(producer): unwrap proxy children in add(), gate setter return on args.length
Addresses two latent correctness concerns from code review:
1. proxy.add() now unwraps __hfReal from any proxy child before passing it
to the real timeline. GSAP's internal tween graph (_first/_next/_prev
linkage) requires real timeline instances — proxy objects lack internal
fields like _dp that GSAP's iteration paths expect.
2. totalTime/time/paused/timeScale now return proxy when called in setter form
(args.length > 0). Previously these returned the real timeline, causing
callers who chain .to(...) after a setter call to bypass batching.
Also: build-hf-early-stub.ts now runs oxfmt on the generated output file
so the format check passes in CI on every build.
* fix(producer): gate __hf.duration=0 while GSAP timelines are batching
The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever
window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF
batch loop is draining queued tl.to() calls).
pollHfReady in the engine polls until window.__hf.duration > 0, so
returning 0 keeps the engine waiting until the hf-timelines-built event
fires and all tweens are committed to the real GSAP timelines.
Without this gate, normal compositions (style-6, style-13, vignelli)
were being captured mid-batch — the real timelines were empty so GSAP
could not seek them, producing frozen/blank frames in the output video.
* fix(producer): flush GSAP batching under virtual time
* fix(producer): gate render bridge on runtime readiness
* fix(producer): preserve timeline child binding under batching
Adds 'innerText' as a supported GSAP property so number roll-up animations
(count-up from 0 to some value) are visible and editable in the GSAP inspector
panel.
- Add 'innerText' to SUPPORTED_PROPS in gsapConstants.ts
- Add label 'Counter Value', tooltip, and step constraint (1) in
gsapAnimationConstants.ts
The snap modifier that controls integer rounding is already preserved
verbatim via the EXTRAS_KEYS round-trip, so rounding behavior survives
edits without any additional UI changes.
Closes#1179
Page.goto with waitUntil:'load' blocks until all video sources finish
preloading metadata. With 6+ video sources (8–21s each), this reliably
exceeds the 60s navigation timeout, causing renders to hang at
'Initializing calibration session...' on M4 and other Apple Silicon macs.
Switch to waitUntil:'domcontentloaded' so navigation resolves as soon as
the DOM is ready, without gating on video media loading.
Fixes#1231
* fix(producer): thread forceScreenshot to probe stage for low-memory mode
The render orchestrator sets captureForceScreenshot=true when low-memory
mode is active and passes it to all three capture stages, but never
passed it to the probe stage. The probe stage received the original cfg
with forceScreenshot=false, so on Linux it launched the browser in
beginframe mode — which hangs on memory-constrained hardware.
Add a forceScreenshot parameter to ProbeStageInput (matching the pattern
used by captureStage, captureStreamingStage, and captureHdrStage) and
wire it through from both the render orchestrator and the distributed
planner.
Fixes heygen-com/hyperframes#1236
* fix(producer): add lowMemoryMode→forceScreenshot bump in distributed plan + regression test
- plan.ts: mirror the renderOrchestrator's lowMemoryMode bump so that
distributed runs on low-RAM hosts also force screenshot capture mode.
Previously the bump was only applied in the in-process path (renderOrchestrator.ts:1598);
plan.ts derived forceScreenshot from compileResult without the bump, leaving the
distributed probe stage in beginframe mode on low-memory hosts (same shape of bug as #1236).
Added TODO to unify the bump into compileStage so all paths share one source of truth.
- probeStage.test.ts: add regression test pinning that createCaptureSession
receives forceScreenshot:true when the stage input carries it but cfg.forceScreenshot
is false (low-memory mode override). Mirrors worker-count test shape from
captureStreamingStage.test.ts. Two cases: override active, override inactive.
Addresses review feedback from #1237.