From 1a617d30ff07d6b4e2dc7236b373ebe50874dfec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 3 Jun 2026 13:22:53 -0700 Subject: [PATCH 01/35] docs(readme): add Frame.md design template gallery (#1182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a **Frame.md** section to the main README — `frame.md` is the design-system-for-video translation layer (a `DESIGN.md` superset rewritten for the frame). Includes a 10-template gallery. Each thumbnail links to its design page on `hyperframes.dev/design/`. ## Templates Biennale Yellow · BlockFrame · Blue Professional · Bold Poster · Broadside · Capsule · Cartesian · Cobalt Grid · Coral · Creative Mode ## Image hosting Preview PNGs uploaded to `static.heygen.ai/hyperframes-oss/docs/images/design-templates/.png` (heygen-public bucket), matching the existing demo-gif convention. All verified `200 image/png`. Design pages all verified `200`. image 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) 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 From 1abe69f3e479b41a804ee651e2874f31b150a4e8 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 3 Jun 2026 20:27:27 -0400 Subject: [PATCH 02/35] feat(docs): add weekly update drafts (#1183) --- docs/contributing/changelog-process.mdx | 20 + docs/docs.json | 1 + docs/weekly-updates.mdx | 11 + package.json | 3 +- scripts/changelog-weekly.test.ts | 79 ++++ scripts/changelog-weekly.ts | 555 ++++++++++++++++++++++++ updates/README.md | 21 + 7 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 docs/weekly-updates.mdx create mode 100644 scripts/changelog-weekly.test.ts create mode 100644 scripts/changelog-weekly.ts create mode 100644 updates/README.md 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/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/scripts/changelog-weekly.test.ts b/scripts/changelog-weekly.test.ts new file mode 100644 index 000000000..c6d917c41 --- /dev/null +++ b/scripts/changelog-weekly.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createWeeklyDraft, parseWeeklyOptions, weeklyPacketPaths } from "./changelog-weekly.ts"; +import { parseCommit, type RawCommit } from "./draft-changelog.ts"; + +function commit(subject: string) { + const raw: RawCommit = { + sha: "1234567890abcdef1234567890abcdef12345678", + shortSha: "1234567", + author: "Test Author", + subject, + }; + + return { + ...parseCommit(raw), + date: "2026-06-03", + }; +} + +describe("weekly changelog arguments", () => { + it("parses date range and write flags", () => { + assert.deepEqual( + parseWeeklyOptions(["--from", "2026-06-01", "--to=2026-06-07", "--write", "--force"]), + { + from: "2026-06-01", + to: "2026-06-07", + write: true, + force: true, + }, + ); + }); +}); + +describe("weekly changelog rendering", () => { + it("creates docs, source, Discord, and X drafts", () => { + const draft = createWeeklyDraft( + { + from: "2026-06-01", + to: "2026-06-07", + write: false, + force: false, + }, + [commit("feat(cli): add render hints (#42)"), commit("fix: repair playback")], + ); + + assert.match(draft.docsUpdate, /label="Week of June 1, 2026"/); + assert.match(draft.weeklyNotes, /HyperFrames weekly digest - June 1, 2026 - June 7, 2026/); + assert.match(draft.discordDraft, /This week's highlights:/); + assert.match(draft.xDraft, /Full update: TODO add docs link/); + }); + + it("keeps internal and editorial-only changes out of top highlights", () => { + const draft = createWeeklyDraft( + { + from: "2026-06-01", + to: "2026-06-07", + write: false, + force: false, + }, + [ + commit("feat(docs): add changelog release workflow (#41)"), + commit("fix(cli): validate cloud render input (#42)"), + commit("chore: update generated baselines (#43)"), + ], + ); + + assert.match(draft.discordDraft, /CLI: Validate cloud render input/); + assert.doesNotMatch(draft.discordDraft, /Docs: Add changelog release workflow/); + assert.doesNotMatch(draft.docsUpdate, /Update generated baselines/); + }); + + it("uses predictable packet paths from the week ending date", () => { + assert.deepEqual(weeklyPacketPaths("2026-06-07"), { + weeklyNotes: "updates/weekly/2026-06-07.md", + discordDraft: "updates/social/2026-06-07.discord.md", + xDraft: "updates/social/2026-06-07.x.md", + }); + }); +}); diff --git a/scripts/changelog-weekly.ts b/scripts/changelog-weekly.ts new file mode 100644 index 000000000..625bbf05e --- /dev/null +++ b/scripts/changelog-weekly.ts @@ -0,0 +1,555 @@ +#!/usr/bin/env tsx + +import { execFileSync } from "child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { pathToFileURL } from "url"; +import { parseMappedArgument, validateCliDate, type InlineValueOption } from "./cli-options.ts"; +import { + escapeForMdx, + formatScope, + parseCommit, + shouldSkipCommit, + type ParsedCommit, + type RawCommit, +} from "./draft-changelog.ts"; + +const ROOT = join(import.meta.dirname, ".."); +const REPO_URL = "https://github.com/heygen-com/hyperframes"; +const DOCS_MARKER = + "{/* New weekly digest entries are prepended by `bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write`. */}"; +const WEEKLY_REVIEW_TODO = ""; + +const CATEGORY_ORDER = [ + "Breaking Changes", + "Features", + "Fixes", + "Performance", + "Catalog", + "Docs & Examples", + "Other Changes", + "Internal", +]; + +const HIGHLIGHT_CATEGORIES = new Set([ + "Breaking Changes", + "Features", + "Fixes", + "Performance", + "Catalog", +]); + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +type WeeklyOptions = { + from: string; + to: string; + write: boolean; + force: boolean; +}; + +type MutableWeeklyOptions = Partial; + +type WeeklyCommit = ParsedCommit & { + date: string; +}; + +type WeeklyDraft = { + docsUpdate: string; + weeklyNotes: string; + discordDraft: string; + xDraft: string; +}; + +type ValueOptionKey = "from" | "to"; +type BooleanOptionKey = "write" | "force"; + +const VALUE_OPTIONS = new Map([ + ["--from", "from"], + ["--to", "to"], +]); + +const BOOLEAN_OPTIONS = new Map([ + ["--write", "write"], + ["--force", "force"], +]); + +const INLINE_VALUE_OPTIONS = [ + { prefix: "--from=", key: "from" }, + { prefix: "--to=", key: "to" }, +] satisfies Array>; + +function main() { + const options = parseWeeklyOptions(process.argv.slice(2)); + const commits = getWeeklyCommits(options); + const draft = createWeeklyDraft(options, commits); + outputWeeklyDraft(options, draft); +} + +export function parseWeeklyOptions(args: string[]): WeeklyOptions { + const parsed = createDefaultOptions(); + + for (let index = 0; index < args.length; index += 1) { + index = parseArgument(args, index, parsed); + } + + return finalizeOptions(parsed); +} + +function createDefaultOptions(): MutableWeeklyOptions { + return { + write: false, + force: false, + }; +} + +function parseArgument(args: string[], index: number, parsed: MutableWeeklyOptions) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + printUsage(); + process.exit(0); + } + + return parseMappedArgument(args, index, parsed, { + inlineValueOptions: INLINE_VALUE_OPTIONS, + valueOptions: VALUE_OPTIONS, + booleanOptions: BOOLEAN_OPTIONS, + parsePositional: (positional) => fail(`Unexpected positional argument: ${positional}`), + fail, + }); +} + +function finalizeOptions(parsed: MutableWeeklyOptions): WeeklyOptions { + const { from, to } = requireDateRange(parsed); + + return { + from, + to, + write: parsed.write ?? false, + force: parsed.force ?? false, + }; +} + +function requireDateRange(parsed: MutableWeeklyOptions) { + if (!parsed.from || !parsed.to) { + printUsage(); + process.exit(1); + } + + validateDateRange(parsed.from, parsed.to); + return { + from: parsed.from, + to: parsed.to, + }; +} + +function validateDateRange(from: string, to: string) { + validateCliDate(from, fail); + validateCliDate(to, fail); + if (from > to) { + fail(`Invalid date range: --from ${from} is after --to ${to}.`); + } +} + +function getWeeklyCommits(options: WeeklyOptions): WeeklyCommit[] { + return getCommits(options.from, options.to) + .filter((commit) => !shouldSkipCommit(commit)) + .map((commit) => ({ + ...parseCommit(commit), + date: commit.date, + })) + .sort(compareCommitsForDigest); +} + +type RawWeeklyCommit = RawCommit & { + date: string; +}; + +function getCommits(from: string, to: string): RawWeeklyCommit[] { + const output = git([ + "log", + "--format=%H%x09%h%x09%an%x09%cs%x09%s", + "--no-merges", + `--since=${from}T00:00:00`, + `--until=${to}T23:59:59`, + ]); + + if (!output) { + return []; + } + + return output.split("\n").map(parseGitLogLine); +} + +function parseGitLogLine(line: string): RawWeeklyCommit { + const [sha = "", shortSha = "", author = "", date = "", ...subjectParts] = line.split("\t"); + return { + sha, + shortSha, + author, + date, + subject: subjectParts.join("\t"), + }; +} + +export function createWeeklyDraft(options: WeeklyOptions, commits: WeeklyCommit[]): WeeklyDraft { + const range = formatDateRange(options.from, options.to); + const highlights = selectHighlights(commits); + + return { + docsUpdate: renderDocsUpdate(options, range, commits, highlights), + weeklyNotes: renderWeeklyNotes(options, range, commits, highlights), + discordDraft: renderDiscordDraft(range, highlights), + xDraft: renderXDraft(range, highlights), + }; +} + +function outputWeeklyDraft(options: WeeklyOptions, draft: WeeklyDraft) { + if (!options.write) { + console.log(draft.weeklyNotes); + console.log("\n--- Discord draft ---\n"); + console.log(draft.discordDraft); + console.log("\n--- X draft ---\n"); + console.log(draft.xDraft); + console.log("\n--- Mintlify update block ---\n"); + console.log(draft.docsUpdate); + console.log( + "\nRun with --write to create the weekly digest packet and prepend the docs entry.", + ); + return; + } + + writeWeeklyPacket(options, draft); + prependDocsUpdate(options, draft.docsUpdate); +} + +function writeWeeklyPacket(options: WeeklyOptions, draft: WeeklyDraft) { + const paths = weeklyPacketPaths(options.to); + mkdirSync(join(ROOT, "updates", "weekly"), { recursive: true }); + mkdirSync(join(ROOT, "updates", "social"), { recursive: true }); + + writeFile(paths.weeklyNotes, draft.weeklyNotes, options.force); + writeFile(paths.discordDraft, draft.discordDraft, options.force); + writeFile(paths.xDraft, draft.xDraft, options.force); +} + +export function weeklyPacketPaths(date: string) { + return { + weeklyNotes: join("updates", "weekly", `${date}.md`), + discordDraft: join("updates", "social", `${date}.discord.md`), + xDraft: join("updates", "social", `${date}.x.md`), + }; +} + +function writeFile(relativePath: string, contents: string, force: boolean) { + try { + writeFileSync(join(ROOT, relativePath), `${contents}\n`, { flag: force ? "w" : "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + fail(`${relativePath} already exists. Pass --force to overwrite it before review.`); + } + throw error; + } + console.log(`Wrote ${relativePath}`); +} + +function prependDocsUpdate(options: WeeklyOptions, docsUpdate: string) { + const weeklyUpdatesPath = join(ROOT, "docs", "weekly-updates.mdx"); + const weeklyUpdates = readFileSync(weeklyUpdatesPath, "utf-8"); + const label = weeklyLabel(options.from); + + if (weeklyUpdates.includes(`label="${label}"`)) { + console.log(`docs/weekly-updates.mdx already has ${label}; leaving it unchanged.`); + return; + } + + if (!weeklyUpdates.includes(DOCS_MARKER)) { + fail(`Could not find insertion marker in ${weeklyUpdatesPath}`); + } + + const updated = weeklyUpdates.replace(DOCS_MARKER, `${DOCS_MARKER}\n\n${docsUpdate}`); + writeFileSync(weeklyUpdatesPath, updated); + console.log(`Prepended ${label} to ${weeklyUpdatesPath}`); +} + +function renderDocsUpdate( + options: WeeklyOptions, + range: string, + commits: WeeklyCommit[], + highlights: WeeklyCommit[], +) { + const alsoNotable = selectAlsoNotable(commits, highlights); + + return [ + "", + WEEKLY_REVIEW_TODO, + "", + "A curated summary of the most important HyperFrames changes this week.", + "", + renderHighlights(highlights, renderMdxWeeklyBullet), + "", + renderAlsoNotable(alsoNotable, renderMdxWeeklyBullet), + "", + "For exact versioned release notes, see the [Changelog](/changelog).", + "", + ].join("\n"); +} + +function renderWeeklyNotes( + options: WeeklyOptions, + range: string, + commits: WeeklyCommit[], + highlights: WeeklyCommit[], +) { + return [ + `# HyperFrames weekly digest - ${range}`, + "", + WEEKLY_REVIEW_TODO, + "", + "This digest is the editable source for the docs weekly update and social drafts.", + "", + "## Highlights", + "", + renderListOrEmpty(highlights, renderMarkdownWeeklyBullet), + "", + "## Full draft", + "", + renderGroupedChanges(commits, renderMarkdownWeeklyBullet), + "", + "## Publishing checklist", + "", + "- Remove the TODO marker after review.", + "- Run this from an up-to-date `main` branch when drafting the real weekly update.", + "- Keep the docs entry in `docs/weekly-updates.mdx` aligned with this source file.", + "- Edit the Discord and X drafts before posting.", + "- Add screenshots, rendered clips, or catalog links where they make the update clearer.", + "", + `Range: ${options.from} through ${options.to}.`, + ].join("\n"); +} + +function renderDiscordDraft(range: string, highlights: WeeklyCommit[]) { + return [ + `# HyperFrames weekly update - ${range}`, + "", + WEEKLY_REVIEW_TODO, + "", + "This week's highlights:", + "", + renderListOrEmpty(highlights, renderPlainWeeklyBullet), + "", + "Read the full update: TODO add docs link after publishing.", + ].join("\n"); +} + +function renderXDraft(range: string, highlights: WeeklyCommit[]) { + const threadItems = + highlights.length > 0 + ? highlights.map((commit, index) => `${index + 1}. ${plainWeeklySummary(commit)}`) + : ["TODO: add the most important user-facing highlights from this week."]; + + return [ + `HyperFrames weekly update - ${range}`, + "", + WEEKLY_REVIEW_TODO, + "", + ...threadItems, + "", + "Full update: TODO add docs link after publishing.", + ].join("\n"); +} + +function renderHighlights( + highlights: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + return ["## Highlights", "", renderListOrEmpty(highlights, renderBullet)].join("\n"); +} + +function renderAlsoNotable( + commits: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + if (commits.length === 0) { + return "## Also notable\n\n- TODO: add any supporting changes worth mentioning."; + } + + return ["## Also notable", "", commits.map(renderBullet).join("\n")].join("\n"); +} + +function renderGroupedChanges( + commits: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + if (commits.length === 0) { + return "No notable changes were found in the selected date range."; + } + + return CATEGORY_ORDER.flatMap((category) => { + const categoryCommits = commits.filter((commit) => commit.category === category); + if (categoryCommits.length === 0) { + return []; + } + + return [`## ${category}`, "", ...categoryCommits.map(renderBullet), ""]; + }) + .join("\n") + .trim(); +} + +function renderListOrEmpty( + commits: WeeklyCommit[], + renderBullet: (commit: WeeklyCommit) => string, +) { + if (commits.length === 0) { + return "- TODO: add the most important user-facing highlights from this week."; + } + + return commits.map(renderBullet).join("\n"); +} + +function renderMarkdownWeeklyBullet(commit: WeeklyCommit) { + const scope = commit.scope ? `**${formatScope(commit.scope)}:** ` : ""; + return `- ${scope}${capitalize(commit.summary)} (${commitLinks(commit).join(", ")})`; +} + +function renderMdxWeeklyBullet(commit: WeeklyCommit) { + const scope = commit.scope ? `**${escapeForMdx(formatScope(commit.scope))}:** ` : ""; + return `- ${scope}${escapeForMdx(capitalize(commit.summary))} (${commitLinks(commit).join(", ")})`; +} + +function renderPlainWeeklyBullet(commit: WeeklyCommit) { + return `- ${plainWeeklySummary(commit)}`; +} + +function plainWeeklySummary(commit: WeeklyCommit) { + const scope = commit.scope ? `${formatScope(commit.scope)}: ` : ""; + return `${scope}${capitalize(commit.summary)}`; +} + +function commitLinks(commit: WeeklyCommit) { + const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`]; + if (commit.prNumber) { + links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`); + } + return links; +} + +function selectHighlights(commits: WeeklyCommit[]) { + const highlighted = commits.filter(isHighImpactCandidate); + const fallback = commits.filter((commit) => commit.category !== "Internal"); + return (highlighted.length > 0 ? highlighted : fallback).slice(0, 5); +} + +function selectAlsoNotable(commits: WeeklyCommit[], highlights: WeeklyCommit[]) { + const highlightedShas = new Set(highlights.map((commit) => commit.sha)); + return commits + .filter((commit) => commit.category !== "Internal") + .filter((commit) => !highlightedShas.has(commit.sha)) + .slice(0, 5); +} + +function isHighImpactCandidate(commit: WeeklyCommit) { + return HIGHLIGHT_CATEGORIES.has(commit.category) && !isEditorialOnlyScope(commit.scope); +} + +function isEditorialOnlyScope(scope: string | undefined) { + if (!scope) { + return false; + } + + return ["docs", "readme", "skills"].includes(scope.toLowerCase()); +} + +function compareCommitsForDigest(a: WeeklyCommit, b: WeeklyCommit) { + const categoryDelta = categoryRank(a.category) - categoryRank(b.category); + if (categoryDelta !== 0) { + return categoryDelta; + } + + return b.date.localeCompare(a.date); +} + +function categoryRank(category: string) { + const index = CATEGORY_ORDER.indexOf(category); + return index === -1 ? CATEGORY_ORDER.length : index; +} + +function weeklyLabel(from: string) { + return `Week of ${formatDate(from)}`; +} + +function formatDateRange(from: string, to: string) { + return `${formatDate(from)} - ${formatDate(to)}`; +} + +function formatDate(date: string) { + const { year, month, day } = dateParts(date); + return `${MONTHS[month - 1]} ${day}, ${year}`; +} + +function dateParts(date: string) { + const [year = "0", month = "0", day = "0"] = date.split("-"); + return { + year, + month: Number(month), + day: Number(day), + }; +} + +function capitalize(value: string) { + if (!value) { + return value; + } + return value[0].toUpperCase() + value.slice(1); +} + +function git(args: string[]) { + return execFileSync("git", args, { + cwd: ROOT, + encoding: "utf-8", + }).trim(); +} + +function printUsage() { + console.log(`changelog:weekly drafts an editable weekly digest packet. + +Usage: + bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD [--write] [--force] + +Examples: + bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 + bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write + bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write --force +`); +} + +function fail(message: string): never { + console.error(`changelog:weekly: ${message}`); + process.exit(1); +} + +function isDirectRun(scriptPath: string | undefined) { + return scriptPath ? import.meta.url === pathToFileURL(scriptPath).href : false; +} + +if (isDirectRun(process.argv[1])) { + main(); +} diff --git a/updates/README.md b/updates/README.md new file mode 100644 index 000000000..70e948e9a --- /dev/null +++ b/updates/README.md @@ -0,0 +1,21 @@ +# Weekly updates + +Weekly digest source files and social drafts live here. + +Generate the next editorial packet with: + +```bash +bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write +``` + +Run the command from an up-to-date `main` branch so the draft reflects the public Git history for the selected week. + +The command creates: + +- `updates/weekly/YYYY-MM-DD.md` +- `updates/social/YYYY-MM-DD.discord.md` +- `updates/social/YYYY-MM-DD.x.md` + +It also prepends a matching entry to `docs/weekly-updates.mdx`. + +Review and rewrite the generated files before publishing. Social drafts are distribution copy for humans to post manually; they are not posted automatically. From 6de6ea53498ddebcfb4c242602dd5b8f75b5e846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 20:38:40 -0400 Subject: [PATCH 03/35] fix: delay ObjectURL revocation and silence TS5 baseUrl deprecations (#1181) - Delay URL.revokeObjectURL() from 0ms to 1000ms in useFrameCapture so the browser has time to initiate the download before the blob is freed. A 0ms timeout fires synchronously after the current microtask queue, before the browser's download machinery reads the URL. - Add ignoreDeprecations: '5.0' to cli and studio tsconfigs to silence TypeScript baseUrl/paths deprecation warnings without changing behavior. Co-authored-by: Jefsky Wong --- packages/cli/tsconfig.json | 3 ++- packages/studio/src/hooks/useFrameCapture.ts | 2 +- packages/studio/tsconfig.json | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 1185f6900..4908e2c64 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -14,7 +14,8 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist", - "declaration": true + "declaration": true, + "ignoreDeprecations": "5.0" }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts index 3cba1d408..c7033bda3 100644 --- a/packages/studio/src/hooks/useFrameCapture.ts +++ b/packages/studio/src/hooks/useFrameCapture.ts @@ -69,7 +69,7 @@ export function useFrameCapture({ document.body.appendChild(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 0); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } catch (fetchErr) { clearTimeout(timeout); if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") { diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index 2d6dc608f..52c0e2b2d 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -23,7 +23,8 @@ "noEmit": true, "incremental": true, "resolveJsonModule": true, - "isolatedModules": true + "isolatedModules": true, + "ignoreDeprecations": "5.0" }, "include": ["src"], "exclude": ["dist", "node_modules", "src/**/*.test.ts"] From cabd0616ea8c194988a1010ff4182a8b72cbe68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 22:53:08 -0400 Subject: [PATCH 04/35] fix(cli): suppress EPIPE crashes in piped agent environments (#1184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): suppress EPIPE crashes in piped agent environments When the CLI runs inside a piped environment (Claude Code, Codex, Cursor), the reader may close the pipe before we finish writing. Node treats EPIPE on stdout/stderr as an uncaughtException, crashing the process with a non-zero exit code. Add stream-level EPIPE handlers on stdout/stderr at the top of the entry point (before any output) and make the uncaughtException handler EPIPE-aware so it exits cleanly (code 0) instead of crash-reporting. PostHog data: ~10,300 EPIPE errors over 10 days, contributing to the preview command's 43-59% failure rate in agent environments. * fix(cli): set commandFailed before EPIPE exit to prevent false success telemetry EPIPE is a pipe-reader-closed signal, not a successful run. The exit handler uses 'code === 0 && !commandFailed' to determine success — without setting commandFailed=true before process.exit(0), every EPIPE exit was recorded as success:true in telemetry. Moves the commandFailed declaration to the top of the file so the stream-error EPIPE handlers (which must run before any writes) can reference it. Also sets commandFailed=true in the uncaughtException EPIPE path for the same reason. --- packages/cli/src/cli.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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, From 8c6faa45b5076d313d66962e818d26f10ba910f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 22:53:16 -0400 Subject: [PATCH 05/35] fix(cli): lazy-load @puppeteer/browsers to prevent debug package crash (#1185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): lazy-load @puppeteer/browsers to prevent debug package crash Convert the static `import { ... } from "@puppeteer/browsers"` in browser/manager.ts to dynamic imports inside the async functions that use them. This eliminates a module-load-time crash when the transitive `debug` dependency is missing or corrupted. Previously, every CLI command (including init, lint, docs, help) would crash with "Cannot find package debug" if the debug package was absent — even though only browser-related commands need @puppeteer/browsers. Also add `debug` as a direct dependency so npm/bun always installs it explicitly rather than relying on transitive resolution. PostHog data: ~3,955 total-CLI-crash occurrences since May 29. * fix(cli): simplify isLinuxArm to sync inline check and surface real load error isLinuxArm() was async only to call detectBrowserPlatform() from @puppeteer/browsers, but that function just checks process.platform + process.arch under the hood. Replace with a direct inline check and make the function sync — no behavioral change, removes an unnecessary async boundary and an eager load of the package we're trying to lazy-load. Also surface the real error from loadPuppeteerBrowsers() catch block instead of hard-coding 'likely missing transitive dependency "debug"' — the actual cause could be anything (missing package, corrupt install, wrong Node ABI). --- .fallowrc.jsonc | 4 ++++ bun.lock | 17 +++++++++-------- packages/cli/package.json | 1 + packages/cli/src/browser/manager.ts | 20 ++++++++++++++++++-- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a14d83a41..f8b76721a 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -99,6 +99,10 @@ // 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/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/packages/cli/package.json b/packages/cli/package.json index 84f2afb05..0cd5ec2a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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 Date: Wed, 3 Jun 2026 23:06:09 -0400 Subject: [PATCH 06/35] fix(engine): fast-fail on zero duration instead of 45s timeout (#1186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): fast-fail on zero duration instead of 45s timeout When a composition's runtime finishes initializing but reports zero duration (no GSAP timeline and no data-duration attribute), the engine previously polled for the full 45-second timeout before failing. Now, after 10 seconds of polling, the engine checks whether the runtime has finished (window.__renderReady === true) with a working seek function but zero duration. If so, it fails immediately with a diagnostic message explaining what's wrong and how to fix it. This also improves the generic timeout error message to include runtime state (whether __player exists, __hf.seek, GSAP timelines, declared duration) so users can self-diagnose. PostHog data: 555-1,234 occurrences/day, each wasting 45s of user time. * fix(engine): throttle diagnostic polls and tighten zero-duration fast-fail Two nit fixes in pollHfReady: 1. Throttle evaluateHfDiagnostic calls to once per ~1000ms after the 10s mark. Previously called on every 100ms loop tick, generating ~350 CDP round-trips per failed render. One check per second is sufficient to detect a permanently-zero composition. 2. Change fast-fail condition from 'duration === 0' to '!hasTimeline && declaredDuration <= 0'. A composition with a GSAP timeline but no data-duration attribute should not be fast-failed — GSAP sets duration synchronously before __renderReady via __timelines, so a non-empty __timelines is a reliable signal that duration will eventually be non-zero. Only compositions with NEITHER a GSAP timeline NOR a declared duration are permanently zero. --- packages/engine/src/services/frameCapture.ts | 153 ++++++++++++++++--- 1 file changed, 128 insertions(+), 25 deletions(-) diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 9bb268cf8..78613c3dc 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file complexity /** * Frame Capture Service * @@ -172,6 +173,7 @@ async function waitForCloseWithTimeout(promise: Promise): Promise 0); + var root = document.querySelector("[data-composition-id]"); + var declaredDuration = root ? Number(root.getAttribute("data-duration")) : -1; + return { + renderReady: renderReady, + hasHf: !!hf, + hasSeek: hasSeek, + hasPlayer: !!player, + duration: duration, + hasTimeline: hasTimeline, + declaredDuration: declaredDuration, + }; +})()`; + +// fallow-ignore-next-line complexity +function buildZeroDurationDiagnostic(diag: { + renderReady: boolean; + hasHf: boolean; + hasSeek: boolean; + hasPlayer: boolean; + duration: number; + hasTimeline: boolean; + declaredDuration: number; +}): string { + const hints: string[] = []; + if (!diag.hasPlayer) { + hints.push("window.__player was never set — the HyperFrames runtime did not initialize."); + } + if (!diag.hasTimeline) { + hints.push( + "No GSAP timeline registered (window.__timelines is empty). " + + "If using CSS/WAAPI/Lottie/Three.js animations, add data-duration to the root element.", + ); + } + if (diag.declaredDuration <= 0 && !diag.hasTimeline) { + hints.push( + 'Fix: add data-duration="" to your root
element.', + ); + } + if (diag.hasSeek && diag.duration === 0 && diag.renderReady) { + hints.push("The runtime finished initializing but reported zero duration — this is permanent."); + } + return ( + `[FrameCapture] Composition has zero duration.\n` + + ` Runtime ready: ${diag.renderReady}, __player: ${diag.hasPlayer}, ` + + `__hf.seek: ${diag.hasSeek}, GSAP timeline: ${diag.hasTimeline}, ` + + `data-duration: ${diag.declaredDuration > 0 ? diag.declaredDuration + "s" : "not set"}\n` + + (hints.length > 0 ? hints.map((h) => ` → ${h}`).join("\n") : "") + ); +} + +interface HfDiagnostic { + renderReady: boolean; + hasHf: boolean; + hasSeek: boolean; + hasPlayer: boolean; + duration: number; + hasTimeline: boolean; + declaredDuration: number; +} + +async function evaluateHfDiagnostic(page: Page): Promise { + return (await page.evaluate(HF_READY_DIAGNOSTIC_EXPR)) as HfDiagnostic; +} + +async function pollHfReady(page: Page, timeoutMs: number, intervalMs: number = 100): Promise { + const readyExpr = `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`; + const FAST_FAIL_AFTER_MS = 10_000; + // Throttle diagnostic CDP calls to ~1000ms — running evaluateHfDiagnostic on + // every 100ms poll tick after the 10s mark generates ~350 unnecessary CDP + // round-trips per failed render. One diagnostic per second is enough. + const DIAGNOSTIC_INTERVAL_MS = 1_000; + const deadline = Date.now() + timeoutMs; + let lastDiagnosticAt = 0; + + while (Date.now() < deadline) { + const ready = Boolean(await page.evaluate(readyExpr)); + if (ready) return; + + const elapsed = timeoutMs - (deadline - Date.now()); + if (elapsed >= FAST_FAIL_AFTER_MS) { + const now = Date.now(); + if (now - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) { + lastDiagnosticAt = now; + const diag = await evaluateHfDiagnostic(page); + // Only fast-fail when BOTH signals are permanently zero: + // 1. No GSAP timeline registered (GSAP sets duration synchronously + // before __renderReady, so a missing timeline won't self-correct). + // 2. No data-duration declared on the root element. + // A composition with a GSAP timeline but no data-duration is still + // valid — GSAP drives duration via __timelines, not data-duration. + if (diag.renderReady && diag.hasSeek && !diag.hasTimeline && diag.declaredDuration <= 0) { + throw new Error(buildZeroDurationDiagnostic(diag)); + } + } + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + const diag = await evaluateHfDiagnostic(page); + if (diag.hasSeek && diag.duration === 0) { + throw new Error(buildZeroDurationDiagnostic(diag)); + } + throw new Error( + `[FrameCapture] window.__hf not ready after ${timeoutMs}ms. ` + + `Page must expose window.__hf = { duration, seek }.\n` + + ` State: __hf=${diag.hasHf}, seek=${diag.hasSeek}, player=${diag.hasPlayer}, ` + + `renderReady=${diag.renderReady}, duration=${diag.duration}`, + ); +} + async function pollSubCompositionTimelines( page: Page, timeoutMs: number, @@ -436,6 +556,7 @@ async function applyVideoMetadataHints( ): Promise { if (!hints || hints.length === 0) return; + // fallow-ignore-next-line complexity await page.evaluate( (metadataHints: CaptureVideoMetadataHint[]) => { for (const hint of metadataHints) { @@ -488,10 +609,12 @@ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Prom } } +// fallow-ignore-next-line unit-size export async function initializeSession(session: CaptureSession): Promise { const { page, serverUrl } = session; // Forward browser console to host with [Browser] prefix + // fallow-ignore-next-line complexity page.on("console", (msg: ConsoleMessage) => { const type = msg.type(); const text = msg.text(); @@ -546,16 +669,7 @@ export async function initializeSession(session: CaptureSession): Promise const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout; - const pageReady = await pollPageExpression( - page, - `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`, - pageReadyTimeout, - ); - if (!pageReady) { - throw new Error( - `[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`, - ); - } + await pollHfReady(page, pageReadyTimeout); await pollSubCompositionTimelines(page, pageReadyTimeout); @@ -666,22 +780,11 @@ export async function initializeSession(session: CaptureSession): Promise // Poll for window.__hf readiness using manual evaluate loop (waitForFunction // uses rAF polling internally, which won't fire in beginFrame mode). const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout; - const pollDeadline = Date.now() + pageReadyTimeout; - while (Date.now() < pollDeadline) { - const ready = await page.evaluate( - `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`, - ); - if (ready) break; - await new Promise((r) => setTimeout(r, 100)); - } - const pageReady = await page.evaluate( - `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`, - ); - if (!pageReady) { + try { + await pollHfReady(page, pageReadyTimeout); + } catch (err) { warmupState.running = false; - throw new Error( - `[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`, - ); + throw err; } await pollSubCompositionTimelines(page, pageReadyTimeout); From c6a91ab0e43f1f356f8b483d08ae46706581bc54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 4 Jun 2026 03:11:40 +0000 Subject: [PATCH 07/35] chore: release v0.6.71 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- docs/changelog.mdx | 26 ++++++++++++++++++++++++ packages/aws-lambda/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/engine/package.json | 2 +- packages/player/package.json | 2 +- packages/producer/package.json | 2 +- packages/shader-transitions/package.json | 2 +- packages/studio/package.json | 2 +- releases/v0.6.71.md | 25 +++++++++++++++++++++++ 13 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 releases/v0.6.71.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 20b3e3c63..85b4b3aa1 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.71", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0b67205e9..69e7f8777 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hyperframes", - "version": "0.6.70", + "version": "0.6.71", "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..369e8f742 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.71", "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/docs/changelog.mdx b/docs/changelog.mdx index 373e280de..928988dbf 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,32 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +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). + + Date: Thu, 4 Jun 2026 03:13:44 -0400 Subject: [PATCH 08/35] fix(cli): support arm64 hosts for `--docker` render (#1196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): support arm64 hosts for `--docker` render The Docker render path pinned `--platform linux/amd64` for both build and run, which on Apple Silicon / Graviton forced qemu emulation of chrome-headless-shell. The emulated chrome process either SEGV'd or hung on page navigation, producing the failures reported in #1193 / #1194 / #1195. Derive the platform from `process.arch` instead. On arm64 hosts: - The image builds natively (no qemu). - The Dockerfile skips the chrome-headless-shell install because Chrome for Testing only publishes a `linux64` build (verified against the known-good-versions manifest). - The wrapper script leaves `PRODUCER_HEADLESS_SHELL_PATH` unset when no headless-shell binary is present, so the engine falls back to the system chromium that the Dockerfile already installs from apt and points at via `PUPPETEER_EXECUTABLE_PATH`. `TARGETARCH` is forwarded as an explicit `--build-arg` instead of relying on BuildKit's automatic platform args — the legacy builder (and some BuildKit configs, including colima on macOS) leaves it unset, which would silently bypass the arch conditional in the Dockerfile. Image tags are now suffixed with `-arm64` on arm64 hosts so amd64 and arm64 images of the same hyperframes version can coexist in the local cache. The arm64 path renders correctly but loses byte-for-byte parity with amd64 (system chromium uses screenshot capture, not HeadlessExperimental.beginFrame). The CLI prints a one-line warning so users comparing against amd64 baselines know. Verified on macOS 26.5 / M4 Max: - Before: `qemu: unknown option 'type=gpu-process'` followed by a chrome-headless-shell SIGSEGV after ~4 minutes. - After: 300/300 frames captured in ~18s of render time (1m18s wallclock including a one-time image build), MP4 produced. Closes #1193 Closes #1194 Closes #1195 * fix(cli): address review feedback on docker arm64 fix Follow-up to 61880cdc. Addresses one substantive review comment from @vanceingalls and three self-review gaps. 1. Restore loud build failure on amd64 when chrome-headless-shell is missing (per @vanceingalls). The original Dockerfile used an `&&` chain that crashed the build if `find` returned empty; the new `if/else` wrapper silently fell through to system chromium even on amd64, which would mask golden-baseline regressions from a future @puppeteer/browsers cache layout change. The else branch now checks `TARGETARCH = amd64` and exits 1 with an actionable error, while arm64 still falls through to the system-chromium wrapper cleanly. 2. Add `HYPERFRAMES_DOCKER_PLATFORM` env override. The fix derives platform from `process.arch`, which silently picks the wrong arch in three real-world cases: x64 Node under Rosetta on Apple Silicon (re-triggers issue #1193), parity-regen for amd64 golden baselines on an arm64 host, and DOCKER_HOST pointing at a remote daemon with a different arch. Empty/whitespace override is a no-op (falls back to arch detection) so `export FOO=""` doesn't pin platform to "". 3. Fail fast when `--gpu` is requested on arm64. Docker Desktop on Apple Silicon doesn't implement `--gpus` passthrough; the previous code would crash at `docker run` with an opaque device-driver error. We now short-circuit with errorBox pointing at the env override as the workaround. 4. Close the test gap on the default-arch resolution. Every previous test passed `arch` explicitly; a refactor that dropped the `= process.arch` default would pass all tests but break every arm64 host at runtime. Added one assertion that calls `resolveDockerPlatform()` with no args, plus coverage for the env override. The new arm64 platform-checking logic is extracted into `resolveDockerHostPlatform()` so `renderDocker` itself stays focused on the build/run wiring (and below the fallow complexity gate). Test plan: - `bunx vitest run packages/cli/src/utils/dockerRunArgs.test.ts` — 31 passed (was 27). - `bunx vitest run packages/cli` — 647 passed (was 643). - E2E on macOS 26.5 / M4 Max: deleted the cached arm64 image, ran `--docker --quality draft --workers 1` against the blank scaffold — 300/300 frames in 1m1s wallclock, MP4 produced. --- packages/cli/src/commands/render.ts | 77 ++++++++++++++++-- packages/cli/src/docker/Dockerfile.render | 40 +++++++-- packages/cli/src/utils/dockerRunArgs.test.ts | 86 +++++++++++++++++++- packages/cli/src/utils/dockerRunArgs.ts | 38 ++++++++- 4 files changed, 225 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4be88de16..ace2072b3 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -49,7 +49,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"; @@ -632,15 +632,23 @@ function dockerImageExists(tag: string): boolean { } } -function ensureDockerImage(version: string, quiet: boolean): string { - const tag = dockerImageTag(version); +function dockerImageTagForPlatform(version: string, platform: string): string { + // Suffix the tag with the arch so amd64 and arm64 images of the same + // hyperframes version coexist in the local cache (a developer who flips + // between hosts shouldn't have to rebuild). + const archSuffix = platform === "linux/arm64" ? "-arm64" : ""; + return `${dockerImageTag(version)}${archSuffix}`; +} + +function ensureDockerImage(version: string, platform: string, quiet: boolean): string { + const tag = dockerImageTagForPlatform(version, platform); if (dockerImageExists(tag)) { if (!quiet) console.log(c.dim(` Docker image: ${tag} (cached)`)); return tag; } - if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`)); + if (!quiet) console.log(c.dim(` Building Docker image: ${tag} (${platform})...`)); const dockerfilePath = resolveDockerfilePath(); @@ -649,16 +657,27 @@ function ensureDockerImage(version: string, quiet: boolean): string { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath)); - // linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries + // Platform is now derived from the host arch (see resolveDockerPlatform). + // Apple Silicon and other arm64 hosts get a native linux/arm64 build; the + // Dockerfile skips chrome-headless-shell on arm64 and falls back to system + // chromium because chrome-headless-shell ships linux64 only. + // + // TARGETARCH is passed explicitly rather than relying on BuildKit's + // automatic platform args because the legacy builder (and some BuildKit + // configurations like colima 0.6.x) leaves it unset, which would defeat + // the arch conditional in the Dockerfile. + const targetArch = platform === "linux/arm64" ? "arm64" : "amd64"; try { execFileSync( "docker", [ "build", "--platform", - "linux/amd64", + platform, "--build-arg", `HYPERFRAMES_VERSION=${version}`, + "--build-arg", + `TARGETARCH=${targetArch}`, "-t", tag, tmpDir, @@ -676,6 +695,47 @@ function ensureDockerImage(version: string, quiet: boolean): string { return tag; } +/** + * Resolves the Docker `--platform` for this host and enforces the constraints + * that come with it — keeping that policy out of `renderDocker` so the + * orchestrator stays focused on build/run wiring. May terminate the process + * via errorBox on unrecoverable mismatches (e.g. --gpu on arm64). + */ +function resolveDockerHostPlatform(options: RenderOptions): string { + const platform = resolveDockerPlatform(); + + // Docker Desktop on Apple Silicon (and colima with VZ) doesn't implement + // the `--gpus` host-passthrough flag, so requesting `--gpu` on a linux/arm64 + // container fails at `docker run` with an opaque device-driver error. Catch + // it early with actionable guidance. + if (options.gpu && platform === "linux/arm64") { + errorBox( + "--gpu is not supported with --docker on arm64 hosts", + "Docker Desktop/colima on Apple Silicon doesn't expose --gpus host passthrough to linux/arm64 containers.", + "Drop --gpu, or run a native (non-Docker) render on this host, or set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 if you need GPU encoding (slow under qemu but works).", + ); + process.exit(1); + } + + if (!options.quiet && platform === "linux/arm64") { + // chrome-headless-shell doesn't publish a linux-arm64 build, so the arm64 + // image falls back to system chromium. That loses byte-for-byte parity + // with amd64 renders — fine for end-user output, not fine if you're + // comparing against an amd64 golden baseline. Set + // HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to keep parity (qemu-emulated, + // slower). + console.log( + c.dim( + " Host is arm64 — using linux/arm64 image with system chromium " + + "(output won't be byte-identical to amd64 renders; " + + "set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to force parity).", + ), + ); + } + + return platform; +} + async function renderDocker( projectDir: string, outputPath: string, @@ -689,9 +749,11 @@ async function renderDocker( console.log(c.dim(" Dev mode: using hyperframes@latest in Docker image")); } + const platform = resolveDockerHostPlatform(options); + let imageTag: string; try { - imageTag = ensureDockerImage(dockerVersion, options.quiet); + imageTag = ensureDockerImage(dockerVersion, platform, options.quiet); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); const isDockerMissing = /connect|not found|ENOENT/i.test(message); @@ -712,6 +774,7 @@ async function renderDocker( projectDir: resolve(projectDir), outputDir: resolve(outputDir), outputFilename, + platform, options: { fps: options.fps, quality: options.quality, diff --git a/packages/cli/src/docker/Dockerfile.render b/packages/cli/src/docker/Dockerfile.render index ba3cb12c5..ab9b2b170 100644 --- a/packages/cli/src/docker/Dockerfile.render +++ b/packages/cli/src/docker/Dockerfile.render @@ -1,6 +1,10 @@ FROM node:22-bookworm-slim ARG HYPERFRAMES_VERSION=latest +# Set automatically by `docker build --platform` (BuildKit); we use it to +# decide whether to install chrome-headless-shell, which only ships for +# linux64 (see https://googlechromelabs.github.io/chrome-for-testing/). +ARG TARGETARCH=amd64 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl unzip ffmpeg chromium \ @@ -16,16 +20,38 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV CONTAINER=true -RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \ - --path /root/.cache/puppeteer +# chrome-headless-shell unlocks BeginFrame-based deterministic capture but the +# project only publishes a linux64 binary. On arm64 we skip the install and +# let the engine fall back to system chromium (set via +# PUPPETEER_EXECUTABLE_PATH above). The wrapper script below only sets +# PRODUCER_HEADLESS_SHELL_PATH when the binary is actually present. +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + npx --yes @puppeteer/browsers install chrome-headless-shell@stable \ + --path /root/.cache/puppeteer; \ + else \ + echo "Skipping chrome-headless-shell install on ${TARGETARCH} (linux64-only); using system chromium."; \ + fi RUN npm install -g hyperframes@${HYPERFRAMES_VERSION} -# Wrapper script: resolves chrome-headless-shell path at build time, -# sets PRODUCER_HEADLESS_SHELL_PATH at runtime so the engine uses -# BeginFrame rendering instead of falling back to system Chromium. -RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f | head -1) \ - && printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render \ +# Wrapper script: resolves chrome-headless-shell path at build time when +# available so the engine uses BeginFrame rendering. On arm64 (no +# chrome-headless-shell) it leaves PRODUCER_HEADLESS_SHELL_PATH unset, so the +# engine falls back to PUPPETEER_EXECUTABLE_PATH (system chromium). +# +# If TARGETARCH=amd64 and the binary is missing, fail the build loudly — the +# previous (pre-PR) wrapper used an `&&` chain that crashed `docker build` in +# this case, and silently downgrading to system chromium on amd64 would mask +# golden-baseline regressions. +RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f 2>/dev/null | head -1); \ + if [ -n "$SHELL_PATH" ]; then \ + printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render; \ + elif [ "$TARGETARCH" = "amd64" ]; then \ + echo "ERROR: chrome-headless-shell binary not found on amd64 — @puppeteer/browsers install must have failed or moved its cache layout." >&2; \ + exit 1; \ + else \ + printf '#!/bin/sh\nexec hyperframes render "$@"\n' > /usr/local/bin/hf-render; \ + fi \ && chmod +x /usr/local/bin/hf-render WORKDIR /project diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 7409bdb85..0b802d586 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildDockerRunArgs, type DockerRenderOptions } from "./dockerRunArgs.js"; +import { + buildDockerRunArgs, + resolveDockerPlatform, + type DockerRenderOptions, +} from "./dockerRunArgs.js"; const BASE: DockerRenderOptions = { fps: { num: 30, den: 1 }, @@ -18,6 +22,10 @@ const FIXED_INPUT = { projectDir: "/abs/proj", outputDir: "/abs/out", outputFilename: "out.mp4", + // Pin platform in tests so snapshots are arch-independent (otherwise they + // flip between linux/amd64 and linux/arm64 depending on the host running + // the test). + platform: "linux/amd64", }; describe("buildDockerRunArgs", () => { @@ -290,4 +298,80 @@ describe("buildDockerRunArgs", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); expect(args).not.toContain("--no-page-side-compositing"); }); + + // Regression for #1193: an arm64 host (Apple Silicon) was being pinned to + // linux/amd64, which forced qemu emulation of chrome-headless-shell and + // produced either navigation timeouts or chrome SEGVs. Each host arch must + // land in its native --platform value. + it("emits linux/arm64 when host platform is arm64", () => { + const args = buildDockerRunArgs({ + imageTag: "hyperframes-renderer:0.0.0-test", + projectDir: "/abs/proj", + outputDir: "/abs/out", + outputFilename: "out.mp4", + platform: "linux/arm64", + options: BASE, + }); + const idx = args.indexOf("--platform"); + expect(idx).toBeGreaterThanOrEqual(0); + expect(args[idx + 1]).toBe("linux/arm64"); + }); + + it("emits linux/amd64 when platform is explicitly amd64", () => { + const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); + const idx = args.indexOf("--platform"); + expect(idx).toBeGreaterThanOrEqual(0); + expect(args[idx + 1]).toBe("linux/amd64"); + }); +}); + +describe("resolveDockerPlatform", () => { + it("maps arm64 hosts to linux/arm64", () => { + expect(resolveDockerPlatform("arm64", {})).toBe("linux/arm64"); + }); + + it("maps x64 hosts to linux/amd64", () => { + expect(resolveDockerPlatform("x64", {})).toBe("linux/amd64"); + }); + + it("treats unknown architectures as linux/amd64 (safe default)", () => { + expect(resolveDockerPlatform("riscv64", {})).toBe("linux/amd64"); + }); + + // Regression guard: the production call site is `resolveDockerPlatform()` + // with no args. If a refactor drops either default parameter, every other + // arch-mapping test would still pass — this one fails loudly. + it("uses process.arch and process.env when called with no arguments", () => { + const result = resolveDockerPlatform(); + // Must equal the explicit-arg form (env override notwithstanding, which + // wouldn't be set in the test runner unless deliberately stubbed). + const expected = process.env.HYPERFRAMES_DOCKER_PLATFORM + ? process.env.HYPERFRAMES_DOCKER_PLATFORM + : resolveDockerPlatform(process.arch, {}); + expect(result).toBe(expected); + }); + + it("honors HYPERFRAMES_DOCKER_PLATFORM override on an arm64 host (Rosetta-Node / parity-regen escape hatch)", () => { + expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: "linux/amd64" })).toBe( + "linux/amd64", + ); + }); + + it("honors HYPERFRAMES_DOCKER_PLATFORM override on an amd64 host", () => { + expect(resolveDockerPlatform("x64", { HYPERFRAMES_DOCKER_PLATFORM: "linux/arm64" })).toBe( + "linux/arm64", + ); + }); + + it("trims whitespace from HYPERFRAMES_DOCKER_PLATFORM and ignores empty override", () => { + expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: " linux/amd64 " })).toBe( + "linux/amd64", + ); + // Empty/whitespace-only override falls back to arch detection — important + // for shells where `export FOO=""` would otherwise pin platform to "". + expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: "" })).toBe("linux/arm64"); + expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: " " })).toBe( + "linux/arm64", + ); + }); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 5237036c8..6e9cc399c 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -17,6 +17,16 @@ export interface DockerRunArgsInput { outputDir: string; /** Filename within `outputDir` (joined to /output inside the container). */ outputFilename: string; + /** + * Docker `--platform` value (`linux/amd64` or `linux/arm64`). When omitted, + * resolves to the host architecture via `resolveDockerPlatform()`. Pinning + * to `linux/amd64` on an arm64 host (the legacy default) forces qemu + * emulation of chrome-headless-shell, which segfaults or stalls on Apple + * Silicon — see issue #1193. Native `linux/arm64` falls back to the + * system chromium baked into the image at the cost of byte-for-byte + * parity with amd64 renders. + */ + platform?: string; options: DockerRenderOptions; } @@ -44,13 +54,39 @@ export interface DockerRenderOptions { pageSideCompositing?: boolean; } +/** + * Maps Node's `process.arch` to a Docker `--platform` string. We only emit + * the two architectures the renderer actively supports — arm64 hosts (Apple + * Silicon, Graviton, Ampere) and everything else (treated as amd64). + * + * Honors `HYPERFRAMES_DOCKER_PLATFORM` as an escape hatch (typed loosely so + * the override can target future platforms without a CLI release): + * + * - Apple Silicon users running an x64 Node binary under Rosetta (where + * `process.arch === "x64"` despite the host being arm64) can set it to + * `linux/arm64` to avoid re-triggering issue #1193. + * - Maintainers regenerating amd64 golden baselines on an arm64 host can set + * it to `linux/amd64` to keep the byte-for-byte guarantee. + * - Users on remote daemons (`DOCKER_HOST=ssh://amd64-server`) can force the + * actual daemon arch instead of relying on local `process.arch`. + */ +export function resolveDockerPlatform( + arch: string = process.arch, + env: NodeJS.ProcessEnv = process.env, +): string { + const override = env.HYPERFRAMES_DOCKER_PLATFORM; + if (override && override.trim() !== "") return override.trim(); + return arch === "arm64" ? "linux/arm64" : "linux/amd64"; +} + export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { const { imageTag, projectDir, outputDir, outputFilename, options } = input; + const platform = input.platform ?? resolveDockerPlatform(); return [ "run", "--rm", "--platform", - "linux/amd64", + platform, "--shm-size=2g", // GPU encoding requires host GPU passthrough. ...(options.gpu ? ["--gpus", "all"] : []), From 72c461d86a8d24a82d03267262d7cd7a9a373c1e Mon Sep 17 00:00:00 2001 From: James Russo Date: Thu, 4 Jun 2026 03:28:20 -0400 Subject: [PATCH 09/35] fix(producer): localize remote sources + await image readiness (#1197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(producer): localize remote sources + await image readiness Producer's frame-capture has `pollVideosReady` (waits readyState >= 2 for every