Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/parsers/htmlParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,87 @@ describe("parseHtml", () => {
expect(result.resolution).toBe("portrait");
});

it("detects landscape-4k resolution from data attribute", () => {
const html = `
<html data-resolution="landscape-4k">
<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("landscape-4k");
});

it("infers landscape-4k from composition dimensions", () => {
const html = `
<html data-composition-width="3840" 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("landscape-4k");
});

it("infers portrait-4k from inline stage style", () => {
const html = `
<html>
<body>
<div id="stage" style="width: 2160px; height: 3840px;">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</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 = `
<html data-composition-width="2560" data-composition-height="1440">
<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("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 = `
<html data-composition-width="1080" data-composition-height="1080">
<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("portrait");
});

it("extracts x, y, scale, opacity from data attributes", () => {
const html = `
<html>
Expand Down
32 changes: 27 additions & 5 deletions packages/core/src/parsers/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand All @@ -102,15 +102,15 @@ 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[^}]*\}/,
);
if (stageMatchReverse) {
const h = parseInt(stageMatchReverse[1] ?? "", 10);
const w = parseInt(stageMatchReverse[2] ?? "", 10);
return w > h ? "landscape" : "portrait";
return resolveResolutionFromDimensions(w, h);
}
}

Expand All @@ -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;
}

Expand All @@ -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");
Expand Down
Loading