diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 20b3e3c63..f16526100 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hyperframes", "description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", - "version": "0.6.70", + "version": "0.6.73", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0b67205e9..8b1305e01 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hyperframes", - "version": "0.6.70", + "version": "0.6.73", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", "author": { "name": "HeyGen", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 2af83f4f3..bd392fb71 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://cursor.com/schemas/cursor-plugin/plugin.json", "name": "hyperframes", "displayName": "HyperFrames by HeyGen", - "version": "0.6.70", + "version": "0.6.73", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", "author": { "name": "HeyGen", diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a14d83a41..54f9fb333 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -27,6 +27,9 @@ "packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs", "scripts/*.{ts,mjs,js}", "scripts/*/run.mjs", + // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. + "packages/studio/src/components/editor/KeyframeDiamond.tsx", + "packages/studio/src/components/editor/SpringEaseEditor.tsx", ], "ignorePatterns": [ "docs/**", @@ -93,12 +96,30 @@ "file": "packages/cli/src/commands/render.ts", "exports": ["resolveBrowserGpuForCli", "renderLocal"], }, + // captureCost.ts: constants and helpers consumed by the runCaptureCalibration + // orchestration function and tests, but the entry-point graph doesn't + // reach them because the orchestrator's caller resolves them dynamically. + { + "file": "packages/producer/src/services/render/captureCost.ts", + "exports": [ + "CAPTURE_CALIBRATION_TARGET_MS", + "MAX_MEASURED_CAPTURE_COST_MULTIPLIER", + "CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS", + "measureCaptureCostFromSession", + "logCaptureCalibrationResult", + "createFailedCaptureCalibrationEstimate", + ], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, // dynamic require() resolution, peer/static-file consumption in tests, // and bun-hoisted workspace devDeps (e.g. happy-dom in root package.json // resolves for every workspace, so workspaces don't redeclare it). + // Required by @puppeteer/browsers and puppeteer-core at runtime; listed + // as a direct dep to guarantee installation even when transitive + // resolution fails (corrupted cache, dedup edge cases). + "debug", "puppeteer", "puppeteer-core", "esbuild", diff --git a/.github/workflows/windows-render.yml b/.github/workflows/windows-render.yml index f230da25c..e040e2503 100644 --- a/.github/workflows/windows-render.yml +++ b/.github/workflows/windows-render.yml @@ -84,6 +84,7 @@ jobs: steps: - name: Disable Windows Defender real-time monitoring shell: pwsh + continue-on-error: true run: Set-MpPreference -DisableRealtimeMonitoring $true - name: Checkout @@ -374,6 +375,7 @@ jobs: steps: - name: Disable Windows Defender real-time monitoring shell: pwsh + continue-on-error: true run: Set-MpPreference -DisableRealtimeMonitoring $true - name: Checkout diff --git a/ADOPTERS.md b/ADOPTERS.md index b5d291448..ed2078f2e 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -16,9 +16,10 @@ If you'd rather not be listed publicly, that's fine — drop a note in [our Disc ## Production -| Organization | Contact | How HyperFrames is used | -| ---------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| [HeyGen](https://www.heygen.com) | [@jrusso1020](https://github.com/jrusso1020) | Powers AI-generated video composition and rendering across HeyGen's video product surface. | -| [tldraw](https://tldraw.com) | [@steveruizok](https://github.com/steveruizok) | Generates automated pull-request walkthrough videos with GSAP-animated code diffs, narration, and captions. | -| [TanStack](https://tanstack.com) | [@AlemTuzlak](https://github.com/AlemTuzlak) | Exploring HyperFrames for short-form code demo videos and documentation. | -| [OptinMonster](https://optinmonster.com) | Angie Meeker | Exploring HyperFrames for marketing and product video content. | +| Organization | Contact | How HyperFrames is used | +| ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| [HeyGen](https://www.heygen.com) | [@jrusso1020](https://github.com/jrusso1020) | Powers AI-generated video composition and rendering across HeyGen's video product surface. | +| [tldraw](https://tldraw.com) | [@steveruizok](https://github.com/steveruizok) | Generates automated pull-request walkthrough videos with GSAP-animated code diffs, narration, and captions. | +| [TanStack](https://tanstack.com) | [@AlemTuzlak](https://github.com/AlemTuzlak) | Exploring HyperFrames for short-form code demo videos and documentation. | +| [OptinMonster](https://optinmonster.com) | Angie Meeker | Exploring HyperFrames for marketing and product video content. | +| [reap](https://reap.video) | [@usamaabid](https://github.com/usamaabid) | Powers agent-first AI video clipping, editing, and rendering across reap.video's creator and agent workflows. | diff --git a/CLAUDE.md b/CLAUDE.md index 4c8b6990a..47657dbdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,42 @@ Never run `bun run --cwd packages/producer test:update` directly from the host to capture a baseline that will be committed — the resulting output.mp4 will not match CI. Use it only for local-only experimentation. +## Releasing + +All eight packages share one version. `.github/workflows/publish.yml` publishes +them when a `v*` tag is pushed (it also accepts a manual `workflow_dispatch` with +a version input). The CLI publishes as the unscoped `hyperframes` package; the +other seven as `@hyperframes/*`. + +Cut a release from `main`: + +```bash +bun run release:prepare # e.g. 0.6.72 +``` + +Run it twice: + +1. **First run** writes `releases/v.md` and prepends an entry to + `docs/changelog.mdx`, then stops with a non-zero exit. Edit both files: write + the 1–2 sentence summary and remove the `` marker. +2. **Second run** (after the TODO is gone) bumps every package and plugin + manifest, creates the `chore: release v` commit, and tags + `v` (lightweight). + +Then push to trigger the publish: + +```bash +git push origin main --tags +``` + +Notes: + +- Pre-release versions (`0.6.72-alpha.1`) publish to the matching npm dist-tag + (`alpha`) instead of `latest`. +- `--skip-changelog-check` skips the changelog gate for emergency releases. +- The publish step skips any version already on npm, so re-running a failed + workflow run is safe. + ## Skills Composition authoring (not repo development) is guided by skills installed via `npx skills add heygen-com/hyperframes`. See `skills/` for source. Invoke `/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, `/tailwind`, or `/gsap` when authoring compositions. Use `/tailwind` for projects created with `hyperframes init --tailwind` so agents follow the pinned Tailwind v4 browser-runtime contract instead of Studio's Tailwind v3 setup. Use `/animejs`, `/css-animations`, `/lottie`, `/three`, or `/waapi` when a composition uses those first-party runtime adapters. Invoke `/hyperframes-media` for asset preprocessing (TTS narration, audio/video transcription, background removal for transparent overlays) — these commands have their own skill so the CLI skill stays focused on the dev loop. When a user provides a website URL and wants a video, invoke `/website-to-hyperframes` — it runs the full 7-step capture-to-video pipeline. diff --git a/README.md b/README.md index 99a812809..861699949 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,69 @@ Need ideas? Browse the [Showcase](https://hyperframes.heygen.com/showcase) for f - Docs-to-video, PDF-to-video, and website-to-video explainers - Reusable motion graphics for automated content pipelines +## Frame.md + +**frame.md — your design system, ready for video.** + +Every brand has a `design.md`. None of them were written for a camera. `frame.md` is the missing translation layer: it takes your web-context design spec and inverts it for the frame — the same tokens, the same rules, but rewritten so an AI agent can compose a promo video without guessing at scale or reaching for web chrome. + +The output is a `DESIGN.md` superset your whole toolchain can read. Atoms stay sacred. Composition stays free. Numbers come from the script. + + + + + + + + + + + + + + + + + + + + + + +
+ Biennale Yellow +
Biennale Yellow +
+ BlockFrame +
BlockFrame +
+ Blue Professional +
Blue Professional +
+ Bold Poster +
Bold Poster +
+ Broadside +
Broadside +
+ Capsule +
Capsule +
+ Cartesian +
Cartesian +
+ Cobalt Grid +
Cobalt Grid +
+ Coral +
Coral +
+ Creative Mode +
Creative Mode +
+ +Browse and remix them all at [hyperframes.dev/design](https://www.hyperframes.dev/design). + ## How It Works Define a video as HTML. Add data attributes for timing and tracks. Use GSAP, CSS, Lottie, Three.js, Anime.js, WAAPI, or your own frame adapter for seekable animation. @@ -122,7 +185,7 @@ HyperFrames is the open-source rendering engine, plus a growing set of tools aro | Studio | Available, evolving | Browser surface for previewing and editing compositions | | AWS Lambda rendering | Available | Deploy a distributed render stack and drive renders from your laptop or CI | | [hyperframes.dev](https://www.hyperframes.dev/) | Available | Community playground for previewing, iterating, sharing, and rendering HTML-native video projects | -| Design.HTML | In development | Visualize a brand identity and turn it into reusable, video-ready HyperFrames compositions | +| [frame.md](https://www.hyperframes.dev/design) | Available | Invert your design system for the camera — a DESIGN.md superset an agent can compose video from | ## Catalog diff --git a/bun.lock b/bun.lock index 00c10ccde..67a6dd8e6 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.51", + "version": "0.6.69", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.51", + "version": "0.6.69", "bin": { "hyperframes": "./dist/cli.js", }, @@ -64,6 +64,7 @@ "adm-zip": "^0.5.16", "citty": "^0.2.1", "compare-versions": "^6.1.1", + "debug": "^4.4.0", "esbuild": "^0.25.12", "fontkit": "^2.0.4", "giget": "^3.2.0", @@ -99,7 +100,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.51", + "version": "0.6.69", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", @@ -128,7 +129,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.51", + "version": "0.6.69", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +147,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.51", + "version": "0.6.69", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -158,7 +159,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.51", + "version": "0.6.69", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -198,7 +199,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.51", + "version": "0.6.69", "dependencies": { "html2canvas": "^1.4.1", }, @@ -210,7 +211,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.51", + "version": "0.6.69", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 373e280de..369bffbe9 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,70 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +Runtime stability and CLI improvements: fixes audio stuttering in the preview player when a media file ends before the composition does, adds a configurable browser navigation timeout, localizes remote `` sources before render to eliminate image flicker, and adds ARM64 Docker render support for Apple Silicon. + +## Fixes + +- **Runtime:** Don't restart non-loop media that has naturally ended ([faa3f588](https://github.com/heygen-com/hyperframes/commit/faa3f588fbc37b3c1d55e3b3e96bbda39ab05ae9), [#1203](https://github.com/heygen-com/hyperframes/pull/1203)) +- **CLI:** Report available memory instead of free memory in doctor ([96795031](https://github.com/heygen-com/hyperframes/commit/967950315876bfbdd792a152089c7de699acaef4), [#1204](https://github.com/heygen-com/hyperframes/pull/1204)) +- **CLI:** Reject directory --composition and add --browser-timeout (#1199) ([6affe2d2](https://github.com/heygen-com/hyperframes/commit/6affe2d212296f81fc67cfd8aeaf7650a7a23ffe), [#1200](https://github.com/heygen-com/hyperframes/pull/1200)) +- **Scripts:** Make release change-guard robust to git status prefix ([8228932e](https://github.com/heygen-com/hyperframes/commit/8228932e17e3371d5cf77ac5d5988f5322892dad), [#1198](https://github.com/heygen-com/hyperframes/pull/1198)) +- **Producer:** Localize remote \ sources + await image readiness ([72c461d8](https://github.com/heygen-com/hyperframes/commit/72c461d86a8d24a82d03267262d7cd7a9a373c1e), [#1197](https://github.com/heygen-com/hyperframes/pull/1197)) +- **CLI:** Support arm64 hosts for `--docker` render ([2be41937](https://github.com/heygen-com/hyperframes/commit/2be41937a94bea6e3ebc9e253f26cfd5609b61df), [#1196](https://github.com/heygen-com/hyperframes/pull/1196)) + +## Internal + +- **CLI:** Cover the cloud client 401-refresh-retry decorator ([0870394d](https://github.com/heygen-com/hyperframes/commit/0870394d20cf6dcd2af7c3b7c3ab6a1f30ddebf9), [#1202](https://github.com/heygen-com/hyperframes/pull/1202)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.72...v0.6.73). + + + +A render-reliability release: the producer now localizes remote `` sources and waits for them to load before capturing frames, fixing blank-frame flicker in compositions that reference remote (S3) images. `--docker` renders also now run on arm64 hosts. + +## Fixes + +- **Producer:** Localize remote \ sources + await image readiness ([72c461d8](https://github.com/heygen-com/hyperframes/commit/72c461d86a8d24a82d03267262d7cd7a9a373c1e), [#1197](https://github.com/heygen-com/hyperframes/pull/1197)) +- **CLI:** Support arm64 hosts for `--docker` render ([2be41937](https://github.com/heygen-com/hyperframes/commit/2be41937a94bea6e3ebc9e253f26cfd5609b61df), [#1196](https://github.com/heygen-com/hyperframes/pull/1196)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.71...v0.6.72). + + + +Fixes three production-observed CLI and engine errors: EPIPE crashes in piped agent environments, `@puppeteer/browsers` import failures that blocked all commands when the debug package was missing, and zero-duration composition timeouts reduced from 45s to ~11s with an actionable diagnostic. + +## Features + +- **Docs:** Add weekly update drafts ([1abe69f3](https://github.com/heygen-com/hyperframes/commit/1abe69f3e479b41a804ee651e2874f31b150a4e8), [#1183](https://github.com/heygen-com/hyperframes/pull/1183)) + +## Fixes + +- **Engine:** Fast-fail on zero duration instead of 45s timeout ([efb8d90f](https://github.com/heygen-com/hyperframes/commit/efb8d90f727885e141d2707e2567e539efeb334d), [#1186](https://github.com/heygen-com/hyperframes/pull/1186)) +- **CLI:** Lazy-load @puppeteer/browsers to prevent debug package crash ([8c6faa45](https://github.com/heygen-com/hyperframes/commit/8c6faa45b5076d313d66962e818d26f10ba910f7), [#1185](https://github.com/heygen-com/hyperframes/pull/1185)) +- **CLI:** Suppress EPIPE crashes in piped agent environments ([cabd0616](https://github.com/heygen-com/hyperframes/commit/cabd0616ea8c194988a1010ff4182a8b72cbe68e), [#1184](https://github.com/heygen-com/hyperframes/pull/1184)) +- Delay ObjectURL revocation and silence TS5 baseUrl deprecations ([6de6ea53](https://github.com/heygen-com/hyperframes/commit/6de6ea53498ddebcfb4c242602dd5b8f75b5e846), [#1181](https://github.com/heygen-com/hyperframes/pull/1181)) + +## Docs & Examples + +- **README:** Add Frame.md design template gallery ([1a617d30](https://github.com/heygen-com/hyperframes/commit/1a617d30ff07d6b4e2dc7236b373ebe50874dfec), [#1182](https://github.com/heygen-com/hyperframes/pull/1182)) +- **Skills:** Prefer frame.md over design.md for video specs ([fcff442a](https://github.com/heygen-com/hyperframes/commit/fcff442ab741b1176f619807c61701367836ad04), [#1180](https://github.com/heygen-com/hyperframes/pull/1180)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.6.70...v0.6.71). + + + + + + Powers agent-first AI video clipping, editing, and rendering across reap.video's creator and agent workflows. + + [@usamaabid](https://github.com/usamaabid) + The HeyGen team's actual launch-video sources (the ones featured in product announcements) live at [hyperframes-launches](https://github.com/heygen-com/hyperframes-launches) — see [Launch Videos](/launch-videos) for the writeup. diff --git a/docs/contributing/changelog-process.mdx b/docs/contributing/changelog-process.mdx index ce22a1662..744c9bce0 100644 --- a/docs/contributing/changelog-process.mdx +++ b/docs/contributing/changelog-process.mdx @@ -77,6 +77,26 @@ bun run changelog:draft 0.6.53 --write --force Without `--force`, the draft command leaves an existing `releases/vX.Y.Z.md` file unchanged and still adds the docs changelog entry if it is missing. If the docs changelog already has that version, edit the existing docs entry manually. +## Weekly digest workflow + +Weekly updates are editorial rollups, not release notes. Keep `docs/changelog.mdx` versioned and use `docs/weekly-updates.mdx` for curated weekly highlights that can also be adapted for Discord and X. + +Generate an editable weekly packet from the repository root: + +```bash +bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write +``` + +Run it from an up-to-date `main` branch so the selected range reflects public history, not a feature branch. + +This creates: + +- `updates/weekly/2026-06-07.md` +- `updates/social/2026-06-07.discord.md` +- `updates/social/2026-06-07.x.md` + +It also prepends a matching entry to `docs/weekly-updates.mdx`. Review and rewrite the generated files before publishing. Social drafts are never posted automatically. + ## Writing style Use plain, user-facing language. Prefer "Fixed Studio render failures when FFmpeg is missing" over "Added pre-flight check in render activity." Link to relevant docs, migration guides, or pull requests when they help users act. diff --git a/docs/docs.json b/docs/docs.json index 2e504e9ec..2a7c3353f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -54,6 +54,7 @@ "pages": [ "introduction", "changelog", + "weekly-updates", "quickstart", "showcase", "examples", diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index c17683861..dd5f3af9a 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -623,6 +623,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--variables` | JSON object | — | Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` | | `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) | | `--strict-variables` | — | off | Fail render if any `--variables` key is undeclared or has a wrong type vs the composition's `data-composition-variables`. Without this flag, mismatches print as warnings and the render continues. | + | `--browser-timeout` | seconds (0.001–86400) | 60 | Puppeteer page-navigation timeout for the entry HTML. Increase when heavy compositions (many videos, fonts, or asset requests) cannot reach `domcontentloaded` within the default 60 s. The flag takes **seconds**; the env fallback `PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS` takes **milliseconds**. This controls `page.goto` only — very heavy compositions may also need `PRODUCER_PUPPETEER_PROTOCOL_TIMEOUT_MS` and/or `PRODUCER_PLAYER_READY_TIMEOUT_MS` bumped (post-navigation `window.__hf` readiness has its own 45 s budget). | CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically. diff --git a/docs/weekly-updates.mdx b/docs/weekly-updates.mdx new file mode 100644 index 000000000..9bc61580b --- /dev/null +++ b/docs/weekly-updates.mdx @@ -0,0 +1,11 @@ +--- +title: "Weekly updates" +description: "Curated weekly highlights for HyperFrames." +rss: true +--- + +Weekly HyperFrames highlights across releases, examples, docs, and community updates. + +For exact versioned release notes, see the [Changelog](/changelog). + +{/* New weekly digest entries are prepended by `bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write`. */} diff --git a/package.json b/package.json index 7f7fe80c3..60e032a88 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "set-version": "tsx scripts/set-version.ts", "release:prepare": "tsx scripts/release-prepare.ts", "changelog:draft": "tsx scripts/draft-changelog.ts", + "changelog:weekly": "tsx scripts/changelog-weekly.ts", "sync-schemas": "tsx scripts/sync-schemas.ts", "sync-schemas:check": "tsx scripts/sync-schemas.ts --check", "lint": "oxlint . && tsx scripts/lint-skills.ts", @@ -31,7 +32,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts", + "test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts", "generate:previews": "tsx scripts/generate-template-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", "upload:docs-images": "bash scripts/upload-docs-images.sh", diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index 2902a9ba7..021d45271 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/aws-lambda", - "version": "0.6.70", + "version": "0.6.73", "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.", "repository": { "type": "git", diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts index 71a7b7e69..1b0219b51 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts @@ -110,6 +110,7 @@ export class HyperframesRenderStack extends Construct { NODE_OPTIONS: "--enable-source-maps", TMPDIR: "/tmp", HYPERFRAMES_LAMBDA_CHROME_SOURCE: chromeSource, + HYPERFRAMES_RENDER_BUCKET: this.bucket.bucketName, }, }); @@ -195,6 +196,7 @@ export class HyperframesRenderStack extends Construct { const NON_RETRYABLE_PLAN = [ "FFMPEG_VERSION_MISMATCH", "PLAN_HASH_MISMATCH", + "S3_URI_NOT_ALLOWED", "BROWSER_GPU_NOT_SOFTWARE", "FONT_FETCH_FAILED", "PLAN_TOO_LARGE", @@ -204,12 +206,14 @@ export class HyperframesRenderStack extends Construct { const NON_RETRYABLE_CHUNK = [ "FFMPEG_VERSION_MISMATCH", "PLAN_HASH_MISMATCH", + "S3_URI_NOT_ALLOWED", "BROWSER_GPU_NOT_SOFTWARE", "ChromeBinaryUnavailableError", ]; const NON_RETRYABLE_ASSEMBLE = [ "FFMPEG_VERSION_MISMATCH", "PLAN_HASH_MISMATCH", + "S3_URI_NOT_ALLOWED", "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", "ChromeBinaryUnavailableError", ]; diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts index c3f1521b2..5a67c2bfd 100644 --- a/packages/aws-lambda/src/handler.test.ts +++ b/packages/aws-lambda/src/handler.test.ts @@ -459,6 +459,69 @@ describe("handler dispatch", () => { }); }); +describe("handler — S3 URI allowlist (security: F-004)", () => { + let prevBucket: string | undefined; + + beforeEach(() => { + prevBucket = process.env.HYPERFRAMES_RENDER_BUCKET; + }); + + afterEach(() => { + if (prevBucket === undefined) { + delete process.env.HYPERFRAMES_RENDER_BUCKET; + } else { + process.env.HYPERFRAMES_RENDER_BUCKET = prevBucket; + } + }); + + it("rejects a plan event whose ProjectS3Uri is outside the allowed bucket", async () => { + process.env.HYPERFRAMES_RENDER_BUCKET = "good-bucket"; + const tmpRoot = makeTmpRoot(); + const s3 = new FakeS3Client(); + + const event: PlanEvent = { + Action: "plan", + ProjectS3Uri: "s3://evil-bucket/project.tar.gz", + PlanOutputS3Prefix: "s3://good-bucket/renders/abc/", + Config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, + }; + const deps = { + s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client, + tmpRoot, + skipChromeResolution: true, + }; + + await expect(handler(event, deps)).rejects.toMatchObject({ + name: "S3_URI_NOT_ALLOWED", + message: expect.stringContaining("evil-bucket"), + }); + expect(s3.ops).toHaveLength(0); + }); + + it("rejects an assemble event with a cross-bucket chunk URI", async () => { + process.env.HYPERFRAMES_RENDER_BUCKET = "good-bucket"; + const tmpRoot = makeTmpRoot(); + const s3 = new FakeS3Client(); + + const event: AssembleEvent = { + Action: "assemble", + PlanS3Uri: "s3://good-bucket/plan.tar.gz", + ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"], + AudioS3Uri: null, + OutputS3Uri: "s3://good-bucket/renders/abc/output.mp4", + Format: "mp4", + }; + const deps = { + s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client, + tmpRoot, + skipChromeResolution: true, + }; + + await expect(handler(event, deps)).rejects.toMatchObject({ name: "S3_URI_NOT_ALLOWED" }); + expect(s3.ops).toHaveLength(0); + }); +}); + // ── helpers ───────────────────────────────────────────────────────────────── /** diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index 91a506def..f5bf8ed6f 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -83,6 +83,7 @@ export interface HandlerDeps { */ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise { const unwrapped = unwrapEvent(event); + validateEventS3Uris(unwrapped); primeRuntimeEnv(); // Single structured boot log line — CloudWatch Logs Insights queries // key off `event=handler_start` to grep for a specific Action / S3 URI @@ -477,6 +478,44 @@ async function downloadChunkObjects( // ── Helpers ───────────────────────────────────────────────────────────────── +/** Collect every S3 URI that the handler will touch for a given event. */ +function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): string[] { + switch (event.Action) { + case "plan": + return [event.ProjectS3Uri, event.PlanOutputS3Prefix]; + case "renderChunk": + return [event.PlanS3Uri, event.ChunkOutputS3Prefix]; + case "assemble": + return [event.PlanS3Uri, ...event.ChunkS3Uris, event.OutputS3Uri, event.AudioS3Uri].filter( + (u): u is string => u != null, + ); + } +} + +/** + * Verify every S3 URI in the event resolves to the configured render bucket. + * Throws `S3_URI_NOT_ALLOWED` (non-retryable) when a URI targets a different + * bucket, preventing event injection from reading or writing arbitrary S3 data. + * + * Skipped when `HYPERFRAMES_RENDER_BUCKET` is unset so existing deployments + * without the env var continue to work. + */ +function validateEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): void { + const allowedBucket = process.env.HYPERFRAMES_RENDER_BUCKET?.trim(); + if (!allowedBucket) return; + + for (const uri of getEventS3Uris(event)) { + const { bucket } = parseS3Uri(uri); + if (bucket !== allowedBucket) { + const err = new Error( + `[handler] S3_URI_NOT_ALLOWED: URI ${JSON.stringify(uri)} targets bucket "${bucket}" but only "${allowedBucket}" is permitted`, + ); + err.name = "S3_URI_NOT_ALLOWED"; + throw err; + } + } +} + function pad(n: number): string { return n.toString().padStart(4, "0"); } diff --git a/packages/cli/package.json b/packages/cli/package.json index 84f2afb05..6019a7f24 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.6.70", + "version": "0.6.73", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", @@ -29,6 +29,7 @@ "adm-zip": "^0.5.16", "citty": "^0.2.1", "compare-versions": "^6.1.1", + "debug": "^4.4.0", "esbuild": "^0.25.12", "fontkit": "^2.0.4", "giget": "^3.2.0", diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index a29c943e4..5751b4e65 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -3,7 +3,20 @@ import { existsSync, readdirSync, rmSync } from "node:fs"; import { basename } from "node:path"; import { homedir } from "node:os"; import { join } from "node:path"; -import { Browser, detectBrowserPlatform, getInstalledBrowsers, install } from "@puppeteer/browsers"; + +type PuppeteerBrowsers = typeof import("@puppeteer/browsers"); + +async function loadPuppeteerBrowsers(): Promise { + try { + return await import("@puppeteer/browsers"); + } catch (err) { + const cause = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to load @puppeteer/browsers: ${cause}\n` + + `Fix: run \`npm install\` or \`bun install\` to restore missing packages, then retry.`, + ); + } +} const CHROME_VERSION = "131.0.6778.85"; const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome"); @@ -84,6 +97,7 @@ async function findFromCache(): Promise { // download-of-last-resort). This is the fallback path: only reached when // no puppeteer-cache binary exists. if (existsSync(CACHE_DIR)) { + const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers(); const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL); if (match) { @@ -303,6 +317,8 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise { + it("blocks loopback, private, and metadata IPv4", () => { + for (const u of [ + "http://127.0.0.1/", + "http://10.0.0.5/", + "http://172.16.0.1/", + "http://192.168.1.1/", + "http://169.254.169.254/", // cloud metadata + ]) { + expect(isPrivateUrl(u), u).toBe(true); + } + }); + + it("blocks 0.0.0.0 and the 0.0.0.0/8 range", () => { + expect(isPrivateUrl("http://0.0.0.0/")).toBe(true); + expect(isPrivateUrl("http://0.1.2.3/")).toBe(true); + }); + + it("blocks IPv6 loopback, IPv4-mapped, ULA, and link-local", () => { + for (const u of [ + "http://[::1]/", + "http://[::ffff:169.254.169.254]/", // IPv4-mapped metadata + "http://[fd00::1]/", // unique-local fc00::/7 + "http://[fe80::1]/", // link-local fe80::/10 + ]) { + expect(isPrivateUrl(u), u).toBe(true); + } + }); + + it("still blocks alternate IPv4 encodings (WHATWG canonicalization)", () => { + expect(isPrivateUrl("http://2130706433/")).toBe(true); // decimal 127.0.0.1 + expect(isPrivateUrl("http://0x7f000001/")).toBe(true); // hex + }); + + it("blocks non-http(s) schemes and internal suffixes", () => { + expect(isPrivateUrl("file:///etc/passwd")).toBe(true); + expect(isPrivateUrl("http://db.internal/")).toBe(true); + expect(isPrivateUrl("http://svc.local/")).toBe(true); + }); + + it("allows ordinary public URLs", () => { + expect(isPrivateUrl("https://example.com/logo.png")).toBe(false); + expect(isPrivateUrl("https://cdn.jsdelivr.net/a.svg")).toBe(false); + }); +}); + +describe("safeFetch — re-validates the denylist on every redirect hop (security: F-002)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("blocks a public URL that redirects to a private/metadata host", async () => { + const fetchMock = vi.fn(async (input: string, _init?: RequestInit) => { + if (input === "https://public.example/logo.png") { + return new Response(null, { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data/" }, + }); + } + // The metadata host must NEVER be fetched. + throw new Error(`safeFetch followed a redirect to a private host: ${input}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const res = await safeFetch("https://public.example/logo.png"); + expect(res).toBeNull(); + // First (public) hop fetched; the redirect target was rejected before fetch. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); + }); + + it("follows a redirect to another public host and returns the final response", async () => { + const fetchMock = vi.fn(async (input: string, _init?: RequestInit) => { + if (input === "https://a.example/x") + return new Response(null, { status: 301, headers: { location: "https://b.example/y" } }); + return new Response("ok", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const res = await safeFetch("https://a.example/x"); + expect(res?.status).toBe(200); + expect(await res?.text()).toBe("ok"); + }); + + it("returns null when the initial URL is private", async () => { + const fetchMock = vi.fn(async () => new Response("ok")); + vi.stubGlobal("fetch", fetchMock); + const res = await safeFetch("http://169.254.169.254/"); + expect(res).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index 106fd1d24..5a5b5afd9 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -254,40 +254,103 @@ export async function downloadAndRewriteFonts(css: string, outputDir: string): P return rewritten; } -/** Block requests to private/internal IP ranges to prevent SSRF */ +// Reserved/loopback/private IPv4 blocks as [firstOctet, secondOctetLo, secondOctetHi]. +const PRIVATE_V4_BLOCKS: ReadonlyArray = [ + [0, 0, 255], // 0.0.0.0/8 (incl. 0.0.0.0, which routes to localhost) + [10, 0, 255], // 10.0.0.0/8 + [127, 0, 255], // 127.0.0.0/8 loopback + [172, 16, 31], // 172.16.0.0/12 + [192, 168, 168], // 192.168.0.0/16 + [169, 254, 254], // 169.254.0.0/16 link-local (cloud metadata) +]; + +/** True for a dotted-quad IPv4 literal in a loopback/private/reserved range. */ +function isPrivateIpv4(host: string): boolean { + const octets = host.split(".").map(Number); + if (octets.length !== 4) return false; + const [a, b] = octets as [number, number, number, number]; + return PRIVATE_V4_BLOCKS.some(([first, lo, hi]) => a === first && b >= lo && b <= hi); +} + +/** True for a bracketed IPv6 hostname in a loopback/private/reserved range. */ +function isPrivateIpv6(bracketed: string): boolean { + const addr = bracketed.replace(/^\[|\]$/g, "").toLowerCase(); + if (addr === "::1" || addr === "::") return true; // loopback / unspecified + const mapped = /^::ffff:(.+)$/.exec(addr); // IPv4-mapped ::ffff:a.b.c.d or ::ffff:hhhh:hhhh + if (mapped) { + const tail = mapped[1]!; + if (tail.includes(".")) return isPrivateIpv4(tail); + const hex = tail.split(":"); + if (hex.length === 2) { + const n = ((parseInt(hex[0]!, 16) << 16) | parseInt(hex[1]!, 16)) >>> 0; + return isPrivateIpv4( + [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."), + ); + } + } + if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local + if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local + return false; +} + +/** + * Block requests to private/internal hosts to prevent SSRF. WHATWG URL parsing + * canonicalizes alternate IPv4 encodings (decimal/octal/hex) to dotted-quad + * before we see them, so only dotted IPv4 and bracketed IPv6 literals reach the + * classifiers below. + */ export function isPrivateUrl(url: string): boolean { try { - const { hostname } = new URL(url); - // Block cloud metadata, localhost, and private IP ranges - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") return true; - if (hostname === "169.254.169.254") return true; // AWS/GCP metadata + const u = new URL(url); + if (u.protocol !== "http:" && u.protocol !== "https:") return true; // no file:, etc. + const hostname = u.hostname; + if (hostname === "localhost") return true; if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true; - // IPv4 private ranges - const parts = hostname.split(".").map(Number); - if (parts.length === 4 && parts.every((p) => !isNaN(p))) { - if (parts[0] === 10) return true; // 10.0.0.0/8 - if (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) return true; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16 - if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16 (link-local) - } - // Block non-HTTP(S) schemes - const scheme = new URL(url).protocol; - if (scheme !== "http:" && scheme !== "https:") return true; + if (hostname.startsWith("[")) return isPrivateIpv6(hostname); + if (/^\d+(\.\d+){3}$/.test(hostname)) return isPrivateIpv4(hostname); return false; } catch { return true; // reject unparseable URLs } } +/** Max redirect hops safeFetch will follow before giving up. */ +const MAX_FETCH_REDIRECTS = 5; + +/** + * fetch() that re-validates the SSRF denylist on EVERY redirect hop. A bare + * `redirect: "follow"` only checks the initial URL, so a public URL can 30x to + * an internal/metadata host. We resolve redirects manually and re-run + * isPrivateUrl on each Location. Returns null when blocked, on too many hops, + * or on network error. + */ +export async function safeFetch(url: string, init?: RequestInit): Promise { + let current = url; + for (let hop = 0; hop <= MAX_FETCH_REDIRECTS; hop++) { + if (isPrivateUrl(current)) return null; + const res = await fetch(current, { ...init, redirect: "manual" }); + if (res.status >= 300 && res.status < 400) { + const loc = res.headers.get("location"); + if (!loc) return res; + try { + current = new URL(loc, current).toString(); + } catch { + return null; // malformed Location header + } + continue; + } + return res; + } + return null; // too many redirects +} + async function fetchBuffer(url: string): Promise { try { - if (isPrivateUrl(url)) return null; - const res = await fetch(url, { + const res = await safeFetch(url, { signal: AbortSignal.timeout(10000), headers: { "User-Agent": "HyperFrames/1.0" }, - redirect: "follow", }); - if (!res.ok) return null; + if (!res || !res.ok) return null; // Reject XML/HTML error pages disguised as 200 OK (common with S3/CloudFront) const ct = res.headers.get("content-type") || ""; if (ct.includes("text/xml") || ct.includes("text/html") || ct.includes("application/xml")) { diff --git a/packages/cli/src/capture/mediaCapture.ts b/packages/cli/src/capture/mediaCapture.ts index cde1ae83b..1f168b368 100644 --- a/packages/cli/src/capture/mediaCapture.ts +++ b/packages/cli/src/capture/mediaCapture.ts @@ -10,7 +10,7 @@ import type { Browser, Page } from "puppeteer-core"; import { mkdirSync, writeFileSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { isPrivateUrl } from "./assetDownloader.js"; +import { safeFetch } from "./assetDownloader.js"; /** Discovered Lottie item from network interception or DOM scan. */ export interface DiscoveredLottie { @@ -42,14 +42,12 @@ export async function saveLottieAnimations( // Already have the JSON data from network interception jsonData = JSON.stringify(lottieItem.data); } else if (lottieItem.url) { - // SSRF guard — don't fetch private/internal URLs - if (isPrivateUrl(lottieItem.url)) continue; - // Download the file - const res = await fetch(lottieItem.url, { + // SSRF guard — safeFetch re-checks the denylist on every redirect hop + const res = await safeFetch(lottieItem.url, { signal: AbortSignal.timeout(10000), headers: { "User-Agent": "HyperFrames/1.0" }, }); - if (!res.ok) continue; + if (!res || !res.ok) continue; const buf = Buffer.from(await res.arrayBuffer()); if (lottieItem.url.endsWith(".lottie")) { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 603d7a9df..3f8aace1c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,26 @@ #!/usr/bin/env node +// ── EPIPE suppression (must run before ANY stdout/stderr write) ──────────── +// When the CLI runs inside a piped agent environment (Claude Code, Codex, +// Cursor, etc.), the reader may close the pipe before we finish writing. +// Node treats EPIPE on stdout/stderr as an uncaughtException, which crashes +// the process. This is a normal lifecycle event — suppress it. +// +// commandFailed must be declared here (before the handlers) so the EPIPE +// stream-error path can set it before process.exit(0). The telemetry exit +// handler reads this flag to determine success/failure — an EPIPE exit +// should NOT score as success:true in telemetry. +let commandFailed = false; + +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "EPIPE") { + commandFailed = true; + process.exit(0); + } + }); +} + // ── Worker entry path bootstrap (must run before any producer/engine load) ── // The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`, // `shaderTransitionWorkerPool`) live in the producer package and try to @@ -194,7 +215,6 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade") { } const commandStart = Date.now(); -let commandFailed = false; // Async flush for normal exit. `beforeExit` re-fires every time the // event loop drains, and the async `_flush()` itself schedules new @@ -220,6 +240,10 @@ process.on("exit", (code) => { }); process.on("uncaughtException", (error) => { + if ((error as NodeJS.ErrnoException).code === "EPIPE") { + commandFailed = true; + process.exit(0); + } commandFailed = true; _trackCliError?.({ error_name: error.name, diff --git a/packages/cli/src/cloud/index.test.ts b/packages/cli/src/cloud/index.test.ts new file mode 100644 index 000000000..a04eee0a6 --- /dev/null +++ b/packages/cli/src/cloud/index.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The 401-retry decorator calls forceRefreshCredentials() and the factory +// resolves base URL / auth headers from auth.js. Mock the module so the +// tests control the token lifecycle without touching the real credential +// store on disk. +vi.mock("./auth.js", () => ({ + forceRefreshCredentials: vi.fn(), + resolveCloudAuthHeaders: vi.fn(), + resolveCloudBaseUrl: vi.fn(() => "https://cloud.test"), +})); + +import { forceRefreshCredentials, resolveCloudAuthHeaders } from "./auth.js"; +import { createCloudClient } from "./index.js"; +import { HyperframesApiError } from "./_gen/client.js"; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const ok = (body: unknown): Response => jsonResponse(200, body); +const unauthorized = (): Response => + jsonResponse(401, { error: { message: "token revoked", code: "unauthorized" } }); + +// Narrow a recorded fetch call to the headers of its RequestInit without +// casting; throws (failing the test) if the call shape is unexpected. +const headersOf = (call: readonly unknown[] | undefined): unknown => { + const init = call?.[1]; + if (init === null || init === undefined || typeof init !== "object" || !("headers" in init)) { + throw new Error("expected fetch to be called with a RequestInit carrying headers"); + } + return init.headers; +}; + +describe("createCloudClient 401-retry decorator", () => { + // The generated client falls back to global fetch when no fetchImpl is + // injected, and createCloudClient doesn't expose that knob — stub the + // global so the decorator under test wraps the same client the cloud + // commands get. + let fetchMock: ReturnType; + let token: string; + + beforeEach(() => { + token = "tok-old"; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.mocked(resolveCloudAuthHeaders).mockImplementation(async () => ({ + authorization: `Bearer ${token}`, + })); + vi.mocked(forceRefreshCredentials).mockReset(); + vi.mocked(forceRefreshCredentials).mockImplementation(async () => { + token = "tok-new"; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("passes a successful call through without refreshing", async () => { + fetchMock.mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } })); + + const client = await createCloudClient(); + const render = await client.getRender({ render_id: "hfr_1" }); + + expect(render).toEqual({ id: "hfr_1", status: "complete" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(forceRefreshCredentials).not.toHaveBeenCalled(); + }); + + it("refreshes once on 401 and retries with the new token", async () => { + fetchMock + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(ok({ data: { id: "hfr_1", status: "complete" } })); + + const client = await createCloudClient(); + const render = await client.getRender({ render_id: "hfr_1" }); + + expect(render).toEqual({ id: "hfr_1", status: "complete" }); + expect(forceRefreshCredentials).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // The retry must re-resolve credentials, not replay the stale header: + // a refresh that isn't picked up would 401 forever. + expect(headersOf(fetchMock.mock.calls[0])).toMatchObject({ + authorization: "Bearer tok-old", + }); + expect(headersOf(fetchMock.mock.calls[1])).toMatchObject({ + authorization: "Bearer tok-new", + }); + }); + + it("surfaces the original 401 when the refresh itself fails", async () => { + fetchMock.mockResolvedValueOnce(unauthorized()); + vi.mocked(forceRefreshCredentials).mockRejectedValueOnce(new Error("refresh_token expired")); + + const client = await createCloudClient(); + const call = client.getRender({ render_id: "hfr_1" }); + + // The decorator promises to surface the 401, not the refresh error — + // the 401 carries the API's message/code, which reportApiError needs. + await expect(call).rejects.toMatchObject({ + name: "HyperframesApiError", + status: 401, + message: "token revoked", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("retries exactly once: a second 401 propagates", async () => { + fetchMock.mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(unauthorized()); + + const client = await createCloudClient(); + const call = client.getRender({ render_id: "hfr_1" }); + + await expect(call).rejects.toMatchObject({ status: 401 }); + expect(forceRefreshCredentials).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not refresh on non-401 API errors", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(500, { error: { message: "internal", code: "internal_error" } }), + ); + + const client = await createCloudClient(); + const call = client.listRenders({}); + + await expect(call).rejects.toBeInstanceOf(HyperframesApiError); + await expect(call).rejects.toMatchObject({ status: 500 }); + expect(forceRefreshCredentials).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not refresh on transport errors that aren't HyperframesApiError", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")); + + const client = await createCloudClient(); + const call = client.getRender({ render_id: "hfr_1" }); + + await expect(call).rejects.toThrow("fetch failed"); + expect(forceRefreshCredentials).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 30f0a07e9..5b087c664 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { buildDoctorReport, redactHome, type CheckOutcome } from "./doctor.js"; +import { buildDoctorReport, redactHome, parseToolVersion, type CheckOutcome } from "./doctor.js"; // ── Fixtures ──────────────────────────────────────────────────────────────── @@ -62,6 +62,32 @@ describe("redactHome", () => { }); }); +describe("parseToolVersion", () => { + it("extracts ffmpeg version from full copyright line", () => { + expect( + parseToolVersion("ffmpeg version 8.1.1 Copyright (c) 2000-2026 the FFmpeg developers"), + ).toBe("ffmpeg 8.1.1"); + }); + + it("extracts ffprobe version from full copyright line", () => { + expect( + parseToolVersion("ffprobe version 8.1.1 Copyright (c) 2007-2026 the FFmpeg developers"), + ).toBe("ffprobe 8.1.1"); + }); + + it("handles Windows gyan.dev builds with suffix", () => { + expect( + parseToolVersion( + "ffmpeg version 7.1.1-essentials_build-www.gyan.dev Copyright (c) 2000-2024", + ), + ).toBe("ffmpeg 7.1.1-essentials_build-www.gyan.dev"); + }); + + it("returns trimmed input when pattern does not match", () => { + expect(parseToolVersion(" some unrecognized output ")).toBe("some unrecognized output"); + }); +}); + describe("buildDoctorReport", () => { it("emits the locked schema shape", () => { const report = buildDoctorReport(OUTCOMES_ALL_OK); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index cf84f1ea3..a79a264e2 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,13 +1,18 @@ import { defineCommand } from "citty"; import { execSync } from "node:child_process"; -import { freemem, platform } from "node:os"; +import { platform } from "node:os"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; import { findBrowser } from "../browser/manager.js"; import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import { VERSION } from "../version.js"; import { getUpdateMeta, withMeta } from "../utils/updateCheck.js"; -import { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "../telemetry/system.js"; +import { + getSystemMeta, + getShmSizeMb, + getFreeDiskMb, + getAvailableMemoryMb, +} from "../telemetry/system.js"; export const examples: Example[] = [ ["Check system dependencies", "hyperframes doctor"], @@ -25,13 +30,23 @@ interface CheckResult { hint?: string; } +/** + * Extract a clean "toolname X.Y.Z" from the verbose first line of + * `ffmpeg -version` / `ffprobe -version` output. Falls back to the + * trimmed input when the pattern doesn't match. + */ +export function parseToolVersion(raw: string): string { + const m = raw.match(/(ffmpeg|ffprobe)\s+version\s+([\d][\d.\-\w]*)/i); + return m ? `${m[1]} ${m[2]}` : raw.trim(); +} + function checkFFmpeg(): CheckResult { const path = findFFmpeg(); if (path) { try { - const version = + const raw = execSync("ffmpeg -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? ""; - return { ok: true, detail: version.trim() }; + return { ok: true, detail: parseToolVersion(raw) }; } catch { return { ok: true, detail: path }; } @@ -47,9 +62,9 @@ function checkFFprobe(): CheckResult { // `ffprobe -version` works cross-platform if it's on PATH — no need for // `which`/`where` shell detection, which differs by OS. try { - const version = + const raw = execSync("ffprobe -version", { encoding: "utf-8", timeout: 5000 }).split("\n")[0] ?? ""; - return { ok: true, detail: version.trim() }; + return { ok: true, detail: parseToolVersion(raw) }; } catch { return { ok: false, @@ -124,18 +139,18 @@ function checkCPU(): CheckResult { function checkMemory(): CheckResult { const sys = getSystemMeta(); - const freeMb = bytesToMb(freemem()); // fresh reading, not cached + const availMb = getAvailableMemoryMb(); const totalGb = (sys.memory_total_mb / 1024).toFixed(1); - const freeGb = (freeMb / 1024).toFixed(1); + const availGb = (availMb / 1024).toFixed(1); - if (freeMb < 2048) { + if (availMb < 2048) { return { ok: false, - detail: `${totalGb} GB total \u00B7 ${freeGb} GB free`, + detail: `${totalGb} GB total \u00B7 ${availGb} GB available`, hint: "Low memory — renders may fail. Close other apps or increase RAM.", }; } - return { ok: true, detail: `${totalGb} GB total \u00B7 ${freeGb} GB free` }; + return { ok: true, detail: `${totalGb} GB total \u00B7 ${availGb} GB available` }; } function checkShm(): CheckResult { diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 5426f58b7..bde06804b 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -212,6 +212,41 @@ describe("renderLocal browser GPU config", () => { expect(producerState.createdJobs[0]?.entryFile).toBeUndefined(); }); + it("forwards --browser-timeout into resolveConfig as pageNavigationTimeout (ms)", async () => { + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + pageNavigationTimeoutMs: 180_000, + }); + + expect(producerState.resolveConfigCalls[0]).toMatchObject({ + pageNavigationTimeout: 180_000, + }); + }); + + it("omits pageNavigationTimeout from resolveConfig when --browser-timeout is not set", async () => { + await renderLocal("/tmp/project", "/tmp/out.mp4", { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + }); + + // Issue #1199: when the flag is omitted, the engine's DEFAULT_CONFIG must + // own the navigation timeout. Forwarding `undefined` would override + // `pageNavigationTimeout: 60_000` to `undefined` and re-introduce the + // bug in a different shape. + expect(producerState.resolveConfigCalls[0]).not.toHaveProperty("pageNavigationTimeout"); + }); + it("forwards outputResolution to createRenderJob when --resolution is set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { fps: { num: 30, den: 1 }, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4be88de16..74434dca9 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -6,6 +6,7 @@ import { resolveVariablesArg, validateVariablesAgainstProject, } from "../utils/variables.js"; +import { resolveBrowserTimeoutMsArg, resolveCompositionEntryArg } from "../utils/renderArgs.js"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -49,7 +50,7 @@ import { maybePromptRenderFeedback } from "../telemetry/feedback.js"; import { bytesToMb } from "../telemetry/system.js"; import { VERSION } from "../version.js"; import { isDevMode } from "../utils/env.js"; -import { buildDockerRunArgs } from "../utils/dockerRunArgs.js"; +import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import type { RenderJob } from "@hyperframes/producer"; @@ -119,7 +120,8 @@ export default defineCommand({ alias: "c", description: "Render a specific composition file instead of index.html (e.g. compositions/intro.html). " + - "Sub-compositions using