Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,11 @@ Piped, non-quiet renders also emit a single `@hf-result ` JSON line after a loca
render succeeds, never after a failure. Version 1 has `operation: "render"` and
`reference_background`, the background the render measured from the composition's
root and body: `{"kind":"solid","rgb":[r,g,b]}`, `{"kind":"none"}`,
`{"kind":"unsupported_non_solid"}` or `{"kind":"unknown"}`. Consumers must ignore
unknown versions and fields. A `--docker` render prints no result line from the
`{"kind":"unsupported_non_solid"}` or `{"kind":"unknown"}`. A render that applied
motion blur also carries `motion_blur`: `tier` (`"capture"` or `"filter"`, absent
when none ran), `movers`, `failed_frames` and a `fingerprint` of 14 snake_case
counts (absent when the page produced none); it is absent when the render has no
blur summary. Consumers must ignore unknown versions and fields. A `--docker` render prints no result line from the
launcher. In batch mode each manifest row carries the same `reference_background` value (`null` until the row completes, and for a
row that failed).

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ import type { ProducerLogger, RenderJob, RenderPerfSummary } from "@hyperframes/
import {
EXTRACT_CACHE_DIR_DISABLED_ALIASES,
sandboxDeniedRemediation,
type PageMotionBlurSummary,
type ProresProfile,
type VideoFrameFormat,
} from "@hyperframes/engine";
Expand Down Expand Up @@ -509,6 +510,7 @@ export default defineCommand({
});

export interface SingleRenderResult extends Partial<ReferenceBackgroundResult> {
motionBlur?: PageMotionBlurSummary;
durationMs?: number;
renderTimeMs: number;
outcome?: "completed" | "completed_with_warnings";
Expand Down Expand Up @@ -1304,6 +1306,7 @@ async function executeLocalRender(
job.outcome === "completed_with_warnings" ? "completed_with_warnings" : "completed";
return {
...producer.referenceBackgroundResult(job[producer.REFERENCE_BACKGROUND_FIELD]),
motionBlur: job.perfSummary?.motionBlur,
renderTimeMs: elapsed,
durationMs,
outcome,
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/commands/render/execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,27 @@ describe("the machine result line after a single render", () => {
});
});

it("prints the motion blur summary beside the background", async () => {
const lines = await resultLines({
renderTimeMs: 5,
reference_background: { kind: "none" },
motionBlur: {
tier: "capture",
reasonCode: null,
movers: 2,
failedFrames: 1,
readEvents: true,
fingerprint: null,
},
});
expect(JSON.parse((lines[0] ?? "").slice("@hf-result ".length))).toEqual({
version: 1,
operation: "render",
reference_background: { kind: "none" },
motion_blur: { tier: "capture", movers: 2, failed_frames: 1 },
});
});

it("prints nothing when the render fails", async () => {
expect(await resultLines(new Error("capture failed"))).toEqual([]);
});
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/telemetry/renderMotionBlur.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,32 @@ export function renderMotionBlurEventProperties(
...Object.fromEntries(counts),
};
}

export interface RenderMotionBlurResult {
tier?: "capture" | "filter";
movers: number;
failed_frames: number;
fingerprint?: Record<string, number>;
}

const count = (value: unknown): number =>
typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;

export function renderMotionBlurResult(motionBlur: PageMotionBlurSummary): RenderMotionBlurResult {
const { tier, fingerprint } = motionBlur;
return {
...(tier === "capture" || tier === "filter" ? { tier } : {}),
movers: count(motionBlur.movers),
failed_frames: count(motionBlur.failedFrames),
...(fingerprint
? {
fingerprint: Object.fromEntries(
MOTION_BLUR_FINGERPRINT_FIELDS.map((field) => [
snake(field),
count(fingerprint[field]),
]),
),
}
: {}),
};
}
97 changes: 97 additions & 0 deletions packages/cli/src/ui/renderResult.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReferenceBackground } from "@hyperframes/producer";
import type { PageMotionBlurSummary } from "@hyperframes/engine";
import { MOTION_BLUR_FINGERPRINT_FIELDS } from "@hyperframes/core";
import { renderMachineResult } from "./renderResult.js";

const originalIsTTY = process.stdout.isTTY;
Expand Down Expand Up @@ -68,4 +70,99 @@ describe("renderMachineResult", () => {
vi.stubEnv("HYPERFRAMES_STDOUT_IS_TTY", "1");
expect(capture(() => renderMachineResult(result, false))).toBe("");
});

