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
43 changes: 33 additions & 10 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,22 +443,22 @@ describe("checkRenderResolutionPreflight", () => {
expect(await checkRenderResolutionPreflight(portraitHtml, "portrait", noModes)).toBeUndefined();
});

it("returns a suggestion when a landscape preset is used on a portrait composition", async () => {
const message = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes);
expect(message).toBeDefined();
expect(message).toContain("--resolution portrait");
it("returns a suggestion + aspect-mismatch kind when a landscape preset is used on a portrait composition", async () => {
const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes);
expect(result?.message).toContain("--resolution portrait");
expect(result?.kind).toBe("aspect-mismatch");
});

it("suggests landscape for a landscape composition rendered with a portrait preset", async () => {
const message = await checkRenderResolutionPreflight(landscapeHtml, "portrait", noModes);
expect(message).toContain("--resolution landscape");
const result = await checkRenderResolutionPreflight(landscapeHtml, "portrait", noModes);
expect(result?.message).toContain("--resolution landscape");
});

it("preserves the 4K tier when suggesting a matching preset (square comp + landscape-4k → square-4k)", async () => {
// Tier-aware suggestion is the load-bearing new behavior; square-4k is the
// preset that only surfaces via a same-tier swap, so guard it explicitly.
const message = await checkRenderResolutionPreflight(comp(2160, 2160), "landscape-4k", noModes);
expect(message).toContain("--resolution square-4k");
const result = await checkRenderResolutionPreflight(comp(2160, 2160), "landscape-4k", noModes);
expect(result?.message).toContain("--resolution square-4k");
});

it("does not false-abort a landscape registry-block composition (data-width/height, no data-resolution)", async () => {
Expand All @@ -471,11 +471,34 @@ describe("checkRenderResolutionPreflight", () => {
});

it("flags alpha output combined with outputResolution", async () => {
const message = await checkRenderResolutionPreflight(landscapeHtml, "landscape-4k", {
const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape-4k", {
alphaRequested: true,
hdrRequested: false,
});
expect(message).toContain("alpha output");
expect(result?.message).toContain("alpha output");
expect(result?.kind).toBe("alpha-incompatible");
});

// The three remaining kinds share the same rejection sink (→ one emit each);
// guard their classification so the telemetry dimension stays accurate.
it("classifies an HDR + outputResolution combination as hdr-incompatible", async () => {
const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape", {
alphaRequested: false,
hdrRequested: true,
});
expect(result?.kind).toBe("hdr-incompatible");
});

it("classifies a preset smaller than the composition as downsampling", async () => {
// 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, target smaller.
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", noModes);
expect(result?.kind).toBe("downsampling");
});

it("classifies a non-integer upscale as non-integer-scale", async () => {
// 1280×720 comp + landscape (1920×1080): same 16:9 aspect, 1.5× scale.
const result = await checkRenderResolutionPreflight(comp(1280, 720), "landscape", noModes);
expect(result?.kind).toBe("non-integer-scale");
});

it("returns undefined when composition dimensions can't be determined (defers to the pipeline)", async () => {
Expand Down
16 changes: 12 additions & 4 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
trackRenderComplete,
trackRenderError,
trackRenderObservation,
trackRenderPreflightRejected,
} from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
Expand All @@ -86,6 +87,7 @@ import {
fpsToNumber,
fpsToFfmpegArg,
type CanvasResolution,
type OutputResolutionIssueKind,
type Fps,
type FpsParseResult,
} from "@hyperframes/core";
Expand Down Expand Up @@ -796,7 +798,7 @@ export default defineCommand({
// own defense-in-depth check rather than blocking a render we can't reason
// about. See render-reliability workstream P1-3.
if (outputResolution) {
let resolutionIssue: string | undefined;
let resolutionIssue: { message: string; kind: OutputResolutionIssueKind } | undefined;
try {
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
resolutionIssue = await checkRenderResolutionPreflight(
Expand All @@ -812,7 +814,11 @@ export default defineCommand({
// the real problem with full context.
}
if (resolutionIssue) {
errorBox("Output resolution incompatible", resolutionIssue);
// Count the pre-flight save so dashboard 1783183 can distinguish
// "caught early by pre-flight" from a deep render failure or a user who
// gave up — i.e. measure whether the P1-3 fix is doing its job.
trackRenderPreflightRejected({ kind: resolutionIssue.kind });
errorBox("Output resolution incompatible", resolutionIssue.message);
process.exit(1);
}
}
Expand Down Expand Up @@ -1073,7 +1079,7 @@ export async function checkRenderResolutionPreflight(
compositionHtml: string,
outputResolution: CanvasResolution | undefined,
modes: { alphaRequested: boolean; hdrRequested: boolean },
): Promise<string | undefined> {
): Promise<{ message: string; kind: OutputResolutionIssueKind } | undefined> {
if (!outputResolution) return undefined;
const dims = await readCompositionDimensions(compositionHtml);
// Couldn't determine the composition's actual dimensions — defer to the
Expand All @@ -1086,7 +1092,9 @@ export async function checkRenderResolutionPreflight(
alphaRequested: modes.alphaRequested,
hdrRequested: modes.hdrRequested,
});
return compat.ok ? undefined : compat.message;
// Narrow to the incompatible case; `message`/`kind` are always set there.
if (compat.ok || !compat.message || !compat.kind) return undefined;
return { message: compat.message, kind: compat.kind };
}

const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/telemetry/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
trackCommandFailure,
trackCliError,
trackRenderFeedback,
trackRenderPreflightRejected,
} = await import("./events.js");

describe("render telemetry events", () => {
Expand Down Expand Up @@ -39,6 +40,13 @@ describe("render telemetry events", () => {
);
});

it("emits render_preflight_rejected with the low-cardinality issue kind", () => {
trackRenderPreflightRejected({ kind: "aspect-mismatch" });
expect(trackEvent).toHaveBeenCalledWith("render_preflight_rejected", {
kind: "aspect-mismatch",
});
});

it("forwards distinctId to trackEvent so studio renders attribute to the browser user", () => {
trackRenderError({
fps: 30,
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { redactTelemetryString } from "@hyperframes/core";
import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
import { trackEvent } from "./client.js";

export interface RenderObservabilityTelemetryPayload {
Expand Down Expand Up @@ -308,6 +308,15 @@ export function trackBrowserInstall(): void {
trackEvent("browser_install", {});
}

// A render was rejected by the output-resolution/alpha/HDR pre-flight (P1-3)
// before any browser/ffmpeg work. Counts the "caught early" saves on dashboard
// 1783183, distinct from deep render failures. `kind` is the low-cardinality
// `OutputResolutionIssueKind` (aspect-mismatch / alpha-incompatible / etc.),
// typed to the union so the metric can never carry free text.
export function trackRenderPreflightRejected(props: { kind: OutputResolutionIssueKind }): void {
trackEvent("render_preflight_rejected", { kind: props.kind });
}

export function trackCliError(props: {
error_name: string;
error_message: string;
Expand Down
Loading