mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
cf2addfb9296b8021f6ea73e18ea7d50df98e9d7
344
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6c533c0b0f | chore: release v0.6.20 | ||
|
|
04aa6a644f | chore: release v0.6.19 | ||
|
|
62ddd29b74 |
feat(cli): add hyperframes lambda policies role/user/validate (#912)
* feat(cli): add hyperframes lambda policies role/user/validate
IAM bootstrap subcommand for the lambda CLI. Closes the "first run hits
'User is not authorized to perform iam:CreateRole'" gap that adopters
otherwise have to figure out by hand.
hyperframes lambda policies user
→ prints an inline-policy doc to attach to the IAM user that runs
the CLI
hyperframes lambda policies role --principal=cloudformation
→ prints { TrustRelationship, InlinePolicy } for a service role
cloudformation can assume
hyperframes lambda policies validate ./infra/policy.json
→ diffs a checked-in policy against the CLI's required action set,
expanding s3:* / s3:Get* / * wildcards, exits non-zero on missing
actions (wire it into CI to catch drift before deploys fail)
The required-actions list is derived from what the SAM template at
examples/aws-lambda/template.yaml needs to create plus what
renderToLambda/getRenderProgress call against S3 + Step Functions at
runtime. Sorted alphabetically per-service so diffs stay readable.
Resource is "*" by design — CloudFormation creates new function /
state-machine / bucket ARNs on every adopter's first deploy. The
generated policy is documented as a starting point; adopters with
stricter postures narrow Resource to the deployed ARNs after the
first successful run.
Tests: 10 unit tests covering the action set, doc shape, trust policy
service principal, and validate() against valid / missing / wildcard /
single-Statement / Deny-statement inputs.
* refactor(cli): /simplify pass on lambda policies
Adds a typed TrustPolicyDocument / TrustPolicyStatement pair so
buildRoleTrustPolicy can return a real type instead of unknown. The
trust-policy shape has a Principal field that the generic
PolicyStatement doesn't model, but it was previously punted via a
return unknown rather than a parallel type.
Test cleanup: drop the `as {...}` casts that the previous return-
unknown signature forced.
* fix(cli): address PR review on lambda policies
One blocker + four importants from Vai's review:
- REQUIRED_ACTIONS was missing `s3:ListAllMyBuckets` (called by
`sam deploy --resolve-s3` on first run to discover/create the
`aws-sam-cli-managed-default-*` artifact bucket) and
`cloudformation:ValidateTemplate` (CFN template validation
during change-set creation). Without these, a first-deploy
adopter with the generated policy hits AccessDenied on the
very call the PR was meant to unblock. Added both.
- `policies role --principal=lambda` was a footgun — it produced
a `lambda.amazonaws.com` trust paired with the full deploy
superset, i.e. a confusingly-overscoped Lambda execution role
no human should attach (the SAM template creates its own
scoped execution role automatically). Dropped `lambda` as a
principal option; `policies role` now always emits a
CloudFormation service-role doc.
- `validatePolicy` silently misreported NotAction/NotResource
statements (treating them as zero grants), producing false
negatives. Detect both shapes and surface them via a new
`warnings: string[]` field; NotAction statements are skipped
(rather than producing a false negative), NotResource is
treated as full action grant + a warning.
- Mid-string wildcards (`s3:Get*Object`, `?`) silently failed
the matcher. End-anchored wildcards still work; mid-string
patterns now warn so users know the validator can't expand
them.
- Dropped the dead `samArtifactBucket` action group (fully
subsumed by `s3Bucket` + `s3Object`).
- `validate --json` now wraps errors in a friendly envelope
(`{ ok: false, error: "..." }`) so CI consumers have one
parse shape regardless of failure mode.
- lambda.ts subcommand description and examples updated to
include `policies`.
Tests: 5 new negative-path tests cover NotAction warning,
NotResource warning, mid-string wildcard warning, missing file
(ENOENT), malformed JSON (SyntaxError), and absent Statement
field. All 21 policies tests pass.
|
||
|
|
e90ad2da61 |
feat(cli): add hyperframes lambda deploy/render/progress/destroy (#910)
* feat(cli): add hyperframes lambda deploy/render/progress/destroy
Wraps the @hyperframes/aws-lambda SDK + the Phase 6a SAM template behind
a single CLI surface so an end-to-end render is three commands instead
of the ~8 manual bun+sam+aws steps the smoke script does today:
hyperframes lambda deploy
hyperframes lambda render ./my-project --width 1920 --height 1080 --wait
hyperframes lambda destroy
Subcommands:
- deploy: build handler.zip + sam-deploy + persist stack outputs
to <cwd>/.hyperframes/lambda-stack-<name>.json
- sites create: pre-upload a project to S3 with a stable content hash
so re-renders skip the tar+PUT pass
- render: start a Step Functions execution; --wait blocks and
streams per-chunk progress + accrued cost
- progress: one-shot snapshot — status, frames, cost breakdown,
errors. Accepts renderId or executionArn
- destroy: sam-delete + drop the local state file (S3 bucket
is Retain'd by the template; documented in --help
and in docs/packages/cli.mdx)
To keep @sparticuz/chromium out of the CLI's transitive deps, this also
adds a dedicated ./sdk subpath export to @hyperframes/aws-lambda; the
CLI imports from @hyperframes/aws-lambda/sdk exclusively. The existing
. barrel still re-exports both handler + SDK for adopters who want one
entry point.
Defaults are deliberately cost-conservative for first-time users:
--concurrency=8 (low enough to never surprise) and --memory=10240 (the
common case; documented for adopters who want to tune down).
Tests: 5 unit tests on the state-file round-trip. CLI integration
against sam local invoke is part of the upcoming PR 6.6 (lambda-local
regression harness).
* refactor(cli): /simplify pass on the lambda command group
Two small cleanups on top of the lambda CLI:
- Replace parseFormat / parseCodec / parseQuality / parseChromeSource
(four near-identical helpers) with a single generic parseEnum() +
typed const-tuple lookups. The four callers now read as one-line
arrow functions that lift the allowed values out of the function
body so they're easy to extend.
- DEFAULT_STACK_NAME was const-declared then re-exported at the
bottom of state.ts; just mark the const export inline.
No behavior changes. All CLI tests still pass.
* fix(cli): keep @hyperframes/aws-lambda external in the tsup bundle
esbuild can't bundle @hyperframes/aws-lambda's transitive AWS SDK
deps (@aws-sdk/* + @smithy/*) cleanly into a node binary — the
SDK's .browser.js conditional re-exports break the resolver:
ESM Build failed
No matching export in "splitStream.browser.js" for import
"splitStream" (and ~10 similar errors)
Mark aws-lambda as `external` so esbuild doesn't follow it, and
move it from devDependencies to dependencies so the published CLI
can resolve it from node_modules at runtime. The lambda subverb
files dynamic-import only on `hyperframes lambda *` invocation, so
the CLI cold-start cost is unchanged.
The install-size hit (AWS SDK + @sparticuz/chromium ≈ 200 MiB) is
documented as a v1 tradeoff; a future split into a lambda-sdk-only
subpackage can pare this back.
* fix(cli): address PR review on lambda CLI
Two blockers + four important items from Vai's review:
- `--memory` was parsed and recorded in the local state file but
never forwarded to `sam deploy` as a parameter override. Worse,
`progress.ts` then read the *recorded* value for cost math, so
`--memory 5120` produced wrong cost numbers downstream. Thread
`LambdaMemoryMb` through samDeploy's --parameter-overrides.
- `--profile` was only consumed by deploy / destroy. render and
progress fell back to the default credentials chain — a user
with `--profile prod` would silently render against their
default account (wrong-account billing footgun). Set
`process.env.AWS_PROFILE` (and `AWS_REGION`) in the dispatcher
before any subverb runs; the AWS SDK reads them natively, so
render / progress / sites all benefit without each subverb
threading the flag through the SDK call.
- `--profile` + destroy now also reads `process.env.AWS_PROFILE`
as a fallback (matching deploy's existing env fallback).
- `--wait --json` printed both the start handle AND the final
progress snapshot, producing two concatenated JSON blobs that
`jq` rejected. Now emits a single document: handle (without
--wait) OR final progress (with --wait).
- Negative integers on `--width` / `--height` / `--chunk-size` /
`--max-parallel-chunks` / `--memory` / `--concurrency` now fail
loudly via a new `parsePositiveInt` wrapper instead of flowing
into the SDK and producing opaque AWS validation errors mid-
render.
- `DEFAULT_STACK_NAME` is now centralized to the literal
`"hyperframes-default"` and consumed from one place. Previously
the value was assembled as `hyperframes-${"default"}` in three
sites and hardcoded as `"hyperframes-default"` in a fourth.
`requireStack`'s hint now matches the dispatcher's default.
The faked `SiteHandle` for `--site-id` keeps the documented
placeholder fields but also surfaces `bucketName` (from PR 909's
extended SiteHandle interface), matching the SDK contract.
All CLI unit tests + the full bundler build still pass.
* fix(cli): keep aws-lambda out of CLI runtime deps
The "Smoke: global install" CI step packs the CLI via `npm pack` and
installs it globally via `npm install -g <tgz>`. npm doesn't understand
the workspace: protocol, so a runtime `dependencies` entry of
`@hyperframes/aws-lambda: workspace:*` blows up with:
npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*
(pnpm rewrites workspace:* on publish; npm pack doesn't.)
Three changes to unblock the smoke + keep the published CLI install
small for users who don't deploy to Lambda:
- Move `@hyperframes/aws-lambda` from CLI's `dependencies` back to
`devDependencies`. It's already external in tsup.config.ts; the
bundle references it via runtime resolution only.
- Convert the static `import { … } from "@hyperframes/aws-lambda/sdk"`
in sites.ts / render.ts / progress.ts to `await import()` inside
each function. tsup with `splitting: false` was inlining those
static imports at the top of the bundle, which made Node eagerly
resolve them at CLI startup (MODULE_NOT_FOUND before any lambda
subcommand even runs). Dynamic imports stay dynamic in the bundle.
- Add a friendly missing-module check in the lambda dispatcher.
When a user runs `hyperframes lambda deploy / render / sites /
progress / destroy` without aws-lambda installed, they now see:
@hyperframes/aws-lambda is not installed.
The `hyperframes lambda deploy` command needs it at runtime.
Install it alongside the CLI:
npm install -g @hyperframes/aws-lambda
Verified locally: pack + global install + `hyperframes init --example
blank` now succeeds end-to-end (was the same scenario the CI smoke job
runs).
|
||
|
|
78fce8bd8a |
chore: release v0.6.18
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
3f976d454c |
chore: release v0.6.17
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
b30fd29695 | chore: release v0.6.16 | ||
|
|
5f8391bd96 | chore: release v0.6.15 | ||
|
|
1fca35b625 | chore: release v0.6.14 | ||
|
|
4e0034f072 |
fix(studio): fix capture button silent failures and broken CLI seek (#904)
* fix(studio): fix capture button silent failures and broken CLI seek
The Capture button could silently fail with no user feedback due to
several compounding issues:
- The click handler's try-catch only covered the fetch call, leaving
waitForPendingDomEditSaves() and URL construction unprotected. Any
error there became an unhandled promise rejection with zero UI
feedback. Wrap the entire handler body in try-catch.
- No timeout on the fetch or save-queue drain, so a hung server or
stuck save queue caused the button to appear permanently broken.
Add a 30s AbortController timeout on the fetch and a 5s race
timeout on waitForPendingDomEditSaves.
- The CLI server's thumbnail seek used `__timeline` (singular) which
doesn't exist — the runtime registers `__timelines` (plural). Also
used `.seek()` instead of `.pause(t)` and didn't kick the GSAP
ticker. Align with the Vite adapter's working seek logic.
- The CLI server's getThumbnailBrowser and generateThumbnail catch
blocks swallowed all errors silently — Chrome launch failures and
screenshot errors were invisible. Add console.warn logging.
- Parse the JSON error body from the server so the toast shows the
actual message ("Chrome browser may not be available") instead of
just "Capture failed (500)".
Closes #902
* fix(cli): apply same seek fix to snapshot command, address review nits
- Fix snapshot.ts seek logic: same __timeline→__timelines + .pause(t)
+ gsap ticker kick fix as studioServer.ts (caught by Vai's review)
- Use typed Window shape in waitForFunction instead of (window as any)
- Use function-form page.evaluate for document.fonts?.ready
* fix(cli): force screenshot mode for thumbnail browser on Linux
Root cause: on Linux, acquireBrowser defaults to beginframe mode
(--enable-begin-frame-control) which makes page.screenshot() hang
indefinitely — beginframe mode expects CDP HeadlessExperimental.beginFrame
commands, not Puppeteer's Page.captureScreenshot.
Pass forceScreenshot: true and captureMode: "screenshot" so the
thumbnail browser always uses screenshot-compatible Chrome flags.
Reproduced on Linux devbox: thumbnail endpoint hung >30s with
beginframe flags; returns a valid PNG instantly in screenshot mode.
|
||
|
|
64b3ae755d |
feat(cli): add browser launch options to preview and play (#884)
* feat(cli): add browser launch options to preview and play * fix(cli): add spawn error listener to prevent ENOENT crash --------- Co-authored-by: AnoKno <122017492+AnoKno@users.noreply.github.com> |
||
|
|
883260aae3 |
chore: release v0.6.13
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
2355d505e1 | chore: release v0.6.12 | ||
|
|
4212a28312 | chore: release v0.6.11 | ||
|
|
1e05d78378 |
fix(engine): enable browser pool and deduplicate concurrent Chrome launches (#889)
## Summary
- **Enable browser pool by default** (`enableBrowserPool: true`) — parallel capture workers now share a single Chrome process via reference-counted pool instead of each spawning their own (~256MB each). A 6-worker render drops from 7+ browser parent processes to 1 shared pool.
- **Add launch-promise deduplication** in `acquireBrowser` — when multiple workers race into the pool simultaneously (via `Promise.all`), they await the same launch Promise instead of each triggering a separate Chrome spawn. Same pattern as the existing `_autoBrowserGpuModeCache` for GPU probes.
- **Add `connected` health check** on pool hit — if Chrome crashes mid-render, subsequent acquires detect the dead browser and launch fresh instead of returning a stale reference.
- **Add `drainBrowserPool()`** for explicit cleanup between independent render jobs.
- **CLI studio server** now uses the shared pool instead of its own redundant `enableBrowserPool: false` singleton, so thumbnail generation shares Chrome with render workers.
## Problem
The engine had a reference-counted browser pool (`browserManager.ts:73-75`) but it was **disabled by default** (`enableBrowserPool: false`). This meant:
1. **Every parallel worker spawned its own Chrome** — a `--workers 6` render launched 7+ independent Chrome processes (1 probe + 6 workers), each ~256MB.
2. **The pool had a race condition** — even if manually enabled, concurrent workers calling `acquireBrowser()` via `Promise.all` could all see `pooledBrowser === null` before the first launch completed, spawning N Chromes instead of 1.
3. **No crash recovery** — if Chrome died, the pool still held the dead reference. Subsequent acquires got a disconnected browser.
4. **CLI studio server ran its own singleton** — `studioServer.ts` explicitly set `enableBrowserPool: false` and managed a separate browser, so thumbnails and renders could never share.
Over time, orphaned Chrome processes accumulated across renders and previews. We observed **344 headless Chrome processes** consuming **569% CPU and 20% memory** on a dev machine.
## Before / After (6-worker parallel render)
| Metric | Before (pool off) | After (pool on) |
|--------|-------------------|-----------------|
| Browser parent processes | 7+ (1 probe + 6 workers) | **2** (1 GPU probe + 1 shared) |
| Total Chrome processes (with helpers) | 40-50+ | **14** |
| Memory during capture | ~20%+ | **4.6%** |
| Render time (1200 frames, 30fps) | ~64s | **53s** (~17% faster) |
| Post-render orphans | Accumulated over time | **0** |
## Changes
| File | Change |
|------|--------|
| `engine/src/config.ts` | `enableBrowserPool` default `false` → `true` |
| `engine/src/services/browserManager.ts` | Extract `launchBrowser()`, add `_pooledBrowserLaunchPromise` dedup, add `connected` check on pool hit, add `drainBrowserPool()` and `_resetBrowserPoolForTests()` |
| `engine/src/index.ts` | Export `drainBrowserPool` |
| `engine/src/services/browserManager.test.ts` | Pool dedup and drain tests |
| `cli/src/server/studioServer.ts` | Remove `enableBrowserPool: false` override — thumbnails now share the pool |
| `producer/src/services/browserManager.ts` | Re-export `drainBrowserPool` |
## Backward compatibility
- `PRODUCER_ENABLE_BROWSER_POOL=false` env var disables pooling (same as before).
- Callers passing `{ enableBrowserPool: false }` explicitly still get isolated browsers.
- Tests that set `enableBrowserPool: false` in their config fixtures continue to work.
## Test plan
- [x] Engine tests pass (597/597)
- [x] Producer tests pass (406/407, 1 pre-existing flaky test in `pngDecodeBlitWorkerPool`)
- [x] Build passes (lint, format, typecheck all green via lefthook pre-commit)
- [x] Manual render: `shortform-financial` with `--workers 6` → 1200 frames in 53s, 0 orphaned Chrome processes after completion
- [x] Process monitoring during render confirmed 2 browser parents (1 GPU probe + 1 shared pool) instead of 7+
|
||
|
|
d3c32a0b4d | chore: release v0.6.10 | ||
|
|
82c9b6b5ea | chore: release v0.6.9 | ||
|
|
4b501762e4 | chore: release v0.6.8 | ||
|
|
f84cc492de |
perf(engine): faster shader transitions via page-side WebGL compositing (#832)
* fix(cli): prefer puppeteer cache + numeric version sort (staff review) Two correctness fixes from PR #821 self-review: 1. Cache priority order. Previous order was hyperframes-managed cache → puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which lags 17+ releases behind upstream; if a user separately installed a newer chrome-headless-shell via @puppeteer/browsers install, the CLI would silently hand engine the older HF-cache binary while engine's own resolveHeadlessShellPath would have picked the newer one. Flip the priority so puppeteer cache wins, matching engine semantics. 2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()` over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would return `linux-99...` first because character '9' outranks '1'. Parse each name into integer segments and compare them numerically. Tests: add both-caches-populated and linux-148-beats-linux-99 cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(engine): page-side compositing for shader transitions (opt-in spike) Add an opt-in `--page-side-compositing` flag (CLI) backed by a new engine config field `enablePageSideCompositing` and env var `HF_PAGE_SIDE_COMPOSITING`. When set, SDR shader-transition compositions skip the Node-side layered blend (the hf#677 chain) and instead run the shader inside Chrome via a page-side WebGL canvas; the engine then captures ONE opaque RGB frame per output frame via the existing streaming capture path. This is the strongest non-beginFrame perf lever for Mac users, who cannot take the beginFrame `~5×` path (Chromium structural limit, crbug.com/40656275). Stacks on top of the hf#677 1.95× baseline. Default OFF — existing fixture pins (byte-exact MP4 output) are preserved. Opt-in path is intentionally PSNR-pinned, not byte-equal (WebGL is f32; Node is f64). HDR content forces the existing layered path regardless. Implementation: - engine: new `EngineConfig.enablePageSideCompositing` (default false). - producer/fileServer: new `HF_PAGE_SIDE_COMPOSITING_STUB` early-page script injected into the served HTML head when the flag is on. - producer/renderOrchestrator: when the flag + no HDR + no png-sequence, route SDR transitions through the streaming path instead of the layered HDR stage. - shader-transitions: new `engineModePageComposite.ts` installs a fullscreen WebGL compositor overlay and wraps `window.__hf.seek` so each seek inside a transition window captures both scenes via the Chromium `drawElementImage` API to GL textures, runs the fragment shader, and displays the composited result on the overlay canvas. The engine takes one screenshot per frame and sees the composited overlay. - cli: new `--page-side-compositing` flag sets `HF_PAGE_SIDE_COMPOSITING=true` before producer load. - scripts/page-side-compositing-smoke: bundled-CLI smoke that renders a representative fixture with and without the flag, validates the canary strings are in the shipped bundles, and writes a wall-time pair. Determinism trade documented in the engine config doc-comment. The smoke script enforces the bundled-CLI validation discipline from prior perf work (see internal feedback note `validate_bundled_cli_not_dev_path`). Runtime requirement: Chromium's `CanvasDrawElement` feature (already enabled by the engine's `--enable-features=CanvasDrawElement` launch flag). When the runtime feature is unavailable, the page-side installer logs a warning and falls back to opacity-flip mode — the engine still takes the streaming path; the transition window degrades to a hard scene swap. Vance will validate on Mac Chrome where the feature is supported. Co-Authored-By: Vai <vai@heygen.com> * fix(shader-transitions): use html2canvas for page-side compositor capture The original drawElementImage approach fails in engine render mode because the virtual-time shim prevents Chromium from generating paint records for cloned elements. drawElementImage requires a cached paint record from the browser's compositor — clones created at capture time never receive one because (a) shimmed rAFs deadlock inside the seek wrapper, (b) original rAFs don't produce real paints under virtual-time control, and (c) layoutsubtree canvases don't apply CSS stylesheet rules to children. Switch scene capture to html2canvas (foreignObjectRendering: false), the same JS-based renderer already used by the preview-mode fallback path in capture.ts. html2canvas reads computed styles and renders via its own canvas drawing pipeline with no dependency on the browser paint cycle. Also fixes: - Engine seek must return the result so Puppeteer awaits async seek promises (frameCapture.ts). - GSAP opacity cache: compositor must restore scene opacity before seek, not after — GSAP caches inline values and skips re-writes. - Support check gates on WebGL availability, not drawElementImage. Perf: 15-scene shader-perf fixture (28s, 14 transitions, 30fps) Baseline (Node-side layered): 137s Page-side (html2canvas+WebGL): 33s → 4.1× speedup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(shader-transitions): simplify review fixes for page-side compositor - Use uploadTexture (zeroes canvas backing store after upload) to prevent ~2.2GB transient memory pressure across 280 html2canvas calls per render - Add ignoreElements + stabilizeTransformedBoxShadows to html2canvas call, matching the preview-path capture.ts behavior - Parallelize from/to scene captures with Promise.all - Wrap post-capture render in try/finally so opacity is always restored - Fix WebGL context leak in isPageSideCompositingSupported probe - Remove dead ResolvedTransition.index field - Export stabilizeTransformedBoxShadows from capture.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(producer): unify page-side compositing gating and Docker forwarding Addresses three issues from staff review: 1. ignoreElements filter stripped all in-scene canvases (Chart.js, D3, p5.js) — narrowed to data-no-capture only since the compositor canvas is a body sibling never in the scene subtree. 2. Docker mode silently dropped --page-side-compositing — thread pageSideCompositing through DockerRenderOptions/buildDockerRunArgs with regression tests. 3. Fragmented gating across 4 independent sites could disagree: - Stub injection gated only on cfg flag (leaked into HDR/alpha) - Probe-created fileServer never got the stub - needsAlpha (WebM/MOV) not excluded from the gate - WebGL-unavailable fallback claimed layered path would run but orchestrator had already disabled it Fix: compute stub injection at the same site as the layered-bypass decision (after hasHdrContent is known), using addPreHeadScript on the already-running fileServer. Single predicate now gates both decisions, including !needsAlpha. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf(engine): two-phase drawElementImage capture for page-side compositing Replace html2canvas with native drawElementImage for scene capture in the page-side compositor. drawElementImage reads from the browser's own paint cache, giving pixel-identical output to the preview path. The blocker was that cloned elements inside layoutsubtree canvases have no cached paint record under virtual time — the compositor only paints when explicitly triggered. Fix: split the seek+composite into two phases with an engine-forced paint between them. Phase 1 (seek wrapper, page-side): - GSAP seek positions the timeline - Clone FROM/TO scenes into visible layoutsubtree staging canvases - Set window.__hf_page_composite_pending flag Engine paint force (frameCapture.ts): - Detect pending flag after seek returns - Fire micro Page.captureScreenshot (1x1 clip) via CDP to force the browser compositor to paint all visible elements including staging canvas children Phase 2 (page.evaluate, page-side): - drawElementImage reads the now-valid paint records - Upload textures to WebGL, run shader, show GL overlay Key insight: staging canvases must be visible (not opacity:0) for the browser to paint their children. They sit at z-index:-9998, behind the main DOM and covered by the GL overlay during transitions. Perf: 15-scene fixture (28s, 14 transitions, 30fps): Baseline (Node-side layered): 137s html2canvas + WebGL: 33s (3.7×) drawElementImage + WebGL: 21s (6.6×) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf(engine): optimize two-phase compositor hot path - uploadTextureSource instead of uploadTexture: eliminates ~2.3GB of canvas buffer alloc/dealloc churn (persistent staging canvases don't need the one-shot zeroing behavior) - Fold hasPending check into seek page.evaluate: eliminates one CDP round-trip per frame (~700 unnecessary IPC calls on non-transition frames) - Fix renderShader error handling: on failure, leave source scenes visible as fallback instead of hiding both scenes + GL overlay (which produced black frames) - Move mutable state declarations above resolveComposite to prevent TDZ risk on refactor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(engine): staff review — staging cleanup, pending flag, beginFrame guard - Clear staging canvas children when leaving transition window (prevents visible clone bleed-through on transparent compositions) - Clear __hf_page_composite_pending on all resolveComposite exit paths - Guard micro-screenshot paint force against beginFrame mode (CDP Page.captureScreenshot conflicts with beginFrame compositor control) - Update CLI flag description: document video/canvas limitation, remove stale PSNR claim Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine): default-on page-side compositing for SDR shader transitions Page-side compositing is now enabled by default for SDR shader-transition renders without video content. The 6.6× speedup applies automatically — no flag needed. Auto-disables when: - HDR content detected - Alpha output (WebM/MOV/PNG-sequence) - Composition contains <video> elements (cloneNode loses playback state) - beginFrame capture mode (Linux headless) Use --no-page-side-compositing to force the Node-side layered path. Changes: - Engine config: enablePageSideCompositing defaults to true - CLI: flag default flipped to true; --no-page-side-compositing disables - Orchestrator: added composition.videos.length === 0 gate - Docker: forwards --no-page-side-compositing when explicitly disabled - Config tests updated for new default Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine): support video elements on page-side compositing fast path Three-phase capture protocol lets shader transitions render video scenes without falling back to the slow Node-side layered pipeline: 1. Seek → compositor records transition metadata, sets pending flag 2. onBeforeCapture → video frame injector updates <img> replacements 3. prepare → cloneNode picks up current video frames, img.decode() awaits 4. micro-screenshot → forces browser to paint cloned elements 5. resolve → drawElementImage reads paint records, shader composites Key changes: - Remove `composition.videos.length === 0` gate from orchestrator - Split compositor resolve into prepare (clone) + resolve (shader) - Move onBeforeCapture before compositor prepare in frameCapture.ts - Await img.decode() on cloned data-URI images to prevent stale frames - Stop manipulating scene opacity in compositor (GL canvas overlay suffices) - Add gsap.set declaration for shader-transitions ambient types - Add video_missing_timing_attrs lint rule for <video> without id/data-start/data-end Performance: compositions with video now render at 7.5s (6 workers) instead of 2m38s on the layered path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): auto-inject data-start on video/audio so frame extraction works without explicit attrs The timing compiler now injects data-start="0" on <video> and <audio> elements that lack it. This makes discoverMediaFromBrowser() find the element (it queries video[data-start]), so the frame extraction pipeline activates automatically. Videos "just work" without requiring authors to add data-start, data-end, or id attributes. Also removes the video_missing_timing_attrs lint rule — the compiler handles the missing attributes automatically, so the lint rule would only false-positive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(core): add data-hf-auto-start sentinel on auto-injected video timing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(producer): add discoverVideoVisibilityFromTimeline for runtime video discovery Seeks the GSAP timeline in Puppeteer to discover when each video's parent scene is visible (opacity > 0). Uses coarse sampling at 100ms steps followed by binary search refinement to frame-level precision (1/60s). Only processes videos with the data-hf-auto-start sentinel so author-specified timing is never overridden. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(producer): integrate runtime video visibility discovery into probe stage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(producer): trigger browser probe for auto-start videos, remove debug logging The probe stage was skipping browser launch when composition duration was already known, which meant discoverVideoVisibilityFromTimeline never ran. Now needsBrowser also checks for data-hf-auto-start sentinel in compiled HTML. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(scripts): use mkdtempSync for smoke test work directory Replaces hardcoded /tmp/hf-page-side-smoke with a unique temp directory via mkdtempSync to resolve CodeQL "insecure temporary file" alert. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: format smoke test script Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Vai <vai@heygen.com> |
||
|
|
4fd9520a90 |
feat(studio): per-composition render button in compositions tab (#874)
* feat(studio): add per-composition render button in compositions tab Thread composition path through the full render pipeline so individual compositions can be rendered independently from the studio UI. - Add download icon button on each comp card (visible on hover) - Accept `composition` field in POST /projects/:id/render - Pass composition as `entryFile` to the producer's createRenderJob - Make the Export button in the Renders panel composition-aware (renders the active composition instead of always index.html) * fix(studio): make composition render buttons always visible The hover-only opacity made them undiscoverable. * fix(studio): address PR review — CLI adapter, path guard, a11y, tests, settings sync - Wire `composition` → `entryFile` in CLI studio adapter (studioServer.ts) so `hyperframes preview` renders the correct composition, not always index.html - Add path-traversal guard: reject composition paths that resolve outside projectDir - Add `aria-label` to the icon-only render button for screen readers - Add 4 tests: forwarding, empty/missing → undefined, path-traversal → 400 - Persist render settings (format/quality/fps) to localStorage so comp card buttons use the same settings as the Export panel * refactor(studio): extract render settings persistence to own module Move getPersistedRenderSettings/persistRenderSettings out of RenderQueue.tsx into renderSettings.ts so code-splitting the component doesn't drag along the helper. |
||
|
|
adeb92ecb7 |
feat(cli): add --no-open flag to preview and play (#871)
Add --no-open boolean flag to both commands via citty's built-in boolean negation (--no-open sets args.open to false). - preview.ts: guard all 4 open() calls with args.open check - play.ts: guard the open() call with args.open check - Default is true (open browser), preserving existing behavior Closes #1 Co-authored-by: AnoKno <122017492+AnoKno@users.noreply.github.com> |
||
|
|
5e56b11615 |
Merge pull request #856 from heygen-com/05-15-fix_security_close_codeql_critical_bad-code-sanitization
fix(security): close CodeQL critical command-line-injection and bad-code-sanitization |
||
|
|
e8e2e81730 | chore: release v0.6.7 | ||
|
|
fc3aa4d49e | fix(security): close CodeQL critical + bad-code-sanitization | ||
|
|
c1ba528a1f |
fix(cli): scan puppeteer cache for chrome-headless-shell; warn on system-chrome fallback (#821)
## What Two correctness fixes to `packages/cli/src/browser/manager.ts` so the CLI picks the right Chrome binary for any perf path that depends on `chrome-headless-shell`: 1. **Also scan the puppeteer-managed cache.** `findFromCache` now reads from both `~/.cache/hyperframes/chrome` (the CLI's own managed cache) and `~/.cache/puppeteer/chrome-headless-shell/<version>/<platform-dir>/chrome-headless-shell` (the path layout that the engine's `resolveHeadlessShellPath` already reads from). When `chrome-headless-shell` is present in either cache, it now wins over system Chrome. 2. **Warn when falling through to a non-`chrome-headless-shell` system binary on Linux.** A single one-time `console.warn` explains the perf consequence and points the user at `npx @puppeteer/browsers install chrome-headless-shell`. Linux-scoped because the BeginFrame perf path is Linux-only. ## Why Discovered in a recent spike on the BeginFrame perf path. On a clean install, the CLI's hyperframes-managed cache (`~/.cache/hyperframes/chrome`) is empty, so `findFromCache` returns `undefined`. The CLI then falls through to `findFromSystem()` and picks `/usr/bin/google-chrome`, exporting it to the engine via `PRODUCER_HEADLESS_SHELL_PATH` in `render.ts`. The engine receives that path, sees it's already set, and skips its own correct `~/.cache/puppeteer/chrome-headless-shell` scan. Regular Chrome (147+) has dropped `HeadlessExperimental.enable`. The engine's BeginFrame probe correctly catches this and silently falls back to screenshot mode — but the operator sees no signal, so any user who installed `chrome-headless-shell` via `npx @puppeteer/browsers install` (the standard puppeteer flow) silently loses the perf path. This is a "two codepaths know about 'the chrome we ship' but look in different places" bug. The fix collapses them. ## How - `findFromCache` now consults both caches. Hyperframes-managed cache wins when both contain a binary (preserves existing behavior). - New `findFromPuppeteerCache` mirrors `resolveHeadlessShellPath` from `packages/engine/src/services/browserManager.ts` — same path layout, same newest-first version sort. A comment in both files notes they need to move together if puppeteer ever changes the on-disk layout. - `warnSystemFallbackOnce` is gated on `process.platform === "linux"` and on the binary name (`basename` of the path being `chrome-headless-shell`/`.exe`). One-shot latch so a long-running `hyperframes studio` process isn't spammed. Exported test-reset helper `_resetSystemFallbackWarnForTests` for the unit tests. No public-API changes. `findBrowser`, `ensureBrowser`, `clearBrowser`, `setBrowserPath` all keep the same signatures. ## Test plan - [x] Unit tests added (`packages/cli/src/browser/manager.test.ts`, 7 tests): - cache hit on hyperframes dir - cache hit on puppeteer dir (the new path) - newest-version preference when multiple versions are cached - system fallback + Linux warning emitted - no warning when the resolved path is itself `chrome-headless-shell` (e.g. `HYPERFRAMES_BROWSER_PATH` override) - no warning on macOS (Linux-only perf path) - one-time warning idempotency across repeated `findBrowser()` calls - [x] `bun run --filter @hyperframes/cli typecheck` — passes - [x] `bun run --filter @hyperframes/cli test` — 305/305 passing - [x] `bun run --filter @hyperframes/cli build` — passes - [ ] Manual smoke on a Linux host with chrome-headless-shell in the puppeteer cache (skipped — sandbox already has both binaries and the unit tests cover the resolution logic deterministically; reviewers welcome to verify) — Vai |
||
|
|
1596fcbe70 |
perf(producer): hybrid layered/parallel path for SDR shader-transition renders (hf#732 PR 4/5) (#759)
## Summary PR 4 of 5 in the hf#732 decomposition stack. **This is where the bulk of the shader-transition speedup lives** (`~2×` verified — see Empirical validation below). Spreads per-frame DOM capture work across N DOM worker sessions and offloads the per-pixel shader-blend onto a `worker_threads` pool (the pool added in #758). ### Gating The hybrid path is gated by `shouldUseHybridLayeredPath`: - SDR content only — HDR raw-frame sources are fd-bound to one worker (per-worker `dup(fd)` is out of scope here). - `workerCount >= 2`. - Not every frame inside a transition window. When the gate trips, the hybrid loop spawns `workerCount - 1` extra DOM sessions, allocates per-worker scratch buffers, and partitions the frame range into contiguous slices via `distributeLayeredHybridFrameRanges`. Each worker walks its slice; transitions dispatch through the shader-blend pool (with inline fallback). A frame-reorder buffer fences the encoder. Pool teardown is guaranteed via try/finally on both the success and error paths. ### Structural change (heads-up to reviewers) `captureHdrStage.ts` on main was already 921 lines (over the project's 500-line ceiling). Adding the hybrid path on top would push it past 1100 and the local pre-commit hook refuses to stage files past 500. **PR 4 splits `captureHdrStage.ts` into 5 files**: - `captureHdrStage.ts` (orchestrator + cleanup invariants, 469 lines) - `captureHdrResources.ts` (HDR video extraction + image decode + dim probing) - `captureHdrFrameShared.ts` (gating predicates, partitioning, per-scene capture) - `captureHdrSequentialLoop.ts` (legacy single-session loop) - `captureHdrHybridLoop.ts` (new multi-worker path) No behavior change in any pre-existing code path: the sequential loop is byte-equivalent to the previous inline implementation (both consume `captureSceneIntoBuffer` from the shared module, so behavior parity is enforced structurally rather than by comment-keeping). `renderOrchestrator.ts` is intentionally unchanged — the stage computes its own worker budget via `calculateOptimalWorkers` rather than receiving it through the call signature. ## Stack Stacked on top of #758 (PR 3: shaderTransition pool). ## Test plan - [x] 14 new vitest tests in `captureHdrFrameShared.test.ts` pinning the hybrid gating predicate and the contiguous-chunking partitioner — all pass - [x] Producer typecheck clean - [x] oxlint clean ### Empirical validation Mark Witt fixture (Mac, Apple Silicon, hardware GPU, no beginframe): - Published CLI (pre-stack): 2m 12.2s - Cascade CLI (this stack): 1m 07.7s - **Measured speedup: 1.95× on Mac.** (Earlier "2.22×" wording was a projection from per-component micro-benchmarks; the empirical end-to-end number is 1.95× on the validated fixture.) Linux CI confirmation pending top-of-stack regression run. — Vai |
||
|
|
27ae55a5c7 | chore: release v0.6.6 (#818) | ||
|
|
30348af3f4 |
feat(producer): add shaderTransitionWorkerPool (hf#732 PR 3/5) (#758)
## Summary PR 3 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that runs the shader-transition blend (one of 15 transition shaders) on a fixed-size worker pool. **No production wiring yet** — the pool stands alone; PR 4 wires it. The shader blend is a hot inner loop over every pixel of every transition frame at 16bpc. Moving it off the main event loop removes the JS-event-loop ceiling that capped throughput in earlier hf#732 iterations. ### New files - `packages/producer/src/services/shaderTransitionWorker.ts` — worker entry. Imports from `@hyperframes/engine/shader-transitions` (zero-import TS source). - `packages/producer/src/services/shaderTransitionWorkerPool.ts` — fixed-size pool. Uses `transferList` so the 16bpc HDR `from`/`to`/`out` buffers move by ownership. - `packages/producer/src/services/shaderTransitionWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence across all 15 shaders, transferList correctness, pool lifecycle. All pass. ### Build wiring - `packages/cli/tsup.config.ts`: third tsup entry emits `dist/shaderTransitionWorker.js`. - `packages/producer/build.mjs`: fourth esbuild entry for direct producer consumers. - `packages/engine/package.json`: adds `./shader-transitions` subpath export. ## Stack Stacked on top of #757 (PR 2: pngDecodeBlit pool). No behavior change in any render. ## Test plan - [x] 6 pool tests pass - [x] Producer + engine typecheck clean - [x] oxlint clean — Vai |
||
|
|
a52c73ebe1 |
fix(studio): serve favicon.svg from embedded preview server (#806)
The /favicon.svg request was falling through to the SPA catch-all, which returns index.html. The browser received HTML instead of an SVG and silently discarded it, leaving the tab with no icon. Added an explicit route for /favicon.svg alongside the existing /assets/* and /icons/* static routes. Closes #804 |
||
|
|
92bccfdf78 |
feat(producer): add pngDecodeBlitWorkerPool (hf#732 PR 2/5) (#757)
## Summary PR 2 of 5 in the hf#732 decomposition stack. Adds a `worker_threads`-based pool that offloads PNG decode + alpha-blit onto a fixed-size pool. **No production wiring yet** — the pool stands alone and ships behind a later PR in the stack. ### New files - `packages/producer/src/services/pngDecodeBlitWorker.ts` — worker entry. Imports from `@hyperframes/engine/alpha-blit` (zero-import TS source, survives the `new Worker(<path>)` loader boundary). - `packages/producer/src/services/pngDecodeBlitWorkerPool.ts` — fixed-size pool with `run()` API. Uses `transferList` for buffer ownership transfer (no 16bpc HDR buffer copies). - `packages/producer/src/services/pngDecodeBlitWorkerPool.test.ts` — 6 vitest tests pinning byte-equivalence with inline path, transferList correctness, concurrent dispatch, termination semantics. All pass. ### Build wiring - `packages/cli/tsup.config.ts`: second tsup entry emits `dist/pngDecodeBlitWorker.js` next to `dist/cli.js`. Without this entry the pool's `new Worker(<path>)` would fail at runtime in the shipped CLI. - `packages/producer/build.mjs`: third esbuild entry mirrors the wiring for direct producer consumers. - `packages/engine/package.json`: adds `./alpha-blit` subpath export pointing at `src/utils/alphaBlit.ts`. ## Stack Stacked on top of #756 (PR 1: worker-count cap). No behavior change in any render. ## Test plan - [x] 6 pool tests pass - [x] Producer + engine typecheck clean - [x] oxlint clean — Vai |
||
|
|
7703122a4d | chore: release v0.6.5 | ||
|
|
246a1911b1 |
fix(studio): auto-reconnect when preview server is not running (#802)
* fix(studio): auto-reconnect when preview server is not running When the preview server is not reachable (tab reload after server died, or opening the URL before running npm run dev), the Studio was silently swallowing the fetch error and rendering an infinite pulsing dot with no recovery path. Users had no idea what happened. Instead of showing an error and asking the user to act, the Studio now polls /api/projects every 2 seconds and automatically transitions into the full editor the moment the server becomes available — no manual reload required. Also fixes how agents are instructed about the dev server: CLAUDE.md and AGENTS.md listed `npm run dev` as a one-liner comment identical to other commands, giving no indication it blocks until stopped. Agents (including Claude Code) were running it in foreground, timing out after ~2 minutes, and silently killing the server. Added an explicit note that it must be started as a background process. * fix(studio): auto-reconnect when preview server is not running Two issues combined to produce the "reloading the tab kills the whole" experience for users running with an AI agent: 1. Agents silently killed the server — CLAUDE.md/AGENTS.md listed npm run dev with no indication it blocks. Agents ran it in foreground, the Bash tool timed out, and the process died. Added an explicit run_in_background instruction. 2. Studio had no recovery path — fetch errors were swallowed, leaving a permanent pulsing dot with no way out. Now the Studio polls every 2s and auto-transitions the moment the server responds. Also fixes the bookmark-reload case: the hash path previously bailed out before pinging the server, so a dead server + saved URL produced a blank editor instead of the waiting state. The server is now always contacted first, regardless of whether a hash project ID is present. Timer cleanup (cancelled flag + clearTimeout) prevents setState on unmounted components under StrictMode dev re-mounts. Extracted into useServerConnection hook to keep App.tsx under the 500 LOC limit. |
||
|
|
0d7d38849c |
fix(studio): align preview fonts with render (#799)
## What Align Studio preview font handling with final render, and harden the transform hook against failures. ## Why Preview and render use different font handling. This bug changes text width and makes text layout look different between preview and final render. ## How - Add a `transformPreviewHtml` hook in `StudioApiAdapter` that adapters can implement to post-process preview HTML before Studio augments it - Use it in both the Vite adapter and the CLI studio server to inject the same deterministic `@font-face` rules that render uses - Wrap the hook in a try/catch so a failing transform (e.g. network error during Google Fonts fetch) degrades gracefully — the preview still loads with the original HTML ## Edge cases covered | Path | Covered | |------|---------| | Bundled HTML (adapter returns string) | ✓ | | Bundle returns null → reads index.html from disk | ✓ | | Bundle throws → catch-block fallback reads index.html | ✓ | | Sub-composition preview | ✓ | | Transform hook throws → graceful fallback to original HTML | ✓ | ## Test plan - [x] Unit tests added for all five paths above - [x] Manual testing performed Closes #797 |
||
|
|
9fe356be14 | chore: bump version to 0.6.4 | ||
|
|
636db23197 | chore: bump version to 0.6.3 | ||
|
|
067bc722e3 |
fix(deps): align @types/node + esbuild + tsx across workspace
The Windows install failures (`ENOENT: failed copying files from cache to destination for package @types/node` / `esbuild`) are caused by bun creating workspace-scoped nested installs under `node_modules/@hyperframes/<pkg>/node_modules/...`. Those nested paths only exist because each workspace package pinned a different `@types/node` / `esbuild` major: - root: `@types/node ^25.0.10`, core: `^24.10.13`, cli/engine/producer: `^22` - core/cli: `esbuild ^0.25.x`, producer: `^0.27.2` Each major-version gap forces bun to install a workspace-scoped copy in a deep `node_modules/@hyperframes/<pkg>/node_modules/<dep>/node_modules/...` tree that bun can't reliably materialize on Windows GHA runners. Aligning versions lets bun dedup to a single root-hoisted install per dep, and the nested workspace block disappears from `bun.lock` entirely. ## Alignment - `@types/node` → `^25.0.10` across root, core, cli, engine, producer - `esbuild` → `^0.25.12` across cli, core, producer - `tsx` → `^4.21.0` across producer (matches root + core) ## Source-level v25 compat (already in this PR) @types/node v25 declares `File` as an interface (not a class) and exposes a conditional global where `FormData.entries()` narrows to `[string, string]` when an `onmessage` global is in scope. `packages/core/src/studio-api/routes/files.ts`'s `value instanceof File` check was relying on the v24 class declaration — already cast the iterator to `Iterable<[string, FileLike | string]>` in the prior commit. Two more v25 source fixes here: - `packages/cli/src/commands/init.ts` - `packages/cli/src/whisper/normalize.ts` `Dirent.path` was removed in @types/node v25 (deprecated alias for `parentPath` since Node 20.12). Drop the `?? e.path` fallback. ## Verification Both install layouts now build clean end-to-end: - `bun install` (isolated, default): full build green, 853 core tests pass, typecheck green across all 7 packages - `bun install --linker=hoisted` (Windows CI): same result - `bun.lock` no longer contains any `@hyperframes/<pkg>/<dep>` nested workspace entries — 70+ lines of nested install blocks gone |
||
|
|
c8a3e5fc4d |
fix(ci): unbreak format check on main + skip cli build:fonts when present
Two narrow fixes pulled out of a larger Windows-CI investigation: ## 1. Format check (`oxfmt`) `packages/core/package.json` and `packages/shader-transitions/package.json` had their `publishConfig` keys reordered to a non-canonical order by the v0.6.1 release commit (`82fd2967`). Releases push directly to main without going through PR CI, so the drift wasn't caught and `bun run format:check` has been failing on every push since. Fix: re-run `oxfmt`. ## 2. `@hyperframes/cli` `build:fonts` skip-when-present `packages/cli`'s `build:fonts` script regenerated `packages/producer/src/services/fontData.generated.ts` unconditionally on every cli build. The script reads `@fontsource/*` packages via `require.resolve(...)`, which walks `packages/producer/node_modules/@fontsource/*` junctions — these trip `EPERM: operation not permitted, stat` on Windows GHA runners because of long-running bun-on-Windows workspace junction bugs (see oven-sh/bun#23615, #18354, #10146). `fontData.generated.ts` is committed to git, so the regeneration is only needed when fonts actually change. Match the skip-when-present pattern already in `@hyperframes/producer`'s own `build:fonts`. Doesn't fix the Windows render verification end-to-end (the producer build itself still trips junction issues — being tackled separately in #765), but at least stops `cli build:fonts` from being its own failure point on Windows. |
||
|
|
86c5fd28e8 |
chore: release v0.6.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
82fd2967c4 |
chore: release v0.6.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
91bdffffe6 |
fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install. |
||
|
|
e25a3b3a4b | chore: release v0.6.0 | ||
|
|
38efe168e2 |
refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)
* feat(studio): add manual DOM editing inspector (#466) * fix: stabilize studio preview and runtime sync * fix: pass selector through timeline thumbnails * feat: add studio timeline editing * fix: disambiguate timeline edit targets * fix: stop timeline auto-scroll in fit mode * feat: use percentage-based timeline zoom * fix: sync timeline playhead on zoom changes * fix: reset timeline scroll when returning to fit * feat(studio): add manual DOM editing inspector * docs: update studio manual dom editing guide * feat(studio): add image asset picker for fills * feat(studio): add inline image uploads for fills * fix(studio): use real file input for image fill uploads * fix(studio): restore toast plumbing after rebase * fix(studio): explain in-app upload limitation * fix(studio): reuse asset-tab upload pattern in fills * feat(studio): refine manual design inspector * fix(studio): polish manual design inspector * fix(studio): keep color picker in viewport * fix(studio): clarify color picker selection * docs: update manual DOM editing guide * fix(studio): keep gradient color picker open * fix(studio): scope text color to text layers * fix(studio): add agent fallback for immovable layers * fix(studio): address manual editing review feedback * fix(studio): make local font selection reliable * fix(studio): improve dom picking and thumbnails * fix(studio): copy absolute paths in agent prompts * fix(studio): prevent timeline track cutoff * fix: copy Studio agent prompts in Safari * fix(studio): hold canvas movement from inspector * feat(studio): add persistent undo redo (#537) Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops. The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit. - Adds a persistent per-project edit-history model for file snapshots. - Stores undo/redo stacks in IndexedDB so history survives Studio refreshes. - Records source editor saves, manual DOM edits, and timeline mutations. - Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`. - Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content. - Keeps history available in memory if IndexedDB persistence fails during a session. - Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper. Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit. Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot. - `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass - `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass - `bun --filter @hyperframes/studio typecheck` - `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors - `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` - `git diff --check` - `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck - Lefthook pre-commit -> lint, format, typecheck pass - Lefthook commit-msg -> commitlint pass - Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`. - Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`. - Refreshed Studio and verified Undo stayed enabled. - Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned. - Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move. - Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`. - Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed. - The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed. - The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request. * fix: align Studio capture with preview (#595) Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404. While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview. - Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction. - Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode. - Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages. - Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds. - Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing. Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched. The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time. The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`. - `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts` - `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts` - `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts` - `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts` - `bun run --cwd packages/studio typecheck` - `bun run --cwd packages/core build:hyperframes-runtime` - `bun run --cwd packages/core typecheck` - `git diff --check` Pre-commit also reran lint, format, and typecheck successfully for the committed files. Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened: ```text http://127.0.0.1:5197/#project/Notion%20Showcase ``` Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`. After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared. Mean pixel diffs for preview vs capture were: - `0s`: `0.0` - `2s`: `0.8641` - `10s`: `0.3496` - `18s`: `0.2309` The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions. - Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed. - The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed. - Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused. * feat: persist studio manual edits via manifest * fix(studio): stabilize manual edit manifest rendering * fix(studio): allow master canvas layer selection * fix(studio): scale master edits in source coordinates * fix(studio): reapply manual edits during playback * fix(studio): keep rotation edit base stable * feat(studio): highlight hovered canvas target * fix(studio): drag hovered canvas targets immediately * fix(studio): rotate manual edits around center * fix(studio): keep rotate handle aligned while dragging * fix(studio): allow small rotation adjustments * fix(studio): match rotate handle size to resize handle * fix(studio): connect rotate handle line to selection * feat(studio): reset selected manual edits * fix(studio): route inspector geometry through manual edits * feat: add studio group repositioning * fix: preserve studio group selections * fix: seed additive studio selection groups * fix: select studio groups on pointerdown * fix: harden studio group overlay events * fix: address studio manual edit review feedback * fix: apply nested manual edits in drilled previews * fix: commit drag offsets from gesture math * fix: persist manual preview edits on refresh * fix: harden manual edit refresh apply * fix: share manual edit render runtime * chore: release v0.5.0-alpha.15 * feat(core): add studio animation preview APIs * feat(studio): add alpha editor layer inspector * chore: release v0.6.0-alpha.1 * feat(studio): enable inspector panels by default * fix(studio): keep motion panel opt-in * chore: release v0.6.0-alpha.2 * feat: auto-open timeline clip layers * feat: show composition loading in studio * feat: disable Studio timeline while composition loads * chore: ignore .claude directory * chore: release v0.6.0-alpha.3 * feat(studio): simplify inspector selection ux * fix(studio): keep notion preview playback moving * fix(studio): handle raster inspector clicks * fix(studio): stale selection, rotation control, design panel polish Fixes and improvements based on power-user testing feedback: 1. Fix stale selection after style edits — handleDomStyleCommit now calls refreshDomEditSelectionFromPreview after persisting, matching every other commit handler. Without this, the PropertyPanel showed frozen computedStyles after color/radius/shadow edits, making it look like editing "didn't work." Also adds error handling around the persist call. 2. Add rotation field to the Design panel Layout section — reads the current rotation angle from the manual edit manifest and commits via the existing handleDomRotationCommit handler. 3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now defaults to true so the Motion tab is discoverable without env vars. 4. Color controls only when element has color — fill color section now only shows when the element has an explicit non-transparent background-color. Text color shows only when the element has a color style. Prevents showing color pickers on elements where color edits have no visible effect. 5. Exclude canvas from selection — added "canvas" to DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the preview or listed in the layer panel. 6. Multi-selection feedback — shows "N elements selected" with guidance instead of the generic empty state when multiple elements are selected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): prevent browser launch timeout from crashing dev server The shared Puppeteer browser pool in getSharedBrowser() could throw a 30s TimeoutError during launch. This error propagated as an uncaught rejection and killed the vite process, even though generateThumbnail had its own try/catch — the browser launch promise rejected outside that scope. Now getSharedBrowser itself catches launch failures and returns null, so thumbnails degrade gracefully instead of crashing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): revert motion panel default to false Motion panel stays opt-in via env var per product direction. Only the Design panel is enabled by default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): prevent read-only property crash in manual edit wrappers The seek/play/applyAfter wrapper functions in manualEdits.ts crashed with "Cannot set property X which has only a getter" when the player or timeline objects define seek/play as getter-only properties. This prevented ALL manual edits (position, rotation, size) from persisting to disk — the error thrown during applyCurrentStudioManualEditsToPreview aborted the save queue. Wrapped all three property assignments in try/catch so wrapping gracefully degrades when the target object is non-configurable. Verified: position edit (X=42px) now persists to .hyperframes/studio-manual-edits.json and survives page refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: alpha preview e2e fixes — exports, init templates, EPIPE crash Three bugs found via automated e2e testing of the v0.6.0-alpha preview: 1. core: add missing package.json export specifiers for studio-api/manual-edits-render-script and studio-api/studio-motion-render-script — the alpha.3 npm publish failed because the studio build could not resolve these sub-paths. 2. cli: fix init --example creating empty projects — tsup leaves empty template directories in dist/ during the build, causing existsSync(templateDir) to return true and skip the remote fetch fallback. Now checks for index.html inside the dir instead. 3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg stdin/stdout had no error handlers, so a write after the ffmpeg process exits throws an uncaught error that crashes the process. Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): thumbnail crash, feature defaults, multi-select UX, fps selector Power-user audit fixes for the alpha studio: - vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer TimeoutError doesn't crash the entire vite dev server as an uncaught rejection. Close the page on error to prevent browser session leaks. - manualEditingAvailability.ts: enable motion panel and manual canvas drag editing by default (were both false, undiscoverable without knowing the env vars). - PropertyPanel.tsx: show "N elements selected" feedback when multiple elements are selected instead of the generic "Select an element" empty state. - RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render export bar instead of hardcoding 30fps. Pass the user's choice through to startRender. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.4 * fix(runtime): update clock duration when root timeline is late-bound Compositions with external sub-compositions (like apple-presentation with 7 slides) load child compositions via fetch(). The root GSAP timeline is only bound after all external compositions finish loading, but the TransportClock duration was only set during initial setup. When bindRootTimelineIfAvailable runs after the external compositions load, it captures the root timeline but never updates the clock. player.getDuration() continues returning 0, so the player's probe interval never fires the 'ready' event, and the Studio shows "Loading composition" indefinitely. Now bindRootTimelineIfAvailable updates clock.setDuration when the root timeline is late-bound. Guarded with try/catch for the early call site where clock is not yet initialized (temporal dead zone). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): block element selection while composition is loading Prevent users from selecting elements in the preview while the composition is still loading (showing "Loading composition" overlay). Selection and hover highlighting are suppressed until the player fires the ready event. Also reverts motion panel and manual drag editing defaults to false — these were accidentally set to true during the PR #693 merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.5 * chore: release v0.6.0-alpha.6 * fix(runtime): remove per-tick timeline.pause() that causes audio stutter The seekRuntimeTimeline helper added timeline.pause() before every totalTime() seek. During transport-driven playback, this runs 60 times per second, causing GSAP to cascade pause events to media elements on every frame. The result: audio plays/stops/plays/stops in a stutter pattern. The captured root timeline is already paused once in player.play() — the TransportClock drives it via totalTime(t) which keeps it paused. The extra per-tick pause() was redundant for the root timeline but actively harmful for media sync. Fix: restore the original inline seek for the captured timeline (totalTime without pause), keep seekRuntimeTimeline with pause() only for standalone child timelines where explicit pause control is needed. Also fixes rebase artifact: missing PropertyPanel props in App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.7 * fix(studio): restore text field handlers lost in rebase Restores handleDomAddTextField and handleDomRemoveTextField that were dropped when resolving App.tsx conflicts during the main→next rebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.8 * fix(runtime): comprehensive audio stutter fix Three changes that together caused audio play/stop/play/stop stutter during transport-driven playback: 1. seekRuntimeTimeline called timeline.pause() before every totalTime() seek, 60x per second. GSAP cascades pause to media elements on every frame. Fix: restore original inline seek for the captured timeline (totalTime without pause). The timeline is already paused once in player.play(). seekRuntimeTimeline with pause() remains only for standalone child timelines. 2. player.play() removed the !tl guard, allowing play without a captured timeline. But getSafeTimelineDurationSeconds(null) returns 0, so the clock has no duration → immediately reaches end → stops → restarts. Fix: when no timeline provides duration, fall back to the root composition element's data-duration attribute. 3. Audio source attachment added networkState guard that could cause the clock to flicker between audio-source and monotonic timing on transient media states. Fix: keep !rawEl.error guard (prevents errored audio from freezing the clock) but drop the networkState check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(runtime): skip drift corrections on playing video elements Seeking a playing video resets the browser's decoder pipeline, causing a ~150ms freeze while it re-buffers. During that freeze the monotonic clock advances, drift grows, and strict sync fires another seek — creating a perpetual stutter loop (176 seek events / 8s observed on the apple-presentation composition). Skip strict and force drift corrections for playing video elements; only hard sync (>0.5s catastrophic drift) warrants the decoder-reset cost. Audio elements are unaffected and retain the full correction tiers. Also propagate the asset-loading overlay state to the timeline so controls are disabled during "Preparing preview assets", matching the existing behavior for the initial composition loading overlay. * chore: release v0.6.0-alpha.9 * feat(studio): consolidate keyboard shortcuts into single handler Move all window-level keyboard shortcuts from 4 separate files into one `handleAppKeyDown` listener in App.tsx: - Shift+T: toggle timeline (was App.tsx, separate useMountEffect) - Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect) - Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect) - Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx) - Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx) - Delete/Backspace: remove selected element (was Timeline.tsx) LeftSidebar exposes a ref handle for tab switching. Timeline watches selectedElement becoming null to clean up popover/range UI state. History hotkey kept as named function for iframe forwarding. Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain in their component hooks — tightly coupled to component state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): sidebar tab overflow + hot-reload double-refresh 1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate on overflow, tighter padding. Fixes tabs clipping outside the rounded pill at narrow sidebar widths. 2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh path (source editor, timeline move/resize/delete, asset drop). The file-change watcher already checks this timestamp and suppresses echoed events — but source editor saves and timeline operations weren't setting it, causing a double refreshKey increment that could leave the player in a non-playable state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): delete key removes preview-selected elements The consolidated keyboard handler only checked selectedElementId (timeline clips). When a user selected a child element in the preview via the inspector, selectedElementId was null because the element didn't correspond to a top-level timeline clip, so Delete/Backspace did nothing. Add handleDomEditElementDelete that removes the element referenced by the current domEditSelection via the remove-element mutation API. The Delete key handler now falls through from timeline selection to DOM edit selection. * fix(studio): remove unused deleteInFlightRef from Timeline Leftover from moving Delete handling to the consolidated keyboard handler in App.tsx. Also suppress pre-existing exhaustive-deps warning on the intentional every-render selection-change watcher. * fix(studio): forward all keyboard shortcuts to preview iframe The consolidated handleAppKeyDown was only added to the parent window. When focus was inside the preview iframe (after clicking an element), keydown events didn't reach the parent, so Delete and other shortcuts didn't fire. Replace the per-function iframe forwarding (handleTimelineToggleHotkey only) with the full app-level handler via a ref-stable wrapper. All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work from within the preview iframe. * fix(core): search inside <template> content when removing elements linkedom's document.querySelectorAll does not traverse <template> content. Elements in template-based compositions (like .title-word, .bullet-text) were invisible to the removal logic, so delete returned changed: false and the element survived the reload. Fall back to template.querySelectorAll when the document-level query returns no matches. Uses template.querySelectorAll directly (not template.content.querySelectorAll) because removing from the content DocumentFragment doesn't update the serialized output. * fix(studio): suppress loading overlay on hot-reload Only show the composition loading overlay on the first iframe load. Hot-reloads (source editor save, timeline edits, element delete) no longer flash the full-screen loading state. * fix(studio): reorder design panel, fix stroke height, rename Blending - Move Text section to the top of the panel (before Layout) - Remove Selection Colors section - Rename "Blending" to "Transparency" - Fix stroke Width/Style height mismatch by making SelectField use inline label layout matching MetricField * fix(studio): prevent panel scroll when wheel-adjusting metric inputs React registers onWheel passively, so preventDefault had no effect on the parent scroll container. Replace with a native wheel listener (passive: false) that blocks both default scroll and propagation. * chore: release v0.6.0-alpha.10 * chore: release v0.6.0-alpha.11 * fix(studio): clean next alpha inspector artifacts * chore: release v0.6.0-alpha.12 * fix(studio,player,core): eliminate double audio and manifest polling loop (#722) Three bugs that compound in Studio preview: 1. **Double audio on pause/resume**: syncRuntimeMedia played audio through the HTML <audio> element while WebAudioTransport simultaneously played the same source through AudioBufferSourceNode. Fixed by passing webAudio.isActive() as outputMuted so HTML elements stay muted when Web Audio owns playback. Also removed the priorMuted restore in stopAll() which raced with the next play cycle. 2. **Manifest polling loop**: applyStudioManualEditsToPreview and applyStudioMotionToPreview unconditionally fetched from disk on every call, even without forceFromDisk. The runtime posts state messages every frame via postMessage, triggering React re-renders that re-invoked these functions ~60x/second. Fixed by returning early when no disk read is requested, and using refs instead of callbacks in useEffect deps. 3. **Parent proxy double-play**: the player web component created parent-frame audio proxies even when the runtime bridge was available, causing two audio sources on autoplay-blocked promotion. Fixed by skipping proxy creation when _hasRuntimeBridge returns true, and synchronously muting iframe media on promotion to close the async race window. Also fixes pre-existing ResolutionPreset type missing square variants. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): improve font picker and text property controls (#736) - Line height and letter-spacing: convert from free-text to select with presets - Font style: remove oblique (browser falls back to italic), keep normal/italic - Font weight: detect available weights via document.fonts.check(), add labels - Font source: local fonts matching Google catalog tagged as Google - Font list: balanced per-source caps prevent any source from being cut off - Sort order: Google fonts rank before Local so curated fonts appear first Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): inspector visibility, undo/redo blinking, and preview caching Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0 because CSS opacity is not inherited — getComputedStyle on the child still returns 1. Walk the ancestor chain in the picker, domEditing, and overlay visibility checks to catch this. Also: - Containers with all-invisible children are no longer selectable - Selection/hover overlay hides during playback and while loading - Undo/redo no longer double-refreshes (echo suppression for all file writes) - Undo/redo reloads iframe in-place instead of recreating the Player, preserving shader transition cache - Preview routes return ETag + Cache-Control headers; composition HTML uses project signature for conditional 304, binary assets use mtime+size - Loading overlay deferred 400ms so cached loads never flash it * fix(studio): remove timeline inspector buttons, enable manual dragging Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline clips. The timeline layer inspector feature and all supporting code is removed. Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H fields in the design panel. Hide the Radius section when the element has no visible background. Fix pre-existing ResolutionPreset type for square presets. * chore: release v0.6.0-alpha.13 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): add rotation field, inline element drag, fix manifest load regression (#743) - Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel. Goes through manifest via handleDomRotationCommit, resettable with Reset Edits. - Auto-promote display:inline elements to inline-block when dragged so translate works on inline spans. - Fix regression from polling fix: iframe load now passes readFromDiskFirst to load manifest from disk, so Reset Edits finds existing entries. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(studio): decompose App.tsx monolith (4297 → 567 lines) (#741) * refactor(studio): decompose App.tsx from 4297 to 567 lines Break the monolithic StudioApp component into focused modules: Hooks (12 new): - usePanelLayout: resizable/collapsible panel state - useFileManager: file tree, CRUD, uploads, derived lists - useManifestPersistence: manual edit + motion manifest save queue - useTimelineEditing: clip move/resize/delete/drop handlers - useDomEditSession: DOM selection, style/text commits, preview interaction - useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync - useCaptionDetection: auto-detect caption compositions - useRenderClipContent: timeline clip thumbnail rendering - useConsoleErrorCapture: preview iframe console error capture - useFrameCapture: frame capture download flow - useLintModal: lint execution and modal state - useCompositionDimensions: stage-size message listener Components (6 new): - AskAgentModal: agent prompt modal - StudioHeader: toolbar with undo/redo, capture, inspector toggle - StudioLeftSidebar: file tree + code editor (handles collapsed state) - StudioPreviewArea: NLELayout + overlays + caption timeline - StudioRightPanel: Design/Motion/Renders tab panel - TimelineToolbar: zoom controls + timeline toggle Utilities (4 new): - studioHelpers: types, path helpers, DOM utilities - studioPreviewHelpers: preview pointer/player interaction - domEditHelpers: selection group algebra - studioFontHelpers: font injection + @font-face management Also removes dead timeline layer inspector code (eye icon, thumbnail toggle, layer panel) that was disabled behind a feature flag. * feat(studio): add Layer (z-index) field to design panel Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout section. Available for all elements regardless of style editing capability since z-index is fundamental to composition stacking order. * docs: architecture spec for studio domain contexts, hook split, and file-size lint * docs: implementation plan for studio contexts, hook split, and file-size lint * refactor(studio): consolidate duplicate helpers in useDomEditSession Remove ~370 lines of helper functions that were copied into the hook instead of imported. All removed functions already exist in the canonical utility files (studioHelpers, studioFontHelpers, studioPreviewHelpers, domEditHelpers). Also removes the duplicate local type definitions for RightPanelTab, AgentModalAnchorPoint, and PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl, importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport). Temporarily excludes useDomEditSession.ts from the 500 LOC file-size check until Tasks 3-5 split it into focused hooks. * refactor(studio): extract useDomSelection from useDomEditSession * refactor(studio): extract useAskAgentModal from useDomEditSession * refactor(studio): extract usePreviewInteraction from useDomEditSession * refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator Split the 897-line useDomEditSession into focused hooks: - useDomEditCommits (439 LOC): manifest commits (path offset, box size, rotation, manual edits reset, motion), persist operations, element delete, font asset resolution - useDomEditTextCommits (329 LOC): style/text/text-field commits - useDomEditSession (339 LOC): thin orchestrator wiring selection, agent modal, preview interaction, and commit hooks All files now under 500 LOC limit. Removed the temporary lefthook filesize exclusion for useDomEditSession. * feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio) Create context providers that wrap hook return values for prop-drilling elimination. Each context destructures and reconstructs the value inside useMemo so exhaustive-deps is satisfied and re-renders are minimized. Not yet wired into App.tsx — that comes in a follow-up. * refactor(studio): wire domain contexts, eliminate prop drilling in 4 components Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar, StudioPreviewArea, and StudioRightPanel to consume contexts instead of props. Prop counts reduced: - StudioHeader: 13 -> 6 - StudioLeftSidebar: 19 -> 4 - StudioPreviewArea: 37 -> 11 - StudioRightPanel: 39 -> 3 Net: -118 lines, 108 props removed from call sites. * chore: upgrade to React 19 Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace. Add resolutions/overrides in root package.json to prevent peer dependency pins (e.g. @phosphor-icons/react) from pulling React 18. Regenerate bun.lock. This enables the React 19 context syntax (<Context value={...}>) used by the new domain contexts. * fix(studio): refresh preview after z-index change so stacking updates visually * fix(studio): remove duplicate duration override causing oscillation The timeline message handler set the duration twice: once via processTimelineMessage and once via a raw durationInFrames override. When drilled into a sub-composition, these could disagree, causing the duration to oscillate after element deletion. * fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs Two changes to fix duration oscillation after deleting a timeline clip: 1. Replace setRefreshKey (full Player remount) with in-place iframe.contentWindow.location.reload() after deleting a clip. The full remount triggered a chaotic re-probing cycle with multiple duration sources (adapter, manifest, postMessage) fighting each other, causing the timeline to oscillate between durations. In-place reload preserves the Player web component and its state. 2. Remove window.confirm dialogs from both timeline clip delete and DOM element delete. Undo is available so the confirmation adds friction without value. * chore: gitignore docs/superpowers * feat(studio): add favicon * perf(studio): skip no-op state updates in timeline sync syncTimelineElements was called 60+ times per page load, each time triggering setElements/setDuration/setTimelineReady even when nothing changed. This caused massive re-render churn and memory usage. Add early-return guards to skip updates when values haven't changed. Also fixes the duration oscillation after element delete. * refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit. Split into cohesive modules by responsibility: - propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants - propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField, SliderControl, SegmentedControl, SelectField, Section - propertyPanelColor.tsx (371) — ColorField, ColorSlider - propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers - propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers - propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls - propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill) - PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers All re-exports from PropertyPanel.tsx preserved for backwards compatibility. No behavioral changes — pure structural split. * fix(studio): use in-place iframe reload for all timeline operations Replace setRefreshKey with in-place iframe reload for move, resize, and asset drop — matching delete which was already fixed. Prevents the Player remount probe cycle that causes duration oscillation. * perf(studio): replace 5s polling loop with event-driven adapter init The Player's onIframeLoad used a setInterval polling loop (25 attempts × 200ms = 5 seconds) to detect when the runtime's __player/__timeline globals appeared. Each poll that missed triggered wasted work, and multiple duration sources fighting during the probe cycle caused oscillation bugs. Replace with event-driven initialization: 1. Fast path: try initializeAdapter() immediately (works for in-place reloads where the adapter is already present) 2. If not ready, listen for the runtime's "state"/"timeline" postMessage signals and initialize on the first one 3. Single 5s timeout as safety net (replaces 25 interval ticks) This eliminates the polling overhead, reduces setDuration/setElements calls to exactly 1 per load, and makes the Player responsive within one frame of the runtime being ready instead of up to 200ms later. * fix(studio): prevent duration oscillation after element delete Two fixes for the duration display oscillating between sub-composition and master durations after deleting an element in the preview: 1. Clear store elements before iframe reload in handleDomEditElementDelete. Without this, stale pre-delete elements remain in the store and cause mergeTimelineElementsPreservingDowngrades to alternate between REPLACE and PRESERVE modes as the element count fluctuates. 2. Add 500ms cooldown on enrichMissingCompositions after timeline messages. The "state" handler was calling enrichMissingCompositions every ~80ms, which added extra elements from GSAP timelines. These fought with the authoritative element list from "timeline" messages (~333ms), creating a feedback loop where element count oscillated and triggered alternating merge strategies with different durations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): single reloadPreview as source of truth for preview refresh Create reloadPreview() in App.tsx that encapsulates the correct behavior (in-place iframe reload with setRefreshKey fallback). Pass it as the sole refresh mechanism to hooks, removing direct setRefreshKey access from useTimelineEditing and useDomEditCommits. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(studio): decompose App.tsx from 4297 to 567 lines Break the monolithic StudioApp component into focused modules: Hooks (12 new): - usePanelLayout: resizable/collapsible panel state - useFileManager: file tree, CRUD, uploads, derived lists - useManifestPersistence: manual edit + motion manifest save queue - useTimelineEditing: clip move/resize/delete/drop handlers - useDomEditSession: DOM selection, style/text commits, preview interaction - useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync - useCaptionDetection: auto-detect caption compositions - useRenderClipContent: timeline clip thumbnail rendering - useConsoleErrorCapture: preview iframe console error capture - useFrameCapture: frame capture download flow - useLintModal: lint execution and modal state - useCompositionDimensions: stage-size message listener Components (6 new): - AskAgentModal: agent prompt modal - StudioHeader: toolbar with undo/redo, capture, inspector toggle - StudioLeftSidebar: file tree + code editor (handles collapsed state) - StudioPreviewArea: NLELayout + overlays + caption timeline - StudioRightPanel: Design/Motion/Renders tab panel - TimelineToolbar: zoom controls + timeline toggle Utilities (4 new): - studioHelpers: types, path helpers, DOM utilities - studioPreviewHelpers: preview pointer/player interaction - domEditHelpers: selection group algebra - studioFontHelpers: font injection + @font-face management Also removes dead timeline layer inspector code (eye icon, thumbnail toggle, layer panel) that was disabled behind a feature flag. * docs: architecture spec for studio domain contexts, hook split, and file-size lint * docs: implementation plan for studio contexts, hook split, and file-size lint * refactor(studio): consolidate duplicate helpers in useDomEditSession Remove ~370 lines of helper functions that were copied into the hook instead of imported. All removed functions already exist in the canonical utility files (studioHelpers, studioFontHelpers, studioPreviewHelpers, domEditHelpers). Also removes the duplicate local type definitions for RightPanelTab, AgentModalAnchorPoint, and PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl, importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport). Temporarily excludes useDomEditSession.ts from the 500 LOC file-size check until Tasks 3-5 split it into focused hooks. * refactor(studio): extract useDomSelection from useDomEditSession * refactor(studio): extract useAskAgentModal from useDomEditSession * refactor(studio): extract usePreviewInteraction from useDomEditSession * refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator Split the 897-line useDomEditSession into focused hooks: - useDomEditCommits (439 LOC): manifest commits (path offset, box size, rotation, manual edits reset, motion), persist operations, element delete, font asset resolution - useDomEditTextCommits (329 LOC): style/text/text-field commits - useDomEditSession (339 LOC): thin orchestrator wiring selection, agent modal, preview interaction, and commit hooks All files now under 500 LOC limit. Removed the temporary lefthook filesize exclusion for useDomEditSession. * refactor(studio): wire domain contexts, eliminate prop drilling in 4 components Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar, StudioPreviewArea, and StudioRightPanel to consume contexts instead of props. Prop counts reduced: - StudioHeader: 13 -> 6 - StudioLeftSidebar: 19 -> 4 - StudioPreviewArea: 37 -> 11 - StudioRightPanel: 39 -> 3 Net: -118 lines, 108 props removed from call sites. * fix(studio): refresh preview after z-index change so stacking updates visually * fix(studio): remove duplicate duration override causing oscillation The timeline message handler set the duration twice: once via processTimelineMessage and once via a raw durationInFrames override. When drilled into a sub-composition, these could disagree, causing the duration to oscillate after element deletion. * fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs Two changes to fix duration oscillation after deleting a timeline clip: 1. Replace setRefreshKey (full Player remount) with in-place iframe.contentWindow.location.reload() after deleting a clip. The full remount triggered a chaotic re-probing cycle with multiple duration sources (adapter, manifest, postMessage) fighting each other, causing the timeline to oscillate between durations. In-place reload preserves the Player web component and its state. 2. Remove window.confirm dialogs from both timeline clip delete and DOM element delete. Undo is available so the confirmation adds friction without value. * chore: gitignore docs/superpowers * perf(studio): skip no-op state updates in timeline sync syncTimelineElements was called 60+ times per page load, each time triggering setElements/setDuration/setTimelineReady even when nothing changed. This caused massive re-render churn and memory usage. Add early-return guards to skip updates when values haven't changed. Also fixes the duration oscillation after element delete. * refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit. Split into cohesive modules by responsibility: - propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants - propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField, SliderControl, SegmentedControl, SelectField, Section - propertyPanelColor.tsx (371) — ColorField, ColorSlider - propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers - propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers - propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls - propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill) - PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers All re-exports from PropertyPanel.tsx preserved for backwards compatibility. No behavioral changes — pure structural split. * fix(studio): use in-place iframe reload for all timeline operations Replace setRefreshKey with in-place iframe reload for move, resize, and asset drop — matching delete which was already fixed. Prevents the Player remount probe cycle that causes duration oscillation. * perf(studio): replace 5s polling loop with event-driven adapter init The Player's onIframeLoad used a setInterval polling loop (25 attempts × 200ms = 5 seconds) to detect when the runtime's __player/__timeline globals appeared. Each poll that missed triggered wasted work, and multiple duration sources fighting during the probe cycle caused oscillation bugs. Replace with event-driven initialization: 1. Fast path: try initializeAdapter() immediately (works for in-place reloads where the adapter is already present) 2. If not ready, listen for the runtime's "state"/"timeline" postMessage signals and initialize on the first one 3. Single 5s timeout as safety net (replaces 25 interval ticks) This eliminates the polling overhead, reduces setDuration/setElements calls to exactly 1 per load, and makes the Player responsive within one frame of the runtime being ready instead of up to 200ms later. * fix(studio): prevent duration oscillation after element delete Two fixes for the duration display oscillating between sub-composition and master durations after deleting an element in the preview: 1. Clear store elements before iframe reload in handleDomEditElementDelete. Without this, stale pre-delete elements remain in the store and cause mergeTimelineElementsPreservingDowngrades to alternate between REPLACE and PRESERVE modes as the element count fluctuates. 2. Add 500ms cooldown on enrichMissingCompositions after timeline messages. The "state" handler was calling enrichMissingCompositions every ~80ms, which added extra elements from GSAP timelines. These fought with the authoritative element list from "timeline" messages (~333ms), creating a feedback loop where element count oscillated and triggered alternating merge strategies with different durations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): single reloadPreview as source of truth for preview refresh Create reloadPreview() in App.tsx that encapsulates the correct behavior (in-place iframe reload with setRefreshKey fallback). Pass it as the sole refresh mechanism to hooks, removing direct setRefreshKey access from useTimelineEditing and useDomEditCommits. * fix: resolve lint errors from rebase (unused imports, duplicate declarations) * fix: prefix unused probeResult variable * fix: restore renderOrchestrator.ts from origin/next (rebase conflict artifact) * fix: resolve rebase conflicts by using main's producer and next's studio/player * fix: restore rebase-conflicted files from origin/next * fix: use 'load' instead of 'networkidle0' for Puppeteer waitUntil (type compatibility) * fix: restore webAudioTransport.ts from main (test compatibility) --------- Co-authored-by: Vance Ingalls <vance@heygen.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
aa715ca8a0 |
feat(core,studio,cli): add square + square-4k canvas resolutions
The four existing presets only cover 16:9 (landscape) and 9:16 (portrait) aspect ratios. A 1080×1080 square comp had nowhere to land at any scale: "Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K mapped to a landscape/portrait preset whose aspect ratio mismatched, which the producer's resolveDeviceScaleFactor validator rejects with "does not match the aspect ratio of the composition". Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array automatically, so the producer's validator, the render API route, and the CLI `--resolution` flag pick the new presets up without further changes. - core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the htmlParser to recognize `data-resolution="square|square-4k"` and to infer square from equal width/height (vs. the prior "square defaults to portrait" tie-breaker). - studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors; collapse isPortraitComp into a 3-way `compAspect` helper so resolveResolution returns the square preset for square comps. - cli: update --resolution help text on `init` and `render` to mention the new presets. - tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor suite (returns 1 for square→square, 2 for square→square-4k, rejects landscape preset on square comp), update the htmlParser test that previously pinned the "square→portrait" tiebreaker. |
||
|
|
15aac00704 |
Merge pull request #684 from TheodorKleynhans/feat/cli-fps-fraction-syntax
feat(cli): accept ffmpeg-style rational fps (NTSC, PAL, slow-mo) |
||
|
|
d4ba9080f3 |
Merge pull request #704 from WadydX/fix/init-video-short-flag-conflict
fix(cli): resolve init --video short-flag collision with global -V |
||
|
|
2778845bd3 | fix(cli): handle init -V with explicit migration error | ||
|
|
57ea5641fe | chore: release v0.5.7 | ||
|
|
e61d1fe002 | fix(cli): avoid -V collision for init video flag | ||
|
|
bd7bbae42d | chore: release v0.5.6 | ||
|
|
5dcc89c930 |
feat(cli): accept ffmpeg-style rational fps (NTSC, PAL, slow-mo)
Replaces the rigid `--fps 24|30|60` whitelist with a numeric range and
adds support for ffmpeg-style fractional framerates so NTSC stays exact
end-to-end.
- `--fps 30` keeps working (integer fps)
- `--fps 30000/1001` now means exact NTSC 29.97 (not the lossy decimal)
- `--fps 24000/1001`, `--fps 60000/1001`, `--fps 25/50/120/240` all work
- Decimals like `--fps 29.97` are rejected with a friendly error pointing
the user at the rational form, since `29.97` and `30000/1001` round
to different framerates inside ffmpeg
Carries an `Fps = { num: number; den: number }` rational end-to-end:
RenderConfig, EncoderOptions, StreamingEncoderOptions, CaptureOptions,
DockerRenderOptions, Studio API request body, regression-harness
meta.json. The `-r` and `-framerate` ffmpeg args emit the rational form
verbatim (`30000/1001`) so no decimal round-trip happens at the encoder
boundary. Frame-interval math uses `1000 * den / num` ms (33.366… for
NTSC, 33.333… for integer 30).
Helpers live in @hyperframes/core:
- `parseFps(input: string | number): FpsParseResult` — discriminated
parser used by both the CLI and the Studio API route
- `fpsToFfmpegArg(fps: Fps): string` — emits "30" or "30000/1001"
- `fpsToNumber(fps: Fps): number` — for arithmetic (telemetry, frame
count, frame-index → time)
Studio API wire format accepts polymorphic `fps: number | string`:
- number → integer fps (`30`)
- string → rational (`"30000/1001"`)
Decimals are rejected; matches the same rule as the CLI.
Existing meta.json fixtures with integer `"fps": 30` continue to load
unchanged — the regression-harness validator now normalizes both number
and string inputs through `parseFps`.
|