From 555c51fcf60f3cbe5221b4ec171f394e5ff2140d Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 02:00:20 +0000 Subject: [PATCH 1/2] feat(core): add 4k canvas resolution presets --- packages/cli/src/commands/info.ts | 15 ++----- packages/core/src/core.types.ts | 4 +- packages/core/src/index.test.ts | 2 + packages/core/src/parsers/htmlParser.test.ts | 45 ++++++++++++++++++++ packages/core/src/parsers/htmlParser.ts | 23 +++++++--- 5 files changed, 72 insertions(+), 17 deletions(-) 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..119284df9 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -212,6 +212,51 @@ describe("parseHtml", () => { expect(result.resolution).toBe("portrait"); }); + it("detects landscape-4k resolution from data attribute", () => { + const html = ` + + +
+
Hello
+
+ + + `; + const result = parseHtml(html); + + expect(result.resolution).toBe("landscape-4k"); + }); + + it("infers landscape-4k from composition dimensions", () => { + const html = ` + + +
+
Hello
+
+ + + `; + const result = parseHtml(html); + + expect(result.resolution).toBe("landscape-4k"); + }); + + it("infers portrait-4k from inline stage style", () => { + const html = ` + + +
+
Hello
+
+ + + `; + const result = parseHtml(html); + + expect(result.resolution).toBe("portrait-4k"); + }); + 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..a8eb9b7ae 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,21 @@ 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 { + const isLandscape = width > height; + const longSide = Math.max(width, height); + const isUhd = longSide >= 2560; + 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"); From 20f8318ee2c81afd41b20718373066fc06a3900c Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 05:08:12 +0000 Subject: [PATCH 2/2] fix(core): tighten 4K threshold to 3840 and pin square=portrait convention --- packages/core/src/parsers/htmlParser.test.ts | 36 ++++++++++++++++++++ packages/core/src/parsers/htmlParser.ts | 11 +++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index 119284df9..b913deb5b 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -257,6 +257,42 @@ describe("parseHtml", () => { 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 = ` + + +
+
Hello
+
+ + + `; + 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 = ` + + +
+
Hello
+
+ + + `; + 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 a8eb9b7ae..ba4c65ffb 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -143,9 +143,18 @@ 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); - const isUhd = longSide >= 2560; + // 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"; }