diff --git a/packages/cli/src/commands/__fixtures__/anamorphic.mp4 b/packages/cli/src/commands/__fixtures__/anamorphic.mp4
new file mode 100644
index 00000000000..c0ed4f294ed
Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/anamorphic.mp4 differ
diff --git a/packages/cli/src/commands/__fixtures__/four-three.mp4 b/packages/cli/src/commands/__fixtures__/four-three.mp4
new file mode 100644
index 00000000000..e7c838f1c1e
Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/four-three.mp4 differ
diff --git a/packages/cli/src/commands/__fixtures__/portrait.mp4 b/packages/cli/src/commands/__fixtures__/portrait.mp4
new file mode 100644
index 00000000000..82d4a85994e
Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/portrait.mp4 differ
diff --git a/packages/cli/src/commands/__fixtures__/rotated-portrait.mp4 b/packages/cli/src/commands/__fixtures__/rotated-portrait.mp4
new file mode 100644
index 00000000000..dfa7e53b246
Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/rotated-portrait.mp4 differ
diff --git a/packages/cli/src/commands/__fixtures__/square.mp4 b/packages/cli/src/commands/__fixtures__/square.mp4
new file mode 100644
index 00000000000..98c3e81220b
Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/square.mp4 differ
diff --git a/packages/cli/src/commands/__fixtures__/three-four.mp4 b/packages/cli/src/commands/__fixtures__/three-four.mp4
new file mode 100644
index 00000000000..bf0b895a0f9
Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/three-four.mp4 differ
diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts
index 0f8c5d87982..72ed7235647 100644
--- a/packages/cli/src/commands/init.test.ts
+++ b/packages/cli/src/commands/init.test.ts
@@ -235,6 +235,31 @@ describe("hyperframes init flag rename", () => {
}
});
+ it.each([
+ ["portrait.mp4", "1080", "1920"],
+ ["rotated-portrait.mp4", "1080", "1920"],
+ ["three-four.mp4", "1080", "1440"],
+ ["four-three.mp4", "1440", "1080"],
+ ["square.mp4", "1080", "1080"],
+ ["anamorphic.mp4", "1920", "1080"],
+ ])("sizes the project to the --video's shape as it plays (%s)", (fixture, width, height) => {
+ const dir = mkdtempSync(join(tmpdir(), "hf-init-portrait-"));
+ const target = join(dir, "proj");
+ const clip = join(dir, fixture);
+ copyFileSync(resolve(fileURLToPath(import.meta.url), `../__fixtures__/${fixture}`), clip);
+ try {
+ const res = runInit([target, "--non-interactive", "--skip-transcribe", "--video", clip]);
+ expect(res.status).toBe(0);
+ const root = readFileSync(join(target, "index.html"), "utf-8").match(
+ /
]*data-composition-id="main"[^>]*>/,
+ )?.[0];
+ expect(root).toContain(`data-width="${width}"`);
+ expect(root).toContain(`data-height="${height}"`);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it("scaffolds a silent --video as a muted clip with no audio element", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-silent-"));
const target = join(dir, "proj");
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 52b59ce8d44..2f53c45ff45 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -136,6 +136,9 @@ function probeVideo(filePath: string): VideoMeta | undefined {
avg_frame_rate?: string;
duration?: string;
nb_frames?: string;
+ tags?: { rotate?: string };
+ side_data_list?: { rotation?: number }[];
+ sample_aspect_ratio?: string;
}[];
format?: { duration?: string };
} = JSON.parse(raw);
@@ -167,10 +170,22 @@ function probeVideo(filePath: string): VideoMeta | undefined {
formatDuration,
});
+ // Non-square pixels (anamorphic SD, "64:45") play wider or narrower than they are stored.
+ const [sarW, sarH] = (videoStream.sample_aspect_ratio ?? "").split(":").map(Number);
+ const sar = sarW && sarH ? sarW / sarH : 1;
+ const width = Math.round((videoStream.width ?? 1920) * sar);
+ const height = videoStream.height ?? 1080;
+ // A phone clip is often stored landscape with a rotation; it plays (and decodes) turned upright.
+ const turn = Number(
+ videoStream.side_data_list?.find((d) => d.rotation !== undefined)?.rotation ??
+ videoStream.tags?.rotate ??
+ 0,
+ );
+ const turned = Math.abs(turn) % 180 === 90;
return {
durationSeconds,
- width: videoStream.width ?? 1920,
- height: videoStream.height ?? 1080,
+ width: turned ? height : width,
+ height: turned ? width : height,
fps,
hasAudio,
videoCodec: videoStream.codec_name ?? "unknown",
@@ -180,6 +195,19 @@ function probeVideo(filePath: string): VideoMeta | undefined {
}
}
+type CanvasSize = { width: number; height: number };
+
+// A from-file stage is the clip at its own shape, 1080 short side like every preset; 16:9 is the template's own.
+function clipFrame(templateId: string, clip: CanvasSize | undefined): CanvasSize | undefined {
+ if (templateId !== "from-file" || !clip || !(clip.width > 0 && clip.height > 0)) return undefined;
+ const even = (n: number) => 2 * Math.round(n / 2);
+ const frame =
+ clip.width >= clip.height
+ ? { width: even((1080 * clip.width) / clip.height), height: 1080 }
+ : { width: 1080, height: even((1080 * clip.height) / clip.width) };
+ return frame.width === 1920 && frame.height === 1080 ? undefined : frame;
+}
+
function isWebCompatible(codec: string): boolean {
return WEB_CODECS.has(codec.toLowerCase());
}
@@ -379,6 +407,12 @@ function patchVideoSrc(dir: string, media: InitMediaOptions): void {
}
}
+async function finishScaffold(destDir: string, templateId: string, tailwind: boolean) {
+ trackInitTemplate(templateId, { tailwind });
+ const transcriptFile = resolve(destDir, TRANSCRIPT_FILE);
+ if (existsSync(transcriptFile)) await patchTranscript(destDir, transcriptFile);
+}
+
async function patchTranscript(dir: string, transcriptPath: string): Promise {
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const { words } = loadTranscript(transcriptPath);
@@ -502,7 +536,14 @@ async function handleVideoFile(
* by hand or move the dimensions inline.
*/
export function applyResolutionPreset(destDir: string, resolution: CanvasResolution): void {
- const { width, height } = CANVAS_DIMENSIONS[resolution];
+ applyCanvasSize(destDir, CANVAS_DIMENSIONS[resolution], resolution);
+}
+
+function applyCanvasSize(
+ destDir: string,
+ { width, height }: CanvasSize,
+ resolution?: CanvasResolution,
+): void {
for (const file of listHtmlFiles(destDir)) {
let html = readFileSync(file, "utf-8");
let changed = false;
@@ -520,7 +561,7 @@ export function applyResolutionPreset(destDir: string, resolution: CanvasResolut
const htmlOpenRe = /]*)>/i;
const htmlOpen = html.match(htmlOpenRe);
- if (htmlOpen) {
+ if (htmlOpen && resolution) {
const attrs = htmlOpen[1] ?? "";
let next: string;
if (/data-resolution=/.test(attrs)) {
@@ -565,7 +606,7 @@ async function scaffoldProject(
templateId: string,
media: InitMediaOptions,
tailwind = false,
- resolution?: CanvasResolution,
+ canvas?: CanvasResolution | CanvasSize,
authoringSkill?: string,
): Promise {
mkdirSync(destDir, { recursive: true });
@@ -581,7 +622,8 @@ async function scaffoldProject(
}
patchVideoSrc(destDir, media);
if (tailwind) writeTailwindSupport(destDir);
- if (resolution) applyResolutionPreset(destDir, resolution);
+ if (typeof canvas === "string") applyResolutionPreset(destDir, canvas);
+ else if (canvas) applyCanvasSize(destDir, canvas);
writeFileSync(
resolve(destDir, "meta.json"),
@@ -753,7 +795,7 @@ export default defineCommand({
resolution: {
type: "string",
description:
- "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
+ "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: a --video clip's own shape as it plays, 1080 on the short side (4:3 -> 1440x1080), else the template's dimensions (typically 1920x1080).",
},
skill: {
type: "string",
@@ -875,6 +917,7 @@ export default defineCommand({
let localVideoName: string | undefined;
let videoHasAudio = true;
let videoDuration: number | undefined;
+ let videoSize: VideoMeta | undefined;
let sourceFilePath: string | undefined;
// Handle video
@@ -884,6 +927,7 @@ export default defineCommand({
localVideoName = result.localVideoName;
videoHasAudio = result.meta.hasAudio;
videoDuration = result.meta.durationSeconds;
+ videoSize = result.meta;
console.log(
`Video: ${result.meta.width}x${result.meta.height}, ${result.meta.durationSeconds.toFixed(1)}s`,
);
@@ -932,7 +976,7 @@ export default defineCommand({
templateId,
media,
tailwind,
- resolutionPreset,
+ resolutionPreset ?? clipFrame(templateId, videoSize),
args.skill,
);
} catch (err) {
@@ -944,11 +988,7 @@ export default defineCommand({
console.error(c.dim("Use --example blank for offline use."));
failCommand();
}
- trackInitTemplate(templateId, { tailwind });
- const transcriptFile = resolve(destDir, TRANSCRIPT_FILE);
- if (existsSync(transcriptFile)) {
- await patchTranscript(destDir, transcriptFile);
- }
+ await finishScaffold(destDir, templateId, tailwind);
console.log(c.success(`Created ${c.accent(name + "/")}`));
for (const f of readdirSync(destDir).filter((f) => !f.startsWith("."))) {
@@ -1046,6 +1086,7 @@ export default defineCommand({
let videoHasAudio = true;
let sourceFilePath: string | undefined;
let videoDuration: number | undefined;
+ let videoSize: VideoMeta | undefined;
if (videoFlag) {
const videoPath = resolve(videoFlag);
@@ -1060,6 +1101,7 @@ export default defineCommand({
localVideoName = result.localVideoName;
videoHasAudio = result.meta.hasAudio;
videoDuration = result.meta.durationSeconds;
+ videoSize = result.meta;
} else if (audioFlag) {
const audioPath = resolve(audioFlag);
if (!existsSync(audioPath)) {
@@ -1164,7 +1206,7 @@ export default defineCommand({
templateId,
media,
tailwind,
- resolutionPreset,
+ resolutionPreset ?? clipFrame(templateId, videoSize),
args.skill,
);
if (!isBundled) {
@@ -1179,13 +1221,8 @@ export default defineCommand({
);
failCommand();
}
- trackInitTemplate(templateId, { tailwind });
-
// 4b. Patch captions with transcript if available
- const transcriptFile = resolve(destDir, TRANSCRIPT_FILE);
- if (existsSync(transcriptFile)) {
- await patchTranscript(destDir, transcriptFile);
- }
+ await finishScaffold(destDir, templateId, tailwind);
const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));