mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
feat(core): add 4k canvas resolution presets (#660)
## What Adds `landscape-4k` (3840×2160) and `portrait-4k` (2160×3840) presets to `CanvasResolution` and `CANVAS_DIMENSIONS` in `@hyperframes/core`. Foundation for end-to-end 4K rendering support. ## Why There is no codified way today to mark a composition as 4K. The string union `CanvasResolution = "landscape" | "portrait"` is the only enum used by templates, generators, and stage-zoom math; without 4K members, scaffolds and helpers always emit 1080p dimensions even when the underlying engine + encoder pipeline can already handle larger viewports (Chrome `setViewport`, ffmpeg `libx264`/`libvpx-vp9`/`prores_ks` all scale fine). This is PR 1 of a 3-PR stack making 4K a first-class option: 1. **PR #660 (this)** — core types/constants + parser detection. 2. **PR #661** — `hyperframes init --resolution 4k` flag to scaffold 4K projects. 3. **PR #662** — byte-budget the frame data-URI cache so 4K renders don't OOM. ## How - `CanvasResolution` extended additively (no breaking change). - `CANVAS_DIMENSIONS` gains `landscape-4k` / `portrait-4k` entries. - `parseResolutionFromHtml` accepts `data-resolution="landscape-4k|portrait-4k"` and infers 4K from `data-composition-width`/`data-composition-height` (long side ≥ 2560 → UHD variant). - `parseResolutionFromCss` reuses the same dimension-aware classifier so inline `#stage { width/height }` styles are detected too. - Helper extracted: `resolveResolutionFromDimensions(w, h)`. - `cli/info.ts` cleanup: replaces a nested `parsed.resolution === "portrait" ? 1080 : 1920` ternary with a `CANVAS_DIMENSIONS[parsed.resolution]` lookup so it stays correct for 4K. Stage-CSS generators (`templates/base.ts`, `generators/hyperframes.ts`) already index `CANVAS_DIMENSIONS[resolution]`, so they pick up the new presets automatically. ## Test plan - [x] Unit tests added/updated — 4 new parser tests, 1 expanded constants test - [x] Manual testing performed — `bun run --cwd packages/core test` (679 pass), `bun run --cwd packages/cli test` (277 pass) - [ ] Documentation updated (deferred to PR #661 where the user-facing flag lands)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user