Merge remote-tracking branch 'origin/main' into miao/journey-test

This commit is contained in:
Miao Yang
2026-10-30 01:17:06 +08:00
61 changed files with 932 additions and 147 deletions
+1 -1
View File
@@ -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 capture for HyperFrames.",
"version": "0.8.215",
"version": "0.8.216",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hyperframes",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.8.215",
"version": "0.8.216",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "hyperframes",
"displayName": "HyperFrames by HeyGen",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.8.215",
"version": "0.8.216",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com"
+67
View File
@@ -8,6 +8,73 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
<Update
label="HyperFrames v0.8.216"
description="Released - 2026-10-29"
tags={["Release", "CLI", "Player", "AWS Lambda"]}
>
This release carries every change from v0.8.215, which never reached npm. iPhone Safari keeps every take's sound, multi-worker renders land each frame in place, and encoding runs faster.
## Features
- **Telemetry:** Identify signed-in CLI users by their HyperFrames user id, and say signed-in usage is not anonymous ([1c3fcf4d2](https://github.com/heygen-com/hyperframes/commit/1c3fcf4d237d031107baa6a5646b5bb1ee82cc2a), [#6703](https://github.com/heygen-com/hyperframes/pull/6703)).
- **Producer:** Report which element set the film length ([3ca1005a8](https://github.com/heygen-com/hyperframes/commit/3ca1005a81e76565a69d5874371c857321b66d59), [#6689](https://github.com/heygen-com/hyperframes/pull/6689)).
## Fixes
- **Player:** iPhone Safari keeps every take's sound after the first seconds of an edit ([48ba0e5b2](https://github.com/heygen-com/hyperframes/commit/48ba0e5b2bf91fc816adc0a7769d7344091aa5d7), [#6724](https://github.com/heygen-com/hyperframes/pull/6724)).
- **AWS Lambda:** The release ffmpeg build falls back to verified mirrors when a source host is down ([f0665f718](https://github.com/heygen-com/hyperframes/commit/f0665f71813278b0e6999df68de691e51d52679d), [#6728](https://github.com/heygen-com/hyperframes/pull/6728)).
- **CLI:** A failed render names its failure code for the Desktop app ([ba8ffffa0](https://github.com/heygen-com/hyperframes/commit/ba8ffffa0b5695d207cc89949e90e5902839fd67), [#6725](https://github.com/heygen-com/hyperframes/pull/6725)).
- **CLI:** A snapshot still shows what playback shows, whatever other times are asked for ([cf1589242](https://github.com/heygen-com/hyperframes/commit/cf15892425245da96dc43d858a7478031533d2f1), [#6720](https://github.com/heygen-com/hyperframes/pull/6720)).
- **CLI:** The lint summary points at hyperframes lint, not a flag only render takes ([98a28cc4b](https://github.com/heygen-com/hyperframes/commit/98a28cc4b2919f1c8418d2cb61bafc66ec9ccb31), [#6723](https://github.com/heygen-com/hyperframes/pull/6723)).
- **Ci:** Skills sync steps past the five hidden ClawHub versions blocking it ([49e031230](https://github.com/heygen-com/hyperframes/commit/49e0312300d6c049b19eb8435930f980621a993f), [#6713](https://github.com/heygen-com/hyperframes/pull/6713)).
- **CLI:** An unknown command like search says which command does that ([bb4deb801](https://github.com/heygen-com/hyperframes/commit/bb4deb8014a8457425c2e6e4a8619969514824ea), [#6710](https://github.com/heygen-com/hyperframes/pull/6710)).
- **CLI:** Check says how many browser-playable copies are done while it makes them ([2f8c4b1da](https://github.com/heygen-com/hyperframes/commit/2f8c4b1dafaebc26f4dcfe6252312faf39ff9d89), [#6712](https://github.com/heygen-com/hyperframes/pull/6712)).
- **Core:** A multi-worker or chunked render lands every frame where an in-order render does ([fa9acbd73](https://github.com/heygen-com/hyperframes/commit/fa9acbd7399b7cd5ca9940399d9066bb33d96973), [#6707](https://github.com/heygen-com/hyperframes/pull/6707)).
- **CLI:** Check says a lint error is why the browser pass was skipped ([11312b359](https://github.com/heygen-com/hyperframes/commit/11312b359b18c07911615656b95f48c7d8d82fea), [#6709](https://github.com/heygen-com/hyperframes/pull/6709)).
- **CLI:** Capture names the network's proxy when it refuses the connection to the site ([f0c5db6ea](https://github.com/heygen-com/hyperframes/commit/f0c5db6ea266a5a96907e66ef762e0f6dd85d623), [#6706](https://github.com/heygen-com/hyperframes/pull/6706)).
- **CLI:** Check waits as long as the --timeout it was given before it counts as idle ([6c1765933](https://github.com/heygen-com/hyperframes/commit/6c17659337d50b578c5c54ae716308a13eb2c822), [#6705](https://github.com/heygen-com/hyperframes/pull/6705)).
- **CLI:** Audio windows read beats off the whole clip; beats snap to the signal and mark silence ([4fa5f5451](https://github.com/heygen-com/hyperframes/commit/4fa5f54519150566b8ef706f60c05ff13c903423), [#6692](https://github.com/heygen-com/hyperframes/pull/6692)).
- **Producer:** A BeginFrame capture that draws no first frame in 20 s switches to screenshot ([a5496b188](https://github.com/heygen-com/hyperframes/commit/a5496b1887950777b78ed5ac2bff3505961cb5fe), [#6701](https://github.com/heygen-com/hyperframes/pull/6701)).
- **CLI:** Media-treatment --capability preset finds the presets area ([cfa653af4](https://github.com/heygen-com/hyperframes/commit/cfa653af4d0f95d751286fb25c8d38bdb56ef663), [#6700](https://github.com/heygen-com/hyperframes/pull/6700)).
- **CLI:** A Windows render no longer prints taskkill's already-exited message ([ac449555f](https://github.com/heygen-com/hyperframes/commit/ac449555f4607ea65895628164cb2835afdd0ce5), [#6698](https://github.com/heygen-com/hyperframes/pull/6698)).
- **CLI:** Snapshot reads https video in the Desktop app instead of failing at ffprobe ([0620067eb](https://github.com/heygen-com/hyperframes/commit/0620067eb096fee9b6748efb9421d852d540cbf6), [#6699](https://github.com/heygen-com/hyperframes/pull/6699)).
- **CLI:** A stopped model download resumes on the next run instead of starting over ([81959201f](https://github.com/heygen-com/hyperframes/commit/81959201f0acb2e265e4d8c30daa89ffc1c9607d), [#6694](https://github.com/heygen-com/hyperframes/pull/6694)).
- **CLI:** Media-treatment finds an area by the name its overview prints ([2c3d1e07f](https://github.com/heygen-com/hyperframes/commit/2c3d1e07fe63d4a2d67d63a3e29d4ad73e291d8e), [#6675](https://github.com/heygen-com/hyperframes/pull/6675)).
- **Engine:** Downloads refuse hosts that resolve to private addresses ([9830059bd](https://github.com/heygen-com/hyperframes/commit/9830059bdb08adbab473d059d1b6db2230a5bb15), [#6571](https://github.com/heygen-com/hyperframes/pull/6571)).
- **CLI:** Check no longer reports outline text as not painted ([9f65efb19](https://github.com/heygen-com/hyperframes/commit/9f65efb19c3f435ef58f6fc81c6909d2a3920f8a), [#6693](https://github.com/heygen-com/hyperframes/pull/6693)).
- **CLI:** Beats --json answers with no track when the composition has no music ([ec10aae54](https://github.com/heygen-com/hyperframes/commit/ec10aae54fd3bcadaebb7e935a2b53d788fee074), [#6690](https://github.com/heygen-com/hyperframes/pull/6690)).
- **Core:** Graded media honors object-position vertically instead of mirroring it ([aa4a71fe4](https://github.com/heygen-com/hyperframes/commit/aa4a71fe417401b5d8a69cff36f56319defd3ba7), [#6691](https://github.com/heygen-com/hyperframes/pull/6691)).
- **Studio:** Audio keeps playing after a timeline move or trim in a static composition ([9aa6346a7](https://github.com/heygen-com/hyperframes/commit/9aa6346a779328f1c0963ec3d37b856ad164c969), [#6428](https://github.com/heygen-com/hyperframes/pull/6428)).
- **Engine:** A render from an app that bundles the engine stamps the engine's version ([cdc86117c](https://github.com/heygen-com/hyperframes/commit/cdc86117c7ffa6bf087017035ea17149b98303da), [#6686](https://github.com/heygen-com/hyperframes/pull/6686)).
- **Studio:** An undo or redo counts as a preview change from its key, not from its reload ([710314c15](https://github.com/heygen-com/hyperframes/commit/710314c15e11bb1f49cd83117f42b3cf2b2fc62e), [#6683](https://github.com/heygen-com/hyperframes/pull/6683)).
- **Producer:** A capture that landed every frame is not failed for the work after its last frame ([067cbf7ac](https://github.com/heygen-com/hyperframes/commit/067cbf7acf55e54517650b5ade24cfa352806c04), [#6674](https://github.com/heygen-com/hyperframes/pull/6674)).
- **CLI:** Check no longer treats a transparent canvas as covering the frame ([203a08dd7](https://github.com/heygen-com/hyperframes/commit/203a08dd7c1d4856e04e6a1288915a4b6f5fa2f6), [#6681](https://github.com/heygen-com/hyperframes/pull/6681)).
- **Core:** Grade groups resolve per composition; freeze frames keep their lane ([c569760a7](https://github.com/heygen-com/hyperframes/commit/c569760a7a7c96df6540d53bbfe4670225ae140e), [#6685](https://github.com/heygen-com/hyperframes/pull/6685)).
## Performance
- **Engine:** Encode high quality with the x264 fast preset ([be05c8693](https://github.com/heygen-com/hyperframes/commit/be05c86932be215ec70621dc568af96415555fa0), [#6640](https://github.com/heygen-com/hyperframes/pull/6640)).
- **Engine:** Size render workers from measured heap and the container's CPU quota ([cd003e201](https://github.com/heygen-com/hyperframes/commit/cd003e201051317234598bcf1c70cfed4770613f), [#6641](https://github.com/heygen-com/hyperframes/pull/6641)).
- **Producer:** The audio true-peak check seeks to each segment instead of decoding up to it ([c2cde2fb6](https://github.com/heygen-com/hyperframes/commit/c2cde2fb624ba5a311615574dff822a41f61ea96), [#6672](https://github.com/heygen-com/hyperframes/pull/6672)).
## Docs & Examples
- **CLI:** Say which audio probes run once per file ([5d437d5ee](https://github.com/heygen-com/hyperframes/commit/5d437d5eecd65f05daa4ff451ebb85b71f88fdb9), [#6731](https://github.com/heygen-com/hyperframes/pull/6731)).
- **CLI:** List check's audio findings with severity, trigger and fix ([fa97f9d4b](https://github.com/heygen-com/hyperframes/commit/fa97f9d4b7b4f401560dcc3e642267e953a2a89e), [#6730](https://github.com/heygen-com/hyperframes/pull/6730)).
## Catalog
- **Skills:** The registry skill says its commands are catalog and add ([1428f8768](https://github.com/heygen-com/hyperframes/commit/1428f876899557a4fe4448a6ac327ff503be53f5), [#6680](https://github.com/heygen-com/hyperframes/pull/6680)).
## Internal
- One regression shard writes the test image cache ([74f323473](https://github.com/heygen-com/hyperframes/commit/74f3234736ad87568831ed51c34a7e289dc668e6), [#6729](https://github.com/heygen-com/hyperframes/pull/6729)).
- **CLI:** The publish abort test checks the summary hint is absent ([077de9bfe](https://github.com/heygen-com/hyperframes/commit/077de9bfe9f248c0807ba2c7f78d1514adf85bc6), [#6726](https://github.com/heygen-com/hyperframes/pull/6726)).
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.8.214...v0.8.216).
</Update>
<Update
label="HyperFrames v0.8.215"
description="Released - 2026-10-29"
+5
View File
@@ -85,6 +85,11 @@ machine-readable `code` and, when there is a next step, a `hint`:
Some commands add their own fields next to `error`, such as `findings` from
`lint` or `reason` and `fix` from a refused `timeline` edit.
**The project `.env`.** The `.env` in the current directory sets only the CLI's own
settings: `HEYGEN_API_KEY`, `HYPERFRAMES_API_KEY`, `ELEVENLABS_API_KEY`, `FIGMA_TOKEN`, and the
vision keys, models, Vertex settings and timeout, and the telemetry opt-outs `HYPERFRAMES_NO_TELEMETRY` and `DO_NOT_TRACK`. A variable already in your environment wins. Any
other key in the file is ignored, and one line on stderr names them.
**Flags, not prompts.** A missing required flag fails immediately with a usage
example rather than waiting for input. Two commands are interactive on a TTY:
`init` prompts for a project name and example unless you pass
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "hyperframes",
"version": "0.8.215",
"version": "0.8.216",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames."
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/aws-lambda",
"version": "0.8.215",
"version": "0.8.216",
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.8.215",
"version": "0.8.216",
"description": "Make videos with code and AI agents: write HTML, render MP4. Open source.",
"keywords": [
"ai-agents",
+2 -2
View File
@@ -8,7 +8,7 @@
import { captureSiteName } from "./localPage.js";
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { applyDotEnv, CAPTURE_ENV_KEYS } from "../utils/dotEnv.js";
import { applyDotEnv, PROJECT_ENV_KEYS } from "../utils/dotEnv.js";
import { writeNewFileSync } from "../utils/writeNewFile.js";
import type { CatalogedAsset } from "./assetCataloger.js";
import type { CaptureResult, DesignTokens } from "./types.js";
@@ -24,7 +24,7 @@ export function loadEnvFile(startDir: string): void {
for (let i = 0; i < 5; i++) {
const envPath = resolve(dir, ".env");
try {
applyDotEnv(readFileSync(envPath, "utf-8"), process.env, CAPTURE_ENV_KEYS);
applyDotEnv(readFileSync(envPath, "utf-8"), process.env, PROJECT_ENV_KEYS);
break;
} catch {
dir = resolve(dir, "..");
+3 -3
View File
@@ -44,8 +44,8 @@ it.each(
[
["capture", false, undefined, "fixture-key", undefined],
["capture", true, undefined, undefined, undefined],
["init", false, process.execPath, "fixture-key", undefined],
["init", true, process.execPath, "fixture-key", undefined],
["init", false, undefined, "fixture-key", undefined],
["init", true, undefined, "fixture-key", undefined],
["capture", false, process.execPath, "fixture-key", process.execPath],
] as const
).flatMap((test) =>
@@ -84,6 +84,6 @@ it.each(
mockTelemetry();
process.argv = ["node", "cli.ts", ...prefix, command];
await import("./cli.js");
expect(observed).toEqual([browser, command === "capture" ? undefined : "--no-warnings", key]);
expect(observed).toEqual([browser, undefined, key]);
},
);
+4 -14
View File
@@ -65,22 +65,12 @@ if (rootVersionRequested) {
process.exit(0);
}
// Capture treats CWD as project data too: its early loader must use the same
// allowlist/opt-out as output ancestors, before browser selection can observe it.
const { applyDotEnv, CAPTURE_ENV_KEYS } = await import("./utils/dotEnv.js");
// The working directory is project data: load only the settings a project `.env` may supply,
// before browser selection can observe the environment.
const { loadProjectDotEnv } = await import("./utils/dotEnv.js");
const captureEnv = argv[commandWordIndex(argv)] === "capture";
if (!captureEnv || process.env.HYPERFRAMES_NO_CAPTURE_DOTENV !== "1") {
try {
const { readFileSync } = await import("node:fs");
const envPath = resolve(process.cwd(), ".env");
applyDotEnv(
readFileSync(envPath, "utf-8"),
process.env,
captureEnv ? CAPTURE_ENV_KEYS : undefined,
);
} catch {
/* .env not present — fine, env vars may be set another way */
}
loadProjectDotEnv(process.cwd(), process.env, (line) => console.error(line));
}
// ── Lazy imports ────────────────────────────────────────────────────────────
+64
View File
@@ -12,6 +12,7 @@ vi.mock("../telemetry/events.js", () => ({
import { contrastRatio, parseColorRGBA } from "./contrast-bg.js";
import { createCheckCommand, parseCheckRange } from "./check.js";
import { readConfig } from "../telemetry/config.js";
import { CheckDeadlineError, CheckStoppedError } from "../utils/checkDeadline.js";
import { trackCommandFailures } from "../utils/command-failure-tracking.js";
import type { CommandDef } from "citty";
@@ -1589,6 +1590,55 @@ describe("check pipeline", () => {
expect(said).not.toContain("Browser session never ran");
});
describe("remembers the newest check for hyperframes feedback", () => {
const run = (runPipeline: () => Promise<CheckReport>) =>
runViaCli(
createCheckCommand({
resolveProject: () => PROJECT,
runPipeline: vi.fn(runPipeline),
prepareMedia: vi.fn(async () => []),
withMeta: (value) => value,
}),
{ rawArgs: [] },
);
it("keeps the error codes of a failed check", async () => {
const lint = lintWith(
"error",
"root_missing_composition_id",
"Root needs data-composition-id.",
);
const { report } = await runScenario(fakeDriver(), {}, { lint });
await run(async () => report);
expect(readConfig().lastCheck).toMatchObject({
exitCode: 1,
codes: { root_missing_composition_id: 1 },
});
});
it("records a check that threw as a failure, not the check before it", async () => {
await run(async () => (await runScenario(fakeDriver(), {}, {})).report);
await run(async () => {
throw new Error("browser died");
});
expect(readConfig().lastCheck).toMatchObject({ exitCode: 1, codes: { runtime_error: 1 } });
});
it("leaves a check that never finished as unfinished, with when it started", async () => {
const { report } = await runScenario(fakeDriver(), {}, {});
await run(async () => report);
let finish!: (value: CheckReport) => void;
const running = run(() => new Promise<CheckReport>((resolve) => (finish = resolve)));
await vi.waitFor(() => expect(readConfig().lastCheck?.exitCode).toBeUndefined());
expect(readConfig().lastCheck?.at).toMatch(/^\d{4}-\d\d-\d\dT/);
finish(report);
await running;
});
});
it("marks browserSkipped true when the linter itself crashes", async () => {
const { deps } = dependencies(fakeDriver());
deps.lintProject = vi.fn(async () => {
@@ -2791,6 +2841,20 @@ describe("check pipeline", () => {
expect(checkExitCode(strict.report)).toBe(0);
});
it("seeks each craft frame with WebGL draws on, so a redrawn canvas reads as moving", async () => {
const driver = craftDriver();
await runScenario(driver);
const painted = new Set(
vi
.mocked(driver.seekGeometry)
.mock.calls.flatMap(([time, seek]) => (seek?.draws ? [time] : [])),
);
const sampled = vi.mocked(driver.collectCraftFrame!).mock.calls.map(([time]) => time);
expect(sampled.length).toBeGreaterThan(0);
expect(sampled.every((time) => painted.has(time))).toBe(true);
});
it("re-measures a short line between dense samples before judging it", async () => {
const driver = craftDriver();
const { report } = await runScenario(driver);
+13
View File
@@ -41,6 +41,8 @@ import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js";
import { preResolveHostileMediaProxies, type PreparedMediaFile } from "../utils/checkBrowser.js";
import { parseTimeoutMs } from "../utils/timeoutArg.js";
import { failureMessage, jsonFailure } from "../utils/jsonError.js";
import { recordLastCheck } from "../telemetry/config.js";
import { failureCodes } from "../utils/checkFailureCodes.js";
export const examples: Example[] = [
["Run the full verification gate", "hyperframes check"],
@@ -287,9 +289,15 @@ export function createCheckCommand(
await runPrepareMedia(dependencies, project, options, asJson);
return;
}
recordLastCheck({ at: new Date().toISOString(), codes: {} });
const report = await withProjectLock(project.dir, "check", () =>
dependencies.runPipeline(project, options),
);
recordLastCheck({
at: new Date().toISOString(),
exitCode: checkExitCode(report),
codes: failureCodes(report),
});
if (asJson) {
console.log(JSON.stringify(dependencies.withMeta(report), null, 2));
} else {
@@ -303,6 +311,11 @@ export function createCheckCommand(
}
setCommandExitCode(checkExitCode(report));
} catch (error) {
recordLastCheck({
at: new Date().toISOString(),
exitCode: 1,
codes: { [error instanceof CliUsageError ? error.code : "runtime_error"]: 1 },
});
reportCheckFailure(error, asJson, (value) => dependencies.withMeta(value));
} finally {
reported.done();
@@ -22,6 +22,43 @@ describe("buildTelemetryJoinKeys", () => {
expect(keys).toBe("fid=f tid=t renders=render-a,render-b!");
});
it("names the last check's exit code and top error codes", () => {
const keys = buildTelemetryJoinKeys({
feedbackId: "f",
anonymousId: "t",
lastCheck: { at: "", exitCode: 1, codes: { text_overlap: 4, contrast_low: 1 } },
});
expect(keys).toBe("fid=f tid=t check=exit1:text_overlap*4,contrast_low*1");
});
it("says unfinished when the last check never reported an exit code", () => {
const keys = buildTelemetryJoinKeys({
feedbackId: "f",
anonymousId: "t",
lastCheck: { at: "", codes: {} },
});
expect(keys).toBe("fid=f tid=t check=unfinished");
});
it("says how many minutes ago the last check ran, so one from another project is spotted", () => {
const keys = buildTelemetryJoinKeys({
feedbackId: "f",
anonymousId: "t",
lastCheck: { at: "2026-10-29T10:00:00.000Z", exitCode: 1, codes: { text_overlap: 4 } },
now: Date.parse("2026-10-29T10:12:30.000Z"),
});
expect(keys).toBe("fid=f tid=t check=exit1:text_overlap*4 check_age=13m");
});
it("says only the exit code when the last check had no error codes", () => {
const keys = buildTelemetryJoinKeys({
feedbackId: "f",
anonymousId: "t",
lastCheck: { at: "", exitCode: 0, codes: {} },
});
expect(keys).toBe("fid=f tid=t check=exit0");
});
it("stays within the backend env cap for a full ring of uuid render ids", () => {
const uuid = "01234567-89ab-cdef-0123-456789abcdef";
const keys = buildTelemetryJoinKeys({
@@ -36,4 +73,20 @@ describe("buildTelemetryJoinKeys", () => {
// submitFeedback caps env at 500 chars; the doctor summary consumes ~100.
expect(keys.length).toBeLessThan(400);
});
it("still fits the env cap with a full ring and three long error codes", () => {
const uuid = "01234567-89ab-cdef-0123-456789abcdef";
const code = "x".repeat(60);
const keys = buildTelemetryJoinKeys({
feedbackId: uuid,
anonymousId: uuid,
recentRenders: Array.from({ length: 5 }, () => ({ id: uuid, at: "", ok: true })),
lastCheck: {
at: "",
exitCode: 1,
codes: { [code + "a"]: 9, [code + "b"]: 8, [code + "c"]: 7 },
},
});
expect(keys.length).toBeLessThan(500 - 100);
});
});
+16 -1
View File
@@ -12,7 +12,7 @@ import {
} from "../telemetry/events.js";
import { shouldTrack } from "../telemetry/client.js";
import { getDoctorSummary } from "../telemetry/feedback.js";
import { readConfig, type RecentRenderRecord } from "../telemetry/config.js";
import { readConfig, type LastCheckRecord, type RecentRenderRecord } from "../telemetry/config.js";
import { publishProjectArchive } from "../utils/publishProject.js";
import { submitCatalogSearchMiss, submitFeedback } from "../utils/submitFeedback.js";
import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js";
@@ -57,11 +57,25 @@ export function buildTelemetryJoinKeys(input: {
feedbackId: string;
anonymousId: string;
recentRenders?: RecentRenderRecord[];
lastCheck?: LastCheckRecord;
now?: number;
}): string {
const parts = [`fid=${input.feedbackId}`, `tid=${input.anonymousId}`];
if (input.recentRenders?.length) {
parts.push(`renders=${input.recentRenders.map((r) => `${r.id}${r.ok ? "" : "!"}`).join(",")}`);
}
if (input.lastCheck) {
const codes = Object.entries(input.lastCheck.codes).map(
([code, n]) => `${code.slice(0, 24)}*${n}`,
);
const result =
input.lastCheck.exitCode === undefined ? "unfinished" : `exit${input.lastCheck.exitCode}`;
parts.push(`check=${result}${codes.length ? `:${codes.join(",")}` : ""}`);
const minutes = Math.round(
((input.now ?? Date.now()) - Date.parse(input.lastCheck.at)) / 60_000,
);
if (Number.isFinite(minutes) && minutes >= 0) parts.push(`check_age=${minutes}m`);
}
return parts.join(" ");
}
@@ -298,6 +312,7 @@ export default defineCommand({
feedbackId,
anonymousId: config.anonymousId,
recentRenders: config.recentRenders,
lastCheck: config.lastCheck,
});
const envWithJoinKeys = doctorSummary ? `${doctorSummary} ${joinKeys}` : joinKeys;
@@ -1362,6 +1362,12 @@
return !!ctx && ctx === preserve3dContext(b);
}
// A covering element marked to cover text, or inside a marked wrapper that does not also hold the text.
function allowsOcclusionOf(hit, element) {
const allowed = authoredElement(hit).closest("[data-layout-allow-occlusion]");
return !!allowed && !allowed.contains(element);
}
// The opaque element painted over (x, y), or null when the topmost element
// there is related to the text, non-opaque, sharing a 3D context with it, or
// part of a transient crossfade overlap.
@@ -1377,7 +1383,7 @@
for (const hit of stack) {
if (!isForeignElement(element, hit)) return null;
// Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
if (hasIgnoreFlag(hit)) continue;
if (hasIgnoreFlag(hit) || allowsOcclusionOf(hit, element)) continue;
if (sharedPreserve3d(element, hit)) continue;
if (isCrossSceneTransitionOverlap(element, hit)) continue;
const matte = seenMattes && matteFrameFor(hit);
@@ -3262,6 +3262,55 @@ describe("layout-audit.browser occlusion", () => {
);
});
it("lets data-layout-allow-occlusion on the covering element silence text_occluded", () => {
const issues = (overlayAttrs?: string) =>
auditOcclusionScene({
overlayAttrs,
overlayStyle: { backgroundColor: "rgb(10, 10, 10)" },
topmostId: "overlay",
});
expect(issues().some((issue) => issue.code === "text_occluded")).toBe(true);
expect(
issues("data-layout-allow-occlusion").some((issue) => issue.code === "text_occluded"),
).toBe(false);
});
it("honours the flag on a graded video whose topmost element is its canvas stand-in", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">Headline copy</div>
<video id="cutout" data-layout-allow-occlusion></video>
<canvas id="overlay" data-hf-color-grading-canvas></canvas>
</div>
`;
installOcclusionGeometry({
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
});
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("still flags a cover inside a flagged wrapper that also holds the text", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="card" data-layout-allow-occlusion>
<div id="headline">Headline copy</div>
<div id="overlay"></div>
</div>
</div>
`;
installOcclusionGeometry({
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
headlineTextRect: rect({ left: 200, top: 500, width: 600, height: 80 }),
topmostId: "overlay",
});
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(true);
});
it("respects the data-layout-allow-occlusion opt-out", () => {
const issues = auditOcclusionScene({
headlineAttrs: "data-layout-allow-occlusion",
+2
View File
@@ -76,6 +76,7 @@ import {
} from "../ui/format.js";
import { warnIfWebmAlphaDropped } from "../utils/webmAlphaCheck.js";
import { renderFailureDiagnostic } from "../ui/renderDiagnostic.js";
import { missingPathKind } from "../telemetry/missingPathKind.js";
import { renderProgress, renderMachineProgress } from "../ui/progress.js";
import {
trackRenderComplete,
@@ -1815,6 +1816,7 @@ function handleRenderError(
// the render_complete success path, since nothing failed to name.
errorName: error instanceof Error ? error.name : "unknown",
failedStageCode: normalizeStageCode(failedStage || "pipeline"),
missingPathKind: missingPathKind(error),
...renderJobObservabilityTelemetryPayload(job),
...getMemorySnapshot(),
...cpu.stop(),
+28 -7
View File
@@ -18,6 +18,7 @@ const snapshotState = vi.hoisted(() => ({
closeServer: vi.fn(async () => undefined),
ffmpegTimesOut: false,
ffmpegFails: false,
ffmpegKilled: false,
noFfmpeg: false,
}));
@@ -39,13 +40,15 @@ vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => {
runFfmpegOnce: (...args: Parameters<typeof actual.runFfmpegOnce>) =>
snapshotState.ffmpegTimesOut
? Promise.resolve({ code: null, timedOut: true, stderr: "" })
: snapshotState.ffmpegFails
? Promise.resolve({
code: 1,
timedOut: false,
stderr: "\nError while decoding stream #0:0\n",
})
: actual.runFfmpegOnce(...args),
: snapshotState.ffmpegKilled
? Promise.resolve({ code: 255, timedOut: false, stderr: "" })
: snapshotState.ffmpegFails
? Promise.resolve({
code: 1,
timedOut: false,
stderr: "\nError while decoding stream #0:0\n",
})
: actual.runFfmpegOnce(...args),
};
});
@@ -757,6 +760,24 @@ describe("extractVideoFrameToBuffer", () => {
return clip;
};
it.skipIf(noProbe)(
"says FFmpeg was stopped, not that a frame is bad, when it exits 255 with no output",
async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-snapshot-killed-"));
snapshotState.ffmpegKilled = true;
try {
const warnings: string[] = [];
expect(await extractVideoFrameOrWarn(warnings, oneSecondClip(dir), 0.5)).toBeNull();
expect(warnings).toEqual([
expect.stringContaining("FFmpeg was stopped before it finished"),
]);
} finally {
snapshotState.ffmpegKilled = false;
rmSync(dir, { recursive: true, force: true });
}
},
);
it.skipIf(noProbe)(
"records the clip, time and FFmpeg's reason when the extract fails",
async () => {
+8 -6
View File
@@ -379,6 +379,12 @@ export function containingSourceFrameIndex(timestamps: readonly number[], time:
class FrameExtractError extends Error {}
const lastLine = (text: string) => text.trim().split("\n").at(-1) ?? "";
const ffmpegFailure = (result: { stderr: string; code: number | null }) =>
lastLine(result.stderr) ||
(result.code === 255
? "FFmpeg was stopped before it finished (exit code 255 with no error output means it was killed, such as by a time limit)"
: `exit code ${result.code}`);
const processStderr = (error: unknown) =>
error instanceof Error && "stderr" in error && typeof error.stderr === "string"
? error.stderr
@@ -639,9 +645,7 @@ export async function extractVideoFrameToBuffer(
);
}
if (result.code !== 0 || !existsSync(outPath)) {
throw new FrameExtractError(
`FFmpeg could not extract ${frameOf}: ${lastLine(result.stderr) || `exit code ${result.code}`}`,
);
throw new FrameExtractError(`FFmpeg could not extract ${frameOf}: ${ffmpegFailure(result)}`);
}
return readFileSync(outPath);
} finally {
@@ -702,9 +706,7 @@ export async function decodeFramesInOnePass(
}
const written = (n: number) => join(tmp, `${n}.png`);
if (result.code !== 0 || !byTime.every((_, i) => existsSync(written(i + 1)))) {
throw new FrameExtractError(
`FFmpeg could not extract ${what}: ${lastLine(result.stderr) || `exit code ${result.code}`}`,
);
throw new FrameExtractError(`FFmpeg could not extract ${what}: ${ffmpegFailure(result)}`);
}
const buffers: Buffer[] = [];
for (const [i, [, indexes]] of byTime.entries()) {
+17
View File
@@ -575,6 +575,23 @@ export interface HyperframesConfig {
* telemetry rows of the renders it describes.
*/
recentRenders?: RecentRenderRecord[];
/** The newest local `check`: exit code and its top error codes, which `hyperframes feedback` attaches. */
lastCheck?: LastCheckRecord;
}
/** The newest `check` in {@link HyperframesConfig.lastCheck}; codes and counts only, no content or paths. */
export interface LastCheckRecord {
at: string;
/** Absent while the check has not finished: it is still running, or was stopped. */
exitCode?: number;
codes: Record<string, number>;
}
/** Remember the newest check so a later `hyperframes feedback` can name why it failed. */
export function recordLastCheck(lastCheck: LastCheckRecord): void {
const config = readConfigFresh();
config.lastCheck = lastCheck;
writeConfig(config);
}
/** One entry in {@link HyperframesConfig.recentRenders}. */
+2
View File
@@ -674,6 +674,7 @@ export function trackRenderError(
errorName?: string;
/** failedStage normalized to a stable snake_case code. */
failedStageCode?: string;
missingPathKind?: string;
errorMessage?: string;
elapsedMs?: number;
peakMemoryMb?: number;
@@ -702,6 +703,7 @@ export function trackRenderError(
failed_stage: props.failedStage,
error_name: props.errorName,
failed_stage_code: props.failedStageCode,
missing_path_kind: props.missingPathKind,
error_message: props.errorMessage ? redactTelemetryMessage(props.errorMessage) : undefined,
elapsed_ms: props.elapsedMs,
...renderOutputShapeEventProperties(props),
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { missingPathKind } from "./missingPathKind.js";
const enoent = (path: string) =>
Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), {
code: "ENOENT",
path,
});
describe("missingPathKind", () => {
it.each([
[
"C:\\Users\\a\\AppData\\Local\\Temp\\hf-cache\\hfcache-v8-0a1b2c\\frame_000041.jpg",
"extract_cache",
],
[
"/tmp/hf-render-Ab12Cd/compiled/__hyperframes_video_frames/v-s0/frame_000041.jpg",
"video_frames",
],
[
"/proj/work-0a1b2c3d-1111-2222-3333-444455556666-Xy12Ab/captured-frames/frame_005280.jpg",
"captured_frames",
],
["C:\\Temp\\hf-render-Ab12Cd\\capture-attempt-1\\worker-3\\frame_000007.jpg", "worker_frames"],
["/tmp/hf-render-Ab12Cd/capture-calibration/frame_000001.jpg", "calibration"],
["/tmp/hf-render-Ab12Cd/compiled/index.html", "compiled"],
["/tmp/hf-render-Ab12Cd/audio-work/mix.wav", "work_dir"],
["/Users/a/Projects/Launch/assets/intro.mp4", "other"],
])("names %s as %s", (path, kind) => {
expect(missingPathKind(enoent(path))).toBe(kind);
});
it("finds the missing file in an error's causes and in a message that only names it", () => {
const wrapped = new Error("[Parallel] Capture failed", {
cause: enoent("/tmp/hf-render-x/captured-frames/frame_1.jpg"),
});
expect(missingPathKind(wrapped)).toBe("captured_frames");
expect(
missingPathKind(
new Error("ENOENT: no such file or directory, open 'C:\\T\\hfcache-v8-ab\\frame_2.jpg'"),
),
).toBe("extract_cache");
});
it("names nothing when no file was missing", () => {
expect(missingPathKind(new Error("Composition has zero duration"))).toBeUndefined();
expect(
missingPathKind(Object.assign(new Error("EPERM: rename"), { code: "EPERM" })),
).toBeUndefined();
});
});
@@ -0,0 +1,37 @@
const KINDS: ReadonlyArray<[RegExp, string]> = [
[/(^|[\\/])hfcache-/, "extract_cache"],
[/(^|[\\/])__hyperframes_video_frames([\\/]|$)/, "video_frames"],
[/(^|[\\/])captured-frames([\\/]|$)/, "captured_frames"],
[
/(^|[\\/])(capture-attempt-\d+|retry-\d+-batch-\d+-worker-\d+|worker-\d+)([\\/]|$)/,
"worker_frames",
],
[/(^|[\\/])capture-calibration/, "calibration"],
[/(^|[\\/])compiled([\\/]|$)/, "compiled"],
[/(^|[\\/])(hf-render-|work-[0-9a-f]{8}-)/, "work_dir"],
];
const quotedPath = (text: string) => /'([^']+)'/.exec(text)?.[1] ?? "";
function errorChain(error: unknown): Error[] {
const chain: Error[] = [];
for (let seen = error; seen instanceof Error && chain.length < 5; seen = seen.cause)
chain.push(seen);
return chain;
}
function missingPathOf(error: unknown): string | undefined {
const enoent = errorChain(error).find(
(seen) => (seen as NodeJS.ErrnoException).code === "ENOENT",
);
if (enoent) return (enoent as NodeJS.ErrnoException).path ?? quotedPath(enoent.message);
const message = error instanceof Error ? error.message : String(error);
return /\bENOENT\b/.test(message) ? quotedPath(message) : undefined;
}
/** Which kind of file a render found missing (a fixed name, never the path), or undefined when nothing was missing. */
export function missingPathKind(error: unknown): string | undefined {
const path = missingPathOf(error);
if (path === undefined) return undefined;
return KINDS.find(([pattern]) => pattern.test(path))?.[1] ?? "other";
}
+4 -1
View File
@@ -108,9 +108,12 @@ describe("render diagnostic wire", () => {
"AUDIO_PROCESSING_FAILED",
],
[
new Error(`Video extraction failed [VIDEO_SOURCE_UNRENDERABLE; source_missing=1] ${PRIVATE}`),
Object.assign(new Error(`Video extraction failed ${PRIVATE}`), {
code: "VIDEO_SOURCE_UNRENDERABLE",
}),
"VIDEO_SOURCE_UNRENDERABLE",
],
[new Error(`[HDR] No Chrome binary found [ACME_LAUNCH] ${PRIVATE}`), "RENDER_FAILED"],
[
Object.assign(new Error(`ENOENT: no such file, open '${PRIVATE}'`), { code: "ENOENT" }),
"ENOENT",
+16 -3
View File
@@ -1,4 +1,8 @@
import { RenderQualityError, type RenderJob } from "@hyperframes/producer";
import {
RenderQualityError,
extractSafeRenderErrorCode,
type RenderJob,
} from "@hyperframes/producer";
import { CaptureFailure, audioFailureDiagnostics } from "@hyperframes/engine";
const FILE_ERRORS = [
@@ -14,7 +18,6 @@ const FILE_ERRORS = [
"EXDEV",
];
const FILE_ERROR = new RegExp(`\\b(${FILE_ERRORS.join("|")})\\b`);
const CLI_CODE = /\[(?!(?:DEBUG|INFO|WARN|ERROR)\b)([A-Z][A-Z0-9_]{2,63})[\];]/;
function fileErrorOf(error: unknown, message: string): string | undefined {
let seen: unknown = error;
@@ -26,6 +29,16 @@ function fileErrorOf(error: unknown, message: string): string | undefined {
return FILE_ERROR.exec(message)?.[1];
}
function producerCodeOf(error: unknown): string | undefined {
let seen: unknown = error;
for (let depth = 0; depth < 5 && seen instanceof Error; depth++) {
const code = extractSafeRenderErrorCode(seen);
if (code) return code;
seen = seen.cause;
}
return undefined;
}
function captureCodeOf(error: unknown, message: string): string | undefined {
if (/Composition has zero duration/.test(message)) return "COMPOSITION_ZERO_DURATION";
if (!(error instanceof CaptureFailure)) return undefined;
@@ -38,7 +51,7 @@ function renderFailureCode(error: unknown): string {
if (error instanceof RenderQualityError && error.warnings[0])
return error.warnings[0].code.toUpperCase();
return (
CLI_CODE.exec(message)?.[1] ??
producerCodeOf(error) ??
fileErrorOf(error, message) ??
captureCodeOf(error, message) ??
"RENDER_FAILED"
@@ -3,7 +3,7 @@ import puppeteer, { type Browser, type Page } from "puppeteer-core";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { measuredInMotion, readContrastMotion } from "./contrastMotion.js";
import { collectSeekClock } from "./checkBrowser.js";
import { collectSeekClock, createPageDriver } from "./checkBrowser.js";
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -119,6 +119,24 @@ describe.runIf(executablePath)("collectSeekClock in Chromium", () => {
await page.close();
});
it("skips a seek's WebGL draws unless the caller asks to paint them", async () => {
page = await browser.newPage();
await page.setContent(`<script>
window.draws = 0;
window.WebGLRenderingContext = function () {};
window.WebGLRenderingContext.prototype.drawArrays = () => { window.draws += 1; };
window.__hf = { seek: () => new window.WebGLRenderingContext().drawArrays() };
</script>`);
const driver = createPageDriver(page);
const drawn = () => page.evaluate(() => Reflect.get(window, "draws"));
await driver.seekGeometry(1);
expect(await drawn()).toBe(0);
await driver.seekGeometry(2, { draws: true });
expect(await drawn()).toBe(1);
await page.close();
});
it("reports nothing on a page with no animations", async () => {
page = await browser.newPage();
await page.setContent("<body><h1>Hello</h1></body>");
+5 -2
View File
@@ -969,9 +969,12 @@ export function createPageDriver(
screenshotFollows ? SCREENSHOT_SEEK_OPTIONS : AUDIT_SEEK_OPTIONS,
);
},
seekGeometry: async (time) => {
seekGeometry: async (time, { draws = false } = {}) => {
clock.at(time);
await seekCompositionTimeline(page, time, DENSE_GEOMETRY_SEEK_OPTIONS);
const options = draws
? { ...DENSE_GEOMETRY_SEEK_OPTIONS, skipWebglDraws: false }
: DENSE_GEOMETRY_SEEK_OPTIONS;
await seekCompositionTimeline(page, time, options);
},
collectLayout: (time, tolerance, layout) => collectLayout(page, time, tolerance, layout),
collectOverlap: (time) => collectOverlap(page, time),
@@ -0,0 +1,24 @@
import type { CheckReport } from "./checkTypes.js";
const MAX_CODES = 3;
/** The error codes of a check report with how often each fired, most frequent first; codes only, never content. */
export function failureCodes(report: CheckReport): Record<string, number> {
const counts = new Map<string, number>();
for (const section of [
report.lint,
report.runtime,
report.layout,
report.motion,
report.contrast,
report.craft,
report.audio,
]) {
for (const finding of section.findings) {
if (finding.severity === "error")
counts.set(finding.code, (counts.get(finding.code) ?? 0) + 1);
}
}
const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, MAX_CODES);
return Object.fromEntries(top);
}
+2 -2
View File
@@ -759,7 +759,7 @@ async function collectMotionOverlapSamples(
const craftTimes = buildCraftSampleTimes(grid.duration).filter((time) => time < grid.duration);
for (const [index, time] of craftTimes.entries()) {
progress(`craft frame at ${time}s`);
await driver.seekGeometry(time);
await driver.seekGeometry(time, { draws: true });
collected.craftFrames.push(...(await collectCraftFrame(driver, time)));
progress();
report("craft", index + 1, craftTimes.length);
@@ -1583,7 +1583,7 @@ export async function runAuditGrid(
noTimeline || sweepFindings.length > 0 || wholeCompositionKeepsMoving(plan.assertions),
},
async (time) => {
await driver.seekGeometry(time);
await driver.seekGeometry(time, { draws: true });
if (!driver.collectCraftFrame) throw new Error("craft sampling unavailable");
return driver.collectCraftFrame(time);
},
+2 -2
View File
@@ -262,8 +262,8 @@ export interface CheckAuditDriver {
getCanvas(): Promise<Canvas>;
findAmbiguousSelectors(selectors: string[]): Promise<AnchoredLayoutIssue[]>;
seek(time: number, screenshotFollows?: boolean): Promise<void>;
/** Settle-free seek for the geometry-only dense content_overlap pass; only collectOverlap consumes it, and getBoundingClientRect is valid synchronously after setTime. */
seekGeometry(time: number): Promise<void>;
/** Settle-free seek for the dense passes; WebGL draws are skipped unless `draws` (a pass reading canvas pixels). */
seekGeometry(time: number, options?: { draws?: boolean }): Promise<void>;
collectLayout(
time: number,
tolerance: number,
+53 -2
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { applyDotEnv } from "./dotEnv.js";
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { applyDotEnv, loadProjectDotEnv } from "./dotEnv.js";
describe("applyDotEnv", () => {
it("copies a project's keys without overwriting what is already set", () => {
@@ -84,3 +87,51 @@ describe("applyDotEnv quoted multiline values", () => {
expect(env.AFTER).toBe("2");
});
});
describe("loadProjectDotEnv", () => {
const projectWith = (content: string): string => {
const dir = mkdtempSync(join(tmpdir(), "hf-dotenv-"));
writeFileSync(join(dir, ".env"), content);
return dir;
};
it("sets the CLI's own settings and ignores every other key, naming them once", () => {
const dir = projectWith(
"GEMINI_API_KEY=abc\nNODE_OPTIONS=--require ./x.js\nHTTPS_PROXY=http://example.test\nNODE_OPTIONS=again\n",
);
try {
const env: NodeJS.ProcessEnv = {};
const warn = vi.fn();
loadProjectDotEnv(dir, env, warn);
expect(env).toEqual({ GEMINI_API_KEY: "abc" });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toContain("NODE_OPTIONS, HTTPS_PROXY");
expect(warn.mock.calls[0]?.[0]).not.toContain("example.test");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("keeps a telemetry opt-out from the project .env", () => {
const dir = projectWith("HYPERFRAMES_NO_TELEMETRY=1\nDO_NOT_TRACK=1\n");
try {
const env: NodeJS.ProcessEnv = {};
loadProjectDotEnv(dir, env, vi.fn());
expect(env).toEqual({ HYPERFRAMES_NO_TELEMETRY: "1", DO_NOT_TRACK: "1" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("says nothing when every key is one the CLI reads, or there is no .env", () => {
const dir = projectWith("OPENROUTER_API_KEY=k\n");
try {
const warn = vi.fn();
loadProjectDotEnv(dir, {}, warn);
loadProjectDotEnv(join(dir, "missing"), {}, warn);
expect(warn).not.toHaveBeenCalled();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+35 -9
View File
@@ -1,6 +1,12 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { FEEDBACK_EMAIL_ENV } from "./feedbackSource.js";
export const CAPTURE_ENV_KEYS = [
export const PROJECT_ENV_KEYS = [
"HEYGEN_API_KEY",
"HYPERFRAMES_API_KEY",
"ELEVENLABS_API_KEY",
"FIGMA_TOKEN",
"OPENROUTER_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
@@ -11,6 +17,9 @@ export const CAPTURE_ENV_KEYS = [
"HYPERFRAMES_OPENROUTER_MODEL",
"HYPERFRAMES_GEMINI_MODEL",
"HYPERFRAMES_VISION_TIMEOUT_MS",
// Telemetry opt-outs only turn sending off, and a project .env could already set them.
"HYPERFRAMES_NO_TELEMETRY",
"DO_NOT_TRACK",
];
// A quoted value ends at its closing quote; unquoted, `#` starts a comment only after whitespace, so `pass#word` stays.
@@ -76,17 +85,34 @@ export function applyDotEnv(
content: string,
env: NodeJS.ProcessEnv,
allowedKeys?: readonly string[],
): void {
): string[] {
const allowed = allowedKeys === undefined ? undefined : new Set(allowedKeys);
const ignored: string[] = [];
for (const entry of logicalLines(content).map(entryOf)) {
if (!entry) continue;
const [key, value] = entry;
if (
key &&
key.toUpperCase() !== FEEDBACK_EMAIL_ENV &&
(allowed === undefined || allowed.has(key)) &&
!(key in env)
)
env[key] = value;
if (!key || key.toUpperCase() === FEEDBACK_EMAIL_ENV) continue;
if (allowed !== undefined && !allowed.has(key)) ignored.push(key);
else if (!(key in env)) env[key] = value;
}
return ignored;
}
export function loadProjectDotEnv(
dir: string,
env: NodeJS.ProcessEnv,
warn: (line: string) => void,
): void {
let content: string;
try {
content = readFileSync(join(dir, ".env"), "utf-8");
} catch {
return;
}
const ignored = [...new Set(applyDotEnv(content, env, PROJECT_ENV_KEYS))];
if (ignored.length > 0) {
warn(
`[hyperframes] .env: ignoring ${ignored.join(", ")} (a project .env only sets the CLI's own settings)`,
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/core",
"version": "0.8.215",
"version": "0.8.216",
"description": "",
"repository": {
"type": "git",
@@ -1924,6 +1924,28 @@ describe("the preview's scene window", () => {
}
});
it("settles when a scene it would retire cannot be retired, instead of planning it again forever", async () => {
bootWindowed([S0, S1, S2], trackingRoot().root);
await tick();
window.__hfSceneAnimations = { s0: [made.a1] };
window.__timelines.elsewhere = made.a1;
try {
await seekAndSettle(5);
const host = sceneHost("s0");
const has = host.hasAttribute.bind(host);
let asks = 0;
host.hasAttribute = (name: string) => {
// A runaway plan starves every timer; seeking back to s0 is what ends it, so a failure fails, not hangs.
if (name === "data-hf-scene-no-swap" && ++asks === 1000) window.__player?.seek(0.5);
return has(name);
};
await new Promise((resolve) => setTimeout(resolve, 0));
expect(asks).toBeLessThan(100);
} finally {
delete window.__hfSceneAnimations;
}
});
it("shows a scene mounted again after the visibility pass hid it by display and it unmounted", async () => {
const leaf: Scene = { ...S2, body: "<p>two</p>", extraAttrs: ' style="position: static"' };
bootWindowed([S0, S1, leaf], trackingRoot().root);
+3 -1
View File
@@ -4580,6 +4580,7 @@ export function initSandboxRuntimeModular(): void {
const wantedNow = (name: string) =>
heldScenes.has(name) ||
scenesWanted(windowScenes, state.currentTime, clock.isPlaying()).has(name);
const parkedBeforeStep = parkedNames.size;
windowWork = (async () => {
if (landed) await mountScene(landed.name);
else if (toMount) {
@@ -4589,7 +4590,8 @@ export function initSandboxRuntimeModular(): void {
} else for (const { name } of toRetire) retireScene(name);
})().finally(() => {
windowWork = null;
windowNear = "";
const stuckStepKeepsItsPlan = parkedNames.size === parkedBeforeStep;
windowNear = stuckStepKeepsItsPlan ? key : "";
moveSceneWindow(state.currentTime);
});
// A seek is not done until the scene it lands in is mounted.
@@ -127,6 +127,42 @@ describe("non-Latin paths", () => {
});
});
describe("paths with spaces", () => {
it.each([
"/Users/kim/내 드라이브/영상 프로젝트/clip.mp4",
"/Users/ivan/Мои документы/Проект Видео/видео.mp4",
"/Users/tanaka/マイ\u3000ドライブ/動画/clip.mp4",
"/Users/alex/Library/CloudStorage/GoogleDrive-alex@example.com/My Drive/Claude Projects/edit.mp4",
"/Users/alex/Dropbox (Personal)/Client Work/edit.mov",
"C:\\Users\\Kim\\OneDrive - Contoso\\Client Videos\\clip.mp4",
"\\\\fileserver\\Team Share\\clips\\clip.mp4",
])("redacts every folder of %s", (path) => {
expect(redactTelemetryString(`ffprobe failed reading ${path}`)).toBe(
"ffprobe failed reading [path]",
);
});
it("keeps the words after the path", () => {
expect(redactTelemetryString("could not read /Users/kim/My Drive/clip.mp4 was not found")).toBe(
"could not read [path] was not found",
);
});
it.each([
" Duration: N/A, start: 0.000000, bitrate: N/A",
"Stream #0:0: Video: h264, 24/1 fps, 30000/1001 tbr",
"r_frame_rate=24/1 avg_frame_rate=30000/1001",
"input/output error then read/write failed",
])("never joins ordinary ffprobe words into a path: %s", (line) => {
expect(redactTelemetryString(line)).toBe(line);
});
it("redacts spaced folders of a path that lost its root", () => {
const out = redactTelemetryString("could not open My Drive/Claude Projects/edit.mp4");
expect(out).not.toMatch(/Drive|Claude|Projects|edit/);
});
});
describe("redactKnownPaths", () => {
// Shape matching has holes by construction. A caller that built the argv
// knows the exact path, so it can name it instead of hoping a regex does.
+13 -12
View File
@@ -12,12 +12,6 @@ export function redactUrlQueryStrings(value: string): string {
.replace(/((?:X-(?:Amz|Goog)-[\w-]*|Signature)=)[^&\s]*/gi, "$1…");
}
/**
* Path characters we treat as part of a single segment. Space is deliberately
* excluded: including it would let a match run past the path and swallow the
* prose after it, and a path with a space still gets its remaining segments
* redacted, which is the part that carries the identifying information.
*/
/**
* A path segment is defined by what ENDS it, not by an alphabet.
*
@@ -34,6 +28,10 @@ export function redactUrlQueryStrings(value: string): string {
*/
const SEGMENT = String.raw`[^\s/\\'"]+`;
/** A folder that another separator follows may hold spaces (`My Drive`, `내 드라이브`, `OneDrive - Contoso`); only
* the last segment stops at a space, so prose after a path survives. Line breaks still end it. */
const FOLDER = String.raw`${SEGMENT}(?:[^\S\r\n]+(?![A-Za-z]:[\\/])${SEGMENT})*(?=[\\/])`;
/** Same, minus the dot, so a trailing `.ext` can be matched separately. */
const SEGMENT_NODOT = String.raw`[^\s/\\'".]+`;
@@ -60,14 +58,15 @@ const TOKEN_TAIL = String.raw`[^\s'")]*`;
* host is kept and only the query is dropped.
*/
const ABSOLUTE_PATH = new RegExp(
String.raw`(?<![:\w/\\])(?:[A-Za-z]:)?(?:[\\/]${SEGMENT}){2,}${TOKEN_TAIL}`,
"g",
// A Windows share (`\\server\share`) starts with two separators; the first is taken as its root.
String.raw`(?<![:\w\p{L}\p{N}\p{M}/\\])(?:[A-Za-z]:|\\(?=\\[^\s\\/]))?(?:[\\/]${FOLDER})+[\\/]${SEGMENT}${TOKEN_TAIL}`,
"gu",
);
/** `./assets/bgm.mp3`, `../out.wav`, `.\tmp\x` — relative paths leak the same
* project structure absolute ones do, and were previously untouched. */
const RELATIVE_PATH = new RegExp(
String.raw`(?<![\w/\\.])\.{1,2}(?:[\\/]${SEGMENT})+${TOKEN_TAIL}`,
String.raw`(?<![\w/\\.])\.{1,2}(?:[\\/]${FOLDER})*[\\/]${SEGMENT}${TOKEN_TAIL}`,
"g",
);
@@ -98,6 +97,8 @@ const BARE_RELATIVE_PATH = new RegExp(
// Two or more separators: `customer/acme/video.mp4`. No extension needed —
// that much structure is already a path.
String.raw`(?<![^\s'\"(=,\[])(?:${SEGMENT}[\\/]){2,}${SEGMENT}${TOKEN_TAIL}`,
// Spaced folders only before a file name with an extension, so ffprobe's `N/A, … bitrate: N/A` is not a path.
String.raw`(?<![^\s'\"(=,\[])${SEGMENT}[\\/](?:${FOLDER}[\\/]){1,8}${SEGMENT_NODOT}\.\w{1,8}\b${TOKEN_TAIL}`,
// One separator, but the last segment carries a file extension:
// `assets/bgm.mp3`. That segment is dot-free on purpose — SEGMENT includes
// `.`, so a greedy one swallows the extension this rule needs.
@@ -141,10 +142,10 @@ function redactFilePaths(value: string): string {
// (`/assets/x.mp3`), so the absolute rule would consume it and leave the
// leading `.` stranded outside the redaction.
.replace(RELATIVE_PATH, "[path]")
// Bare-relative BEFORE absolute: a bare path's interior satisfies the
// absolute rule, which would claim it and strand the first segment.
.replace(BARE_RELATIVE_PATH, "[path]")
// Absolute BEFORE bare-relative: the folders after a space in `/Users/kim/My Drive/x.mp4` look like a bare path
// on their own, and claiming them first would strand the start of the absolute one.
.replace(ABSOLUTE_PATH, "[path]")
.replace(BARE_RELATIVE_PATH, "[path]")
.replace(ASSET_BASENAME, "[file]")
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/engine",
"version": "0.8.215",
"version": "0.8.216",
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
"repository": {
"type": "git",
@@ -1309,6 +1309,7 @@ async function cssEffectRiskFrames(
fps,
totalFrames,
totalFrames * MAX_RISK_FRAME_FRACTION,
{ onProgress: session.options.onEffectScanProgress },
);
return live && withFramesAfterCallback(live, scan.callbackFrom, fps, totalFrames);
} catch (error) {
@@ -102,6 +102,52 @@ const frames = (live: Set<number> | null) => [...(live ?? [])].sort((a, b) => a
const range = (from: number, count: number) => Array.from({ length: count }, (_, i) => from + i);
describe("scanCssEffectLiveFrames", () => {
it("stops after one chunk when the whole scan would hold back the first frame too long", async () => {
const seeks: number[] = [];
const page = makePage(between(0, 0));
const evaluate = page.evaluate.bind(page);
page.evaluate = (async (...args: Parameters<Page["evaluate"]>) => {
seeks.push((args[1] as number[]).length);
return evaluate(...args);
}) as Page["evaluate"];
let clock = 0;
// 24 ms a frame, what a heavy page costs: 40,219 frames would take 16 minutes.
const now = () => (clock += 240 * 24);
expect(
await scanCssEffectLiveFrames(page, 60, 40_219, 40_219, { budgetMs: 60_000, now }),
).toBeNull();
expect(seeks).toEqual([240]);
});
it("reports the frames scanned after each chunk", async () => {
const seen: Array<[number, number]> = [];
await scanCssEffectLiveFrames(makePage(between(0, 0)), 10, 500, 500, {
onProgress: (scanned, total) => seen.push([scanned, total]),
});
expect(seen).toEqual([
[240, 500],
[480, 500],
[500, 500],
]);
});
it("scans to the end when the whole scan fits the budget", async () => {
let clock = 0;
const now = () => (clock += 240);
const live = await scanCssEffectLiveFrames(
makePage(between(2, 4)),
10,
1000,
1000,
60_000,
now,
);
expect(frames(live)).toEqual(range(20, 20));
});
it("returns the frames where the effect element shows", async () => {
const live = await scanCssEffectLiveFrames(makePage(between(2, 4)), 10, 100, 100);
expect(frames(live)).toEqual(range(20, 20));
+56 -35
View File
@@ -9,6 +9,9 @@ import { quantizeTimeToFrame } from "@hyperframes/core";
/** Frames per page round trip while scanning; a scan is one in-page loop per chunk. */
const SCAN_CHUNK_FRAMES = 240;
/** Longest the scan may hold back the first frame; above it the whole render captures by screenshot. */
const SCAN_BUDGET_MS = 120_000;
/**
* Above this share of risk frames the second browser and the switching cost outweigh the fast
* frames, so the whole render captures by screenshot as it always has.
@@ -26,53 +29,71 @@ const PIXELS_1080P = 1920 * 1080;
/** Risk ranges closer than this many frames are joined, so capture never alternates modes. */
export const RISK_RANGE_HYSTERESIS_FRAMES = 12;
async function scanChunk(page: Page, times: number[]): Promise<number[]> {
return page.evaluate(async (ts: number[]): Promise<number[]> => {
type HfWindow = Window & {
__hf?: { seek?: (t: number) => void };
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfCssFxTargets?: Element[];
};
const w = window as HfWindow;
const targets = w.__hfCssFxTargets ?? [];
// A hidden element still shows a descendant that sets `visibility: visible` itself.
const paints = (el: Element): boolean =>
getComputedStyle(el).visibility !== "hidden" ||
Array.from(el.querySelectorAll("*")).some(
(d) => getComputedStyle(d).visibility === "visible",
);
const shows = (el: Element): boolean => {
for (let n: Element | null = el; n; n = n.parentElement) {
const cs = getComputedStyle(n);
if (cs.display === "none" || Number(cs.opacity) === 0) return false;
}
return paints(el);
};
const out: number[] = [];
for (let i = 0; i < ts.length; i++) {
w.__hf?.seek?.(ts[i] as number);
await w.__hfWaitForSeekCompletion?.();
if (targets.some(shows)) out.push(i);
}
return out;
}, times);
}
interface ScanOptions {
budgetMs?: number;
now?: () => number;
onProgress?: (scannedFrames: number, totalFrames: number) => void;
}
/**
* Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
* Null once more than `maxFrames` frames qualify.
* Null once more than `maxFrames` frames qualify, or once the whole scan would take over `budgetMs`.
*/
export async function scanCssEffectLiveFrames(
page: Page,
fps: number,
totalFrames: number,
maxFrames: number,
{ budgetMs = SCAN_BUDGET_MS, now = () => performance.now(), onProgress }: ScanOptions = {},
): Promise<Set<number> | null> {
const live = new Set<number>();
for (let from = 0; from < totalFrames; from += SCAN_CHUNK_FRAMES) {
const started = now();
for (let from = 0; from < totalFrames && live.size <= maxFrames; from += SCAN_CHUNK_FRAMES) {
const to = Math.min(totalFrames, from + SCAN_CHUNK_FRAMES);
const times: number[] = [];
for (let f = from; f < Math.min(totalFrames, from + SCAN_CHUNK_FRAMES); f++) {
times.push(quantizeTimeToFrame(f / fps, fps));
for (let f = from; f < to; f++) times.push(quantizeTimeToFrame(f / fps, fps));
for (const i of await scanChunk(page, times)) live.add(from + i);
onProgress?.(to, totalFrames);
const projectedMs = ((now() - started) / to) * totalFrames;
if (to < totalFrames && projectedMs > budgetMs) {
console.error(
`[engine] fast capture: the effect scan of ${totalFrames} frames would take ` +
`${Math.round(projectedMs / 1000)} s, over its ${budgetMs / 1000} s budget`,
);
return null;
}
const hits = await page.evaluate(async (ts: number[]): Promise<number[]> => {
type HfWindow = Window & {
__hf?: { seek?: (t: number) => void };
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfCssFxTargets?: Element[];
};
const w = window as HfWindow;
const targets = w.__hfCssFxTargets ?? [];
// A hidden element still shows a descendant that sets `visibility: visible` itself.
const paints = (el: Element): boolean =>
getComputedStyle(el).visibility !== "hidden" ||
Array.from(el.querySelectorAll("*")).some(
(d) => getComputedStyle(d).visibility === "visible",
);
const shows = (el: Element): boolean => {
for (let n: Element | null = el; n; n = n.parentElement) {
const cs = getComputedStyle(n);
if (cs.display === "none" || Number(cs.opacity) === 0) return false;
}
return paints(el);
};
const out: number[] = [];
for (let i = 0; i < ts.length; i++) {
w.__hf?.seek?.(ts[i] as number);
await w.__hfWaitForSeekCompletion?.();
if (targets.some(shows)) out.push(i);
}
return out;
}, times);
for (const i of hits) live.add(from + i);
if (live.size > maxFrames) break;
}
return live.size > maxFrames ? null : live;
}
+1
View File
@@ -246,6 +246,7 @@ export interface CaptureOptions {
*/
deVerifySamples?: number;
deFrameFallback?: boolean;
onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void;
}
export interface CaptureVideoMetadataHint {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/gcp-cloud-run",
"version": "0.8.215",
"version": "0.8.216",
"description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/inspector",
"version": "0.8.215",
"version": "0.8.216",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/lint",
"version": "0.8.215",
"version": "0.8.216",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/parsers",
"version": "0.8.215",
"version": "0.8.216",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/player",
"version": "0.8.215",
"version": "0.8.216",
"description": "Embeddable web component for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/producer",
"version": "0.8.215",
"version": "0.8.216",
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
"repository": {
"type": "git",
+1
View File
@@ -24,6 +24,7 @@ export {
type RenderPerfSummary,
type ProgressCallback,
} from "./services/renderOrchestrator.js";
export { extractSafeRenderErrorCode } from "./services/safeRenderErrorCode.js";
export {
RENDER_JOB_DIR,
createOwnedRenderDir,
+3 -23
View File
@@ -51,6 +51,9 @@ import {
} from "@hyperframes/core";
import { parseEncoderOptions } from "@hyperframes/studio-server/encoder-options";
import { createRenderRequest, renderConfigFromRequest } from "./renderRequest.js";
import { extractSafeRenderErrorCode } from "./services/safeRenderErrorCode.js";
export { extractSafeRenderErrorCode };
// ---------------------------------------------------------------------------
// Types
@@ -128,29 +131,6 @@ interface PreparedRenderInput {
const DEFAULT_SERVER_FPS = { num: 30, den: 1 } as const;
const SSE_KEEPALIVE_MS = 30_000;
const SAFE_RENDER_ERROR_CODES = new Set<string>([
"ASSET_MEDIA_TYPE_MISMATCH",
"HEIF_IMAGE_CONVERSION_FAILED",
"HEIF_TOOL_UNAVAILABLE",
"NOT_MEDIA_PAYLOAD",
"INVALID_VIDEO_METADATA",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
"ENCODER_INTERRUPTED",
"RENDER_TOO_SLOW",
"RENDER_STAGE_STALLED",
]);
/**
* Preserve only bounded producer error codes across JSON/SSE. Never derive a
* code from the message: it may contain local paths or signed source URLs.
*/
export function extractSafeRenderErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
const code = error.code;
return typeof code === "string" && SAFE_RENDER_ERROR_CODES.has(code) ? code : undefined;
}
export interface SafeRenderErrorMetadata {
errorCode: string;
errorOwner?: "system" | "user";
@@ -3683,6 +3683,15 @@ async function executeRenderPipeline(input: {
// sample frame indices from this so they land inside the drained range.
compositionDurationSeconds: job.duration,
deFrameFallback: true,
// A line per scanned chunk, so a long effect scan reads as preparing, not as a stall.
onEffectScanProgress: (scanned, total) =>
updateJobStatus(
job,
"rendering",
`Checking effects, ${scanned} of ${total} frames`,
25,
onProgress,
),
requiresWebGpu: compositionRequiresWebGpu(compiled.html),
// Live route for Chrome memory (spec §5 Phase −1). The aggregate route
// through CapturePerfSummary only exists on success; a mid-capture
@@ -0,0 +1,22 @@
const SAFE_RENDER_ERROR_CODES = new Set<string>([
"ASSET_MEDIA_TYPE_MISMATCH",
"HEIF_IMAGE_CONVERSION_FAILED",
"HEIF_TOOL_UNAVAILABLE",
"NOT_MEDIA_PAYLOAD",
"INVALID_VIDEO_METADATA",
"VIDEO_SOURCE_UNRENDERABLE",
"VIDEO_EXTRACTION_FAILED",
"ENCODER_INTERRUPTED",
"RENDER_TOO_SLOW",
"RENDER_STAGE_STALLED",
]);
/**
* Preserve only bounded producer error codes across JSON/SSE. Never derive a
* code from the message: it may contain local paths or signed source URLs.
*/
export function extractSafeRenderErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
const code = error.code;
return typeof code === "string" && SAFE_RENDER_ERROR_CODES.has(code) ? code : undefined;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/sdk",
"version": "0.8.215",
"version": "0.8.216",
"description": "Headless, framework-neutral HyperFrames composition editing engine",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/shader-transitions",
"version": "0.8.215",
"version": "0.8.216",
"description": "WebGL shader transitions for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio-server",
"version": "0.8.215",
"version": "0.8.216",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
"version": "0.8.215",
"version": "0.8.216",
"description": "",
"repository": {
"type": "git",
@@ -44,6 +44,20 @@ function shadowPlayerReachable(iframe: HTMLIFrameElement | null): boolean {
}
}
function shadowPagePhase(iframe: HTMLIFrameElement | null): string {
const doc = iframe?.contentDocument;
if (!doc || doc.URL === "about:blank") return "no-page";
const win = iframe!.contentWindow as unknown as Record<string, unknown>;
if (doc.readyState === "loading") return "page-loading";
if (win.__hfStartupError) return "startup-error";
if (!win.__hyperframes) return "runtime-not-run";
if (!win.__hyperframeRuntimeBootstrapped) return "scripts-waiting";
return "no-player";
}
const pageGotTo = (iframe: HTMLIFrameElement | null) =>
shadowPlayerReachable(iframe) ? "" : ` Its page had got to: ${shadowPagePhase(iframe)}.`;
function isDocumentHidden(): boolean {
return typeof document !== "undefined" && document.visibilityState === "hidden";
}
@@ -123,6 +137,7 @@ export function useShadowPreviewReload({
: pendingCommitRef.current?.gen !== gen
? "its timeline to be ready"
: "its loaders to clear";
const got = pageGotTo(shadowIframeRef.current);
shadowGenRef.current += 1;
stopPendingShadow();
shadowIframeRef.current = null;
@@ -130,7 +145,7 @@ export function useShadowPreviewReload({
pendingSeekRef.current = null;
setPreviewSlots(planShadowDiscard);
setPreviewReloading(false);
const message = `The preview did not reload (${cause}). It was still waiting for ${waitingFor}. The previous preview is still showing.`;
const message = `The preview did not reload (${cause}). It was still waiting for ${waitingFor}.${got} The previous preview is still showing.`;
logReload("shadow-failed", { cause, waitingFor });
const refused = refusedPreviewStatus(cause);
const pageMissingNow = refused !== null && refused < 500;
@@ -405,6 +405,36 @@ describe("shadow reload readiness and failure", () => {
unmount(root);
});
const loaded = { URL: "http://127.0.0.1/preview", readyState: "complete" };
it.each([
["no-page", {}, { ...loaded, URL: "about:blank" }],
["page-loading", {}, { ...loaded, readyState: "loading" }],
["startup-error", { __hfStartupError: "failed", __hyperframes: {} }, loaded],
["runtime-not-run", {}, loaded],
// Its runtime ran, but the composition's scripts never finished (fonts that never load, say).
["scripts-waiting", { __hyperframes: {} }, loaded],
["no-player", { __hyperframes: {}, __hyperframeRuntimeBootstrapped: true }, loaded],
])(
"says its page got to %s when a shadow gives up waiting for the runtime",
(phase, win, doc) => {
vi.useFakeTimers();
vi.spyOn(console, "error").mockImplementation(() => {});
const onPreviewReloadFailed = vi.fn();
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
const shadow = makeFakeIframe(win);
Object.defineProperty(shadow, "contentDocument", { value: doc });
act(() => {
getApi().setShadowIframeNode(shadow);
getApi().onShadowIframeLoad(gen);
});
act(() => void vi.advanceTimersByTime(FULL_WAIT_MS));
expect(onPreviewReloadFailed).toHaveBeenCalledWith(
expect.stringContaining(`Its page had got to: ${phase}.`),
);
unmount(root);
},
);
it("names the timeline when a shadow's runtime runs but never reports a length", () => {
vi.useFakeTimers();
vi.spyOn(console, "error").mockImplementation(() => {});
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "hyperframes",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.8.215",
"version": "0.8.216",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+66
View File
@@ -0,0 +1,66 @@
# HyperFrames v0.8.216
Released on 2026-10-29.
This release carries every change from v0.8.215, which never reached npm. iPhone Safari keeps every take's sound, multi-worker renders land each frame in place, and encoding runs faster.
## Features
- **Telemetry:** Identify signed-in CLI users by their HyperFrames user id, and say signed-in usage is not anonymous ([1c3fcf4d2](https://github.com/heygen-com/hyperframes/commit/1c3fcf4d237d031107baa6a5646b5bb1ee82cc2a), [#6703](https://github.com/heygen-com/hyperframes/pull/6703)).
- **Producer:** Report which element set the film length ([3ca1005a8](https://github.com/heygen-com/hyperframes/commit/3ca1005a81e76565a69d5874371c857321b66d59), [#6689](https://github.com/heygen-com/hyperframes/pull/6689)).
## Fixes
- **Player:** iPhone Safari keeps every take's sound after the first seconds of an edit ([48ba0e5b2](https://github.com/heygen-com/hyperframes/commit/48ba0e5b2bf91fc816adc0a7769d7344091aa5d7), [#6724](https://github.com/heygen-com/hyperframes/pull/6724)).
- **AWS Lambda:** The release ffmpeg build falls back to verified mirrors when a source host is down ([f0665f718](https://github.com/heygen-com/hyperframes/commit/f0665f71813278b0e6999df68de691e51d52679d), [#6728](https://github.com/heygen-com/hyperframes/pull/6728)).
- **CLI:** A failed render names its failure code for the Desktop app ([ba8ffffa0](https://github.com/heygen-com/hyperframes/commit/ba8ffffa0b5695d207cc89949e90e5902839fd67), [#6725](https://github.com/heygen-com/hyperframes/pull/6725)).
- **CLI:** A snapshot still shows what playback shows, whatever other times are asked for ([cf1589242](https://github.com/heygen-com/hyperframes/commit/cf15892425245da96dc43d858a7478031533d2f1), [#6720](https://github.com/heygen-com/hyperframes/pull/6720)).
- **CLI:** The lint summary points at hyperframes lint, not a flag only render takes ([98a28cc4b](https://github.com/heygen-com/hyperframes/commit/98a28cc4b2919f1c8418d2cb61bafc66ec9ccb31), [#6723](https://github.com/heygen-com/hyperframes/pull/6723)).
- **Ci:** Skills sync steps past the five hidden ClawHub versions blocking it ([49e031230](https://github.com/heygen-com/hyperframes/commit/49e0312300d6c049b19eb8435930f980621a993f), [#6713](https://github.com/heygen-com/hyperframes/pull/6713)).
- **CLI:** An unknown command like search says which command does that ([bb4deb801](https://github.com/heygen-com/hyperframes/commit/bb4deb8014a8457425c2e6e4a8619969514824ea), [#6710](https://github.com/heygen-com/hyperframes/pull/6710)).
- **CLI:** Check says how many browser-playable copies are done while it makes them ([2f8c4b1da](https://github.com/heygen-com/hyperframes/commit/2f8c4b1dafaebc26f4dcfe6252312faf39ff9d89), [#6712](https://github.com/heygen-com/hyperframes/pull/6712)).
- **Core:** A multi-worker or chunked render lands every frame where an in-order render does ([fa9acbd73](https://github.com/heygen-com/hyperframes/commit/fa9acbd7399b7cd5ca9940399d9066bb33d96973), [#6707](https://github.com/heygen-com/hyperframes/pull/6707)).
- **CLI:** Check says a lint error is why the browser pass was skipped ([11312b359](https://github.com/heygen-com/hyperframes/commit/11312b359b18c07911615656b95f48c7d8d82fea), [#6709](https://github.com/heygen-com/hyperframes/pull/6709)).
- **CLI:** Capture names the network's proxy when it refuses the connection to the site ([f0c5db6ea](https://github.com/heygen-com/hyperframes/commit/f0c5db6ea266a5a96907e66ef762e0f6dd85d623), [#6706](https://github.com/heygen-com/hyperframes/pull/6706)).
- **CLI:** Check waits as long as the --timeout it was given before it counts as idle ([6c1765933](https://github.com/heygen-com/hyperframes/commit/6c17659337d50b578c5c54ae716308a13eb2c822), [#6705](https://github.com/heygen-com/hyperframes/pull/6705)).
- **CLI:** Audio windows read beats off the whole clip; beats snap to the signal and mark silence ([4fa5f5451](https://github.com/heygen-com/hyperframes/commit/4fa5f54519150566b8ef706f60c05ff13c903423), [#6692](https://github.com/heygen-com/hyperframes/pull/6692)).
- **Producer:** A BeginFrame capture that draws no first frame in 20 s switches to screenshot ([a5496b188](https://github.com/heygen-com/hyperframes/commit/a5496b1887950777b78ed5ac2bff3505961cb5fe), [#6701](https://github.com/heygen-com/hyperframes/pull/6701)).
- **CLI:** Media-treatment --capability preset finds the presets area ([cfa653af4](https://github.com/heygen-com/hyperframes/commit/cfa653af4d0f95d751286fb25c8d38bdb56ef663), [#6700](https://github.com/heygen-com/hyperframes/pull/6700)).
- **CLI:** A Windows render no longer prints taskkill's already-exited message ([ac449555f](https://github.com/heygen-com/hyperframes/commit/ac449555f4607ea65895628164cb2835afdd0ce5), [#6698](https://github.com/heygen-com/hyperframes/pull/6698)).
- **CLI:** Snapshot reads https video in the Desktop app instead of failing at ffprobe ([0620067eb](https://github.com/heygen-com/hyperframes/commit/0620067eb096fee9b6748efb9421d852d540cbf6), [#6699](https://github.com/heygen-com/hyperframes/pull/6699)).
- **CLI:** A stopped model download resumes on the next run instead of starting over ([81959201f](https://github.com/heygen-com/hyperframes/commit/81959201f0acb2e265e4d8c30daa89ffc1c9607d), [#6694](https://github.com/heygen-com/hyperframes/pull/6694)).
- **CLI:** Media-treatment finds an area by the name its overview prints ([2c3d1e07f](https://github.com/heygen-com/hyperframes/commit/2c3d1e07fe63d4a2d67d63a3e29d4ad73e291d8e), [#6675](https://github.com/heygen-com/hyperframes/pull/6675)).
- **Engine:** Downloads refuse hosts that resolve to private addresses ([9830059bd](https://github.com/heygen-com/hyperframes/commit/9830059bdb08adbab473d059d1b6db2230a5bb15), [#6571](https://github.com/heygen-com/hyperframes/pull/6571)).
- **CLI:** Check no longer reports outline text as not painted ([9f65efb19](https://github.com/heygen-com/hyperframes/commit/9f65efb19c3f435ef58f6fc81c6909d2a3920f8a), [#6693](https://github.com/heygen-com/hyperframes/pull/6693)).
- **CLI:** Beats --json answers with no track when the composition has no music ([ec10aae54](https://github.com/heygen-com/hyperframes/commit/ec10aae54fd3bcadaebb7e935a2b53d788fee074), [#6690](https://github.com/heygen-com/hyperframes/pull/6690)).
- **Core:** Graded media honors object-position vertically instead of mirroring it ([aa4a71fe4](https://github.com/heygen-com/hyperframes/commit/aa4a71fe417401b5d8a69cff36f56319defd3ba7), [#6691](https://github.com/heygen-com/hyperframes/pull/6691)).
- **Studio:** Audio keeps playing after a timeline move or trim in a static composition ([9aa6346a7](https://github.com/heygen-com/hyperframes/commit/9aa6346a779328f1c0963ec3d37b856ad164c969), [#6428](https://github.com/heygen-com/hyperframes/pull/6428)).
- **Engine:** A render from an app that bundles the engine stamps the engine's version ([cdc86117c](https://github.com/heygen-com/hyperframes/commit/cdc86117c7ffa6bf087017035ea17149b98303da), [#6686](https://github.com/heygen-com/hyperframes/pull/6686)).
- **Studio:** An undo or redo counts as a preview change from its key, not from its reload ([710314c15](https://github.com/heygen-com/hyperframes/commit/710314c15e11bb1f49cd83117f42b3cf2b2fc62e), [#6683](https://github.com/heygen-com/hyperframes/pull/6683)).
- **Producer:** A capture that landed every frame is not failed for the work after its last frame ([067cbf7ac](https://github.com/heygen-com/hyperframes/commit/067cbf7acf55e54517650b5ade24cfa352806c04), [#6674](https://github.com/heygen-com/hyperframes/pull/6674)).
- **CLI:** Check no longer treats a transparent canvas as covering the frame ([203a08dd7](https://github.com/heygen-com/hyperframes/commit/203a08dd7c1d4856e04e6a1288915a4b6f5fa2f6), [#6681](https://github.com/heygen-com/hyperframes/pull/6681)).
- **Core:** Grade groups resolve per composition; freeze frames keep their lane ([c569760a7](https://github.com/heygen-com/hyperframes/commit/c569760a7a7c96df6540d53bbfe4670225ae140e), [#6685](https://github.com/heygen-com/hyperframes/pull/6685)).
## Performance
- **Engine:** Encode high quality with the x264 fast preset ([be05c8693](https://github.com/heygen-com/hyperframes/commit/be05c86932be215ec70621dc568af96415555fa0), [#6640](https://github.com/heygen-com/hyperframes/pull/6640)).
- **Engine:** Size render workers from measured heap and the container's CPU quota ([cd003e201](https://github.com/heygen-com/hyperframes/commit/cd003e201051317234598bcf1c70cfed4770613f), [#6641](https://github.com/heygen-com/hyperframes/pull/6641)).
- **Producer:** The audio true-peak check seeks to each segment instead of decoding up to it ([c2cde2fb6](https://github.com/heygen-com/hyperframes/commit/c2cde2fb624ba5a311615574dff822a41f61ea96), [#6672](https://github.com/heygen-com/hyperframes/pull/6672)).
## Docs & Examples
- **CLI:** Say which audio probes run once per file ([5d437d5ee](https://github.com/heygen-com/hyperframes/commit/5d437d5eecd65f05daa4ff451ebb85b71f88fdb9), [#6731](https://github.com/heygen-com/hyperframes/pull/6731)).
- **CLI:** List check's audio findings with severity, trigger and fix ([fa97f9d4b](https://github.com/heygen-com/hyperframes/commit/fa97f9d4b7b4f401560dcc3e642267e953a2a89e), [#6730](https://github.com/heygen-com/hyperframes/pull/6730)).
## Catalog
- **Skills:** The registry skill says its commands are catalog and add ([1428f8768](https://github.com/heygen-com/hyperframes/commit/1428f876899557a4fe4448a6ac327ff503be53f5), [#6680](https://github.com/heygen-com/hyperframes/pull/6680)).
## Internal
- One regression shard writes the test image cache ([74f323473](https://github.com/heygen-com/hyperframes/commit/74f3234736ad87568831ed51c34a7e289dc668e6), [#6729](https://github.com/heygen-com/hyperframes/pull/6729)).
- **CLI:** The publish abort test checks the summary hint is absent ([077de9bfe](https://github.com/heygen-com/hyperframes/commit/077de9bfe9f248c0807ba2c7f78d1514adf85bc6), [#6726](https://github.com/heygen-com/hyperframes/pull/6726)).
## Full changelog
https://github.com/heygen-com/hyperframes/compare/v0.8.214...v0.8.216