diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts
index 2d79faa9c..f5074d375 100644
--- a/packages/cli/src/commands/info.ts
+++ b/packages/cli/src/commands/info.ts
@@ -7,7 +7,7 @@ export const examples: Example[] = [
["Output as JSON", "hyperframes info --json"],
];
import { join } from "node:path";
-import { parseHtml } from "@hyperframes/core";
+import { parseHtml, CANVAS_DIMENSIONS } from "@hyperframes/core";
import { c } from "../ui/colors.js";
import { formatBytes, label } from "../ui/format.js";
import { ensureDOMParser } from "../utils/dom.js";
@@ -52,16 +52,9 @@ export default defineCommand({
const heightMatch =
html.match(/data-composition-id[^>]*data-height=["'](\d+)["']/) ||
html.match(/data-height=["'](\d+)["'][^>]*data-composition-id/);
- const width = widthMatch?.[1]
- ? parseInt(widthMatch[1], 10)
- : parsed.resolution === "portrait"
- ? 1080
- : 1920;
- const height = heightMatch?.[1]
- ? parseInt(heightMatch[1], 10)
- : parsed.resolution === "portrait"
- ? 1920
- : 1080;
+ const fallback = CANVAS_DIMENSIONS[parsed.resolution];
+ const width = widthMatch?.[1] ? parseInt(widthMatch[1], 10) : fallback.width;
+ const height = heightMatch?.[1] ? parseInt(heightMatch[1], 10) : fallback.height;
const resolution = `${width}x${height}`;
const size = totalSize(project.dir);
diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts
index 929fef6de..d46da4a6b 100644
--- a/packages/core/src/core.types.ts
+++ b/packages/core/src/core.types.ts
@@ -19,11 +19,13 @@ export interface Asset {
export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition";
export type MediaElementType = "video" | "image" | "audio";
-export type CanvasResolution = "landscape" | "portrait";
+export type CanvasResolution = "landscape" | "portrait" | "landscape-4k" | "portrait-4k";
export const CANVAS_DIMENSIONS = {
landscape: { width: 1920, height: 1080 },
portrait: { width: 1080, height: 1920 },
+ "landscape-4k": { width: 3840, height: 2160 },
+ "portrait-4k": { width: 2160, height: 3840 },
} as const;
export interface TimelineElementBase {
diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts
index 97757c1d9..0c0bf94be 100644
--- a/packages/core/src/index.test.ts
+++ b/packages/core/src/index.test.ts
@@ -8,6 +8,8 @@ describe("@hyperframes/core public API exports", () => {
expect(core.CANVAS_DIMENSIONS).toBeDefined();
expect(core.CANVAS_DIMENSIONS.landscape).toEqual({ width: 1920, height: 1080 });
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 });
});
it("exports TIMELINE_COLORS", () => {
diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts
index a4c79ea63..b913deb5b 100644
--- a/packages/core/src/parsers/htmlParser.test.ts
+++ b/packages/core/src/parsers/htmlParser.test.ts
@@ -212,6 +212,87 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("portrait");
});
+ it("detects landscape-4k resolution from data attribute", () => {
+ const html = `
+
+
+
+
+
+ `;
+ const result = parseHtml(html);
+
+ expect(result.resolution).toBe("landscape-4k");
+ });
+
+ it("infers landscape-4k from composition dimensions", () => {
+ const html = `
+
+
+
+
+
+ `;
+ const result = parseHtml(html);
+
+ expect(result.resolution).toBe("landscape-4k");
+ });
+
+ it("infers portrait-4k from inline stage style", () => {
+ const html = `
+
+
+
+
+
+ `;
+ const result = parseHtml(html);
+
+ expect(result.resolution).toBe("portrait-4k");
+ });
+
+ it("classifies 1440p (QHD) as landscape, not landscape-4k", () => {
+ // Regression: an earlier `>= 2560` cutoff misclassified QHD compositions
+ // as 4K. The current rule uses the canonical 4K long-side (3840) so
+ // 2560×1440 stays in the landscape preset.
+ const html = `
+
+
+
+
+
+ `;
+ const result = parseHtml(html);
+
+ 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.
+ const html = `
+
+
+
+
+
+ `;
+ const result = parseHtml(html);
+
+ expect(result.resolution).toBe("portrait");
+ });
+
it("extracts x, y, scale, opacity from data attributes", () => {
const html = `
diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts
index 2027ee895..ba4c65ffb 100644
--- a/packages/core/src/parsers/htmlParser.ts
+++ b/packages/core/src/parsers/htmlParser.ts
@@ -90,7 +90,7 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
const w = parseInt(inlineStyle.width, 10);
const h = parseInt(inlineStyle.height, 10);
if (w && h) {
- return w > h ? "landscape" : "portrait";
+ return resolveResolutionFromDimensions(w, h);
}
}
}
@@ -102,7 +102,7 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
if (stageMatch) {
const w = parseInt(stageMatch[1] ?? "", 10);
const h = parseInt(stageMatch[2] ?? "", 10);
- return w > h ? "landscape" : "portrait";
+ return resolveResolutionFromDimensions(w, h);
}
const stageMatchReverse = cssText.match(
/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/,
@@ -110,7 +110,7 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
if (stageMatchReverse) {
const h = parseInt(stageMatchReverse[1] ?? "", 10);
const w = parseInt(stageMatchReverse[2] ?? "", 10);
- return w > h ? "landscape" : "portrait";
+ return resolveResolutionFromDimensions(w, h);
}
}
@@ -120,7 +120,12 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
const htmlEl = doc.documentElement;
const resolutionAttr = htmlEl.getAttribute("data-resolution");
- if (resolutionAttr === "landscape" || resolutionAttr === "portrait") {
+ if (
+ resolutionAttr === "landscape" ||
+ resolutionAttr === "portrait" ||
+ resolutionAttr === "landscape-4k" ||
+ resolutionAttr === "portrait-4k"
+ ) {
return resolutionAttr;
}
@@ -130,13 +135,30 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
const width = parseInt(widthAttr, 10);
const height = parseInt(heightAttr, 10);
if (width && height) {
- return width > height ? "landscape" : "portrait";
+ return resolveResolutionFromDimensions(width, height);
}
}
return 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.
+ const isUhd = longSide >= 3840;
+ if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
+ return isUhd ? "portrait-4k" : "portrait";
+}
+
export function parseHtml(html: string): ParsedHtml {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");