describe("motion blur summary", () => {
const summary = (over: Partial<PageMotionBlurSummary> = {}): PageMotionBlurSummary => ({
tier: "filter",
reasonCode: "accelerated-canvas",
movers: 4,
failedFrames: 2,
readEvents: true,
fingerprint: {
...Object.fromEntries(MOTION_BLUR_FINGERPRINT_FIELDS.map((field) => [field, 0])),
canvas2d: 3,
threeD: 1,
} as NonNullable<PageMotionBlurSummary["fingerprint"]>,
...over,
});

it("carries tier, movers, failed frames and all fingerprint counts next to the background", () => {
const line = capture(() =>
renderMachineResult(
{ reference_background: { kind: "none" }, motionBlur: summary() },
false,
),
);
const parsed = parse(line) as { motion_blur: { fingerprint: Record<string, number> } };
expect(parsed).toEqual({
version: 1,
operation: "render",
reference_background: { kind: "none" },
motion_blur: {
tier: "filter",
movers: 4,
failed_frames: 2,
fingerprint: expect.objectContaining({ canvas2d: 3, three_d: 1, svg_clip: 0 }),
},
});
expect(Object.keys(parsed.motion_blur.fingerprint)).toHaveLength(14);
});

it("omits motion_blur when the render has no summary", () => {
const line = capture(() =>
renderMachineResult({ reference_background: { kind: "none" } }, false),
);
expect(parse(line)).toEqual({
version: 1,
operation: "render",
reference_background: { kind: "none" },
});
});

it("prints a line for a summary alone, without inventing a background", () => {
const line = capture(() => renderMachineResult({ motionBlur: summary() }, false));
const parsed = parse(line) as Record<string, unknown>;
expect(parsed).not.toHaveProperty("reference_background");
expect(parsed).toHaveProperty("motion_blur");
});

it("omits tier and fingerprint when the summary has none", () => {
const line = capture(() =>
renderMachineResult(
{ motionBlur: summary({ tier: null, fingerprint: null, movers: 0, failedFrames: 0 }) },
false,
),
);
expect(parse(line)).toEqual({
version: 1,
operation: "render",
motion_blur: { movers: 0, failed_frames: 0 },
});
});

it("never prints free text, whatever the summary carries", () => {
const line = capture(() =>
renderMachineResult(
{
motionBlur: summary({
reasonCode: "/private/clip.mp4 #secret-selector",
tier: "/private/tier" as never,
movers: "/private/movers" as never,
}),
},
false,
),
);
expect(line).not.toContain("/private");
expect(line).not.toContain("secret");
expect(line).not.toContain("reason");
expect(line).not.toContain("accelerated-canvas");
expect(parse(line)).toMatchObject({ motion_blur: { movers: 0 } });
});

it("stays silent for quiet mode and a terminal even with a summary", () => {
expect(capture(() => renderMachineResult({ motionBlur: summary() }, true))).toBe("");
expect(capture(() => renderMachineResult({ motionBlur: summary() }, false), true)).toBe("");
});
});
});
19 changes: 12 additions & 7 deletions packages/cli/src/ui/renderResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ import {
referenceBackgroundResult,
type ReferenceBackgroundResult,
} from "@hyperframes/producer";
import type { PageMotionBlurSummary } from "@hyperframes/engine";
import { renderMotionBlurResult } from "../telemetry/renderMotionBlur.js";

/** Versioned, content-free success line for a piped CLI: the background the render measured, never a path or a word of the composition. */
export function renderMachineResult(
result: Partial<ReferenceBackgroundResult>,
quiet: boolean,
): void {
export interface RenderMachineResultInput extends Partial<ReferenceBackgroundResult> {
motionBlur?: PageMotionBlurSummary;
}

/** Versioned, content-free success line for a piped CLI: the background and motion blur counts the render measured, never a path or a word of the composition. */
export function renderMachineResult(result: RenderMachineResultInput, quiet: boolean): void {
if (quiet || process.stdout.isTTY || process.env.HYPERFRAMES_STDOUT_IS_TTY === "1") return;
if (!(REFERENCE_BACKGROUND_FIELD in result)) return;
const hasBackground = REFERENCE_BACKGROUND_FIELD in result;
if (!hasBackground && !result.motionBlur) return;
const line = {
version: 1,
operation: "render",
...referenceBackgroundResult(result[REFERENCE_BACKGROUND_FIELD]),
...(hasBackground ? referenceBackgroundResult(result[REFERENCE_BACKGROUND_FIELD]) : {}),
...(result.motionBlur ? { motion_blur: renderMotionBlurResult(result.motionBlur) } : {}),
};
process.stdout.write(`@hf-result ${JSON.stringify(line)}\n`);
}
Loading