mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #661 from heygen-com/05-07-feat_cli_add_--resolution_flag_to_hyperframes_init_for_4k_scaffolding
feat(cli): add --resolution flag to hyperframes init for 4k scaffolding
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { injectTailwindBrowserScript } from "./init.js";
|
||||
import { applyResolutionPreset, injectTailwindBrowserScript } from "./init.js";
|
||||
|
||||
const cliEntry = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts");
|
||||
const tailwindScript =
|
||||
@@ -146,3 +146,168 @@ describe("hyperframes init flag rename", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResolutionPreset", () => {
|
||||
function withFixture(fn: (dir: string) => void): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-resolution-test-"));
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const sampleHtml = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
" <head>",
|
||||
' <meta name="viewport" content="width=1920, height=1080" />',
|
||||
" <style>",
|
||||
" html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }",
|
||||
" </style>",
|
||||
" </head>",
|
||||
" <body>",
|
||||
' <div id="root" data-composition-id="main" data-width="1920" data-height="1080">',
|
||||
" </div>",
|
||||
" </body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
|
||||
it("rewrites every dimension fingerprint for landscape-4k", () => {
|
||||
withFixture((dir) => {
|
||||
const file = join(dir, "index.html");
|
||||
writeFileSync(file, sampleHtml, "utf-8");
|
||||
|
||||
applyResolutionPreset(dir, "landscape-4k");
|
||||
const out = readFileSync(file, "utf-8");
|
||||
|
||||
expect(out).toContain('data-resolution="landscape-4k"');
|
||||
expect(out).toContain('data-width="3840"');
|
||||
expect(out).toContain('data-height="2160"');
|
||||
expect(out).toContain("width: 3840px");
|
||||
expect(out).toContain("height: 2160px");
|
||||
expect(out).toContain('content="width=3840, height=2160"');
|
||||
expect(out).not.toContain("1920");
|
||||
expect(out).not.toContain("1080");
|
||||
});
|
||||
});
|
||||
|
||||
it("swaps to portrait dimensions for portrait-4k", () => {
|
||||
withFixture((dir) => {
|
||||
const file = join(dir, "index.html");
|
||||
writeFileSync(file, sampleHtml, "utf-8");
|
||||
|
||||
applyResolutionPreset(dir, "portrait-4k");
|
||||
const out = readFileSync(file, "utf-8");
|
||||
|
||||
expect(out).toContain('data-width="2160"');
|
||||
expect(out).toContain('data-height="3840"');
|
||||
expect(out).toContain('data-resolution="portrait-4k"');
|
||||
});
|
||||
});
|
||||
|
||||
it("scaffolds a 4k project end-to-end via --resolution 4k", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
const target = join(dir, "proj");
|
||||
try {
|
||||
const res = runInit([
|
||||
target,
|
||||
"--example",
|
||||
"blank",
|
||||
"--resolution",
|
||||
"4k",
|
||||
"--non-interactive",
|
||||
"--skip-skills",
|
||||
]);
|
||||
expect(res.status).toBe(0);
|
||||
|
||||
const html = readFileSync(join(target, "index.html"), "utf-8");
|
||||
expect(html).toContain('data-width="3840"');
|
||||
expect(html).toContain('data-height="2160"');
|
||||
expect(html).toContain('data-resolution="landscape-4k"');
|
||||
expect(html).toContain("width: 3840px");
|
||||
expect(html).toContain("height: 2160px");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown --resolution value", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
const target = join(dir, "proj");
|
||||
try {
|
||||
const res = runInit([
|
||||
target,
|
||||
"--example",
|
||||
"blank",
|
||||
"--resolution",
|
||||
"8k",
|
||||
"--non-interactive",
|
||||
"--skip-skills",
|
||||
]);
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.stderr).toContain("Invalid --resolution");
|
||||
expect(existsSync(target)).toBe(false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites height-before-width inline CSS", () => {
|
||||
withFixture((dir) => {
|
||||
const file = join(dir, "index.html");
|
||||
// Reversed property order — same as the parser's stageMatchReverse path.
|
||||
const reversedOrderHtml = sampleHtml.replace(
|
||||
"html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; }",
|
||||
"html, body { margin: 0; height: 1080px; width: 1920px; overflow: hidden; }",
|
||||
);
|
||||
writeFileSync(file, reversedOrderHtml, "utf-8");
|
||||
|
||||
applyResolutionPreset(dir, "landscape-4k");
|
||||
const out = readFileSync(file, "utf-8");
|
||||
|
||||
expect(out).toContain("height: 2160px");
|
||||
expect(out).toContain("width: 3840px");
|
||||
expect(out).not.toContain("1080px");
|
||||
expect(out).not.toContain("1920px");
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op on a file with no dimension fingerprint (does not error)", () => {
|
||||
withFixture((dir) => {
|
||||
const file = join(dir, "fragment.html");
|
||||
// No data-width/height, no html/body block, no viewport — just markup.
|
||||
const minimal = "<!doctype html><html><head></head><body><p>hi</p></body></html>";
|
||||
writeFileSync(file, minimal, "utf-8");
|
||||
|
||||
expect(() => applyResolutionPreset(dir, "landscape-4k")).not.toThrow();
|
||||
const out = readFileSync(file, "utf-8");
|
||||
// The htmlOpenRe path adds `data-resolution="landscape-4k"` because
|
||||
// the <html> tag is present. That's correct: an explicit signal of
|
||||
// intended resolution survives even when no dim fields exist.
|
||||
expect(out).toContain('data-resolution="landscape-4k"');
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts uppercase --resolution value (4K)", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
|
||||
const target = join(dir, "proj");
|
||||
try {
|
||||
const res = runInit([
|
||||
target,
|
||||
"--example",
|
||||
"blank",
|
||||
"--resolution",
|
||||
"4K",
|
||||
"--non-interactive",
|
||||
"--skip-skills",
|
||||
]);
|
||||
expect(res.status).toBe(0);
|
||||
const html = readFileSync(join(target, "index.html"), "utf-8");
|
||||
expect(html).toContain('data-width="3840"');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Example } from "./_examples.js";
|
||||
export const examples: Example[] = [
|
||||
["Create a project with the interactive wizard", "hyperframes init my-video"],
|
||||
["Pick a starter example", "hyperframes init my-video --example warm-grain"],
|
||||
["Scaffold a 4K project", "hyperframes init my-video --resolution 4k"],
|
||||
["Scaffold a portrait video", "hyperframes init my-video --resolution portrait"],
|
||||
["Start from an existing video file", "hyperframes init my-video --video clip.mp4"],
|
||||
["Start from an audio file", "hyperframes init my-video --audio track.mp3"],
|
||||
["Scaffold with Tailwind CSS", "hyperframes init my-video --example blank --tailwind"],
|
||||
@@ -34,6 +36,34 @@ import { fetchRemoteTemplate } from "../templates/remote.js";
|
||||
import { trackInitTemplate } from "../telemetry/events.js";
|
||||
import { hasFFmpeg } from "../whisper/manager.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
|
||||
|
||||
const VALID_RESOLUTIONS: readonly CanvasResolution[] = [
|
||||
"landscape",
|
||||
"portrait",
|
||||
"landscape-4k",
|
||||
"portrait-4k",
|
||||
] as const;
|
||||
|
||||
const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
|
||||
"1080p": "landscape",
|
||||
hd: "landscape",
|
||||
"1080p-portrait": "portrait",
|
||||
"portrait-1080p": "portrait",
|
||||
"4k": "landscape-4k",
|
||||
uhd: "landscape-4k",
|
||||
"4k-portrait": "portrait-4k",
|
||||
"portrait-4k": "portrait-4k",
|
||||
};
|
||||
|
||||
function normalizeResolutionFlag(input: string | undefined): CanvasResolution | undefined {
|
||||
if (!input) return undefined;
|
||||
const lowered = input.toLowerCase();
|
||||
if ((VALID_RESOLUTIONS as readonly string[]).includes(lowered)) {
|
||||
return lowered as CanvasResolution;
|
||||
}
|
||||
return RESOLUTION_ALIASES[lowered];
|
||||
}
|
||||
|
||||
interface VideoMeta {
|
||||
durationSeconds: number;
|
||||
@@ -416,6 +446,78 @@ async function handleVideoFile(
|
||||
return { meta, localVideoName };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyResolutionPreset — rewrite stage dimensions in scaffolded HTML
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Rewrite the canvas dimensions in every scaffolded HTML file to match a
|
||||
* preset. We rewrite by regex rather than DOM-parsing so template comments
|
||||
* and indentation survive byte-for-byte — these are review-target files,
|
||||
* not transient build artifacts.
|
||||
*
|
||||
* Scope: HTML files only. Templates whose `#stage` dimensions live in an
|
||||
* external `.css` stylesheet are not patched — the bundled `blank` template
|
||||
* inlines its CSS, and that's the convention for new templates. If you
|
||||
* author a template with external CSS, replicate the dimension swap there
|
||||
* by hand or move the dimensions inline.
|
||||
*/
|
||||
export function applyResolutionPreset(destDir: string, resolution: CanvasResolution): void {
|
||||
const { width, height } = CANVAS_DIMENSIONS[resolution];
|
||||
for (const file of listHtmlFiles(destDir)) {
|
||||
let html = readFileSync(file, "utf-8");
|
||||
let changed = false;
|
||||
|
||||
const dataWidthRe = /(data-width=)["'](\d+)["']/g;
|
||||
if (dataWidthRe.test(html)) {
|
||||
html = html.replace(dataWidthRe, `$1"${width}"`);
|
||||
changed = true;
|
||||
}
|
||||
const dataHeightRe = /(data-height=)["'](\d+)["']/g;
|
||||
if (dataHeightRe.test(html)) {
|
||||
html = html.replace(dataHeightRe, `$1"${height}"`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const htmlOpenRe = /<html\b([^>]*)>/i;
|
||||
const htmlOpen = html.match(htmlOpenRe);
|
||||
if (htmlOpen) {
|
||||
const attrs = htmlOpen[1] ?? "";
|
||||
let next: string;
|
||||
if (/data-resolution=/.test(attrs)) {
|
||||
next = attrs.replace(/data-resolution=["'][^"']*["']/, `data-resolution="${resolution}"`);
|
||||
} else {
|
||||
next = `${attrs.replace(/\s+$/, "")} data-resolution="${resolution}"`;
|
||||
}
|
||||
if (next !== attrs) {
|
||||
html = html.replace(htmlOpenRe, `<html${next}>`);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Inline `html, body { ... }` CSS: handle width-before-height and
|
||||
// height-before-width orderings. Hand-authored templates can use either.
|
||||
const bodyCssRe = /(html\s*,\s*body\s*\{[^}]*?width:\s*)\d+px([^}]*?height:\s*)\d+px/i;
|
||||
if (bodyCssRe.test(html)) {
|
||||
html = html.replace(bodyCssRe, `$1${width}px$2${height}px`);
|
||||
changed = true;
|
||||
}
|
||||
const bodyCssReverseRe = /(html\s*,\s*body\s*\{[^}]*?height:\s*)\d+px([^}]*?width:\s*)\d+px/i;
|
||||
if (bodyCssReverseRe.test(html)) {
|
||||
html = html.replace(bodyCssReverseRe, `$1${height}px$2${width}px`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const viewportRe = /(<meta[^>]*name=["']viewport["'][^>]*content=["'])width=\d+,\s*height=\d+/i;
|
||||
if (viewportRe.test(html)) {
|
||||
html = html.replace(viewportRe, `$1width=${width}, height=${height}`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) writeFileSync(file, html, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scaffoldProject — copy template, patch video refs, write meta.json
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -427,6 +529,7 @@ async function scaffoldProject(
|
||||
localVideoName: string | undefined,
|
||||
durationSeconds?: number,
|
||||
tailwind = false,
|
||||
resolution?: CanvasResolution,
|
||||
): Promise<void> {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
@@ -439,6 +542,7 @@ async function scaffoldProject(
|
||||
}
|
||||
patchVideoSrc(destDir, localVideoName, durationSeconds);
|
||||
if (tailwind) writeTailwindSupport(destDir);
|
||||
if (resolution) applyResolutionPreset(destDir, resolution);
|
||||
|
||||
writeFileSync(
|
||||
resolve(destDir, "meta.json"),
|
||||
@@ -540,6 +644,11 @@ export default defineCommand({
|
||||
type: "boolean",
|
||||
description: "Add Tailwind CSS browser-runtime support",
|
||||
},
|
||||
resolution: {
|
||||
type: "string",
|
||||
description:
|
||||
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.template !== undefined) {
|
||||
@@ -563,6 +672,20 @@ export default defineCommand({
|
||||
const languageFlag = args.language;
|
||||
const interactive = !nonInteractive && process.stdout.isTTY === true;
|
||||
|
||||
let resolutionPreset: CanvasResolution | undefined;
|
||||
if (args.resolution !== undefined) {
|
||||
resolutionPreset = normalizeResolutionFlag(args.resolution);
|
||||
if (!resolutionPreset) {
|
||||
console.error(
|
||||
c.error(
|
||||
`Invalid --resolution: "${args.resolution}". ` +
|
||||
`Use one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Non-interactive mode — all inputs from flags, defaults where missing
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -645,6 +768,7 @@ export default defineCommand({
|
||||
localVideoName,
|
||||
videoDuration,
|
||||
tailwind,
|
||||
resolutionPreset,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -840,7 +964,15 @@ export default defineCommand({
|
||||
spin.start(`Downloading example ${c.accent(templateId)}...`);
|
||||
}
|
||||
try {
|
||||
await scaffoldProject(destDir, name, templateId, localVideoName, videoDuration, tailwind);
|
||||
await scaffoldProject(
|
||||
destDir,
|
||||
name,
|
||||
templateId,
|
||||
localVideoName,
|
||||
videoDuration,
|
||||
tailwind,
|
||||
resolutionPreset,
|
||||
);
|
||||
if (!isBundled) {
|
||||
spin.stop(c.success(`Downloaded ${templateId}`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user