diff --git a/packages/cli/src/commands/cloud/render.test.ts b/packages/cli/src/commands/cloud/render.test.ts new file mode 100644 index 000000000..55fa5dd85 --- /dev/null +++ b/packages/cli/src/commands/cloud/render.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + resolveAspectRatioForSubmit, + validateResolutionFormatCombo, + type ProjectInputSource, +} from "./render.js"; + +// errorBox writes to console; silence it so test output stays clean. +beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Make process.exit throw so we can assert on the failure path. */ +function trapExit() { + return vi.spyOn(process, "exit").mockImplementation((code?: string | number | null): never => { + throw new Error(`process.exit:${code ?? ""}`); + }); +} + +function writeComposition(width: number, height: number): string { + const dir = mkdtempSync(join(tmpdir(), "hf-cloud-render-test-")); + writeFileSync( + join(dir, "index.html"), + `
`, + "utf-8", + ); + return dir; +} + +describe("validateResolutionFormatCombo", () => { + it("rejects 4k + webm and 4k + mov", () => { + const exit = trapExit(); + expect(() => validateResolutionFormatCombo("4k", "webm")).toThrow("process.exit:1"); + expect(() => validateResolutionFormatCombo("4k", "mov")).toThrow("process.exit:1"); + expect(exit).toHaveBeenCalled(); + }); + + it("allows 4k + mp4 and 1080p + any format", () => { + trapExit(); + expect(() => validateResolutionFormatCombo("4k", "mp4")).not.toThrow(); + expect(() => validateResolutionFormatCombo("1080p", "webm")).not.toThrow(); + expect(() => validateResolutionFormatCombo(undefined, undefined)).not.toThrow(); + }); +}); + +describe("resolveAspectRatioForSubmit — non-local sources", () => { + it("trusts an explicit flag for asset_id / url", () => { + trapExit(); + const asset: ProjectInputSource = { kind: "asset_id", assetId: "a" }; + expect(resolveAspectRatioForSubmit(asset, undefined, "9:16", true)).toBe("9:16"); + const url: ProjectInputSource = { kind: "url", url: "https://x/z.zip" }; + expect(resolveAspectRatioForSubmit(url, undefined, undefined, true)).toBeUndefined(); + }); +}); + +describe("resolveAspectRatioForSubmit — local dir", () => { + it("auto-detects from composition dims when no explicit flag", () => { + trapExit(); + const dir = writeComposition(1920, 1080); + expect(resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, undefined, true)).toBe( + "16:9", + ); + const tall = writeComposition(1080, 1920); + expect( + resolveAspectRatioForSubmit({ kind: "dir", dir: tall }, undefined, undefined, true), + ).toBe("9:16"); + }); + + it("accepts an explicit flag that matches the composition", () => { + trapExit(); + const dir = writeComposition(1920, 1080); + expect(resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, "16:9", true)).toBe("16:9"); + }); + + it("rejects an explicit flag that conflicts with the composition", () => { + const exit = trapExit(); + const dir = writeComposition(1920, 1080); + expect(() => resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, "1:1", true)).toThrow( + "process.exit:1", + ); + expect(exit).toHaveBeenCalled(); + }); + + it("rejects an explicit flag when the composition ratio is unsupported (no-match)", () => { + const exit = trapExit(); + // 1080×1350 is 4:5 — not one of 16:9 / 9:16 / 1:1, so detection is `no-match`. + const dir = writeComposition(1080, 1350); + expect(() => + resolveAspectRatioForSubmit({ kind: "dir", dir }, undefined, "9:16", true), + ).toThrow("process.exit:1"); + expect(exit).toHaveBeenCalled(); + }); + + it("fails fast when the --composition entry is missing", () => { + const exit = trapExit(); + const dir = writeComposition(1920, 1080); + expect(() => + resolveAspectRatioForSubmit( + { kind: "dir", dir }, + "compositions/missing.html", + undefined, + true, + ), + ).toThrow("process.exit:1"); + expect(exit).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/cloud/render.ts b/packages/cli/src/commands/cloud/render.ts index e02979acd..85c52cd0b 100644 --- a/packages/cli/src/commands/cloud/render.ts +++ b/packages/cli/src/commands/cloud/render.ts @@ -59,6 +59,7 @@ import type { HyperframesRenderDetail, } from "../../cloud/index.js"; import { isAbsolute, resolve as resolvePath } from "node:path"; +import { existsSync } from "node:fs"; const VALID_QUALITY = ["draft", "standard", "high"] as const; const VALID_FORMAT = ["mp4", "webm", "mov"] as const; @@ -202,13 +203,23 @@ export default defineCommand({ url: args.url, }); - // When the user didn't pass --aspect-ratio explicitly AND the project is - // a local dir, parse the entry HTML's root composition div for - // data-width/data-height and pick the matching supported ratio. Saves - // the user from having to specify a value that's already implicit in - // the composition they authored. Explicit flag always wins. - const aspectRatio = - explicitAspectRatio ?? maybeAutoDetectAspectRatio(project, args.composition, asJson); + // 4k supersampling runs through the alpha-incompatible screenshot path; + // reject the combination client-side instead of failing mid-render. + validateResolutionFormatCombo(resolution, format); + + // Aspect ratio is derived from the composition's authored dimensions: for + // a local dir we parse the entry HTML and auto-detect, so the user rarely + // needs --aspect-ratio at all. When they DO pass it, we validate it + // matches the composition (the renderer can't reshape, only supersample to + // a matching ratio) and fail fast on a mismatch. This also fails fast when + // the --composition entry file is missing, rather than uploading a zip the + // render rejects with a generic server-side error. + const aspectRatio = resolveAspectRatioForSubmit( + project, + args.composition, + explicitAspectRatio, + asJson, + ); const variables = resolveVariablesAndValidateIfLocal( args.variables, @@ -322,7 +333,7 @@ function validateIdempotencyKey(key: string | undefined): void { // Project resolution (dir | asset-id | url) — exactly one source // --------------------------------------------------------------------------- -interface ProjectInputSource { +export interface ProjectInputSource { kind: "dir" | "asset_id" | "url"; dir?: string; assetId?: string; @@ -354,40 +365,100 @@ function resolveProjectInput(opts: { } /** - * Best-effort aspect-ratio detection for the cloud-render submit body when - * the user hasn't passed `--aspect-ratio`. Returns the detected value (one - * of `"16:9" | "9:16" | "1:1"`) or `undefined` to let the server's default - * (16:9) apply. + * Resolve the aspect ratio for the submit body, validating local inputs. * - * Detection only fires when the project source is a local directory — for - * `--asset-id` and `--url` the composition zip isn't on disk and parsing - * it client-side isn't worth the extra fetch. The user gets a one-line - * note explaining the fallback. + * Aspect ratio is a property of the composition (its `data-width`/ + * `data-height`), not an independent render knob — the pipeline supersamples + * to a *matching* ratio and can't reshape. So for a local dir we auto-detect + * from the entry HTML and the user rarely needs `--aspect-ratio`. Behaviour: * - * Logs to stdout in human-readable mode; suppressed in `--json` mode so the - * machine-readable output stays clean. + * - Local dir, no explicit flag → auto-detect and log the result. + * - Local dir, explicit flag that conflicts with the detected dims → hard + * error (the render would otherwise fail or silently ignore the request). + * - Local dir with a missing `--composition` entry → hard error before + * upload, instead of a generic server-side render failure. + * - `--asset-id` / `--url` → the zip isn't on disk; trust an explicit flag, + * otherwise let the server default (16:9) apply. + * + * Logs are suppressed in `--json` mode so machine output stays clean. */ // fallow-ignore-next-line complexity -function maybeAutoDetectAspectRatio( +export function resolveAspectRatioForSubmit( project: ProjectInputSource, compositionArg: string | undefined, + explicit: "16:9" | "9:16" | "1:1" | undefined, asJson: boolean, ): "16:9" | "9:16" | "1:1" | undefined { if (project.kind !== "dir") { - const reason = project.kind === "asset_id" ? "--asset-id" : "--url"; - logDetection(asJson, `Auto-detect skipped (project is ${reason})`); - return undefined; + if (!explicit) { + const reason = project.kind === "asset_id" ? "--asset-id" : "--url"; + logDetection(asJson, `Auto-detect skipped (project is ${reason})`); + } + return explicit; } const dir = project.dir ?? "."; const entryRelative = compositionArg ?? "index.html"; const entryPath = resolvePath(dir, entryRelative); + if (!existsSync(entryPath)) { + errorBox( + "Composition not found", + `Entry file "${entryRelative}" does not exist in ${dir}.`, + "Pass --composition with a path that exists inside the project, or omit it to use index.html.", + ); + process.exit(1); + } + const detection = detectAspectRatioFromHtml(entryPath); + + if (explicit) { + // The renderer matches the composition's authored aspect ratio — it can't + // reshape. Both a `matched` ratio that differs from `explicit` AND a + // `no-match` (dims are known but the ratio isn't 16:9/9:16/1:1, so it can + // never equal the requested supported ratio) are definite conflicts. + // Other kinds (no-dims / no-root-div / invalid-dims / read-error) leave the + // ratio unknown, so we can't prove a conflict and forward the explicit value. + const conflictDetail = + detection.kind === "matched" && detection.aspectRatio !== explicit + ? `${detection.width}×${detection.height} → ${detection.aspectRatio}` + : detection.kind === "no-match" + ? `${detection.width}×${detection.height}, ratio ${detection.ratio.toFixed(2)} — not a supported ratio` + : undefined; + if (conflictDetail) { + errorBox( + "Aspect ratio mismatch", + `--aspect-ratio ${explicit} doesn't match the composition (${conflictDetail}).`, + "The renderer matches the composition's authored aspect ratio — it can't reshape it. Drop --aspect-ratio (it's auto-detected) or re-author the composition at the target ratio.", + ); + process.exit(1); + } + return explicit; + } + logDetection(asJson, summarizeDetection(detection, entryRelative)); return detection.kind === "matched" ? detection.aspectRatio : undefined; } +/** + * 4k output is produced by supersampling through the screenshot capture path, + * which doesn't support an alpha channel. webm/mov carry alpha, so the + * combination can't be satisfied — reject it before upload. + */ +export function validateResolutionFormatCombo( + resolution: "1080p" | "4k" | undefined, + format: "mp4" | "webm" | "mov" | undefined, +): void { + if (resolution === "4k" && (format === "webm" || format === "mov")) { + errorBox( + "Unsupported combination", + `--resolution 4k cannot be combined with --format ${format}.`, + "The alpha (webm/mov) capture path doesn't support 4k supersampling. Render 4k as mp4, or render alpha at composition resolution.", + ); + process.exit(1); + } +} + const ASPECT_FALLBACK_HINT = "server will default aspect_ratio to 16:9. Pass --aspect-ratio to override.";