Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| Flag | Description |
|------|-------------|
| `--example, -e` | Example to scaffold (required in default mode, interactive in `--human-friendly`) |
| `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840). Aliases: `1080p`, `4k`, `uhd`. Default: keep template dimensions. |
| `--video, -V` | Path to a video file (MP4, WebM, MOV) |
| `--audio, -a` | Path to an audio file (MP3, WAV, M4A) |
| `--tailwind` | Add Tailwind CSS browser-runtime support to scaffolded HTML |
Expand Down
169 changes: 167 additions & 2 deletions packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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 });
}
});
});
134 changes: 133 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -427,6 +529,7 @@ async function scaffoldProject(
localVideoName: string | undefined,
durationSeconds?: number,
tailwind = false,
resolution?: CanvasResolution,
): Promise<void> {
mkdirSync(destDir, { recursive: true });

Expand All @@ -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"),
Expand Down Expand Up @@ -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) {
Expand All @@ -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
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -645,6 +768,7 @@ export default defineCommand({
localVideoName,
videoDuration,
tailwind,
resolutionPreset,
);
} catch (err) {
console.error(
Expand Down Expand Up @@ -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}`));
}
Expand Down
Loading