mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
Merge remote-tracking branch 'origin/main' into miao/journey-test
This commit is contained in:
@@ -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,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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
@@ -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, "..");
|
||||
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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}. */
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -246,6 +246,7 @@ export interface CaptureOptions {
|
||||
*/
|
||||
deVerifySamples?: number;
|
||||
deFrameFallback?: boolean;
|
||||
onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void;
|
||||
}
|
||||
|
||||
export interface CaptureVideoMetadataHint {
|
||||
|
||||
@@ -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,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,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,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,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,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",
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
type RenderPerfSummary,
|
||||
type ProgressCallback,
|
||||
} from "./services/renderOrchestrator.js";
|
||||
export { extractSafeRenderErrorCode } from "./services/safeRenderErrorCode.js";
|
||||
export {
|
||||
RENDER_JOB_DIR,
|
||||
createOwnedRenderDir,
|
||||
|
||||
@@ -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,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,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,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,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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user