From 38bb25f1493697b589005373bea63b1b32fcdfc9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 18:42:45 -0400 Subject: [PATCH 1/7] fix(cli): a portrait clip passed to init --video makes a portrait project --- .../src/commands/__fixtures__/portrait.mp4 | Bin 0 -> 2557 bytes .../__fixtures__/rotated-portrait.mp4 | Bin 0 -> 2793 bytes packages/cli/src/commands/init.test.ts | 21 +++++++ packages/cli/src/commands/init.ts | 56 +++++++++++++----- 4 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/commands/__fixtures__/portrait.mp4 create mode 100644 packages/cli/src/commands/__fixtures__/rotated-portrait.mp4 diff --git a/packages/cli/src/commands/__fixtures__/portrait.mp4 b/packages/cli/src/commands/__fixtures__/portrait.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..82d4a85994e501b2c1b570bb149e98054152c1f9 GIT binary patch literal 2557 zcmai04OA4@6`sY2L@7bTpCVzRi$(*>%(5b&11y3Nl>-DJN<3X=XJ*-znVn%~fL&+> zQ$R_H7)?S&C5qJsYO2IWV^0V)#wJzaBr!>$U`0?Q2|vlvG}c7veG5eD$vM4e-@D(t z|L@($JTMHyEvi$pDWZU3ZW!V)^mGwJ3X*}uFwDaua~y`bFA`W<1)D?eGsnjd^d^1T zI@q0Z=C#l+ynFHJm_r|Fz{7AxlsTM?Tmdje2cq!Q^rYl418&S-13H#tK(R)YoIGby zaXmpqgy{)AvbL(K6cZj^TwJUxvay`V({`OG=Y^xVI;$%1U?WPZO|&a9I76Fhh9Ys9 zvrrK@%b9tR$*0H|B8I?eJIy;4jv^cpF@%F8BXEJEtegW^3eA94;*#P7SC~yQOOZMP zoM6HQn}cIbC?W|SCYiS9aTFPeGges?XcM@SxGHlzZ&Lt8Iigrb1;7*t6agjBY>8;+ zC_NcUlDLIdRFkCS+azQ$5uiXai582(sZ^LASFJL5D3l)OMKPbY!ooBqBXNbdF;L5t zfWYmtD+gl}Xcg6Av#XrU(_jR0GhZmvP7@;v60O3Tfg@67+Gd9kU?|hb#v;=Kr@&#E zO_CF^jfLE16V1{R+J)Ieu2PLw#iDNmF@J0X<7HJ@`rY)Zd^ zn1m+5W~WF3R~XLDF@-8+AW)eyjgD026f3yPjOnNCBE8H|jBLRLGn^~h7Az=zgpR-q z&}Jw?7YR^8fnA(-P?6CfP*hH$47g2#Cc%Y(9zra-0Q!%*iIu}0_2PC`V6Wd!81-^% zto_Ip3WjK1PiZPY9CGIrl8 zF@&AFkkVRh+IHlVZ-$A!EAJ!+&OWIBF>v3WCACY|?rSZI-uwCW5Pw7F(6-Zi>%VrE z4maMtfB)sSZSHYezqrj^^K#y)I{q2|?$zGkT_nGYJ=Q+!H~)$bETi1+Y3|+H^@UAw=DFK{dFxW=;FetPzny=xpogxhj@-Ix*@^78{XH&i z--+GJUUX`~M?$Cf?v*=fuTM-^T|_C@D_3W?en8A3ig)E6izQ?Ch#Ll;Egd@Gy|coj ztNPlGbzMhO8s@~u-xj->hC=PK{qePd8=o_ttYSM0m;3u~1ZX8ULbIU*6NaKXlP@L)uVYMaO7MbWr{h#k#%8FX+SU_xo4* zU+-I+mcE#KDLkWoUrx#UK4+8v?J!mMEGsHY?uq>H)A{o|VuKGila?EgrFiLT{@fp~ z?0-A8(b;B6@Bb8^wd->FO8AOdjrCeSvFvQd@zk<$-?JwkT|z{?bt!ms!(i^yUU^SE zqIv%BufB2W>djMa_`2{M`?{RZA`kWrSH1pih(9A$)Y)vpJM!>nodh0W97_Iv@c#H+8FMW~G*j^iS zX=~rH%VWNm>nkn>3_c&Y`JZiT`%O!mlPeZ~eB(bsXMVln(SDy7q?_XxhMS%w>~r6$ z^!=kgyk(ox`>f|ib9d#YvAs&o<-wa>=e`=93pwT)8>*7#h87oBzLzVEe0SUb?!AdB zjzVFsDxji2po-!_9%8I-f{J*kX#IFd*4_ZW?5~=?XmeJ)(wR0iYjx(4Gk^KhK1xRtB+lE+=vAIj zy5@lKao~|)wMN5Yv&XMt^Yefo@7~Z*rtyZ8!qB9N|8~*lTn*-K{d(ZyqV{`t?jUDx ziB=@ws17uI;5C>$Wa493BMMMdR|616HnTD85rlnUU#KyNru=i62ex2`E1A?GhUz>G9QEJ{7>~p7gn@eDH;K`D84TD9 zBLYW+_Cmlg7qTi1JV)SM93b}O^a})+PNpRZ#s7)IY!kOMq)8P~z##ay4Fy3#G3cZP KI!VHhApZ+a&-oev literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dfa7e53b246eae46c9ec266078ce9559b2830239 GIT binary patch literal 2793 zcmZuz3tUWT8$T0rDPKei({@s9eJOM6B8F0_Y>~2|Pdj^N=FCMibEb1nHP+lya9%JfoVi!R7j|&2oH=60-v0efg_00r)fw9iwk%>p{IZ+K+!6e z)%%Yak&=?)m8_PNl!nlGQM7Ud;`LIoS`EZddR9&87=K(wNC}ykkJF?=EX3ucR71%U z#C(6AKMyB#geHw4#k^FZKQEQf7vfq{tRho!CP@m&7uPdskP5AYmW%mbJV=5D*Q!%V zxdbutAwxnFIwdLQ``|JaO=$@Ur1Ei=CN&y01IRDcPcCBt$P%?;9!!Cd&!TjsSim37 z=i>^3VI_JdL9Is-W`ab$giR05%b7s)3ZqfyIXE*}g$Tt}P6 zkg2rq;TjN);;wAEhUC5+$Vvui2=F)C4jOxWxi*-g_v~ zs&!&M4`*bgj+7;_Vi6BblP1uX(j=pTbXq3)xLo9?Wnvkvz_n7?SF|j65etM~JUkIC zM$Gf_0jNjBrk+R@`-}tw!;*Tj2v_T&NN^yahQLK6Lj6%Sv3YPtJ; zIUW8@+L-e%;$KnjvnT$$w#mZfzM{ULckPeewl%g!bPPV`6nb~d^6Cjrze|%3l{~gw zTD&n}S#PC}#hRH1<4a1#*Z6<-nD4!-c2AbWMa$TnvsO0imi)FqB`j4Pwc^PNkDtp@ zvF#T(WyP1)6!mSYn?E%($G-3ntMJKZE{_!*ygV{s%fhPNPM6&6L$a0YZ-ig(qpiL8 zJX6uNzTfeTYl-o}+Nm4c9Ro^t)<1A8o}0tnzHcXcuetq3&xqlQ8rz*o(oD`vkERBX z!hNcwy7Q$Pm-0a`I$F5x(I<|O3p)miVtnq5oU!*(`5~JH_eCi^vHS)@et~y+LCB(d zK~8kp_S^M$PgWN!3(EO%S=MDyWq!ks6Pe8XbFwa)~?V&5UKK<&U$^5~b5hFKGsxFKD*MeE0FAIhj+xMKe=f?Tc{*^bQINgdM zEjG|FZnX1i_l%oEUEXaze$M+6oGTrpybAedZOWIr|`lV&&;cntcUjB zns+9$w74jye%~4K4(t3}zZ$=&>Rv|=bMxN$zT4pTu&E)w=?MkQV*(Ab@J5?qF%}e1 z7Ys-qcV~mam%E_iD0gY|Z*#6sdm6R;W#7UDUBga$8#N_mxzRR3(|5I(_dRyKac1>@ zfgv=|knxehh;0cMyWO^Rvrm_Fj9YjmZH}c?iPOC{?sA#%enojzuyCzm&ztAlM#X#m zG0#F2WQfRA*&jL(w`uVKpo$1I6qu=8j9-7JJXKkCG{kMi6;<@{^(Wn{GMr-6gN=C; zzgwihHPe??bPE}Db!Xopb)28$_OxY1j^9vuJi+g*fBNO+Gdkr~@xT`uXs9ytc^HKg zikDe8wQV-k?#L*;eJ)u3>T2VI@k1rtknGoMYDe$SFBxDrz2_RkuR%pTTh;15!~Db8 z+%C%KU$TQIJbBqU=3yBSh6Eb!eyS_OqeC=Sj>1Lw9FZPtH4VRNJABcE?xjoE8ql`ht&AcC|WAk`pi??Uz z*p3Tr-+MZXv!ukSEvDsp&G1#*ug;iL2sxtybKT83w#LcqlR+?ClkQ!KY0smK!@ z?qYK+cWqAhCr5_5{&nZZ%Fu|rH5dFW9pv)W`EwWPF|131mZFk@Mx#wu$)W#Dx-^5o z8RKB+`6U1I0_HE?13pFmKV$)4$2IU-P5|c&Ho-Jzm(K#kZl(P)Ht1rnp`h))nFYur5>>Bq$fDgX|x%P$VcG#L5r=`49^{COjH^ zvrHaygZQri^aYIr9S8aiXcXvV&~Q++{;{Ai2O0XzAwj%6P-&8!C7_SflBOOY=KOi# n;jqwzUXNxS}EwM2bNL6i@*^JUss2TQQ(2 literal 0 HcmV?d00001 diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 0f8c5d87982..28080f23d2a 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -235,6 +235,27 @@ describe("hyperframes init flag rename", () => { } }); + it.each(["portrait.mp4", "rotated-portrait.mp4"])( + "sizes the project to a portrait --video as it plays (%s)", + (fixture) => { + 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="1080"'); + expect(root).toContain('data-height="1920"'); + } 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..2bd31fa6b1b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -136,6 +136,8 @@ function probeVideo(filePath: string): VideoMeta | undefined { avg_frame_rate?: string; duration?: string; nb_frames?: string; + tags?: { rotate?: string }; + side_data_list?: { rotation?: number }[]; }[]; format?: { duration?: string }; } = JSON.parse(raw); @@ -167,10 +169,19 @@ function probeVideo(filePath: string): VideoMeta | undefined { formatDuration, }); + const width = videoStream.width ?? 1920; + 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 +191,17 @@ function probeVideo(filePath: string): VideoMeta | undefined { } } +/** The stage shape a clip scaffolded into `from-file` asks for; other templates keep their own. + * Landscape is the template's own 1920x1080, so it needs no preset. */ +function clipResolution( + templateId: string, + clip: { width: number; height: number } | undefined, +): CanvasResolution | undefined { + if (templateId !== "from-file" || !clip) return undefined; + if (clip.height > clip.width) return "portrait"; + return clip.height === clip.width ? "square" : undefined; +} + function isWebCompatible(codec: string): boolean { return WEB_CODECS.has(codec.toLowerCase()); } @@ -379,6 +401,13 @@ function patchVideoSrc(dir: string, media: InitMediaOptions): void { } } +/** Records the template and patches captions from a transcript the media left. */ +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); @@ -753,7 +782,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 (portrait or square, as it plays), else the template's dimensions (typically 1920x1080).", }, skill: { type: "string", @@ -875,6 +904,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 +914,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 +963,7 @@ export default defineCommand({ templateId, media, tailwind, - resolutionPreset, + resolutionPreset ?? clipResolution(templateId, videoSize), args.skill, ); } catch (err) { @@ -944,11 +975,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 +1073,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 +1088,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 +1193,7 @@ export default defineCommand({ templateId, media, tailwind, - resolutionPreset, + resolutionPreset ?? clipResolution(templateId, videoSize), args.skill, ); if (!isBundled) { @@ -1179,13 +1208,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}/`)); From e1f6e54843afb5fd97ca713c40ee08ecb2b36c1d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 18:50:59 -0400 Subject: [PATCH 2/7] fix(cli): keep init's comment share and the skill's citations within the checks --- packages/cli/src/commands/init.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2bd31fa6b1b..10f64ebe370 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -191,8 +191,7 @@ function probeVideo(filePath: string): VideoMeta | undefined { } } -/** The stage shape a clip scaffolded into `from-file` asks for; other templates keep their own. - * Landscape is the template's own 1920x1080, so it needs no preset. */ +// Only from-file's stage is the clip; other templates keep their own design. function clipResolution( templateId: string, clip: { width: number; height: number } | undefined, @@ -401,7 +400,6 @@ function patchVideoSrc(dir: string, media: InitMediaOptions): void { } } -/** Records the template and patches captions from a transcript the media left. */ async function finishScaffold(destDir: string, templateId: string, tailwind: boolean) { trackInitTemplate(templateId, { tailwind }); const transcriptFile = resolve(destDir, TRANSCRIPT_FILE); From 3486a3204b837f3e9e073cde68243c74d5c9424c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 19:29:08 -0400 Subject: [PATCH 3/7] feat(cli): init --video snaps the frame to 9:16, 4:5 or 1:1 so a phone clip shows whole --- .../cli/src/commands/__fixtures__/square.mp4 | Bin 0 -> 2850 bytes .../src/commands/__fixtures__/three-four.mp4 | Bin 0 -> 2666 bytes packages/cli/src/commands/init.test.ts | 42 ++++++++-------- packages/cli/src/commands/init.ts | 46 ++++++++++++------ 4 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/commands/__fixtures__/square.mp4 create mode 100644 packages/cli/src/commands/__fixtures__/three-four.mp4 diff --git a/packages/cli/src/commands/__fixtures__/square.mp4 b/packages/cli/src/commands/__fixtures__/square.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..98c3e81220b88f3406e04de3228e110a2082180e GIT binary patch literal 2850 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 Q2nvcpBW=(~3VtN@Uo#vnjsO4v literal 0 HcmV?d00001 diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 28080f23d2a..d592ff2b14e 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -235,26 +235,28 @@ describe("hyperframes init flag rename", () => { } }); - it.each(["portrait.mp4", "rotated-portrait.mp4"])( - "sizes the project to a portrait --video as it plays (%s)", - (fixture) => { - 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="1080"'); - expect(root).toContain('data-height="1920"'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - ); + it.each([ + ["portrait.mp4", "1920"], + ["rotated-portrait.mp4", "1920"], + ["three-four.mp4", "1350"], + ["square.mp4", "1080"], + ])("sizes the project to the --video's shape as it plays (%s)", (fixture, 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="1080"'); + 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-")); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 10f64ebe370..43033713f24 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -191,14 +191,24 @@ function probeVideo(filePath: string): VideoMeta | undefined { } } -// Only from-file's stage is the clip; other templates keep their own design. -function clipResolution( - templateId: string, - clip: { width: number; height: number } | undefined, -): CanvasResolution | undefined { +type CanvasSize = { width: number; height: number }; + +// A from-file stage (the clip itself) snaps to the nearest of 9:16, 4:5, 1:1; 16:9 is the template's own. +const CLIP_FRAMES: CanvasSize[] = [ + { width: 1080, height: 1920 }, + { width: 1080, height: 1350 }, + { width: 1080, height: 1080 }, + { width: 1920, height: 1080 }, +]; + +function clipFrame(templateId: string, clip: CanvasSize | undefined): CanvasSize | undefined { if (templateId !== "from-file" || !clip) return undefined; - if (clip.height > clip.width) return "portrait"; - return clip.height === clip.width ? "square" : undefined; + const distance = (frame: CanvasSize) => + Math.abs(Math.log(frame.width / frame.height) - Math.log(clip.width / clip.height)); + const nearest = CLIP_FRAMES.reduce((best, frame) => + distance(frame) < distance(best) ? frame : best, + ); + return nearest.width > nearest.height ? undefined : nearest; } function isWebCompatible(codec: string): boolean { @@ -529,7 +539,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; @@ -547,7 +564,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)) { @@ -592,7 +609,7 @@ async function scaffoldProject( templateId: string, media: InitMediaOptions, tailwind = false, - resolution?: CanvasResolution, + canvas?: CanvasResolution | CanvasSize, authoringSkill?: string, ): Promise { mkdirSync(destDir, { recursive: true }); @@ -608,7 +625,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"), @@ -780,7 +798,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: a --video clip's own shape (portrait or square, as it plays), else the template's 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 shape as it plays, snapped to 9:16, 4:5 or 1:1 (1080 wide), else the template's dimensions (typically 1920x1080).", }, skill: { type: "string", @@ -961,7 +979,7 @@ export default defineCommand({ templateId, media, tailwind, - resolutionPreset ?? clipResolution(templateId, videoSize), + resolutionPreset ?? clipFrame(templateId, videoSize), args.skill, ); } catch (err) { @@ -1191,7 +1209,7 @@ export default defineCommand({ templateId, media, tailwind, - resolutionPreset ?? clipResolution(templateId, videoSize), + resolutionPreset ?? clipFrame(templateId, videoSize), args.skill, ); if (!isBundled) { From 87e47537d6aa5f5a3eddbce84628e433940c7e36 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 20:43:48 -0400 Subject: [PATCH 4/7] fix(cli): init --video frames a non-square-pixel clip by the shape it plays at --- .../cli/src/commands/__fixtures__/anamorphic.mp4 | Bin 0 -> 2845 bytes .../cli/src/commands/coreSkillContent.test.ts | 10 ++++++++++ packages/cli/src/commands/init.test.ts | 13 +++++++------ packages/cli/src/commands/init.ts | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/__fixtures__/anamorphic.mp4 diff --git a/packages/cli/src/commands/__fixtures__/anamorphic.mp4 b/packages/cli/src/commands/__fixtures__/anamorphic.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..dcab85b9fdafc70a426961a59c2e917ce5ce5e9b GIT binary patch literal 2845 zcmZuz30M=?79JLbhZ`z>E(IMo>IO_gKtNL2R4kyVwJr}yCzA<@Ntj_~*p#J+sHlKa zp@<6j{3;($MR7wxTzJN%iYtn!RiIiC0e3`E^Ue*>Qr|mY&OPUzPuyDmj|QFpKYW3Q~ZWfA#KVFy!5z@wDQPTL04QDR=PN zv8~Tz#I6$D0jC&_#zn3!p!z$4$tfs!#w-U3?)|+l_!Kk+4qt|i)zV4<7ZZfDgP0Jb zXq6za9!^ft(b0}kY6Z<`$#6%83v)uajw(T?g&2kv)J!<SDv(UCHgiwhjB)v9@^7LA7i6$cpzNXsx9QUw!Pw@rleS~Uf|bQ=gf zoHN#-)H+f?eW=3)n$waH1a`SLk|Sef6r*EF0p=8JlE9JbaDae7jzlp^j?~e-)D4%* z*jUij3aBlYkqVL(jGA0c@~DWKqhVdncr>jJQwee?#;|m_ER12n+i7GWK|{v^wp8pw zbg43(IvnjTPtoBt6)8w11ZtBb(Ux*FuYz=rlKr_{sVkaGDNc#&7V51~sq^|B@;02nMN^ms`hXhvw&Jb#m5pV&}X<|QOm`M%2E(sfKs4m0n zxw_=~n#L5h9JSNtsvOBG&2~z00Yxo7WN^40&U2amUbZ@sT}# zBkiaM@yj7*qCPeEjMbfukC)0WoXGfXl|Hy50^1x|6Ty2$uefH**_C}YdBe2z34Gq= zz=j+82$!s}0r%P${Myf@VD*;Q0`u7+ar>|2Rjp6d^j+3d_b;|SvdlS{o;_@}$Ck{M z897bo)1?jn*%2RCsoc8wZJ#yEvr^Oi+-~eQD^D%?5Wl-5d8|!fae=3IRotRa$#;+4 zj|i+km=!eSq)GFrcN$&2yH+&RV4fEoe`P?_Rh^e(u5y6k6Laxq`HS)}_kzu*=jre4 zir=*`Ugb3XB;%_u2|e=1yr7wbPEE1Mb>1J}T5xZ^EXATDGO>2WKyBHA?6+$Zi1$@D zXI~+wyc!*pd1kPd+Md$it}(wYw60&HuYQD!=Hk`K)d#)(342A@{aw+Lql=46y*H1q(tf?8 zC2Y^0?^^V~ZF5;>QgW&=tJjd^19gRm+CM)KHP}c}$rk-or{B+=C|;W6xv%E1>)W(5 zykB8sP_7#Ze@I8Yb1hreWiVY;}PTjuoTFEc`xApuy^X)H=IPQIwxa?-+Pr)ID(>A^=m})SvPwi{e zH=jJ8+2VaJ`+Y*g;Y!PWlA(=<8#2rvQ>13rsLVmb&&Zh6~ewb zpQ7HUNj_XjDQ&haCUkQWnors-cEKl3w;B8~Ri4u3wId|imqHJQ}-WU1aakY~}iNRGbde;sbldy?eKIvADn8@N&Y+Z^c-8g^uN@<_r zJ7z<&he_Tr`*psCkL~lUo=m-W{nn8{Kc9xTNsp2OGY;NrBDaq5bt$yb4S-y+V(&Vxw#Ts`bkb9zNWOF-HNvs8X~L8vnybocfTx2Jxt z3fx~0AOwKy>jddPv_NLF+rRqZJ=<4KE3J0LG0T^Cc=X*G{i%96w(Y^uH`g~cnQn$0 z3FK6D7fu@=e1DYthN}VDm5HB+W(SQqUmB4=d&zwB!)8Aj`UE>ao1FNr?d2J7$Xn%| zJA9&xsp)lbj`iB|%6(6BU+=E%y*7@xXeduS{_w!J>4`+<-HH*Sxv62^YLqp7&I zWYOdF{D7V2t9n22Ik&sss@Z-+aZ^Ck$mwk>v&+reE*GWlKDMDXBkGBNMaV(TX(4NX z(x;fa{PfM8L7U?)m)_0&YWb28k6x~4`WrenWuJyFq$YWz`(1!-qdm`Mn7kWM6t91G zVO8?=^|`D2)fy@#OWt>A#(awvDSWn^w8e&d)$8f_?r{Myn4p|uWkz8?@REPtN~YupfT1o>k-z4hH8I7 zU)1@(btBH(b~P}LRtP*G*wR`a-V8hwXwSCCDLQ?&3iu$QWFX}48_8+$ zPTi!K7vx&d&+!7^eIO|2D0&cqMAQ?r)8Ylo_6HR)B9kG35br8>6uUW!2obJT%h5Zg z(`g)s84MsjAqv5{dQ_j2Zb_J-5un5iG^7G{oyU{C+-fj^pH7OF&7?6&-w!#o3#KP*(@gPEW#QxM@u@;>+ zI4YwL?~;9ygeznuxHd*C$TiwQ8t1lvO?Aa|V?$kI4Nvf`=<*?f`bh#=1}p{s26!a! z3}CcdZoq4Ry@8=;iUElT;S#{|NQFRxT&kmu5>#w-&+7tLj3ZeVo#>zBpzger { }); }); +describe("talking-head-recut ratio policy", () => { + it("lets a ratio the user named win, and otherwise infers it from the footage as it plays", () => { + const skill = read("skills", "talking-head-recut", "SKILL.md"); + expect(skill).toContain("a ratio or platform the request already names wins"); + expect(skill).toContain( + "stream=width,height,sample_aspect_ratio,r_frame_rate:stream_side_data=rotation", + ); + }); +}); + describe("media-use TTS documentation", () => { it("does not advertise flags unsupported by the published tts command", () => { const tts = read("skills", "media-use", "audio", "references", "tts.md"); diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index d592ff2b14e..f3e4773bf73 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -236,11 +236,12 @@ describe("hyperframes init flag rename", () => { }); it.each([ - ["portrait.mp4", "1920"], - ["rotated-portrait.mp4", "1920"], - ["three-four.mp4", "1350"], - ["square.mp4", "1080"], - ])("sizes the project to the --video's shape as it plays (%s)", (fixture, height) => { + ["portrait.mp4", "1080", "1920"], + ["rotated-portrait.mp4", "1080", "1920"], + ["three-four.mp4", "1080", "1350"], + ["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); @@ -251,7 +252,7 @@ describe("hyperframes init flag rename", () => { const root = readFileSync(join(target, "index.html"), "utf-8").match( /]*data-composition-id="main"[^>]*>/, )?.[0]; - expect(root).toContain('data-width="1080"'); + expect(root).toContain(`data-width="${width}"`); expect(root).toContain(`data-height="${height}"`); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 43033713f24..e1740ac0b91 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -138,6 +138,7 @@ function probeVideo(filePath: string): VideoMeta | undefined { nb_frames?: string; tags?: { rotate?: string }; side_data_list?: { rotation?: number }[]; + sample_aspect_ratio?: string; }[]; format?: { duration?: string }; } = JSON.parse(raw); @@ -169,7 +170,10 @@ function probeVideo(filePath: string): VideoMeta | undefined { formatDuration, }); - const width = videoStream.width ?? 1920; + // 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( From 1526a00d4d5cf619aaa0dc7c7a957a1c2333c899 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 20:47:49 -0400 Subject: [PATCH 5/7] fix(cli): init --video frames the project to the clip's own shape, so the whole picture shows --- .../src/commands/__fixtures__/anamorphic.mp4 | Bin 2845 -> 3186 bytes .../src/commands/__fixtures__/four-three.mp4 | Bin 0 -> 2234 bytes packages/cli/src/commands/init.test.ts | 3 +- packages/cli/src/commands/init.ts | 26 +++++++----------- 4 files changed, 12 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/commands/__fixtures__/four-three.mp4 diff --git a/packages/cli/src/commands/__fixtures__/anamorphic.mp4 b/packages/cli/src/commands/__fixtures__/anamorphic.mp4 index dcab85b9fdafc70a426961a59c2e917ce5ce5e9b..c0ed4f294ed2ac17991375ee0e8666847fc00ac5 100644 GIT binary patch delta 1709 zcmV;e22%N*7V;R7Dgp?pu`H|te*!vXh=BkT|J1r5;fFj)K0cN$@dOqd1C-xABc85`hLa$aCC&?0J{1Xjr#6waa(GCk}oxYt0Quu4^*>}8O z(|vCKFbE2%vvYz$gj^Cse_MGDo1)EjRM1wfeK)Fs^30Fg*`6S1P3>B(YWLs^vzOY^_n+b3qhI)&4`ce3?sb3BQd&H3T>oodc0B> z=U;ZJyouX_bq6dLfOrckAC3R*)gwU(44Ic96)$!|p>7;Ue`nc(+eypTUjBg|78wZu zQ^;}Cm}=k8e)!3m5VYpi0Hd^DA#~<}sPZoKNUc)3WZc+RT)d8alzua(MuovpKVg4Q zT6<}4{U6HJD$K{?3VtZrkfZ5k0*W2(Xf@T*B*NXR$`$&HpW>GOVPpOY&z;|cOt-~_ zgDgAZWjZRke=+Y<9FYDt)F+3=H*+_KF~0G%zYJf}e=B=smQ=e`Z@xlgph2i#{wjcgL?{un8Uz0L_O->SorS8>*m-G%}Wk3J`0AxX)5N8oz_cD3B z=X}d`Yp!Iw)-dRQMizwiXL(#3e8$h!G{K3UhnIE#fBqYH)Zw_{Jf6bed zUHd)oAWgJEGhjnU>t`i4rW48)zPKL>Z#}70IlohTz||@qlPN&}001umo){*GL;v{H zkGwz-?4hZ9jVQ?{sy}G6$ zt>wX?t?ng40PWPO+jjlt6Nm@|-c8e11WTECbAQ3`cI|y5qp4NhnFNeg@Fs_vHs5qp zfdBviRRNwDtw=-v!!Nxp^s>AK-y+MrdiI(0e;L5s-<;8a2jKlQixlJIwFY~vB4;d( zx={iEglf4Eb@KgyOxxFSi)bJz&r^IcenqEc57}kq{b)nN*Q0>|0065&ni(lI0s_%8 zm=K4uLPWh%qJM-}-rl+GmH6>Y?O>q~4D;jZz}XIkACjU@Fl-B64p4GE&c#ELsW~GM ze<)r(XN!G=7J1n}O8sp8I*0nP$c2fbbp;5tQcFt)durs2zS@N%R6`f}l5jLGV5i#w zz0~bT?vjRwco~Z^A?EcAkaDrs4S)Uqe6WE48LRYduf4wYZjo{d`AY_~qkDYwmx_0M zd%q6#629R8003M;o*?bSXS>Q|ZF+8xe?(NmZFC)y^3onn_wzxtSl(CKnG`}N&Zqj% z-23fh&+Kbzkzg1Vt~FU;F?-03<*F00RI( z000F8#R_20003G0001J0000p0000k0000;00014000250001B0000rlcx%H DbOb4= delta 1365 zcmYL_2{e>z7=Zs-!X4RlQMM@Eq?$65C1RLFj7Sc;mL^-FBl{KOzeqJPwlNbEV>(V8 zH%3!-S#N{Nl6|W%<JLh@dbKduR-tRr%l5*a!>AFbqD?GgtB#0RD zeGm^QK>f9vlc5udlS4B}Hzm$P?l&g-XBBLW#7?{N4wAjhCtPl0&hhb$wSDj7KJ8mU ziw{HV;#;?CdTEu$*rzXpkMgOkX3TKP)K#zcS7{#;_zs&PK!)8=NSJxlo%gDtYPAQB zC7*p57sl?e6ZG;!@C9@8D~r zeAhTDGbSxt*p_N4_&bjh+v1nwx+-}ufI+1Z4SUN_byVIDN+B;%ThWfoF(Guux@;$o zR(}Ywn|#3_?Q1~HYODp`nluVJr39hb4wNpL$nIO_Cs=+m(01^fzB+KB&fkcW(d5V< zE}#@RQ*N9zYY4XF^E@m5aU@yEH|mMA^vfvooUzMZN#dIk;K&`hpjs#9>iszEMrWT* z7g=xVAStt1A;>#7Nm^|tU*tIkol>7 z*{}IuoHE5qeyhj*0fJpdo|dewY^RpbH-7MSUg`Mwe<+B;Rl0Jk2pNYvgO23Q`9FJh zWsd*xG2R2gYb;{y*_ZfyqUh!N&ZkoXMI4np$2U0nx%CzIYP?D5Q2CivT5}k&Xokcx zBm)3YNnlCLJxH6~t6jfGn4X@U>Ha$ULVxaj&9mdea8j`w_&yb@sBN40&H2$XpBhoB ze^X8|t8uV**)PobFWJj%bnZgMiIOGIqc4K);9hh#Wsze5LASr^4BhOT{%$o#Xk~81 z3Cdd}N^wdv<$^Tyls7r>f<~si%Jsz>>5@x_cs1UP9INiw`tVZw?Z^!p?nhTr)vN>; zd&@pq;CVYMJ`S7?nVT*tiPOwsQSsJl$By8*12RM zpAb88%oD57xga(ouN9Z>9iTgqeHzOMOH##ib$i=JyQ(Br@@!z)7{ON7d0+``tvh0- z4aRx~D(r|Bg4Osh@pfr12884sElYfnVj#$F2qdq~fxl05U9uURUYd6=d6WX{`(fQ4 z*4NJx_2>2Ojn}{2RF{6I7VW|bfx85_OS86w+C84~X}-meBZA^3@;7vMe9XTST|zC? zjIl{O{mSoVQ|sgAY-!0TYM2VgQ$LI}I(Ti+V5ifrY!V)Vg@=@a|4orr;&>?*TKA8S zsV<$2!4$*>2Lx;y@6CzY?hgPSf2v&RNf#nBUV+sbBZPTIwd!%GhF*fo{&4jbD2ubw|#<65SzAWEu6&6>Lhc8wnB9?Q4F&V zCF&@q7FH+Er$tU%*So(6Yzk+{_*rm6JHD6B2tKvhGTaKrJ}8jmII}UP+8AgOQ1|x%FeRaAq7KxA29QJmKmq98 z;9z3Sl>3vugV948U!&_u+Usb-ivS1!*t?sNy8%8ZVE6j}bp)LwX>d3muGGSq!kmLq ThdB>}gE54;2ScE5Nk;w)n1Fgz 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 0000000000000000000000000000000000000000..e7c838f1c1e2a9f1cddebaabf5720483c5c8fc15 GIT binary patch literal 2234 zcmZt|3vdi)c<&;l5%DOJR5D!zp>o-~++E^oNt|#s)SGzC+1uT_TiLzc>~7AT5U;i} zv|{@38j~2!AX+9;ty0epiczZQBp#vF3DJ^j(F{}9@87#{wBO|WzxU(+zyJOXhGDo{ z4N9CM3K*ux5Ct?*e%dHVW+R4Sac-GqF|6GqfuU6J+0s5X6so*h(0s6|!LfJk;Cj5F z@4c3Q$!f+kaaxpF+-S7`>o9;SYuto_(V1pEf0`X^3`>K-E=ob3b*s3EAhI(}gb8_j zRaLTOWzC;I-{9vMR^%z4L6kjND6he*3Ox9TlFErb#fHV92xQxcfH6B87g(}H@Zw5|3pgWzOG*%eVRgz3X*3WJ1q&{40hV!U zIU&R;Q$7z%n#?%ul|_MaqF7v&S)S(<(uC&*av53$6ZoEN1O72JO zqLO9;wJB5RNM%;>LbyyjpKX`4BAKLRH!ir~T+y~bfG`7r7o*LPgaLetC6rjBRDiS& z0fnNn5^2Uc35En$0%i!csA9MPXqwm(4AX69%a>wVqaIBfa&A{+xv$1P$gygq#SpX3 zvMJ}n7c1Y+_~gQjxJ$Q~Lxc2?RqZHtMep**6a41$BVvx^7S5?kSg#MwtPAv-aeCiZ<=@7x zSW{7ZykSkeYkBn*pO}&S*EdPGQWqB6i=>2WQ{K2++h-J!wm8s}@8{Y%p`P#b$2!_{ z<;RQx%oFSB;zaB4z{NM4^QYUsTR3aOmH}&)zOMAYxoCv`y(G){oc3p48ycL;>G!?$ zS#{F>($hnB9;;n+deGEDg6Mo|Ygtv(a97W|4cA9c+%a$0ere&8>WF zx!F_7Nl>aMPTw^qamQox9U|ShiTrJG)6kDR{SFroymx;}d0JI{dPQPV->0AEwiML0 zlo$T`regC(K>r77!tuS3Jk3m9DdvNe9*iCnhzZI(3eXi!p>;n`!?a#{EaCCEqeWV z)A}we4jkB8vw!V7;eg(;Cn0(XhQ(|WM9~jQUhsPvpivNW8|=3+9o)7k?TZ2!UNB-< zUbOFjc|E+aC*Vg@1g6<)kybM%$}N%&)qV+Hs`I~f^B{)M;?h}0Re(!nc?Di|4Il%` z8yD$WKTKda3M{z5yu9}eP-kFMk7;q4UWS)74i@x!xABNhL$A4m@-aM%e00t5Yjy+E z-7jcKquWkr!nRZfo(vQWfb0n+GLMIOU5%o;c;JsJs?s(P{QMCym9{I$>Hx}{T zVPj}A2-@Nu^mE@1VLePqv*Qt(fGFG^4dBLTxgcKC1J&eI$W2Fl+Hyl(t literal 0 HcmV?d00001 diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index f3e4773bf73..72ed7235647 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -238,7 +238,8 @@ describe("hyperframes init flag rename", () => { it.each([ ["portrait.mp4", "1080", "1920"], ["rotated-portrait.mp4", "1080", "1920"], - ["three-four.mp4", "1080", "1350"], + ["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) => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e1740ac0b91..7d39c988dc9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -197,22 +197,16 @@ function probeVideo(filePath: string): VideoMeta | undefined { type CanvasSize = { width: number; height: number }; -// A from-file stage (the clip itself) snaps to the nearest of 9:16, 4:5, 1:1; 16:9 is the template's own. -const CLIP_FRAMES: CanvasSize[] = [ - { width: 1080, height: 1920 }, - { width: 1080, height: 1350 }, - { width: 1080, height: 1080 }, - { width: 1920, height: 1080 }, -]; - +// A from-file stage (the clip itself) takes the clip's own shape with a 1080 short side, as every preset has, so the +// whole picture shows with no bars; a clip that matches a preset gets exactly it. 16:9 is the template's own size. function clipFrame(templateId: string, clip: CanvasSize | undefined): CanvasSize | undefined { - if (templateId !== "from-file" || !clip) return undefined; - const distance = (frame: CanvasSize) => - Math.abs(Math.log(frame.width / frame.height) - Math.log(clip.width / clip.height)); - const nearest = CLIP_FRAMES.reduce((best, frame) => - distance(frame) < distance(best) ? frame : best, - ); - return nearest.width > nearest.height ? undefined : nearest; + 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 { @@ -802,7 +796,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: a --video clip's shape as it plays, snapped to 9:16, 4:5 or 1:1 (1080 wide), else the template's 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", From e012374b818863d9f30c5c81c39022c93e2c056f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 20 Oct 2026 21:04:50 -0400 Subject: [PATCH 6/7] fix(cli): keep init's comment share at its base --- packages/cli/src/commands/init.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 7d39c988dc9..2f53c45ff45 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -197,8 +197,7 @@ function probeVideo(filePath: string): VideoMeta | undefined { type CanvasSize = { width: number; height: number }; -// A from-file stage (the clip itself) takes the clip's own shape with a 1080 short side, as every preset has, so the -// whole picture shows with no bars; a clip that matches a preset gets exactly it. 16:9 is the template's own size. +// 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); From d0f5c58352ae01128aa3bf1111aaea130b373972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 22 Oct 2026 11:00:40 -0400 Subject: [PATCH 7/7] test(cli): drop the ratio check of the retired talking-head skill --- packages/cli/src/commands/coreSkillContent.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/cli/src/commands/coreSkillContent.test.ts b/packages/cli/src/commands/coreSkillContent.test.ts index fd16cc48815..cefa8352bb6 100644 --- a/packages/cli/src/commands/coreSkillContent.test.ts +++ b/packages/cli/src/commands/coreSkillContent.test.ts @@ -75,16 +75,6 @@ describe("hyperframes contract docs", () => { }); }); -describe("talking-head-recut ratio policy", () => { - it("lets a ratio the user named win, and otherwise infers it from the footage as it plays", () => { - const skill = read("skills", "talking-head-recut", "SKILL.md"); - expect(skill).toContain("a ratio or platform the request already names wins"); - expect(skill).toContain( - "stream=width,height,sample_aspect_ratio,r_frame_rate:stream_side_data=rotation", - ); - }); -}); - describe("media-use TTS documentation", () => { it("does not advertise flags unsupported by the published tts command", () => { const tts = read("skills", "media-use", "audio", "references", "tts.md");