* docs(skills): make captions non-optional in changelog-video
The Jul 13-20 build shipped without captions because the pipeline had two soft
failure modes:
- Empty vo-words.json from the TTS provider was silently accepted, so
align-captions.mjs had nothing to align. captions.json was never produced.
- Step 5 tolerated the master-skeleton's LINES array staying empty. The build
lint-checked and validated clean, but the caption rail rendered no text.
This adds three hard gates on top of the existing scaffold:
- Step 4 adds a whisper forced-alignment fallback so a missing TTS timestamp
payload no longer breaks the caption pipeline. Whisper only supplies timings;
captions still use the DISPLAY layer from script-tokens.json.
- Step 5 flags an empty LINES array as a shipped bug and shows the exact
IIFE-input shape it expects.
- Step 6 adds gate 5: sample 3-4 frames across the VO window and confirm visible
caption text on each. If any spoken window renders no caption, the build is
red.
Anti-patterns table gets two rows covering the empty-LINES and
missing-word-timings failure modes. .claude and .agents mirrors stay
byte-identical.
Follow-up to #2669 (pre-build gate); paired with the v5 rebuild that
retrofitted captions onto the shipped Jul 13-20 video.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): bump changelog caption size to 32px for phone-screen legibility
25px reads too small at 1080² when the video is viewed on a phone without
zoom. Bumped to 32px (font-size 25→32, top 1002→990, height 40→52, alpha
.92→.94) so captions carry across small-screen playback. Change is CSS-only
on the master-skeleton; existing per-scene chrome and layouts are untouched.
Retrofitted the Jul 13-20 v6 build with the same values (identical CSS
block) so the shipped video and the future default match. .claude and
.agents mirrors stay byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): sync caption position refs to top: 990 across SKILL.md + build-spec
Miga caught: the master-skeleton bump to top: 990 / 32px / height: 52 was
not reflected in the SKILL.md prose or build-spec.md. All three files now
name the same values so the doc, gate, and code stay consistent. Also
clarifies build-spec.md's caption-rail line to point at SKILL.md step 5 as
the source of the LINES-populated requirement.
.claude and .agents mirrors byte-identical.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(skills): sync script-voice.md caption rail to top: 990 / 32px
Third and final stale reference to top: 1002 / 25px — script-voice.md was
still stating the pre-bump values. Now matches master-skeleton.html,
build-spec.md, and SKILL.md. Also bumps the 'keep critical small text out'
guidance from ~80px to ~100px to reflect the slightly taller caption rail.
Full-directory sweep confirms zero remaining occurrences of 1002 or 25px
across .claude/ and .agents/ skill mirrors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs(changelog): weekly digest 2026-07-13–2026-07-20
* docs(changelog): embed the weekly video in the Jul 13-20 digest
Adds the Jul 13-20 changelog video (produced via the changelog-video
skill, hosted at static.heygen.ai) at the top of the Update block using
Mintlify's <Frame> component with a native <video controls>.
Video URL: https://static.heygen.ai/hyperframes/changelog-videos/weekly-changelog-jul13-20.mp4
Slack thread: C0ACCNHLG3U · 1784565857.520599
Adds an explicit Step 0 above the pipeline that spells out the concrete file
copies (fonts, bgm, ffmpeg-encoded bg-pattern, master-skeleton.html) before
any composition HTML is written. Extends the anti-patterns table with the
three failure modes James caught on the Jul 13-20 weekly run: starting from
a prior video's index.html, hand-crafting @font-face / WebGL shader / custom
BGM, and shipping without a CloudFront invalidation after the S3 replace.
Why: the previous SKILL.md said "follow references/build-spec.md exactly" in
step 5, but that lets an agent read the build-spec after already writing
composition HTML on the wrong scaffold. Ships of the Jul 13-20 video went
through three off-brand iterations before I finally started from
master-skeleton.html (Jake called v3 "trash" — the skill's assets, fonts,
and scaffold ARE the brand, not the SKILL.md prompt on its own).
The three added anti-patterns are the concrete traps: agents will keep
starting from prior-project templates unless the skill explicitly bans it.
Mirrors to .agents/skills/ (Codex CLI) and .claude/skills/ (Claude Code)
byte-identical, per the skill-mirror gate.
Auto-generated weekly digest (bun run changelog:weekly), hand-cleaned to
publish quality. Grouped into Features / Fixes / Performance / Docs &
Examples with readable one-liners and commit + PR links.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.
Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor
Layout:
- .claude/skills/ - Claude Code project-local auto-discover
- .agents/skills/ - Codex CLI project-local auto-discover (verified via
Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)
Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.
.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.
CI:
- Extends changes.skills filter to match .claude/skills/**,
.agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
lint-skills.ts (schema-driven; required { name, description } + optional
{ license, allowed-tools, metadata }, name pattern check, description
length check) plus a new check-skill-mirror.mjs byte-integrity script
(24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.
Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
(non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.
Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).
Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.
Co-authored-by: Jake Moran <jake@heygen.com>
`hyperframes lambda deploy` runs `sam deploy --resolve-s3`, and SAM's
managed artifacts bucket (aws-sam-cli-managed-default) is created with
default SSE encryption. Setting that requires s3:PutEncryptionConfiguration,
which the generated deploy policy did not grant, so a first deploy by a
user provisioned exactly per `lambda policies user` 403s on the bucket and
the managed stack rolls back.
Add s3:GetEncryptionConfiguration and s3:PutEncryptionConfiguration to the
s3Bucket action set (Get pairs with Put for CloudFormation update/drift
reads, matching the existing Get/Put pairs in the list). Also add a hint to
the sam-deploy failure path pointing at the ROLLBACK_COMPLETE recovery step,
since first-time users hit the stuck-rollback error on their retry.
Fixes#2137
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ninth PR of the template-variables stack: the promote-a-property gesture.
Select an element on the canvas/timeline, open the Variables tab, and the
panel offers per-property bind actions.
- "Bind selected" card in the Variables panel, built from the selection:
image/media source (img/video/audio), text, text color, background, and
font. Each action declares a variable whose default is the element's
CURRENT value (promoting never changes the render — computed rgb colors
convert to hex, the first computed font family becomes the font default)
and writes the declarative binding the runtime resolves: data-var-src /
data-var-text attributes or `<prop>: var(--id)` styles. Declare + bind
run as one batched schema edit (one undo step); binding to an
already-declared id skips the declare and just binds.
- guarded to selections from the composition the session models — a
selection in another source file never writes bindings into this one.
- core: extract readVariablesForElement into runtime/variableScope.ts,
shared by color grading and the declarative bindings (was duplicated).
- fix(studio-server): buildSubCompositionHtml's extractElementAttrs
rebuilt html/body attributes without HTML-escaping values, shredding
quote-bearing attributes — data-composition-variables (a JSON array)
came out as mangled bogus attributes, so getVariables() silently
returned {} on every /preview/comp/* page (no declared defaults, no
runtime bindings). Pre-existing bug surfaced by live-testing this
feature; regression test added.
Verified end-to-end in a live session: select headline → Bind text color
→ declaration + var(--headline-color) written to disk → override in the
panel → runtime applies the custom prop and the element renders the
override.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sixth PR of the template-variables Studio stack — closing the loop from
preview to render to developer handoff.
- renders started from the Renders tab now carry the active preview
variable overrides (StartRenderOptions.variables → POST /render →
RenderConfig.variables), so "render" produces exactly what the user is
previewing.
- Variables panel "Use this template" footer: copy the effective values
(defaults merged with overrides) as JSON, or as a ready-to-run
`npx hyperframes render <comp> --variables '<json>'` command.
- gitignore: negate the renders/ output rule for the tracked
src/components/renders/ source dir — without it, pre-commit's format
re-stage (`git add {staged_files}`) hard-fails on any change to those
files.
- docs: the Studio panel docs/concepts/variables.mdx described was
aspirational — replace with the real Variables-in-Studio section
(declare/edit, render-truthful preview, render-with-values, handoff,
usage badges); document the new SDK variable APIs in
docs/sdk/reference/composition.mdx (declaration ops, read APIs,
setPreviewVariables).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fourth PR of the template-variables Studio stack — the HTTP plumbing.
- preview routes (/preview and /preview/comp/*) accept
?variables=<url-encoded json> and inject
`window.__hfVariables = {...}` into <head>, before the runtime and any
composition script — the exact global the engine sets via
evaluateOnNewDocument at render time, so preview-with-values cannot
diverge from render output. Values are escaped against </script>
breakout, malformed payloads 400 instead of silently previewing
defaults, and the ETag is salted with a hash of the payload so cached
previews revalidate when values change.
- POST /projects/:id/render accepts variables ({variableId: value}) and
forwards them through StudioApiAdapter.startRender into the producer's
RenderConfig.variables — the same channel `hyperframes render
--variables` uses. Wired in both adapters (CLI embedded server + vite
dev adapter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): bump @puppeteer/browsers to ^3.0.6 to fix render hang on node >=24.16
`hyperframes render` (and `browser ensure --force`) hangs forever during
Chrome provisioning on Node >= 24.16 (repro'd on macOS arm64 / Node 26.5.0;
fine on Node 22). Root cause is a transitive extractor bug, not our logic:
@puppeteer/browsers@2.13.x install()
-> extract-zip@2.0.1 -> yauzl@2.10.0
A classic-stream backpressure regression (nodejs/node#63487, works 24.15,
breaks 24.16+) surfaces a latent fd-slicer destroy() bug in yauzl 2.x
(yauzl#169). The inflate read stream stalls partway through the first entry
large enough to cross the write highWaterMark (chrome-headless-shell's
1.86MB LICENSE.headless_shell, stalls at ~1.31MB), never emits `end`, so
stream.pipeline never settles and extraction busy-spins. The half-extracted
cache has no executable, so every later render re-enters
"Cached binary missing -> re-download" and hangs again (puppeteer#14957).
Fix: @puppeteer/browsers 3.0.2 dropped extract-zip/yauzl entirely (now uses
modern-tar). Verified 3.0.6 extracts chrome-headless-shell cleanly under
Node 26.5.0 and keeps the full API manager.ts uses (install,
getInstalledBrowsers, Cache, computeExecutablePath, detectBrowserPlatform,
Browser) with an identical on-disk cache layout. Cross-platform (the same
.zip/yauzl path affected Linux + Windows too).
Adds a regression guard asserting the pin stays on the extractor-free
major (>= 3) and never reintroduces extract-zip/yauzl.
Fixes#2103
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(cli): clarify extractor-guard wording — yauzl is an optional peer, not dropped entirely
Review note on #2104: @puppeteer/browsers 3.0.6 keeps yauzl as an optional
peer fallback (default extractor is modern-tar), so the regression-guard
comment + it-text shouldn't say it was 'dropped entirely'. Test assertions
(extract-zip + yauzl absent from `dependencies`) unchanged and correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2066 fixed sub-composition data-variable-values on the render path for a single
mount, but the reusable-template pattern from #2064 (the same sub-comp mounted
multiple times with different values) still diverged from preview/snapshot:
every mount shared one __hfVariablesByComp key and one CSS scope selector, so
the last mount's values clobbered the earlier ones and all-but-one instance
rendered blank.
The producer now assigns per-instance runtime composition ids
(assignBundledRuntimeCompositionIds) and threads hostIdentityMap into the shared
inliner, mirroring the preview bundler. The shared inliner's default
buildScopeSelector already scopes by the runtime id, and timelines remap to it
via the scoping proxy, so each instance's variables, CSS, and timeline land
under its own id.
Pixel-verified end to end: two mounts of one sub-comp with different
data-variable-values now render their own content (green CARD_A / blue CARD_B),
matching snapshot; single-instance behavior is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Modal as a third official deployment template alongside Vercel and
Cloudflare: comparison-table row, a Modal tab (deploy commands, what-you-get,
performance, pricing, async spawned-function note), architecture/pre-baking
updates, swap-the-composition steps, and a source card. Update the cloud.mdx
cross-reference to include Modal.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cli/telemetry): surface unrecognized agents in the agent_runtime=null bucket
agent_runtime is a closed allowlist: an agent we have no rule for collapses
to null with no trace of what it was, so ~18% of CLI users are unattributable
and new agents stay invisible until reverse-engineered by hand.
Add detectAgentHints(), a self-populating residual signal computed only for
the null bucket (gated off classified events):
- agent_hint: value of AGENT / AI_AGENT (the emerging self-identification
convention; Crush and Goose set AGENT=<name>) — names agents the allowlist
misses.
- term_program: raw TERM_PROGRAM (editor name) — catches the IDE-terminal
class the same way the cursor/windsurf rules do.
- agent_env_hints: sorted, comma-joined "agent-ish" env-var KEY names present
but matched by no vendor rule — a fingerprint that clusters by agent.
Privacy stays consistent with the existing "never read secret-shaped values"
stance: agent_env_hints emits key names only; the three value-reads are vars
whose sole purpose is non-secret identification, each passed through a strict
short-slug allowlist so anything long/spaced/secret-shaped is dropped.
Breaking down agent_hint / agent_env_hints filtered to agent_runtime IS NULL
AND is_tty=false gives a ranked leaderboard of new agents to promote into
VENDOR_RULES.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli/telemetry): guard agent_hint/term_program against short credential-shaped values
Review feedback (Magi, #1978): the short-slug allowlist in sanitizeHint() still
accepted short credential-shaped values (AGENT=sk-ant-api03,
AGENT=AKIAIOSFODNN7EXAMPLE, AGENT=github_pat_abc), so the "never emit a secret"
claim wasn't actually enforced — only overlong values were dropped.
Add a credential-shape guard on top of the slug allowlist:
- known token/credential prefixes (sk-, ghp_, github_pat_, akia, ya29, ...)
- any unbroken alphanumeric run >= 16 chars (key bodies, hex, base64-ish),
while agent names segment on _/-/. and keep each run short.
Replace the single overlong-value test with the short credential shapes from the
review (parametrized) plus a positive case (gemini_managed_agent) proving real
multi-segment names still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Miguel's fix (f999b40d) added the require/__filename/__dirname shims to the
Lambda handler bundle after #1932 crashed every render at import with
"__dirname is not defined in ES module scope" (wawoff2's emscripten build
reads __dirname at module scope; it's inlined via producer -> fontCompression).
The accompanying test only grepped build-zip.ts for the banner literals, so it
passes even if the shim is renamed, reordered into a broken form, or if a new
inlined CJS dep needs a global the banner doesn't provide.
Replace it with a behavioral test: extract the banner to _handlerBanner.ts
(build-zip.ts self-executes on import, so it can't be imported directly),
bundle a fixture that touches __dirname/__filename/require with the real
banner, and import the output under real Node -- not the bun test runtime,
which defines __dirname in ESM and would mask a missing shim. The import
faithfully reproduces Lambda's Node ESM environment and fails with the exact
#1932 error when any shim is dropped.
Handler bundle output is unchanged (identical banner string).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(cli): regenerate cloud client for createAssetUpload + completeAssetUpload
Regenerated from experiment-framework `master` at commit `e74815f7af` (the
merge of EF#41085, which added `/v3/assets/direct-uploads` +
`/v3/assets/{asset_id}/complete` to the `TARGET_ENDPOINTS` allowlist in
`scripts/generate_hyperframes_cli_client.py`).
The `sync-hyperframes-codegen.yml` workflow that normally auto-opens this
PR failed with a `gh: Not Found (HTTP 404)` on the PR-creation step (run
28556975483); regenerated manually with:
cd experiment-framework
PYTHONPATH=. python3 scripts/generate_hyperframes_cli_client.py \\
--out /path/to/hyperframes-oss
This commit is codegen-only — no hand edits. The direct-upload wire-up
that consumes the new `createAssetUpload` + `completeAssetUpload` methods
lands in the follow-up commit.
— Jerrai
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): migrate cloud-render upload to /v3/assets/direct-uploads (200MB)
Replaces the legacy `client.uploadAsset(...)` multipart POST to
`/v3/assets` (32 MB in-memory proxy path) with the three-step direct-to-
S3 flow that lifts the practical per-project ceiling to 200 MB:
1. `POST /v3/assets/direct-uploads` — declares filename, content-type,
size, and SHA256 checksum; returns `asset_id`, presigned
`upload_url`, and required `upload_headers`.
2. Raw `PUT` to `upload_url` with the zip bytes + `upload_headers`
verbatim. No CLI auth attached — the presigned URL signature carries
authorization, and any extra headers would break the signature.
3. `POST /v3/assets/{asset_id}/complete` — finalizes into a reusable
asset. Retried up to 5x on 409 ("Uploaded object not found yet"), a
documented race between S3 write consistency and the finalize check.
The returned `asset_id` is the same namespace the legacy path produced
(both write into `movio_asset`), so the downstream render submission at
`createRender({project: {type: "asset_id", asset_id}})` is unchanged.
Server-side context (EF#41085): the direct-upload endpoint now accepts
`application/zip` via a scoped `_ZIP_MIME_TO_EXT` map — the shared media/
PDF allowlist stays zip-free. The exact-MIME cross-check at the sniff
step guards against zip<->PDF confusion under the shared 'document'
category. Canonical S3 key layout matches the legacy proxy path
(`document/{asset_id}/original.zip`), so the render-side head_object
gate is transparent to which upload path produced the asset.
The prior codegen commit added the generated createAssetUpload +
completeAssetUpload methods this commit consumes.
— Jerrai
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The P1-3 aspect/alpha/HDR pre-flight (#1843) aborts an incompatible render before any browser/ffmpeg work, but that "save" was invisible on dashboard 1783183 — indistinguishable from a deep failure or a user giving up.
checkRenderResolutionPreflight now returns { message, kind } (kind = the existing low-cardinality OutputResolutionIssueKind), and the render command emits render_preflight_rejected { kind } before exiting. No parsers change — the helper already carried kind. trackRenderPreflightRejected is typed to the union so the metric can't carry free text.
Tests: preflight tests assert kind for all five kinds; an events test locks the emit.
Further follow-up (still log-only): encoder-frame-0-exit counter and a P1-4 doctor cli_env_check event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI Test job (bun run --filter '!@hyperframes/producer' test) intermittently failed unrelated PRs (#1843, #1850) with `Test timed out in 5000ms` / `Hook timed out in 10000ms`. Root cause: multiple CLI tests cold-import a heavy command module graph via dynamic import() (render.js, auth/status.js, telemetry/system.js), which under the full parallel monorepo run contends for CPU and blows vitest's 5s/10s defaults on constrained runners. Not a product bug.
Fix at the right altitude: set testTimeout 20s + hookTimeout 30s once in packages/cli/vitest.config.ts instead of per-test/per-hook bandaids, and remove the now-redundant explicit 30s beforeAll timeouts added in #1843.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the render-reliability batch (#1841/#1842/#1843). Threads two capture-reliability counters through the existing observability → CLI-telemetry pipeline (no new PostHog wiring) so #1842's hardening is measurable on dashboard 1783183:
- transient-retry burn (CaptureAttemptSummary.reason gains "transient-retry"; counted into RenderCaptureObservability.transientRetries on BOTH the recovered and the still-failed paths via a shared helper).
- OOM classification (memoryExhaustionDetected set when describeMemoryExhaustion classifies the failure).
Surfaced as capture_transient_retries + capture_memory_exhaustion_detected render-event props. Tests cover the attempt tagging and the payload mapping.
Further follow-up (different subsystems): encoder-frame-0-exit signal, and P1-3 pre-flight-rejection / P1-4 cli_env_check counters.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users.
- New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset.
- CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages.
- Suggest (not auto-select); defers when dims can't be determined rather than guessing.
- suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD.
- render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention.
Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four independent capture-infra hardening changes for the P2-5 failure bucket (~15K err / ~7K users):
- protocolTimeout auto-scales by device-scaled output area (applied before probe launch, since it's immutable post ppt.launch()).
- Single bounded transient retry (MAX_TRANSIENT_CAPTURE_RETRIES=1) on Target closed / Page crashed in the parallel disk-capture path; abort short-circuits before retry.
- Narrow OOM classification (Set maximum size exceeded etc., disjoint from transient) → actionable guidance naming output dims.
- StreamingEncoder.getExitError() threads FFmpeg's real exit reason into frame-0 encoder-death errors.
Render-reliability workstream P2-5. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WSL first-render success (34.7%) is dominated by a downloaded chrome-headless-shell that launches into `libnss3.so: cannot open shared object file` — doctor/preflight only checked the binary exists, never that it can load its libraries.
- New linuxDeps.ts: /etc/os-release distro detection (Debian/Fedora/Arch/Alpine) + WSL detection, per-distro Chrome dep set, ldd-based shared-lib probe.
- preflight.checkChrome downgrades a found-but-unlaunchable Chrome to a render-blocking error with the exact per-distro install command.
- Distro-aware ffmpeg hints; launch failures converted to actionable guidance pointing at `hyperframes doctor` (skipped on ARM64).
- Detect + print remediation (no auto-install).
Render-reliability workstream P1-4. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional
The #2 render failure bucket ("Composition has zero duration") accounts for
~27K errors / ~7K affected users over 30 days (PostHog project 356858). Root
cause: only GSAP timelines got their duration auto-detected — CSS, WAAPI, and
Lottie compositions had no source of truth for total duration unless the
author remembered to set data-duration on the root element, and the render
engine hard-failed capture when neither was present.
Adds getInferredDurationSeconds() to the CSS, WAAPI, and Lottie runtime
adapters (packages/core/src/runtime/adapters/*.ts) — each reports the longest
finite end time it can discover from its own animations (CSS: computed
timing offset by data-start; WAAPI: effect.getComputedTiming().endTime;
Lottie: totalFrames/frameRate or the player's own duration). Infinite/
unbounded animations correctly return null and still require data-duration.
Wires this into the runtime's existing duration-floor resolution
(resolveAdapterDurationFloorSeconds in runtime/init.ts), alongside the
existing media-duration and authored-composition floors, so
window.__hf.duration becomes positive without any author action for
finite-duration non-GSAP compositions. Three.js is unchanged — no
AnimationClip/AnimationMixer inspection exists in that adapter, so
data-duration remains required there.
Tightens frameCapture.ts's zero-duration fast-fail gate to also check
hf.duration directly (not just the two authored signals), so a composition
mid-inference isn't fast-failed before its adapter-derived duration lands.
Adds a new lint rule (root_composition_missing_duration_source) that errors
only on genuinely non-inferable cases: no animation signal at all, Three.js
without data-duration, or an infinite/unbounded CSS or WAAPI animation
without data-duration. Deliberately silent on finite CSS/WAAPI/Lottie
animations, since the runtime now infers those — an autofix that "inserts
the inferred value" was considered and rejected: every case the rule flags
has no derivable value (an infinite spinner has no finite end time; a
duration-less Three.js scene has nothing to measure), so any autofix would
have to fabricate a placeholder, trading a loud correct failure for a silent
wrong-length render.
Updates the CSS/WAAPI/Lottie/Three adapter skill docs and the
hyperframes-core determinism-rules/data-attributes references to document
the new optionality and the runtime mechanism backing it.
Verified end-to-end against the real render pipeline (not just unit tests):
a CSS-only composition with a finite 3s animation, no GSAP timeline, and no
data-duration now renders a correct 3.000s MP4 via `hyperframes render`
(previously: "Composition has zero duration" failure). The infinite-CSS
negative control still fails fast with a clear diagnostic, matching the new
lint rule.
Adds a file-level fallow health exemption for lottie.ts's pre-existing
`seek` handler — unrelated to this change, but its line numbers shifted when
new functions were added earlier in the file, tripping fallow's
inherited-finding fingerprint (documented pattern already used elsewhere in
.fallowrc.jsonc for the same reason).
Known limitation: the static WAAPI usage detector in the lint rule
(/\.animate\(\s*[\[$A-Za-z_]/) can miss unusual call shapes; it only affects
whether the "no signal at all" branch fires, and errs toward NOT flagging
(reducing false positives) rather than over-flagging.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(lint): close 3 correctness gaps in root_composition_missing_duration_source
- Strip JS/CSS comments before scanning for GSAP/WAAPI/Three/Lottie/CSS
animation signals, so a commented-out `.animate()` call or a commented
`animation: ... infinite` rule can no longer satisfy the "has a duration
source" check and mask a real zero-duration render failure.
- Broaden the WAAPI detection regex to also match the object-literal
(PropertyIndexedKeyframes) form of `.animate()`, e.g.
`el.animate({ opacity: [0,1] }, { duration: 2000 })`, which the previous
character class silently missed. Corrected the adjacent comment that
incorrectly claimed this shape "can't be a false negative".
- Fix hasInfiniteCssAnimation to stop false-positiving on animation NAMEs
that merely contain the substring "infinite" (e.g. `infinite-spin`) by
anchoring the `infinite` keyword with hyphen-aware boundaries instead of
a bare `\b`. Also makes the longhand `animation-name` + separately
declared `animation-iteration-count: infinite` pattern detected
consistently.
Adds targeted unit tests for each fixed false-positive/false-negative.
* fix(runtime): keep finite duration signal when an unbounded animation coexists
getInferredDurationSeconds in the CSS and WAAPI adapters returned null
outright whenever any animation on the composition was unbounded
(infinite iteration count), even when other finite animations on the
same composition could still supply a valid duration. This disagreed
with the new root_composition_missing_duration_source lint rule, which
treats any animation-name as sufficient — so a composition mixing a
finite fadeIn with a decorative infinite spin passed lint but still
failed at render with "zero duration".
Unbounded animations are now skipped when computing the max end time
instead of short-circuiting the whole calculation. null is only
returned when every animation on the composition is unbounded, i.e.
there is no finite signal to fall back on at all.
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(skills): fix table separator width in data-attributes.md
oxfmt flagged the merged Composition Root table from the post-rebase
merge of the auto-infer-duration docs onto main's reformatted table —
the separator row was one dash short of the header width.
* fix(lint): keep infinite-CSS duration rule strict but make its message honest
Post-review (Vance): after the finite+infinite adapter fix, the runtime infers
a length for a mixed finite+infinite CSS composition, but this lint rule still
(intentionally) errors on it — an unbounded animation makes the intended total
length ambiguous, so we require explicit data-duration. Keep that strictness
(lint is advisory by default; it only blocks under --strict, and data-duration
is the one duration signal guaranteed correct across every adapter, known and
future). But the message wrongly claimed the render "will fail" — false for the
mixed case, where the runtime falls back to the finite animation. Rewrite it to
describe the ambiguity honestly, correct the rule's block comment, and add a
mixed finite+infinite test asserting it still errors with an honest message.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>