feat(core,studio,cli): add square + square-4k canvas resolutions

The four existing presets only cover 16:9 (landscape) and 9:16 (portrait)
aspect ratios. A 1080×1080 square comp had nowhere to land at any scale:
"Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K
mapped to a landscape/portrait preset whose aspect ratio mismatched, which
the producer's resolveDeviceScaleFactor validator rejects with
"does not match the aspect ratio of the composition".

Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS
in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation
extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array
automatically, so the producer's validator, the render API route, and
the CLI `--resolution` flag pick the new presets up without further
changes.

- core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the
  htmlParser to recognize `data-resolution="square|square-4k"` and to
  infer square from equal width/height (vs. the prior "square defaults
  to portrait" tie-breaker).
- studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors;
  collapse isPortraitComp into a 3-way `compAspect` helper so
  resolveResolution returns the square preset for square comps.
- cli: update --resolution help text on `init` and `render` to mention
  the new presets.
- tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor
  suite (returns 1 for square→square, 2 for square→square-4k, rejects
  landscape preset on square comp), update the htmlParser test that
  previously pinned the "square→portrait" tiebreaker.
This commit is contained in:
James
2026-05-11 16:48:54 +00:00
parent 976ceabedc
commit aa715ca8a0
9 changed files with 144 additions and 41 deletions
+5
View File
@@ -149,6 +149,8 @@ export const CANVAS_DIMENSIONS = {
portrait: { width: 1080, height: 1920 },
"landscape-4k": { width: 3840, height: 2160 },
"portrait-4k": { width: 2160, height: 3840 },
square: { width: 1080, height: 1080 },
"square-4k": { width: 2160, height: 2160 },
} as const;
// Single source of truth: derive the type from the table so adding a preset
@@ -172,6 +174,9 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"4k": "landscape-4k",
uhd: "landscape-4k",
"4k-portrait": "portrait-4k",
"1080p-square": "square",
"square-1080p": "square",
"4k-square": "square-4k",
};
/**
+8
View File
@@ -10,6 +10,8 @@ describe("@hyperframes/core public API exports", () => {
expect(core.CANVAS_DIMENSIONS.portrait).toEqual({ width: 1080, height: 1920 });
expect(core.CANVAS_DIMENSIONS["landscape-4k"]).toEqual({ width: 3840, height: 2160 });
expect(core.CANVAS_DIMENSIONS["portrait-4k"]).toEqual({ width: 2160, height: 3840 });
expect(core.CANVAS_DIMENSIONS.square).toEqual({ width: 1080, height: 1080 });
expect(core.CANVAS_DIMENSIONS["square-4k"]).toEqual({ width: 2160, height: 2160 });
});
it("exports VALID_CANVAS_RESOLUTIONS derived from CANVAS_DIMENSIONS", () => {
@@ -18,6 +20,8 @@ describe("@hyperframes/core public API exports", () => {
"portrait",
"landscape-4k",
"portrait-4k",
"square",
"square-4k",
]);
});
@@ -27,6 +31,10 @@ describe("@hyperframes/core public API exports", () => {
expect(core.normalizeResolutionFlag("1080p")).toBe("landscape");
expect(core.normalizeResolutionFlag("landscape-4k")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("UHD")).toBe("landscape-4k");
expect(core.normalizeResolutionFlag("square")).toBe("square");
expect(core.normalizeResolutionFlag("square-4k")).toBe("square-4k");
expect(core.normalizeResolutionFlag("1080p-square")).toBe("square");
expect(core.normalizeResolutionFlag("4k-square")).toBe("square-4k");
expect(core.normalizeResolutionFlag("8k")).toBeUndefined();
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
});
+17 -5
View File
@@ -275,10 +275,7 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("landscape");
});
it("classifies square compositions as portrait by convention", () => {
// 1080×1080 has no obvious orientation. The parser collapses the tie to
// portrait — same bias the prior `w > h ? landscape : portrait` ternary
// had. Pinning so a future refactor doesn't silently flip it.
it("infers square resolution from equal width/height", () => {
const html = `
<html data-composition-width="1080" data-composition-height="1080">
<body>
@@ -290,7 +287,22 @@ describe("parseHtml", () => {
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
expect(result.resolution).toBe("square");
});
it("infers square-4k from equal width/height ≥ 2160", () => {
const html = `
<html data-composition-width="2160" data-composition-height="2160">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("square-4k");
});
it("extracts x, y, scale, opacity from data attributes", () => {
+13 -11
View File
@@ -124,7 +124,9 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
resolutionAttr === "landscape" ||
resolutionAttr === "portrait" ||
resolutionAttr === "landscape-4k" ||
resolutionAttr === "portrait-4k"
resolutionAttr === "portrait-4k" ||
resolutionAttr === "square" ||
resolutionAttr === "square-4k"
) {
return resolutionAttr;
}
@@ -143,17 +145,17 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
}
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
// `width === height` (square) falls into the portrait branch by convention —
// the same bias the previous `w > h ? landscape : portrait` ternary used.
// Square compositions are rare; pick portrait-as-default so we don't surprise
// the existing call sites that depend on this behavior.
const isLandscape = width > height;
const longSide = Math.max(width, height);
// UHD cutoff is the long side of `landscape-4k` / `portrait-4k` (3840). A
// looser threshold (e.g. ≥ 2560) would silently misclassify QHD/1440p
// (2560×1440) as 4K, which is the wrong default for a common authoring
// resolution closer to 1080p than to UHD. Authors who genuinely want the
// 4K preset can still set `data-resolution="landscape-4k"` explicitly.
// UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` /
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. ≥ 2560)
// would silently misclassify QHD/1440p (2560×1440) as 4K, which is the
// wrong default for a common authoring resolution closer to 1080p than to
// UHD. Authors who genuinely want the 4K preset can still set
// `data-resolution="..."` explicitly.
if (width === height) {
return longSide >= 2160 ? "square-4k" : "square";
}
const isLandscape = width > height;
const isUhd = longSide >= 3840;
if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
return isUhd ? "portrait-4k" : "portrait";