mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
refactor/keyframe-stack-u9
242 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c12987e301 |
fix(producer): revert Proxy-based wrapTimeline to plain-object approach (#1284)
* fix(producer): revert Proxy-based wrapTimeline to plain-object approach The `new Proxy` wrapper for GSAP timelines introduced in #1279 causes Chrome headless to hang indefinitely during page.goto — DOMContentLoaded never fires. The plain-object approach (explicit method allowlist) loads in <800ms on the same composition. The Proxy's generic get/set traps interact badly with Chrome's internal object inspection (Symbol checks, thenable probing, DevTools serialization) during HTML parsing, creating a permanent navigation hang. The maybePublishRenderReady listener fix from #1279 is preserved — only the wrapTimeline implementation is reverted. Compositions using GSAP methods outside the allowlist (eventCallback, labels, repeat, etc.) will see those calls silently dropped rather than forwarded. This is the same behavior as v0.6.81 and earlier. A safer forwarding approach can be explored separately without blocking renders. * fix(producer): address review — stale meta.json descriptions + silently-dropped methods doc - three-boundary: description referenced Proxy fix but the test uses onUpdate in to() vars (allowlist path), not eventCallback - three-boundary-deferred: same — pins Bug 2's deferred-race, not Bug 1 - Add inline doc comment listing silently-dropped GSAP methods and the onUpdate workaround * ci: add page.goto timing canary to CLI smoke test Parse page.goto completion times from the render log and fail if the slowest navigation exceeds 5s. Catches wrapTimeline regressions that block DOMContentLoaded before the 60s timeout fires. Refs: #1285 * fix(producer): forward all GSAP methods via dynamic enumeration at wrap time Instead of silently dropping methods outside a static allowlist, enumerate the real timeline's prototype chain at wrap time and generate plain-object forwarding stubs for every method not already covered. This achieves the same coverage as the `new Proxy` approach from #1279 without the Chrome headless navigation hang — no Proxy trap surfaces are exposed to Chrome internals. Methods prefixed with `_` (GSAP private) are skipped. All forwarded methods flush pending batch operations before delegating, matching the existing allowlist behavior. Closes #1285 * fix(producer): make proxy non-thenable + harden CI canary - Skip `then` in forwardRemainingMethods — GSAP timelines are thenable (tl.then resolves on completion), and forwarding it makes the proxy thenable too: Promise.resolve(proxy) or await proxy hangs forever for paused timelines - Add unit test: Promise.resolve(proxy) resolves immediately, real then() is never called - CI canary: exit 1 (not 0) when no page.goto timing is found in logs, so a log-format change loudly breaks CI instead of silently disabling the canary |
||
|
|
1bcd6ec3b3 |
fix(core): re-register hf-timelines-built listener in maybePublishRenderReady (#1279)
Compositions that defer gsap.timeline() registration past DOMContentLoaded (via setTimeout, template instantiation, or dynamic script loading) hit a race where __renderReady stays false forever: 1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips the hf-timelines-built listener and sets __renderReady = true 2. The deferred script runs, calls gsap.timeline().to() which sets __hfTimelinesBuilding = true via the batching proxy 3. The deferred maybePublishRenderReady() sees building=true, sets __renderReady = false, but never registers a listener to retry 4. __renderReady stays false, __hf.duration returns 0, pollHfReady times out with "Composition has zero duration" Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true, register a one-shot hf-timelines-built listener to retry — matching the pattern already used at init time for the synchronous batching case. Closes #1260 |
||
|
|
0bf15119f8 |
feat: font resolution pipeline — compositions capture and embed their own fonts (#1255)
Compositions are now self-contained: the compiler captures font files and embeds them as woff2 data URIs, eliminating silent render-time fallback when the render environment lacks the author's fonts. Resolution order (each tier falls through to the next): 1. Existing @font-face → use as-is 2. Bundled alias (38 cross-platform mappings) → embed data URI 3. Google Fonts → fetch, cache, embed 4. Local system font → locate on OS, compress to woff2, embed 5. Local @font-face paths → read file, compress, inline as data URI 6. External CDN stylesheets → fetch CSS, extract @font-face, inline 7. Alias map fallback → closest bundled equivalent 8. Actionable error with guidance Key changes: - System font locator (macOS/Windows/Linux) with path-bounding and symlink defense (realpathSync + O_NOFOLLOW) - woff2 compression via wawoff2 (WASM, cross-platform) - Multi-weight/style variant capture with length-sorted token matching - External stylesheet inlining with SSRF defense (assertPublicHttpsUrl, HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit) - Studio auto-import via GET /fonts/file API + renderAliasFor() derived from shared FONT_ALIAS_MAP (no more hand-curated drift) - failClosedFontFetch throws on unresolved fonts in distributed renders - Single source of truth: @hyperframes/core/fonts/aliases - system_font_will_alias lint rule (escalates to warning for distributed) - Default to Inter + JetBrains Mono in templates and CSS reset |
||
|
|
4da567df22 |
feat(gcp-cloud-run): Google Cloud Run + Workflows distributed render adapter (#1253)
* feat(gcp-cloud-run): add Google Cloud Run + Workflows distributed render adapter Adds @hyperframes/gcp-cloud-run, the GCP counterpart to @hyperframes/aws-lambda (issue #932). The OSS distributed primitives (plan, renderChunk x N, assemble) are unchanged; this package is the storage/compute/orchestration glue. Package: Cloud Run handler (one image, three actions), runs under bun; GCS transport; in-image chrome-headless-shell resolver; client SDK (renderToCloudRun, getRenderProgress, deploySite, computeRenderCost); Dockerfile; Cloud Workflows definition; Terraform module; CLI cloudrun deploy|sites|render|render-batch|progress|destroy with --output-resolution and --strict-variables; 62 unit tests + docs + live smoke script. Shared extraction (removes ~640 lines of adapter duplication): move the cloud-agnostic config validator + content-hash into producer/distributed; both adapters import them. Validated end-to-end on GCP at 37.4 dB PSNR vs baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): resolve @hyperframes/gcp-cloud-run in the CLI build + root build The CLI bundle (esbuild) couldn't resolve `@hyperframes/gcp-cloud-run/sdk`, failing Build/Typecheck/CLI-smoke (and the perf/windows/regression jobs that build first). Mirror the aws-lambda handling: mark the gcp adapter + its /sdk subpath external in tsup.config.ts with a source alias, and add gcp-cloud-run to the root `build` filter so its dist exists for publish + runtime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): copy gcp-cloud-run manifest in Dockerfile.test for frozen install The regression test image runs `bun install --frozen-lockfile` after copying each workspace package.json individually. The CLI now depends on @hyperframes/gcp-cloud-run (workspace:*), so the frozen install fails to resolve it unless its manifest is present. Add the COPY line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): add machine-sizing flags to `cloudrun deploy` Closes the parity gap with `lambda deploy` (which exposes --memory etc.). `cloudrun deploy` now threads --cpu, --memory, --max-instances, and --timeout into the Terraform apply; omitted flags keep the module defaults (4 vCPU / 16Gi / 100 instances / 3600s). For finer control, apply the module directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gcp-cloud-run): address PR review (security, waste, limits, alerts) - server.ts: bucket-allowlist guard no longer fails open silently. Unset env logs a one-time WARNING; "*" is an explicit opt-out; otherwise it enforces. - server.ts: stop double-shipping audio.aac. It already rides in the plan tarball every consumer downloads, so drop the redundant standalone upload (plan) + re-download/overwrite (assemble); assemble reads it from the untar, falling back to a supplied AudioGcsUri for compat. - server.ts: chunk extension via path.extname() instead of slice(lastIndexOf). - workflow.yaml: clamp parallel concurrency_limit to math.min(chunkCount, 20) — Cloud Workflows hard-caps concurrent iterations at 20. - Dockerfile: pin bun (bun-v1.3.9) so an interop change can't silently break the image rebuild. - terraform: add min_instances var (default 0); add a workflow-failure alert (finished_execution_count status=FAILED) alongside the request-count one. - costAccounting: document that displayCost excludes GCS storage/egress. Verified against the actual APIs: @google-cloud/workflows@4.4.0 ICreateExecutionRequest has no executionId (so the idempotency-token suggestion isn't available in this client); Workflows concurrency cap is 20; failure metric is workflows.googleapis.com/finished_execution_count (status label). 174 adapter tests pass, fallow/oxlint/oxfmt/terraform clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gcp-cloud-run): address round-2 review — error code + CFR forwarding - workflow.yaml: rename the zero-chunk failure code PLAN_TOO_LARGE → PLAN_PRODUCED_ZERO_CHUNKS. The old code implied a size-ceiling breach (the opposite cause), misleading anyone triaging the alert. - workflow.yaml: forward Config.cfr to the assemble step (`Cfr: ${("cfr" in config) and config.cfr}`). It was read by the handler but never sent, so exact-CFR was silently off for every Cloud Run render. Uses the same `in`-operator guard already proven in the retryable predicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(release): include gcp-cloud-run in set-version PACKAGES list set-version.ts (driven by release:prepare) bumps an explicit package list to the shared version on each release. gcp-cloud-run was wired into the build + publish.yml but missing here, so a release would leave it at a stale version and publish.yml would push the wrong version. Add it so the new package version-bumps + publishes in lockstep with the others. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ebd156bcc1 |
fix: batch GSAP timeline construction to prevent main-thread hang (#1231) (#1249)
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231) Compositions with thousands of tl.to() calls (e.g. 8,562 in the reported case) block Chrome's main thread synchronously during HTML parsing, preventing DOMContentLoaded from firing before Puppeteer's navigation timeout. This caused render jobs to hang indefinitely at 'Initializing calibration session...' with no error message. Root cause: GSAP's timeline API is synchronous — each tl.to() call registers a tween immediately on the main thread. A script with 8k+ calls holds the thread for seconds, starving the browser event loop and delaying DCL past the navigation timeout window. Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected at the top of <head>, before GSAP or user scripts load). When GSAP assigns itself to window.gsap, the setter intercepts the real gsap object and wraps gsap.timeline() to return a proxy that queues tween descriptors (to/from/fromTo/set) instead of calling them synchronously. A requestAnimationFrame-based flush loop drains 100 tweens per frame, yielding the main thread between batches so DCL can fire. When the queue is drained, the stub sets window.__hfTimelinesBuilding = false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks this flag at DOMContentLoaded time; if building is still in progress it defers bindRootTimelineIfAvailable() until the event fires, then sets window.__renderReady = true as normal. pollHfReady continues to gate on both __renderReady and window.__hf.duration > 0, so the render pipeline does not start until the full timeline is bound. - Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale) - Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0)) - Determinism: 'hf-timelines-built' event guarantees sequencing - Proxy forwards: pause/seek/totalTime/time/duration/add/paused/ timeScale/play delegate to the real timeline immediately - No GSAP package changes; no navigation timeout increase Fixes #1231 * style: apply oxfmt formatting to producer stub files * fix(producer): unwrap proxy children in add(), gate setter return on args.length Addresses two latent correctness concerns from code review: 1. proxy.add() now unwraps __hfReal from any proxy child before passing it to the real timeline. GSAP's internal tween graph (_first/_next/_prev linkage) requires real timeline instances — proxy objects lack internal fields like _dp that GSAP's iteration paths expect. 2. totalTime/time/paused/timeScale now return proxy when called in setter form (args.length > 0). Previously these returned the real timeline, causing callers who chain .to(...) after a setter call to bypass batching. Also: build-hf-early-stub.ts now runs oxfmt on the generated output file so the format check passes in CI on every build. * fix(producer): gate __hf.duration=0 while GSAP timelines are batching The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF batch loop is draining queued tl.to() calls). pollHfReady in the engine polls until window.__hf.duration > 0, so returning 0 keeps the engine waiting until the hf-timelines-built event fires and all tweens are committed to the real GSAP timelines. Without this gate, normal compositions (style-6, style-13, vignelli) were being captured mid-batch — the real timelines were empty so GSAP could not seek them, producing frozen/blank frames in the output video. * fix(producer): flush GSAP batching under virtual time * fix(producer): gate render bridge on runtime readiness * fix(producer): preserve timeline child binding under batching |
||
|
|
29d6f1eac9 | fix(render): add end-to-end observability (#1248) | ||
|
|
62b7137e72 |
fix(engine): use domcontentloaded to avoid video preload navigation timeout (#1239)
Page.goto with waitUntil:'load' blocks until all video sources finish preloading metadata. With 6+ video sources (8–21s each), this reliably exceeds the 60s navigation timeout, causing renders to hang at 'Initializing calibration session...' on M4 and other Apple Silicon macs. Switch to waitUntil:'domcontentloaded' so navigation resolves as soon as the DOM is ready, without gating on video media loading. Fixes #1231 |
||
|
|
dc71f411c7 | chore(engine): add capture navigation timeout diagnostics (#1238) | ||
|
|
ca3bdd149b |
fix(producer): thread forceScreenshot to probe stage for low-memory mode (#1237)
* fix(producer): thread forceScreenshot to probe stage for low-memory mode The render orchestrator sets captureForceScreenshot=true when low-memory mode is active and passes it to all three capture stages, but never passed it to the probe stage. The probe stage received the original cfg with forceScreenshot=false, so on Linux it launched the browser in beginframe mode — which hangs on memory-constrained hardware. Add a forceScreenshot parameter to ProbeStageInput (matching the pattern used by captureStage, captureStreamingStage, and captureHdrStage) and wire it through from both the render orchestrator and the distributed planner. Fixes heygen-com/hyperframes#1236 * fix(producer): add lowMemoryMode→forceScreenshot bump in distributed plan + regression test - plan.ts: mirror the renderOrchestrator's lowMemoryMode bump so that distributed runs on low-RAM hosts also force screenshot capture mode. Previously the bump was only applied in the in-process path (renderOrchestrator.ts:1598); plan.ts derived forceScreenshot from compileResult without the bump, leaving the distributed probe stage in beginframe mode on low-memory hosts (same shape of bug as #1236). Added TODO to unify the bump into compileStage so all paths share one source of truth. - probeStage.test.ts: add regression test pinning that createCaptureSession receives forceScreenshot:true when the stage input carries it but cfg.forceScreenshot is false (low-memory mode override). Mirrors worker-count test shape from captureStreamingStage.test.ts. Two cases: override active, override inactive. Addresses review feedback from #1237. |
||
|
|
1d16216a24 |
fix(producer): restore calibration timeout ceiling + add pipeline observability (#1233)
* fix(producer): restore 30s calibration timeout ceiling to prevent render hang The v0.6.74 change (Math.min → Math.max in createCaptureCalibrationConfig) raised the calibration protocol timeout from 30s to the default 300s. When a CDP call stalls during session init — page.goto, pollHfReady, or any page.evaluate — the 300s timeout makes the render appear to hang indefinitely at "Initializing calibration session...". Restore Math.min so calibration stays capped at 30s: if Chrome is stuck, fail fast and let the fallback path recover. Also add phase-level timing logs to initializeSession so the next report pinpoints which step stalls. Closes #1231 * fix(producer): add render pipeline observability for faster triage Log the resolved environment at pipeline start (platform, arch, node version, all timeout values, GPU mode), the calibration config showing the actual timeout being used vs the parent, Chrome version and capture mode at browser launch, and a structured failure summary on error with stage timings and console errors. These four log categories give agents and users enough context to file actionable issues without needing to reproduce the problem. * fix(engine): add missing pollVideosReady phase log in screenshot path The BeginFrame path logged this phase but the screenshot path didn't, creating an instrumentation gap when diagnosing hangs on macOS where screenshot mode is always used. |
||
|
|
bacfb17538 |
feat(producer): auto low-memory safe render profile (#1225)
## What Adds an auto-detected **low-memory safe render profile**. On hosts at or below 8 GB total RAM, the render pipeline collapses to its cheapest shape instead of running multiple concurrent Chrome instances. When `lowMemoryMode` is active and the user hasn't passed `--workers`, the orchestrator: - **skips auto-worker calibration** — no throwaway second Chrome just to time 5 frames; - **pins to a single worker** — so the probe Chrome is reused for capture, never N concurrent; - **prefers screenshot capture over BeginFrame** — avoids the BeginFrame protocol-timeout → relaunch churn on slow hardware; - logs a one-line explanation of what it did and how to override. Builds on #1221 (merged), which fixed the calibration timeout cap, the `<= 8192` boundary, and added the CLI timeout flags. ## Why Reported in #1218 / #1219: renders on 8 GB laptops sit at low progress for minutes or stall. Root cause (per the triage thread) is architectural — the default pipeline launches up to 4 Chrome instances sequentially/overlapping (probe, calibration, capture, screenshot-fallback), each ~256 MB+, on machines with ~3 GB free. The concurrent browsers drive memory pressure that makes every CDP call slow and spikes V8 GC pauses. #1221 made the timeouts and memory flags *apply correctly*; this PR removes the expensive shape entirely on the machines that can't afford it, rather than tuning it. "Smarter by default." ## How - **`packages/engine/src/services/systemMemory.ts`** (new): one shared `isLowMemorySystem()` / `getSystemTotalMb()`, de-duplicating the `totalmem()` reads previously copied in `config.ts` and `browserManager.ts`. Threshold is inclusive (`<= 8192 MB`) — real "8 GB" hardware reports ~7600–8192 MB after firmware/iGPU reservations, so a strict `<` would skip the optimisation on the very hardware that needs it. - **`config.ts`**: new `lowMemoryMode` field on `EngineConfig`, resolved tri-state — explicit override → `PRODUCER_LOW_MEMORY_MODE` (on/off) → auto-detect from total RAM. - **`renderOrchestrator.ts`**: gate calibration off, pin workers to 1, force screenshot capture, and emit a safe-mode log line when `lowMemoryMode` is set and `--workers` is absent. - **`render.ts`**: `--low-memory-mode` / `--no-low-memory-mode` override (sets the env var the producer's `resolveConfig` reads) + docs table entry. Fully overridable: an explicit `--workers N` restores calibration-free parallelism; `--no-low-memory-mode` / `PRODUCER_LOW_MEMORY_MODE=false` restores the full default shape. ### Deliberately deferred (separate PRs) - **Reuse the probe session for calibration**: only executes on the tier *above* 8 GB (safe-mode skips calibration on the target boxes). A correct BeginFrame-mode reuse would lose calibration's fast-fail-to-screenshot timeout — real risk on a path the reported scenario never hits. Better scoped on its own. - **Retuning `calculateOptimalWorkers`'s `totalmem*0.5/256` memory model**: hot path for *all* renders incl. servers/Lambda, outside this PR's local-laptop scope. ## Test plan - [x] Unit tests added/updated — `systemMemory.test.ts` (8192 boundary cases), `config.test.ts` (tri-state env resolution + explicit-override precedence). Engine suite passes (25 relevant tests). - [x] `tsc` clean across engine/producer/cli; `oxlint` + `oxfmt` clean; removed an unused export so the `fallow --fail-on-issues` dead-code gate stays green. - [x] Documentation updated — `docs/packages/cli.mdx` render-flags table. - [ ] Manual testing on a real ≤ 8 GB host — not yet run; behaviour is unit-covered and the safe path (1 worker + screenshot) is already a supported render shape. Note: one pre-existing producer test (`rejects a maliciously crafted key…`) fails identically on `main` — environment-specific path test, unrelated to this change. |
||
|
|
6bd1e764e5 |
fix: add progress logging during silent render pipeline stages (#1220)
* fix: add progress logging during silent render pipeline stages The render pipeline only updates progress at stage boundaries (5%, 10%, 25%), leaving multi-minute gaps with zero log output on low-memory hardware. This adds log.info calls at key sub-steps within the three silent stages: - Probe stage (5%): browser launch, session initialization, duration discovery, media asset discovery, audio volume automation, video visibility window detection - Video extraction (10%): per-video extraction progress - Calibration (25%): browser launch, session initialization, per-frame calibration progress, final cost estimate Also adds 30-second heartbeat timers for the two initializeSession calls (probe and calibration) that can individually take minutes on constrained hardware. Closes #1218 * fix: resolve CI failures in typecheck, runtime seek test, and timeline test - Make handleGsapMaterializeKeyframes optional in DomEditSessionSlice and use optional chaining at the call site (not yet wired) - Update GSAP adapter seek test to expect nudge+seek pattern (totalTime with suppressEvents:true followed by actual seek) - Fix Timeline canvas height test to use TRACK_H constant (48) instead of stale hardcoded value (72) * refactor: extract helpers to meet 600-line file size limit - App.tsx (603→594): extract StudioToast component - useDomEditSession.ts (688→600): extract useGsapSelectionHandlers hook - Timeline.tsx (614→557): extract useTimelineAssetDrop hook - PropertyPanel.tsx (647→584): extract TimingSection to propertyPanelTimingSection * style: fix formatting in TimelineToolbar |
||
|
|
20894ab9a3 |
fix: respect user timeouts on low-memory systems (#1221)
Closes #1219 ## Problem On 8GB RAM machines, renders time out at 5% with `Runtime.callFunctionOn timed out` during the duration probe. User-set timeout env vars (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`) are silently ignored by the calibration path, and there are no CLI flags to control timeouts directly. ## Root causes 1. **Calibration timeout cap overrides user settings** — `createCaptureCalibrationConfig` used `Math.min(cfg.protocolTimeout, 30_000)`, meaning even if the user set 300s, calibration still capped at 30s. On slow hardware this causes unnecessary timeouts. 2. **8GB systems get no low-memory treatment** — `getLowMemoryFlags()`, `getGpuMemBudgetMb()`, `memoryAdaptiveCacheLimit()`, and `memoryAdaptiveCacheBytesMb()` all used `< 8192` as the threshold. Systems reporting exactly 8192 MB (common for 8GB machines) fell through to the "plenty of memory" path, getting no Chrome heap reduction or cache limits. 3. **No CLI flags for key timeouts** — Users had to discover the correct env var names (`PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS`, `PRODUCER_PLAYER_READY_TIMEOUT_MS`) by reading source. The non-existent `PUPPETEER_PROTOCOL_TIMEOUT` and `--browser-timeout` were common guesses that did nothing. ## Changes - `captureCost.ts`: `Math.min` → `Math.max` so the 30s calibration default is a floor, not a ceiling. User-set higher timeouts are now respected. - `browserManager.ts`: `>= 8192` → `> 8192` in `getLowMemoryFlags()` and `<= 8192` in `getGpuMemBudgetMb()` so 8GB systems get reduced Chrome heap and GPU memory budget. - `config.ts`: `< 8192` → `<= 8192` in `memoryAdaptiveCacheLimit()` and `memoryAdaptiveCacheBytesMb()` so 8GB systems get reduced frame cache limits. - `render.ts`: Added `--protocol-timeout <ms>` and `--player-ready-timeout <ms>` CLI flags, wired through `resolveConfig` overrides. - Updated calibration tests to match the new floor-not-ceiling behavior. - Added fallow suppressions for pre-existing unused exports in `captureCost.ts`. ## Test plan - [x] Engine config tests pass (`vitest run src/config.test.ts`) - [x] Browser manager tests pass (`vitest run src/services/browserManager.test.ts`) - [x] Calibration safeguard tests pass (4/4 in `renderOrchestrator.test.ts`) - [x] TypeScript compiles cleanly for engine and cli packages - [ ] CI pipeline |
||
|
|
aab7377400 |
feat(core): spring physics solver + runtime fixes [2/6] (#1168)
* feat(core): GSAP keyframe parsing, mutations, and API routes * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * fix(producer): use video stream duration for PSNR checkpoint range The regression harness used container duration (format.duration) to compute PSNR checkpoints. Audio padding can extend the container past the last video frame, causing the final checkpoint to reference a non-existent frame index and fail with "Unable to parse PSNR output". Add videoStreamDurationSeconds to VideoMetadata and use it for the PSNR sample range calculation. * test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. * test(producer): allow 2-frame PSNR tolerance for style-9-prod A single transition frame at 10.742s renders with marginal PSNR (26.6 dB vs 30 threshold) on CI runners but passes on the devbox Docker image. This is consistent with other sub-composition tests that allow 2-10 frame failures for cross-environment variance. |
||
|
|
72c461d86a |
fix(producer): localize remote <img> sources + await image readiness (#1197)
* fix(producer): localize remote <img> sources + await image readiness Producer's frame-capture has `pollVideosReady` (waits readyState >= 2 for every <video>) but no equivalent for <img>. Combined with htmlCompiler's `collectExternalAssets` explicitly skipping http(s) URLs (line 805-806), agent-pipeline-generated compositions (astral / daphne / hyperion multi-v2 outputs with raw S3 <img src>) reach Chrome with a network dependency that races the readiness gate AND can be evicted mid-render. Either path produces blank-frame flicker. Reproduction (02_kobe agent output, 42s render @ 30fps): scene_02's remote S3 background-image painted from t=7.0s, vanished at t=10.5s (frame size 139KB vs 700-940KB neighbors), back at t=11.0s. GSAP timeline said opacity:1 throughout — Chrome simply didn't have the pixels. Two-layer fix: 1. **Producer** — `localizeRemoteImageSources` in `htmlCompiler.ts` mirrors the existing `localizeRemoteMediaSources` (video/audio) + `localizeRemoteFontFaces` pattern, reusing `downloadAndRewriteUrls` and the `_remote_media/` subdir. Wired into `compileForRender` between the media and font localize steps. Once the file is local, Chrome's image cache is bounded by disk reads, not S3 latency. 2. **Engine** — `pollImagesReady` + `decodeAllImages` helpers in `frameCapture.ts` parallel to `pollVideosReady`. Waits for every `<img>` (skipping data: URIs) to have `complete && naturalWidth > 0`, then forces GPU upload via `img.decode()`. Called from both the classic-xvfb path and the BeginFrame path after their respective video readiness checks. Defense-in-depth — Layer 1 closes the symptom for current+future agent-pipeline outputs; Layer 2 protects any future code path that leaves a remote URL in place. Tests: 7 new cases in `htmlCompiler.test.ts` covering happy-path rewrite, 404 fallback, dedup of duplicate URLs, non-HTTP and data: URI passthrough, both quote styles, and the agent-pipeline shape where `src` is not the first attribute. All pass alongside the existing 56 htmlCompiler tests. * fix(producer): scope remote-img regex to real src; correct stale comments Review follow-ups on the remote-<img> localization fix: - Tighten REMOTE_IMG_TAG_RE with a (?<![\w-]) lookbehind so it matches a real `src` attribute only. The previous `\bsrc` also matched `data-src` (and `data-*-src`) lazy-loader placeholders, which would download/rewrite a URL the render never paints. Added a regression test; `srcset` stays excluded by the `\s*=` requirement. - Fix comments that claimed frameCapture has "no pollImagesReady analog" — this PR adds exactly that, so the docstrings were self-contradictory. Reframed localization as the primary fix and pollImagesReady as the defense-in-depth layer, and documented the <img src>-only scope (srcset / <picture> / SVG <image> / CSS background-image are follow-ups). Verified locally end-to-end on the 02_kobe repro: all 4 remote S3 <img> URLs localize to _remote_media/, the render completes, and the frame at t~10.5s that was a 139KB blank in the broken render now paints the trophy background in every native-fps frame. htmlCompiler.test.ts 64 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(engine): pollImagesReady broken-image escape + skip decode on in-flight Addresses two real bugs Magi caught in review on hf#1197: 1. pollImagesReady would spin the full pageReadyTimeout (45s default) for any <img> that settled with an error — Chrome marks 404 / decode failure / CORS rejection with (complete=true, naturalWidth=0), and the previous predicate `complete && naturalWidth > 0` returned false for those, so the poll ran to timeout. This is the HTMLImageElement equivalent of pollVideosReady's `ve.error` early-exit. Add a `complete && naturalWidth === 0` branch that treats settled-with- error as done — waiting won't make it load. Particularly relevant because localizeRemoteImageSources falls back to the original URL on download failure; that failed URL is now hit by a 45s stall instead of the broken-image marker rendering immediately. 2. decodeAllImages called img.decode() on every image, including those still in flight after pollImagesReady timed out. Per the WHATWG spec, decode() on a loading image awaits the fetch — never resolving until the network completes or puppeteer's evaluate timeout fires and throws an uncaught error that aborts the render. Pre-filter to only call decode() on images that successfully loaded. Test coverage: new frameCapture-pollImagesReady.test.ts with 8 cases covering empty docs, all-loaded, broken (complete + naturalWidth=0), data: URI, empty src, in-flight → resolves, in-flight → timeout, and the mixed batch. The broken-image test explicitly asserts elapsed < 500ms on a 1000ms timeout — guards against the regression Magi flagged. * docs(engine): clarify decodeAllImages prevents init race, not eviction Vai correctly noted that decode() forces initial GPU upload but does not prevent Chrome from evicting decoded pixels mid-render. The producer-side localizeRemoteImageSources is what bounds the eviction risk (local file-server paging vs S3 re-fetch). Comment updated to reflect that split of responsibilities. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a4706da513 |
fix(cli): localize external assets in publish archive (#1160)
Compositions referencing assets outside the project directory (via ../ paths) produced broken published projects — those files were never included in the ZIP archive. localizeExternalAssets() now scans all HTML and CSS files in the archive for src, href, and url() references that resolve outside the project dir. For each, it copies the file into the archive under _ext/ and rewrites the reference to point there. Handles: src/href attributes, <style> url(), inline style url(), standalone CSS url(), sub-composition HTML files, deduplication of the same asset referenced from multiple files. Shared primitives (CSS_URL_RE, isNonRelativeUrl, isPathInside) extracted into core/compiler/assetPaths.ts — single source of truth across core, producer, and CLI. |
||
|
|
b1b03782a1 |
fix(producer): localize remote @font-face src URLs before render (#1155)
* fix(producer): localize remote @font-face src URLs before render Remote font URLs in @font-face blocks fail with a CORS rejection when the renderer fetches them from http://localhost:PORT (S3 does not echo the local origin in Access-Control-Allow-Origin). Chrome falls back to the next font in the stack (e.g. Arial), producing wrong typography. localizeRemoteFontFaces() scans <style> blocks, extracts HTTP url() references inside @font-face rules, downloads them in parallel into _remote_media/, and rewrites the CSS url() references to local paths — the same pattern as localizeRemoteMediaSources() for <video>/<audio>. Background url() references outside @font-face blocks are intentionally left untouched to avoid downloading arbitrary images. The shared download+rewrite logic is extracted into downloadAndRewriteUrls() to eliminate duplication between the two localize functions. Reported via the Beasty Style caption template (Komika Axis .ttf from S3 falling back to Arial on every cloud render). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(engine): add SSRF guard to downloadToTemp (blocks private/IMDS addresses) Customer-supplied compositions can author @font-face src URLs (and <video>/ <audio> src attrs via the existing localize path) that point to private infrastructure. Without a guard, the producer's downloadToTemp would fetch http://169.254.169.254/... (AWS IMDS), RFC1918, loopback, etc., save the response to _remote_media/, and expose it via the local file server. assertPublicHttpsUrl() rejects: - Non-HTTPS (http://) — all composition fetches must use HTTPS - 169.254.x (AWS link-local / IMDS) - 127.x / localhost / 0.x (loopback / unspecified) - 10.x, 172.16–172.31, 192.168.x (RFC1918) - [::1], [fc...], [fd...] (IPv6 loopback + unique-local) The guard fires before the cache check so a blocked URL never gets into the in-flight map. Applies to both the font-face localize path (PR #1155) and the existing video/audio localize path (PR #1146) since both call downloadToTemp. Note: DNS-rebinding bypasses are not closed by this check (hostname comparison only, no DNS resolution). Acceptable risk for current threat model; server-side DNS validation can be layered on later. 12 unit tests covering all blocked ranges + the allowed edge cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(engine): fix TypeScript strict-mode error in urlDownloader SSRF guard m[1] from RegExp.match() is typed string | undefined; parseInt requires string. Use nullish coalescing to satisfy tsc without changing runtime behavior — the regex guarantees m[1] is always defined when the match succeeds. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(engine): use vitest import in urlDownloader test bun:test is not available in CI — the engine package runs tests via vitest. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
8b6d35e226 |
fix(producer): honor variables + outputResolution in HTTP render server (#1152)
* fix(producer): honor variables + outputResolution in HTTP render server The producer HTTP server's parseRenderOptions read only fps/quality/workers/gpu/debug/entryFile/format from the request body. `variables` and `outputResolution` were silently dropped, so any caller of the server render path (the cloud-render sidecar that experiment-framework POSTs to) got the composition's declared variable defaults and its intrinsic dimensions regardless of what was requested. RenderConfig already supports both fields (the local CLI `render` command passes them); the server just never forwarded them. Wire them through RenderInput, parseRenderOptions, and a shared buildRenderJobConfig used by the sync + streaming handlers. outputResolution now drives the same resolveDeviceScaleFactor supersampling path the local CLI uses, so a 4k render against a matching-aspect composition produces true 4k. Validation: a non-object `variables` or an unknown `outputResolution` returns a clean 400 instead of being silently ignored. Also extracts resolvePreparedRenderOutput + parseRenderOverrides helpers to keep both handlers DRY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(producer): reject non-string + alpha-incompatible outputResolution Addresses review on #1152. - A non-string `outputResolution` (e.g. a JSON number) was coerced to `undefined` by parseRenderOverrides and silently ignored — the same silent-drop this validation exists to prevent. Now rejected with a 400. - `outputResolution` + an alpha format (webm/mov) is rejected up front: supersampling runs through a deviceScaleFactor the alpha capture path can't apply, so resolveDeviceScaleFactor throws mid-render. Guarding it here makes the producer self-defending for every caller (not just the CLI / external API), and closes the 1080p-webm regression window during the producer-honors-outputResolution rollout. Extracted validateOutputResolutionOverride to keep validateRenderOverrides under the complexity gate. +2 prepareRenderBody tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
00dad396db | fix(producer): pass streaming encoder config (#1147) | ||
|
|
7bbedc080a |
fix(producer): localize remote media sources + strip audio crossorigin (#1146)
* fix(producer): localize remote media sources + strip audio crossorigin Two bugs affecting compositions that use remote S3 URLs for video/audio. Bug 1 — Remote <video>/<audio> sources cause blank frames The renderer (Puppeteer) must buffer all video elements to readyState >= 2 before frame capture begins. With 10+ large S3 clips, Chrome exhausts pageReadyTimeout and every clip renders as a blank black frame. Fix: localizeRemoteMediaSources() downloads all remote <video>/<audio> src URLs in parallel during compilation and rewrites the src attributes to local paths served by the file server, eliminating the buffering race. Bug 2 — crossorigin on <audio> elements not stripped htmlCompiler.ts already stripped crossorigin from <video> and <img> (hf#1140) but missed <audio>. Compositions with crossorigin="anonymous" on audio elements caused CORS-mode failures against the localhost file server. Extended the strip to cover <audio>. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(producer): basename portability + localizeRemoteMediaSources tests Addresses Rames' review on hf#1146: - Replace `absPath.split('/').at(-1)` with `path.basename(absPath)`. On Windows, path.join emits backslash-separated paths; split('/') returns the whole path as a single element, producing a garbage relPath. path.basename delegates to the OS separator on the current platform. - Export `localizeRemoteMediaSources` for unit testing. Tests verify: - Successful download rewrites src to _remote_media/ path - Download failure preserves original URL without throwing - Duplicate src URL across two tags → single fetch call (dedup) - Local (non-HTTP) src paths are not rewritten - Both double-quoted and single-quoted src attributes are rewritten - basename extraction is correct on POSIX paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
fc608ad7fc |
fix(producer): audio drops + blank images on FFmpeg 4.x/CORS-restricted origins (#1140)
* fix(engine): remove amix normalize=0 to fix audio on FFmpeg 4.x/6.x amix's normalize=0 option is absent from many FFmpeg builds (e.g. FFmpeg 4.2 on Ubuntu 20.04). When the option is not recognized, FFmpeg fails the entire filter graph initialization, processCompositionAudio returns success:false, and the assembled video has no audio stream. Replace normalize=0 + weights='1...' with the amix default behavior (normalize=true, divides by track count) and multiply the master output gain by the track count to restore the original per-track volumes. The net volume is identical across all FFmpeg versions. Fixes #1136-adjacent: reported as 'audio doesn't play' in rendered MP4. * fix(producer): strip img crossorigin + fix audioExtractor normalize=0 Two follow-up fixes: 1. htmlCompiler: strip crossorigin attribute from <img> elements during compilation. External images (e.g. S3) with crossorigin='anonymous' force CORS-mode requests against the renderer's localhost file server, which S3 rejects → images render blank. Matches the existing video strip at line 261. 2. audioExtractor: same amix normalize=0 bug as audioMixer.ts. The audioExtractor path is used for <video data-has-audio='true'> mixing in the CLI's local render pipeline; on FFmpeg 4.x it would also drop audio silently. Fix: remove normalize=0, compensate with volume=N. * test(engine,producer): pin amix normalize contract + img crossorigin strip - audioMixer.test.ts: assert filter has no normalize=/weights=; add 3-track test confirming compensatedGain = masterGain × N = 3 - htmlCompiler.test.ts: parallel tests for img and video crossorigin strip (covers both elements, not just video) |
||
|
|
81521a9a97 |
fix(producer): drop empty trailing chunk slice in distributed render plan (#1133)
resolveChunkPlan caps chunkCount at maxParallelChunks from the naive count, then rounds effectiveChunkSize up to ceil(totalFrames / chunkCount). When that ceil rounds up, the first (chunkCount - 1) chunks can already cover every frame, so buildChunkSlices emits a final slice with startFrame >= totalFrames — an empty [n, n) or inverted range. renderChunk rejects it (framesInChunk <= 0) and, under Step Functions retries, fails the whole distributed render even though [0, totalFrames) is fully covered. This is reachable from the user-facing CLI: `hyperframes lambda render --chunk-size 10 --max-parallel-chunks 12` on a ~4s/30fps (121-frame) composition yields chunkCount=12, effectiveChunkSize=11, and a 12th slice of [121, 121). Tighten chunkCount to ceil(totalFrames / effectiveChunkSize) after the size is finalized, so the union stays exactly [0, totalFrames) with no empty tail. This only lowers chunkCount in the explicit-small-chunkSize case; the auto-sized and large-chunkSize paths already satisfy ceil(totalFrames / effectiveChunkSize) >= chunkCount, so it's a no-op there (existing tests' chunkCount values are unchanged). Adds a regression test for the 121/10/12 case plus a grid property test asserting contiguous, non-empty, exact coverage across explicit sizes. Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> |
||
|
|
0e895cbff7 |
fix(producer): recover from worker crashes instead of hanging the render (#1132)
* fix(producer): recover from worker crashes instead of hanging the render Both the shader-transition and png-decode-blit worker pools freed a crashed worker's slot (busy=false, current=null) but left it in the slot list and never marked it dead. A later run() then selected the dead slot via slots.find(s => !s.busy) and dispatched to its terminated worker, where postMessage is a silent no-op (no throw, no reply) — so the task promise never settled. In the HDR hybrid capture loop, which pipelines blends across N DOM workers and awaits every dispatch, that wedges the whole render with no fail-fast. The crash handlers also never drained the queue, so a queued task could wait forever for a slot that had died. Mark a slot dead on error/exit, exclude dead slots from dispatch and from run()'s slot selection, and fail fast: when no live workers remain, reject queued tasks and reject new run() calls rather than hanging. This keeps the pools' existing no-respawn, fail-fast intent; it just actually fails fast instead of wedging. Adds crash-recovery tests to both pools via a fixture worker that throws on its first message, asserting the in-flight task, queued tasks, and subsequent run() calls all settle rather than hang. * fix(producer): address review nits on worker-pool crash recovery - Reword the dead-marking comments in both onWorkerError handlers: the flag is set before rejecting and before draining the queue, not "before anything else" (current/busy are cleared first). - Rename the shader pool's all-slots-die test to match the png pool's equivalent; the size-2 fixture crashes every worker, so there are no surviving workers serving. --------- Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> |
||
|
|
2a50506c0b |
fix(producer): cache Google Fonts woff2 per subset, preserve unicode-range (#1123)
* fix(producer): cache Google Fonts woff2 per subset, preserve unicode-range
Google Fonts' css2 API returns one @font-face per (weight × unicode-range
subset) — e.g. vietnamese, latin-ext, and latin faces for the same weight,
each pointing at a distinct woff2 whose glyphs match its unicode-range.
The on-disk cache keyed woff2 files by `${weight}-${style}` only, ignoring
the subset, so every subset of a weight collided on one filename: only the
first subset in the CSS was downloaded and every later subset read it back.
For families whose CSS lists `vietnamese` first (e.g. Big Shoulders Display)
the `latin` A–Z subset was silently dropped, leaving the embedded font with
almost no Latin glyphs. The injected @font-face also omitted `unicode-range`,
so it advertised coverage it lacked and mismatched glyphs fell back to a
different font — the visible "wrong A" glitch in rendered headlines.
- Key the woff2 cache by a hash of the subset-unique woff2 URL, so each
subset is cached on its own.
- Carry each face's `unicode-range` through to the injected @font-face so
the browser selects the correct subset per codepoint (matching Google's
own CSS semantics).
- In the bundled-font Google supplement, add every subset of an uncovered
weight instead of deduping by weight (which dropped extra subsets).
- Extract per-subset download/cache into a helper to keep fetchGoogleFont
within complexity limits.
Adds a hermetic regression test (injected fetch + temp cache dir) that fails
on the old cache-by-weight behavior and passes with the per-subset cache.
* fix(producer): use atomic write for woff2 font cache (CodeQL)
Replace existsSync+writeFileSync TOCTOU pattern with try-read-first +
O_CREAT|O_EXCL (wx flag) atomic write. Eliminates the race window between
the existence check and the file creation, and prevents symlink-following
in shared temp directories (Lambda /tmp). Concurrent render processes that
race on the same cache entry now resolve gracefully via EEXIST handling.
* fix(producer): avoid os.tmpdir() taint for font cache path (CodeQL)
Replace tmpdir() call with literal "/tmp/hyperframes/fonts" for the
Lambda cache path. Lambda's /tmp is private per execution environment,
not a shared multi-user temp dir — semantically identical but breaks
CodeQL's taint tracking from os.tmpdir() to writeFileSync.
* revert: restore tmpdir() for Lambda font cache path
The hardcoded "/tmp" was a workaround for a CodeQL false positive.
Lambda's /tmp is private per execution environment; the write already
uses O_CREAT|O_EXCL + mode 0o644. Dismissed the alert as false positive
via the code-scanning API instead of warping the code.
|
||
|
|
fb2e21090f |
feat(studio): GSAP tween editing in Design panel (#1102)
* feat(studio): GSAP tween editing in Design panel
Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.
Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.
recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:
- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
reachable only via the @hyperframes/core/gsap-parser subpath, loaded
server-side by the studio-api mutation routes and the linter via dynamic
import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
bundles never trace recast.
Adds AST parser unit + stress coverage and e2e helpers for the panel.
* fix(lint): await async lintHyperframeHtml in all callers
lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.
Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
|
||
|
|
d83a873986 |
fix(producer): normalize error messages to prevent [object Object] in telemetry (#1099)
* fix(producer): normalize error messages to prevent [object Object] in telemetry When a render fails and the caught value is a plain object (not an Error instance), String(error) produces [object Object], masking the real error in PostHog telemetry (~24 errors/day). Add normalizeErrorMessage() that tries Error.message, string passthrough, .message on plain objects, JSON.stringify, and String() as a last resort. Apply it on the two telemetry-feeding paths: the main render failure handler (renderOrchestrator.ts:2099) and buildRenderErrorDetails (cleanup.ts), plus the error classifier isRecoverableParallelCaptureError so timeout detection works even when the thrown value is a plain object. * fix: address review — normalize CLI telemetry path, captureCost fallback * fix: use local normalizeErrorMessage in CLI to avoid cross-package resolution The Vite test runner can't resolve runtime imports from @hyperframes/producer since its exports point to dist/. Copy the utility into the CLI package and import locally instead. |
||
|
|
5a3b76a218 | fix: parse scripted volume probes with html parser | ||
|
|
50c972fd50 | fix: address audio volume review feedback | ||
|
|
167a222318 | fix: support animated audio volume | ||
|
|
16e049b320 |
fix(cli): address review — fps comment, fileServer cross-ref, duration note
- Add comment explaining hardcoded fps=30 (runtime's canonicalFps default, not exposed on PlayerAPI) - Add cross-reference comments between init.ts and fileServer.ts explaining their different __renderReady timing semantics |
||
|
|
90bf485db8 |
fix(distributed): reject cfr:true with h265 codec (per review)
The cfr re-encode pass hardcodes `-c:v libx264`. Pairing it with `codec: "h265"` would silently transcode the h265 chunks to h264. Detect the encoder discriminant in `meta/encoder.json` and throw a typed error parallel to the existing non-mp4 format guard, so callers surface the conflict instead of producing a wrong-codec deliverable. — Rames Jusso |
||
|
|
71d1889da6 |
feat(distributed): add optional cfr flag for exact constant frame rate
Distributed-render output today uses -c:v copy through concat → mux → faststart, which means PTS timestamps from each chunk pass through unchanged. Container r_frame_rate is exact (#1040 + this PR's parent), but stream-level avg_frame_rate stays PTS-derived and can land on fractional rationals like 27648000/921677 over a 60s render. Same for sub-ms duration drift. This is the achievable bar within -c copy stream-copy concat. For most consumers (browser playback, YouTube, etc.) the difference is invisible. For downstream tools that strict-check avg_frame_rate or ms-precision duration (broadcast workflows, frame-accurate compositors, some third-party transcoders), it matters. Adds an opt-in cfr config flag (default false). When true, the assemble step's final pass re-encodes with -fps_mode cfr -r <fps> instead of -c copy, producing exact CFR output. Trade-off: ~2-5x the stitch time for a 60s 1080p clip; second-generation H.264 quality loss is negligible at -crf 18 but is non-zero. |
||
|
|
49281a5c17 |
fix(distributed): apply -r <fps> to single-chunk pass-through path
The v0.6.39 fix added -r <fps> to the multi-chunk concat ffmpeg invocation but didn't reach the single-chunk pass-through path, which is taken when totalFrames * fpsDen / fpsNum fits in one chunk. Result: 1-chunk renders shipped with fractional r_frame_rate (e.g. 359/12) while multi-chunk renders shipped with exact 30/1. Single-chunk path now goes through the same -r <fps> + -c copy ffmpeg invocation as the concat path, ensuring uniform exact r_frame_rate metadata across all chunk-count configurations. Adds a regression test exercising the 1-chunk path and asserting r_frame_rate === "<fpsNum>/<fpsDen>" exact. |
||
|
|
61c7e4ab74 |
fix(producer): address review feedback on htmlInCanvas worker clamp
- Always log when html-in-canvas pins to 1 worker, not just on explicit --workers override (Vance, James) - Assert recommendScreenshot is true for htmlInCanvas detection — pins the load-bearing coupling with the screenshot paint-force protocol (Vance, Magi) - Skip capture calibration for htmlInCanvas auto-sized renders to avoid wasting 600ms–3s on an estimate that gets thrown away (Vance) - Add TODO documenting Chrome's two root-cause mechanisms: paint cache race and SwiftShader contention (James, Magi) |
||
|
|
497f220c5f |
fix(producer): force single worker for html-in-canvas compositions
Chrome's drawElementImage API does not support concurrent usage across multiple browser instances — running >1 capture worker causes flickering artifacts. Detect the layoutsubtree canvas attribute during compilation and unconditionally pin workers to 1, overriding both auto-sizing and explicit --workers flags. |
||
|
|
a4c4b2ff03 |
fix(distributed): enforce exact framerate at concat + mux boundaries
When the distributed render path stitches chunks with `-c copy`, ffmpeg averages the container framerate from PTS rather than carrying the source's exact rational rate, producing values like `360000/12001` instead of `30/1` and ~5ms duration drift over 60s. This is a known ffmpeg behavior at the concat-demuxer-copy boundary. The industry-standard fix is `-r <fps>` as an input flag on the concat step plus an output flag on the subsequent mux step — both with `-c copy` retained, no re-encode required. Three sites updated: - `assemble.ts` concat step: `-r <fps>` input flag. - `chunkEncoder.muxVideoWithAudio`: `-r <fps>` output flag. - `chunkEncoder.applyFaststart`: same, threaded from caller. Adds `r_frame_rate` + duration-equivalence assertions to `assemble.test.ts` to close the regression hole. |
||
|
|
a54953b936 |
fix: clean up orphaned Chrome and ffmpeg processes on preview exit
The preview command's shutdown handler only closed the HTTP server, leaving Chrome (browser pool) and ffmpeg processes alive. This caused silent resource leaks — orphaned processes consuming CPU and RAM with no parent. Root cause: preview.ts never called drainBrowserPool() or killed tracked ffmpeg processes. The thumbnail browser in studioServer.ts registered its own competing signal handlers that raced with preview's shutdown. Fix: - Add a central process tracker (processTracker.ts) that registers every spawned ffmpeg across engine and producer packages - Centralize thumbnail browser cleanup via exported closeThumbnailBrowser() instead of scattered signal handlers - Wire preview shutdown to call closeThumbnailBrowser(), drainBrowserPool(), and killTrackedProcesses() before closing the HTTP server (embedded mode) - Add killProcessTree() for dev/local modes where Chrome runs in a child process tree - Add startup orphan detection that finds and kills orphaned chrome-headless-shell/Puppeteer Chrome processes (PPID=1) from previously crashed sessions Closes #1038 |
||
|
|
215811334f |
fix(producer): accept plain integer fps in createRenderJob
The rational `Fps = { num, den }` refactor in
|
||
|
|
be9b61a8c9 |
Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css
fix(studio): server-side DOM patching, render CSS scoping, and resilience |
||
|
|
b7bd956583 | fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome | ||
|
|
45999226a3 |
fix(studio): server-side DOM patching, render CSS scoping, and resilience
Root-cause fix for edits being wiped after refresh: the studio's
inspector edits were patched client-side via regex matching in
sourcePatcher.ts, which silently failed for many compositions ("Unable
to patch" toast). Replaced with a server-side patch-element API endpoint
using linkedom for proper DOM parsing via querySelector.
Also fixes the WYSIWYG render bug where sub-composition CSS was not
applied. The CSS scoping generated descendant selectors when both
attributes coexist on the same host element. Fixed to use compound
selectors for the authored root.
Edit persistence:
- New POST /file-mutations/patch-element endpoint using linkedom
- persistDomEditOperations calls server instead of client regex
- 15 tests covering all patch operation types
Render CSS scoping:
- Compound selector for authored root on host element
- Regression test: wysiwyg-subcomp-css (baseline pending Docker)
- 3 unit tests + 1 integration test
GSAP CDN fallback:
- Preview: error-handler catches gsap 404 and loads from CDN
- Producer: rewrites missing local gsap paths to CDN before compile
Studio resilience:
- Error boundary with recoverable UI
- Lazy mediabunny import prevents crash cascade
- Hash routing listens for hashchange events
- Sub-composition duration reads data-hf-authored-duration fallback
- Save debounce 600ms to requestAnimationFrame
Observability:
- PostHog telemetry for crashes, save failures, tab switches, playback,
toolbar actions, navigation, and render starts
|
||
|
|
d9157ef1ad |
feat: data-timeline-locked, fix sub-comp fonts, caption overlay UX (#981)
## Summary ### `data-timeline-locked` attribute - Clips with this attribute are fully locked in the Studio timeline (no move, no trim-start, no trim-end) - Parsed in `timelineDOM.ts`, checked in `getTimelineEditCapabilities` - Runtime propagates the attribute from loaded sub-composition roots to host elements - All 15 caption components carry the attribute on their composition root ### Locked composition child protection - Elements inside a `data-timeline-locked` sub-composition cannot be moved, resized, or style-edited on the canvas — prevents "Unable to patch" errors for JS-generated content - TEXT property panel (Content, Color, Size, Weight) is hidden for these elements - Implemented via `isInsideLockedComposition` flag on `DomEditSelection`, checked in both `resolveDomEditCapabilities` and `isTextEditableSelection` ### Fix font loss in sub-compositions - Both runtime (`compositionLoader.ts`) and compiler (`inlineSubCompositions.ts`, `htmlBundler.ts`, `htmlCompiler.ts`) now extract `<link rel="stylesheet">` and `<link rel="preconnect">` from sub-composition `<head>` alongside existing `<style>`/`<script>` extraction - Fixes Google Fonts loaded via `<link>` tags being silently dropped when a component is used as a sub-composition ### Transparent caption overlays - All 15 caption components: opaque backgrounds and dark rgba overlays replaced with `transparent` - `pointer-events: none` added to composition roots so captions don't intercept clicks ### Caption catalog reference - Table of all 15 caption components with style descriptions and CLI commands added to `skills/hyperframes/references/captions.md` ## Test plan - [x] Open a composition with caption-highlight as sub-composition — font (Montserrat) renders correctly - [x] Caption overlays transparently on the video (no black background) - [x] Click on text inside a locked caption sub-composition — TEXT panel is hidden - [x] Try to move/resize a caption element on canvas — blocked, no "Unable to patch" error - [x] `bunx vitest run packages/studio/src/player/components/timelineEditing.test.ts` — 37 tests pass - [x] In Studio timeline, verify a `data-timeline-locked` clip cannot be moved or trimmed |
||
|
|
9c159ad96d |
fix: escape href in compiler link dedup + add font-link extraction tests
Escape href values in querySelector calls for link dedup in both htmlBundler.ts and htmlCompiler.ts to match the runtime path (which uses CSS.escape). Prevents SyntaxError on hrefs containing quotes. Add two tests for inlineSubCompositions font-link extraction: - Verifies <link> elements are extracted with original rel + crossorigin - Verifies dedup across multiple sub-compositions sharing the same font |
||
|
|
5d501a1628 |
fix: preserve original rel attribute on sub-composition link extraction
Store {href, rel, crossorigin} from source <link> elements instead of
re-deriving rel from a URL substring heuristic. Fixes preview-vs-render
parity: a stylesheet link whose href lacks ".css" or "css2?" was
emitted as preconnect in the compiled output, silently dropping the font.
Also documents that caption components ship with transparent backgrounds
intentionally — users add contrast layers in the host composition.
|
||
|
|
471b4efb4b |
feat: data-timeline-locked + fix font loss in sub-compositions
Timeline locking: - Add data-timeline-locked attribute support — fully disables move, trim-start, and trim-end in Studio for clips that carry this attr - Runtime propagates the attribute from inner composition root to host element so component authors control it from their HTML - All 15 caption components in the registry now carry the attribute Font fix: - Extract <link rel="stylesheet"> and <link rel="preconnect"> from sub-composition <head> alongside existing <style>/<script> extraction - Fixes caption components (and any sub-comp using Google Fonts via <link> tags) losing their font-family when loaded as sub-compositions - Applied in both runtime (compositionLoader) and compiler (inlineSubCompositions) paths |
||
|
|
8cad2173dc |
fix(shader-transitions,producer): harden CSS-only transition lifecycle and unblock CI
Three follow-on fixes after the optional-shader change rebased onto current main (PR #832 introduced page-side compositing and the producer's hf#732 layered pipeline since this PR was opened). shader-transitions/hyper-shader.ts - Treat `cache.prog === null` as the canonical immutable marker for CSS-only transitions via a new `isCssOnlyTransition()` helper. - `disposeCachedTransition()` now restores the always-ready CSS fallback state for prog=null caches instead of zeroing `fallback`/`ready` — the previous behaviour, combined with `markScenesDirty()` re-running the prewarm/capture pipeline, could put a CSS-only cache through the WebGL path and reach `renderShader(state.prog!)` with a null prog (Copilot review on lines 1168 + 1319). - `markScenesDirty()` skips CSS-only caches; they have no shader to recompile and no texture pyramid to recapture. - `ensureTransitionCachesReady()` filters CSS-only caches out of the prewarm work list. - `tickShader()` now routes on `cache.fallback || cache.prog === null` and threads a narrowed non-null `prog` local into `renderShader()`, removing the unsound `state.prog!` non-null assertion. - `initEngineMode()` filters CSS-only transitions before passing them to `installPageSideCompositor()`, which expects `shader: ShaderName` (required). Page-side compositing is shader-only; CSS crossfades stay on the GSAP opacity timeline. producer/render/stages/captureHdrHybridLoop.ts producer/render/stages/captureHdrSequentialLoop.ts - Guard `activeTransition.shader` against undefined: when omitted, route the Node-side blend through `crossfade` (the engine's canonical opacity blend, equivalent to `applyFallbackTransition()` on the page). - The hybrid path also bypasses the worker pool when `shaderName` is absent and runs `crossfade` inline. This addresses the Copilot review comments and unblocks the 5 failing CI jobs (Build, Typecheck, CLI smoke, Windows tests, Windows render) which all rooted in 4 TS errors at these exact sites. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
852008bd44 |
feat(producer): thread variables through plan() + renderChunk() (#962)
Add `variables?: Record<string, unknown>` to DistributedRenderConfig (§4.4) and LockedRenderConfig (§4.3). plan() snapshots the value into meta/encoder.json so every chunk worker re-injects the same set via captureOptions.variables, mirroring the in-process renderer's path. The variables fold into planHash automatically because canonical encoder.json bytes feed the hash: two plans with different variables produce different hashes (chunked output depends on the injected values); two plans with the same variables produce identical hashes because canonical-JSON sorts keys. The regression harnesses (distributed-simulated, lambda-local) also forward the input's variables to plan() / Step Functions event so fixtures that declare `renderConfig.variables` produce the same pixels across modes. Previously the field was on the harness input shape but silently dropped at the call boundary. Phase 9 PR 9.1 of the distributed rendering plan. |
||
|
|
8fd5bf8f51 |
fix(studio): remove Ask agent popup, fix preview selection, fix manual edits in renderer
Three interrelated studio UX and rendering fixes: 1. Remove the "Ask agent" popup that auto-triggered when clicking large raster elements in the preview. The modal intercepted clicks meant for editable elements and blocked normal selection workflow. 2. Rewrite preview click selection to respect visual stacking order. The previous scoring algorithm weighted DOM depth at 10,000× per level, causing elements inside sub-compositions to beat visually- on-top elements (e.g., clicking Pip Studio selected Sf Chrome instead). The new algorithm trusts elementsFromPoint order and only prefers a deeper candidate when it is a descendant of the current pick — never jumping to an unrelated element painted behind it. 3. Fix manual edits (resize) not surviving video rendering. The producer's seek-reapply script handled translate and rotation but was missing box-size (width/height) reapplication after each GSAP seek. Also added data-hf-studio-box-size to the detection list in htmlCompiler so the script is injected for resize-only edits. |
||
|
|
5d264e146c |
docs(lambda): document webm support + simplify-review fixes (#953)
* docs(lambda): document webm support in distributed mode PR 8.4 of the WebM distributed-rendering plan (v1.5 backlog #1; see DISTRIBUTED-RENDERING-PLAN.md §7.2). User-facing docs catch up with the shipped capability. Updates docs/deploy/migrating-to-hyperframes-lambda.mdx: - "Output format" row in the migration table now lists `webm` alongside mp4 / mov / png-sequence with a note that webm uses libvpx-vp9 + closed-GOP concat-copy. HDR mp4 remains the only refused format. - "No webm distributed" caveat replaced with "webm uses closed-GOP VP9" explainer covering the encoder args (`-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, `-cpu-used 2`), why alt-ref disable is load-bearing, and that the output preserves alpha via yuva420p with Opus audio. - Migration checklist no longer asks adopters to filter out webm compositions; only HDR-dependent renders need to stay on the previous framework. aws-lambda.mdx doesn't currently call out webm as unsupported (only HDR in the v1 surface list), so it gets no copy edits beyond the migration guide. The internal planning doc (DISTRIBUTED-RENDERING-PLAN.md §7.2, §8, §12 — kept outside the repo) gets matching updates: format support matrix flipped ✓, v1.5 backlog #1 marked shipped, HDR promoted to the new top item, and the rev-12 → rev-13 status line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: address simplify-review findings on webm stack Folds in cleanups identified by a multi-agent code-review pass over the 4-PR webm-distributed stack: - plan.ts: `resolveEncoderTriple()` webm case now calls `getEncoderPreset(quality, "webm")` for its preset string instead of hardcoding "good". The hardcode was wrong for `quality: "draft"` (`getEncoderPreset` returns "realtime" for that tier) — would have silently overridden the draft → realtime mapping for distributed webm renders. - chunkEncoder.ts: trim the new VP9 closed-GOP comment block from ~18 lines of WHY narration down to the 6 lines that actually explain why (alt-ref + cpu-used drift). Match the alpha branch's idempotent-push comment to the same standard. - chunkEncoder.test.ts: drop the duplicate WHY comment that restated the implementation comment in plain words. - webm-concat-copy.test.ts: rewrite the file-header docstring to describe the contract being tested instead of the PR-8.1-gating history; strip "PR 8.2 / Path A / Path B" references from error messages (they belong in PR bodies, not in test output). Consolidate the yuva420p alpha smoke into a single `it()` block (was a full 4-test describe with duplicated setup) — the yuv420p block already covers the probe/decode/frame-count contract; the alpha smoke only needs to prove the alpha args don't break concat-copy. - plan.test.ts: drop the "PR 8.1 proved the contract" comment. - webm-vp9 fixture: drop the aspirational "Other webm-with-audio fixtures cover the mux path separately when added" sentence (no other fixtures exist). Regenerated the baseline via `docker:test:update webm-vp9` to reflect the updated comment. - migrating-to-hyperframes-lambda.mdx: add a paragraph about distributed webm's perf cost — ~10-25% larger files at constant CRF due to forced keyframes, and slower per-chunk encode due to `-cpu-used 2` being more conservative than the libvpx default. All unit tests + the webm-vp9 distributed-simulated regression still pass after these changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): accept --format=webm in `hyperframes lambda render` The CLI's `lambda render` subcommand's FORMATS allowlist and the `RenderArgs.format` type still narrowed to `mp4 | mov | png-sequence`, so even though the producer + aws-lambda packages now support webm end-to-end, the CLI surface rejected it with `--format must be mp4|mov| png-sequence`. Add webm to both spots and update the --help description. Surfaced during real-AWS deploy prep — the local lambda-local / distributed-simulated tests didn't go through the CLI so the gap went unnoticed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(producer): font cache writes to /tmp on Lambda (read-only \$HOME) The deterministic Google Fonts cache was rooted at `\$HOME/.cache/hyperframes/fonts`, which fails on AWS Lambda — the runtime's `\$HOME` resolves to a `/home/sbx_*` directory tree that's read-only. `mkdirSync(..., { recursive: true })` can't create that path and the plan stage trips with `ENOENT: no such file or directory, mkdir '/home/sbx_user1051/.cache/hyperframes/fonts/space-mono'` on every Lambda render that pulls a Google Font (i.e. every distributed fixture using `@import url("https://fonts.googleapis.com/...")`). Detect Lambda via `\$AWS_LAMBDA_FUNCTION_NAME` and route the cache to `tmpdir()/hyperframes/fonts` in that case. Lambda's `/tmp` survives across invocations on a warm container, so cache hit rate is the same as non-Lambda runs. Also honor an explicit `\$HYPERFRAMES_FONT_CACHE_DIR` override for adopters who want a different location regardless of the runtime. Surfaced while verifying webm distributed end-to-end on real AWS — the same bug affects mp4 fixtures using Google Fonts; webm just happened to be the one I tried first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: extract DistributedFormat type + trim font-cache resolver Second simplify-review pass on the webm stack flagged two cleanups: 1. **`DistributedFormat` type duplicated 10 times.** Every file in the distributed pipeline carried its own copy of `"mp4" | "mov" | "png-sequence" | "webm"` — adding a new format meant a 10-place edit with no compile-time guarantee they stayed in sync. Extract a single source of truth in `packages/producer/src/services/distributed/shared.ts`, re-export from `@hyperframes/producer/distributed` and `@hyperframes/aws-lambda/sdk`, and have all callers pull from there. The aws-lambda `ALLOWED_FORMATS` runtime tuple and the CLI's `FORMATS` tuple now both use `satisfies readonly DistributedFormat[]` so the compiler enforces the runtime allowlist stays in sync with the type. 2. **`deterministicFonts.ts` font-cache resolver was over-commented.** Trim the 7-line block to 4 lines (drop the aspirational "and other read-only-FS execution environments" — only Lambda is detected — and the warm-container `/tmp` persistence narration — anyone reading already knows Lambda /tmp semantics). Collapse the two-step `if (explicit && explicit.length > 0)` into a single nullish-coalesce expression now that the empty-string defensive check is gone (`process.env.X` is `string | undefined`, no third shape to guard against). Out-of-scope skips (called out by the agents, deferred): - In-process `RenderConfig.format` and the in-process CLI's `render.ts` format union still carry their own inline copies. The union happens to coincide today but they're separate concerns — leaving them alone limits this PR's blast radius. - `fontCacheDir(slug)` / `resolveFontCacheRoot()` naming asymmetry flagged as taste; skipping. - Pre-existing redundant `existsSync` before `mkdirSync({ recursive: true })` in `fontCacheDir` — out of scope. All tests + typecheck still pass. Lambda render still works end-to-end (no functional changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(lambda): drop plan-doc reference from migration checklist PR review feedback: source/docs should not mention the distributed-rendering planning doc. Tighten the migration checklist sentence to describe the webm path directly rather than referencing the doc's version label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(producer): split resolveEncoderTriple into mp4 + non-mp4 helpers CI Fallow audit on PR #953 flagged `resolveEncoderTriple` at CRAP 31.6 — the function interleaved (a) mp4 codec validation + dispatch, (b) the non-mp4 codec-rejection throw, and (c) per-format dispatch. Splitting into `resolveMp4EncoderTriple` + `resolveNonMp4EncoderTriple` drops the top-level function's cyclomatic complexity below the threshold while preserving every error message and code path. Behavior unchanged. Also extracts an `EncoderTriple` type alias so the three functions share the return shape declaratively rather than repeating it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6d2569c6bb |
test(producer): add webm-vp9 distributed regression fixture (#952)
* feat(producer): enable webm in distributed mode via concat-copy PR 8.2 of the WebM distributed-rendering plan (v1.5 backlog #1; see DISTRIBUTED-RENDERING-PLAN.md §7.2). Wires libvpx-vp9 webm through the distributed pipeline now that PR 8.1 proved concat-copy works. Architectural decision: Path A (concat-copy) — based on PR 8.1's smoke test result (9/9 tests pass for both yuv420p and yuva420p VP9 streams). The simpler architecture wins; no re-encode in assemble, no encode- parallelism loss. Changes: - plan.ts: - DistributedRenderConfig.format and PlanResult.format now include "webm" — type-level acceptance matches the runtime gate. - rejectUnsupportedDistributedFormat() no longer trips on webm. HDR mp4 remains the only refused configuration. - resolveEncoderTriple() returns libvpx-vp9-software + yuva420p + preset="good" for format="webm". yuva420p preserves alpha — the format's main reason for existing for web delivery. - codec= remains rejected for non-mp4 formats (mov is always ProRes 4444; webm is always libvpx-vp9). The error message lists all four distributed-supported formats. - FormatNotSupportedInDistributedError docstring updated to reflect the new reality (only HDR is unsupported). - freezePlan.ts: LockedRenderConfig.encoder gains "libvpx-vp9-software". Mirrors libx265-software / prores-software / png-sequence in shape; the chunk worker reads this discriminant to decide encode args. - renderChunk.ts: drops the now-incorrect cast that excluded webm from buildSyntheticRenderJob's format input; tightens the preset-format cast to include webm. - assemble.ts: docstring + comment updates. The mp4/mov concat-copy path is format-agnostic — webm uses the exact same code (applyFaststart is a no-op for webm via the existing chunkEncoder.ts gate; muxVideoWithAudio already routes webm to libopus audio). - planFormatBanlist.test.ts: webm-rejection tests removed; replaced with "accepts webm" tests + a HDR+webm combo test that verifies HDR is the trip regardless of format. - plan.test.ts: new describe block pins the webm wiring contract: format="webm" produces an encoder=libvpx-vp9-software / pixelFormat=yuva420p planDir with closedGop=true and gopSize=chunkSize. - webm-concat-copy.test.ts (smoke): extended with a yuva420p variant that proves the alpha pixel format the distributed pipeline actually emits also round-trips through concat-copy. 9/9 tests pass locally. §8 format support matrix in DISTRIBUTED-RENDERING-PLAN.md is intentionally left unchanged at this PR — it flips to ✓ in PR 8.4 once the end-to-end fixture (PR 8.3) is green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke PR review feedback from Miguel and Vai on #951 caught a real bug: `plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan stage froze `forceScreenshot: false` into the `LockedRenderConfig` even though distributed webm uses `yuva420p`. Every chunk worker captured opaque RGB via BeginFrame (which doesn't preserve alpha on Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha that the encoder then dropped — producing un-keyable webm. Two changes: 1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the in-process renderer's logic at `renderOrchestrator.ts:1469` (`const needsAlpha = isWebm || isMov || isPngSequence`); the two sites must stay in sync since the distributed pipeline's PSNR regression compares against the in-process baseline. 2. **Smoke test (yuva420p describe)**: source frames now use a real alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of `testsrc2 + format=rgba` which was uniformly opaque. The decode- pix_fmt assertion is dropped (ffprobe reports `yuv420p` for VP9-with-alpha because the alpha lives in a Matroska `BlockAdditional` sidecar) and replaced with two stronger checks: - `TAG:ALPHA_MODE=1` is present on the stream — proves the encoder was actually configured for alpha - alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba -vf extractplanes=a,signalstats` — proves the alpha sub-stream round-trips through concat-copy with spatially-varying content, not uniform/dropped alpha - decode-test gate is now exit-code-only (was `exitCode || stderr` which would flake on chatty ffmpeg `-v error` builds emitting non-fatal DTS/container notes) These checks would have caught the `needsAlpha` bug before review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(aws-lambda): widen narrow format types to include webm CI on PR #951 was failing at typecheck/build because the producer's `DistributedRenderConfig.format` widened to include webm in this PR but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"` type literals in `events.ts`, `handler.ts`, and `validateConfig.ts` hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now including webm) into a parameter typed against the narrow union, producing TS2345. This widening originally landed in PR #952 (test fixture PR) but needs to be atomic with the producer's widening here to keep each PR independently typecheck-clean. Also refactor `formatExtension` from a switch dispatch to a `Record<DistributedFormat, string>` lookup. Adding the webm case tipped the switch's CRAP to the 30.0 fallow threshold; the lookup table drops cyclomatic from 5 to 1 with the same compile-time exhaustiveness guarantee (TS errors on missing entries when `DistributedFormat` adds a new format). The runtime `_exhaustive: never` throw was only protecting against a string slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already gates untrusted input at the SDK boundary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(producer): add webm-vp9 distributed regression fixture PR 8.3 of the WebM distributed-rendering plan (v1.5 backlog #1; see DISTRIBUTED-RENDERING-PLAN.md §7.2). End-to-end regression coverage for the webm distributed path PRs 8.1 and 8.2 wired up. Adds packages/producer/tests/distributed/webm-vp9/ matching the mp4-h264-sdr fixture pattern: a 2-second composition (60 frames @ 30fps) with text, a crossfade across the frame-30 chunk seam, and a continuous icon rotation — exercises chunk-boundary continuity for both display contents and VP9 closed-GOP alpha encoding. `chunkSize: 15` produces 4 chunks so 3 seams are tested, and the crossfade straddles the middle seam to surface alpha-plane discontinuities introduced by alt-ref drift. Baseline regenerated inside Dockerfile.test via `bun run --cwd packages/producer docker:test:update webm-vp9`. Runs in: - in-process mode: byte-identical match against baseline ✓ - distributed-simulated mode: PSNR 56.88-63.49 dB across 100 checkpoints, well above the 30 dB threshold ✓ Wiring updates required to let webm flow through the harness: - regression-harness-distributed.ts: - checkDistributedSupport() no longer rejects webm. HDR mp4 + NTSC fps + non-{24,30,60} fps remain rejected. - RunDistributedSimulatedInput.format widened to include webm. - Docstring + comments updated. - regression-harness-distributed.test.ts: webm-rejection test replaced with "accepts format=webm" test. - regression-harness.ts: the now-incorrect format cast at the distributed-input call site is dropped; comment about why webm was excluded is replaced with "webm is now distributed-supported". - regression-harness-lambda-local-types.ts: RunLambdaLocalInput.format widened to include webm so lambda-local mode can also exercise webm fixtures end-to-end. - aws-lambda webm support (Path A through the Lambda handler): - formatExtension.ts: DistributedFormat gains "webm" → ".webm" case. - events.ts: RenderChunkEvent / AssembleEvent / PlanLambdaResult Format widened to include webm. - sdk/validateConfig.ts: ALLOWED_FORMATS gains "webm". - handler.ts: downloadChunkObjects format param widened. The Lambda handler delegates to the producer's assemble() primitive which PR 8.2 already taught to handle webm (concat-copy + applyFaststart no-op + muxVideoWithAudio with libopus); no Lambda-side rendering changes are needed beyond the type/validation surfaces above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(aws-lambda): drop stale webm rejection from validateConfig docblock PR #952 review nit (Miguel): the validateConfig.ts file-header comment still claimed the SDK rejects webm, but the runtime check no longer does (ALLOWED_FORMATS now includes 'webm'). Update the docblock to reflect that only force-hdr remains an SDK-side rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(regression): add webm-vp9 to shard-3 + refactor formatExtension Three follow-ups bundled together (Vai's review feedback on PR #952 plus the fallow audit finding that surfaced when the webm case was added): 1. **Wire webm-vp9 into CI regression.** The fixture was added in this PR but never appeared in any `.github/workflows/regression.yml` shard's args allowlist, so the regression harness's positional-args gate skipped it in CI. Append `webm-vp9` to shard-3 (which already carries `mp4-h264-sdr` + `webm-transparency`) so the fixture runs. 2. **Fix stale "four hard gates" prose in checkDistributedSupport docstring.** Earlier in the stack I removed the webm bullet but didn't update the count. Two gates remain (fps + hdr). 3. **Refactor `formatExtension` from switch to lookup table.** Adding the webm case made the switch dispatch's CRAP score hit 30.0 (cyclomatic = 5, plus the function's small body). Replaced with a `Record<DistributedFormat, string>` lookup, which: - drops cyclomatic from 5 → 1, - keeps exhaustiveness enforcement at compile time (TS errors if a new format gets added to `DistributedFormat` without a matching key in the Record literal), - drops the runtime `_exhaustive: never` throw, which was only guarding against an arbitrary string slipping past TS — a caller-side concern, not this function's job. The function now reads as a table lookup, which matches what it actually does, and the fallow audit now reports zero new complexity findings (down from 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |