Skip to content
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added packages/cli/src/commands/__fixtures__/square.mp4
Binary file not shown.
Binary file not shown.
25 changes: 25 additions & 0 deletions packages/cli/src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
/<div\b[^>]*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");
Expand Down
77 changes: 57 additions & 20 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -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());
}
Expand Down Expand Up @@ -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<void> {
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const { words } = loadTranscript(transcriptPath);
Expand Down Expand Up @@ -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;
Expand All @@ -520,7 +561,7 @@ export function applyResolutionPreset(destDir: string, resolution: CanvasResolut

const htmlOpenRe = /<html\b([^>]*)>/i;
const htmlOpen = html.match(htmlOpenRe);
if (htmlOpen) {
if (htmlOpen && resolution) {
const attrs = htmlOpen[1] ?? "";
let next: string;
if (/data-resolution=/.test(attrs)) {
Expand Down Expand Up @@ -565,7 +606,7 @@ async function scaffoldProject(
templateId: string,
media: InitMediaOptions,
tailwind = false,
resolution?: CanvasResolution,
canvas?: CanvasResolution | CanvasSize,
authoringSkill?: string,
): Promise<void> {
mkdirSync(destDir, { recursive: true });
Expand All @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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`,
);
Expand Down Expand Up @@ -932,7 +976,7 @@ export default defineCommand({
templateId,
media,
tailwind,
resolutionPreset,
resolutionPreset ?? clipFrame(templateId, videoSize),
args.skill,
);
} catch (err) {
Expand All @@ -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("."))) {
Expand Down Expand Up @@ -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);
Expand All @@ -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)) {
Expand Down Expand Up @@ -1164,7 +1206,7 @@ export default defineCommand({
templateId,
media,
tailwind,
resolutionPreset,
resolutionPreset ?? clipFrame(templateId, videoSize),
args.skill,
);
if (!isBundled) {
Expand All @@ -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}/`));
Expand Down
Loading