diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fccdf5d088..d9ce99121a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hyperframes", "description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.", - "version": "0.8.215", + "version": "0.8.216", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b2626131c2..2225c56f62 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c011f3b1e7..02c97a6bc8 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -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" diff --git a/docs/changelog.mdx b/docs/changelog.mdx index b67ce4392f..cbbd2020cf 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,73 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +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). + + @@ -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]); }, ); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0bd19ad398..07ae6954a3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 ──────────────────────────────────────────────────────────── diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 1dc0f5be32..970fdb65a1 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -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) => + 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((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); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 3a402382eb..ac2026b343 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -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(); diff --git a/packages/cli/src/commands/feedback.telemetryJoinKeys.test.ts b/packages/cli/src/commands/feedback.telemetryJoinKeys.test.ts index 069fb2310f..4a60962e61 100644 --- a/packages/cli/src/commands/feedback.telemetryJoinKeys.test.ts +++ b/packages/cli/src/commands/feedback.telemetryJoinKeys.test.ts @@ -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); + }); }); diff --git a/packages/cli/src/commands/feedback.ts b/packages/cli/src/commands/feedback.ts index ac4f166000..cf29c43c6e 100644 --- a/packages/cli/src/commands/feedback.ts +++ b/packages/cli/src/commands/feedback.ts @@ -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; diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index e26425dbeb..b1bf1fdc93 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -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); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 0f7f20a69e..d0099f0d4a 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -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 = ` +
+
Headline copy
+ + +
+ `; + 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 = ` +
+
+
Headline copy
+
+
+
+ `; + 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", diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 0bab9786ea..b40207a9a7 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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(), diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 74e9f7f71b..545350acca 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -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) => 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 () => { diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index adda886b29..a24c0a9c6f 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -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()) { diff --git a/packages/cli/src/telemetry/config.ts b/packages/cli/src/telemetry/config.ts index 0af74c5982..430a5e2d85 100644 --- a/packages/cli/src/telemetry/config.ts +++ b/packages/cli/src/telemetry/config.ts @@ -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; +} + +/** 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}. */ diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 62d524f754..bb23942a3d 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -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), diff --git a/packages/cli/src/telemetry/missingPathKind.test.ts b/packages/cli/src/telemetry/missingPathKind.test.ts new file mode 100644 index 0000000000..c23386b94c --- /dev/null +++ b/packages/cli/src/telemetry/missingPathKind.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/telemetry/missingPathKind.ts b/packages/cli/src/telemetry/missingPathKind.ts new file mode 100644 index 0000000000..281d0cc7b3 --- /dev/null +++ b/packages/cli/src/telemetry/missingPathKind.ts @@ -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"; +} diff --git a/packages/cli/src/ui/renderDiagnostic.test.ts b/packages/cli/src/ui/renderDiagnostic.test.ts index a18ed2670d..0fdaacf411 100644 --- a/packages/cli/src/ui/renderDiagnostic.test.ts +++ b/packages/cli/src/ui/renderDiagnostic.test.ts @@ -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", diff --git a/packages/cli/src/ui/renderDiagnostic.ts b/packages/cli/src/ui/renderDiagnostic.ts index f9dc899fce..409e667152 100644 --- a/packages/cli/src/ui/renderDiagnostic.ts +++ b/packages/cli/src/ui/renderDiagnostic.ts @@ -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" diff --git a/packages/cli/src/utils/checkBrowser.chromium.test.ts b/packages/cli/src/utils/checkBrowser.chromium.test.ts index b218ea1648..144380970b 100644 --- a/packages/cli/src/utils/checkBrowser.chromium.test.ts +++ b/packages/cli/src/utils/checkBrowser.chromium.test.ts @@ -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(``); + 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("

Hello

"); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 924a976c8e..98ee3b7adc 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -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), diff --git a/packages/cli/src/utils/checkFailureCodes.ts b/packages/cli/src/utils/checkFailureCodes.ts new file mode 100644 index 0000000000..930e9bffd2 --- /dev/null +++ b/packages/cli/src/utils/checkFailureCodes.ts @@ -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 { + const counts = new Map(); + 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); +} diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 1730884676..7ee085a0b2 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -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); }, diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index a01956b3b3..33ef1c8141 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -262,8 +262,8 @@ export interface CheckAuditDriver { getCanvas(): Promise; findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number, screenshotFollows?: boolean): Promise; - /** 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; + /** 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; collectLayout( time: number, tolerance: number, diff --git a/packages/cli/src/utils/dotEnv.test.ts b/packages/cli/src/utils/dotEnv.test.ts index c49b19bc3b..571680d9e0 100644 --- a/packages/cli/src/utils/dotEnv.test.ts +++ b/packages/cli/src/utils/dotEnv.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/cli/src/utils/dotEnv.ts b/packages/cli/src/utils/dotEnv.ts index 336063f7f5..0cf4ca6c61 100644 --- a/packages/cli/src/utils/dotEnv.ts +++ b/packages/cli/src/utils/dotEnv.ts @@ -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)`, + ); } } diff --git a/packages/core/package.json b/packages/core/package.json index 210e51dd97..f10578c85a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.8.215", + "version": "0.8.216", "description": "", "repository": { "type": "git", diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 3cd4a58d5d..80a0cec09b 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -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: "

two

", extraAttrs: ' style="position: static"' }; bootWindowed([S0, S1, leaf], trackingRoot().root); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a45270ee4e..e2d997848b 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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. diff --git a/packages/core/src/telemetryRedaction.test.ts b/packages/core/src/telemetryRedaction.test.ts index 6a6a9a5daf..5d5552061d 100644 --- a/packages/core/src/telemetryRedaction.test.ts +++ b/packages/core/src/telemetryRedaction.test.ts @@ -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. diff --git a/packages/core/src/telemetryRedaction.ts b/packages/core/src/telemetryRedaction.ts index 84b12f90b4..fa923534f0 100644 --- a/packages/core/src/telemetryRedaction.ts +++ b/packages/core/src/telemetryRedaction.ts @@ -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`(? | 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) => { + 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)); diff --git a/packages/engine/src/services/riskFrames.ts b/packages/engine/src/services/riskFrames.ts index 87d127753f..90e089b311 100644 --- a/packages/engine/src/services/riskFrames.ts +++ b/packages/engine/src/services/riskFrames.ts @@ -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 { + return page.evaluate(async (ts: number[]): Promise => { + type HfWindow = Window & { + __hf?: { seek?: (t: number) => void }; + __hfWaitForSeekCompletion?: () => Promise; + __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 | null> { const live = new Set(); - 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 => { - type HfWindow = Window & { - __hf?: { seek?: (t: number) => void }; - __hfWaitForSeekCompletion?: () => Promise; - __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; } diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 606f2bbec9..a4b2436744 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -246,6 +246,7 @@ export interface CaptureOptions { */ deVerifySamples?: number; deFrameFallback?: boolean; + onEffectScanProgress?: (scannedFrames: number, totalFrames: number) => void; } export interface CaptureVideoMetadataHint { diff --git a/packages/gcp-cloud-run/package.json b/packages/gcp-cloud-run/package.json index 3e63a1f703..5867035077 100644 --- a/packages/gcp-cloud-run/package.json +++ b/packages/gcp-cloud-run/package.json @@ -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", diff --git a/packages/inspector/package.json b/packages/inspector/package.json index c19663afdf..e7a5086d97 100644 --- a/packages/inspector/package.json +++ b/packages/inspector/package.json @@ -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", diff --git a/packages/lint/package.json b/packages/lint/package.json index 921d27f6b1..82a63bda29 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -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", diff --git a/packages/parsers/package.json b/packages/parsers/package.json index 4cdca00cb1..d1377742f2 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -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", diff --git a/packages/player/package.json b/packages/player/package.json index 60152f2772..b59b05f432 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -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", diff --git a/packages/producer/package.json b/packages/producer/package.json index 6e76ebc0c6..b739d98071 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -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", diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts index 7d8193a7dc..3ea7e361fb 100644 --- a/packages/producer/src/index.ts +++ b/packages/producer/src/index.ts @@ -24,6 +24,7 @@ export { type RenderPerfSummary, type ProgressCallback, } from "./services/renderOrchestrator.js"; +export { extractSafeRenderErrorCode } from "./services/safeRenderErrorCode.js"; export { RENDER_JOB_DIR, createOwnedRenderDir, diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 6fe066c83b..2f056e2131 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -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([ - "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"; diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 9d4abc0ba1..2b815a120a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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 diff --git a/packages/producer/src/services/safeRenderErrorCode.ts b/packages/producer/src/services/safeRenderErrorCode.ts new file mode 100644 index 0000000000..1f86b88947 --- /dev/null +++ b/packages/producer/src/services/safeRenderErrorCode.ts @@ -0,0 +1,22 @@ +const SAFE_RENDER_ERROR_CODES = new Set([ + "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; +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f744be253b..fea06196dd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -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", diff --git a/packages/shader-transitions/package.json b/packages/shader-transitions/package.json index d283b88e2f..63fdced0f2 100644 --- a/packages/shader-transitions/package.json +++ b/packages/shader-transitions/package.json @@ -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", diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 546d528376..08d3c22ff2 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -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", diff --git a/packages/studio/package.json b/packages/studio/package.json index cce3179992..623d85c13a 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/studio", - "version": "0.8.215", + "version": "0.8.216", "description": "", "repository": { "type": "git", diff --git a/packages/studio/src/player/hooks/useShadowPreviewReload.ts b/packages/studio/src/player/hooks/useShadowPreviewReload.ts index bb93ec87bd..a4a8697bad 100644 --- a/packages/studio/src/player/hooks/useShadowPreviewReload.ts +++ b/packages/studio/src/player/hooks/useShadowPreviewReload.ts @@ -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; + 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; diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts index be8ec26e9f..dde074101f 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts @@ -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(() => {}); diff --git a/plugin.json b/plugin.json index 7b83d5061a..104087b613 100644 --- a/plugin.json +++ b/plugin.json @@ -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", diff --git a/releases/v0.8.216.md b/releases/v0.8.216.md new file mode 100644 index 0000000000..a40180e808 --- /dev/null +++ b/releases/v0.8.216.md @@ -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