feat(cli): validate cloud render aspect/composition/format before upload (#1156)

* feat(cli): validate cloud render aspect/composition/format before upload

`hyperframes cloud render` accepted inputs the render pipeline can't
satisfy and only failed server-side with a generic message. Add three
client-side, pre-upload checks:

- Missing `--composition` entry → clean "Composition not found" error
  instead of uploading a zip the render rejects opaquely.
- Explicit `--aspect-ratio` that conflicts with the composition's
  authored data-width/data-height → "Aspect ratio mismatch" error.
  Aspect ratio is derived from the composition (auto-detected for local
  dirs), so the flag is rarely needed and can't reshape — only match.
- `--resolution 4k` with `--format webm|mov` → rejected, since the alpha
  capture path can't supersample.

Replaces maybeAutoDetectAspectRatio with resolveAspectRatioForSubmit,
which folds detection + explicit-flag validation into one pass. Both new
validators are exported and unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): reject explicit --aspect-ratio on unsupported-ratio compositions

Addresses review on #1153.

The mismatch guard only fired for `matched` compositions. For a composition
whose dims resolve to an unsupported ratio (e.g. 4:5 → detection `no-match`),
a conflicting explicit `--aspect-ratio` silently passed through and was
forwarded to the server, which rejected it later — the opposite experience
from a `matched` composition with the same wrong flag.

Extend the guard to the `no-match` case: dims are known and the ratio can
never equal a supported (16:9/9:16/1:1) explicit value, so it's a definite
conflict. Kinds with unknown dims (no-dims/no-root-div/invalid-dims/read-error)
still forward the explicit value since a conflict can't be proven. +1 test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-01 18:09:19 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8b6d35e226
commit 42ad305073
2 changed files with 208 additions and 22 deletions
+93 -22
View File
@@ -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.";