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}/`));