From d65eefad5ed351e9d36d2caeb6049878be0aba85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 14 Oct 2026 18:19:17 +0000 Subject: [PATCH] feat(cli): init --keep-original copies a clip as is, without transcoding (#5612) * feat(cli): init --keep-original copies a clip as is, without transcoding `hyperframes init --keep-original` copies the --video file under its own name and wires it into the project the way a web-safe clip is wired today: no transcode and no prompt, interactive or not. Default behaviour is unchanged. Theora leaves init's web-safe codecs and joins the preview proxy's browser-hostile codecs, so init converts it and the preview proxy serves an H.264 copy instead of a blank picture. * fix(studio-server): keep the codec table's comment share at its base level --- docs/packages/cli.mdx | 1 + .../cli/src/commands/__fixtures__/mpeg4.mp4 | Bin 0 -> 1693 bytes .../cli/src/commands/__fixtures__/theora.ogv | Bin 0 -> 4229 bytes packages/cli/src/commands/init.test.ts | 41 ++++++++++++++++++ packages/cli/src/commands/init.ts | 14 ++++-- .../src/helpers/mediaCodecMap.test.ts | 2 +- .../src/helpers/mediaCodecMap.ts | 1 + 7 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/__fixtures__/mpeg4.mp4 create mode 100644 packages/cli/src/commands/__fixtures__/theora.ogv diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index f9f5aa1c87..b1d91149b2 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -110,6 +110,7 @@ npx hyperframes init my-video --tailwind | `--tailwind` | Add Tailwind CSS browser-runtime support to the scaffolded HTML | | `--non-interactive` | Never prompt. For agents and CI. | | `--skip-transcribe` | Skip the automatic Whisper transcription | +| `--keep-original` | Copy the `--video` file as is instead of converting a non-web codec to H.264 MP4 | | `--model` | Whisper model for that transcription (for example `small.en`, `medium.en`, `large`) | | `--language` | Language code for transcription (`en`, `es`, `ja`, …). Filters out speech in other languages. | | `--skip-skills` | Currently ignored. Set `HYPERFRAMES_SKIP_SKILLS=1` to opt out in CI or tests. | diff --git a/packages/cli/src/commands/__fixtures__/mpeg4.mp4 b/packages/cli/src/commands/__fixtures__/mpeg4.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..66a56c3148fab14e2ba3211d248ebd6b67c71368 GIT binary patch literal 1693 zcmZuy3rtgI6#j2p%Bu*~f-ox-C=3_C7KoS#SeOV55TT%xDQlrc3Y2zjsc1;nDz8O( zI2n(LA|onHSa4I^!ZK;CA&ZL`5hXrG(afpQ**Q?hlZ#UT|AxB)--CWhfO)gA-*?3WWRZ>+7VLrCPLvVBHWiQ8`=IK=>%vwF$*L^N?B4+*T-uS!L z7hXS)897RN@av(z<9BYAJ+Zv~~WIMBMUIxr@NUiAC-eop+OUv0a{d@m^0 z|8*Z&sE*m|)(_gc62 zM~zNHRR8zuBAsh>=9stVM8nB`k=7QTwC*smYfAvbC`zX4ES-PuBRG|#bK5eR&wF1q zg=9qz8g_ZDswg|n_3@>$1gb-!j2-k9rzr7)G86E4wCul|>;9Q_2tPrYQyWThbPqgD zqCQsY?F82{7{|Vh5z9lv?(y}yjUCH7U_i$D&B2NcoStP!n#39DS*`a{Hg=9wH=G}N zS@BNswRP#!PaCT@C3Su~*NH2R;%wm*nXMP*^tvXVV?1^1aA`jfePe5NdR~xISMbWu z&AsjXgt5^#`Pnvhy)gU4p~{(cuX<~`gmK*`{hu*U&MdLeW(>lZ7&WbFknzqg++W zv0*~1*5sE0ex%l@-wXuj+=LGq9uyXPPMb#nx19_SD;8o|npj(~CL%myZ8)FD=g5`P zol&;X{OWDB0uo8p5`0XEs?+H*fS3k0p2H+vZUTgOItTEq2F3yCN}T;n%@Q2*9-d5p@x7_BM+rmtptmAjFpwIQQ5{pwXuWGTdg5S1(3{fK!}Dh3r>LC z+q@swv%yz@gCK7>A)mw{5ZgmWYzz1)DF9*fC>)dr8UYHt1q!fOFh?k8ez`;=2AeCF vk`^HHw;ww^Dk`y3i6`@qgvo5rR#2}-feBZ_UkfI|qzKNV5YC?uKOX;Y@nF$e literal 0 HcmV?d00001 diff --git a/packages/cli/src/commands/__fixtures__/theora.ogv b/packages/cli/src/commands/__fixtures__/theora.ogv new file mode 100644 index 0000000000000000000000000000000000000000..f8442efff07e658cf16fdd2ac3cab94453a8599d GIT binary patch literal 4229 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 G2>$?m8?jyh literal 0 HcmV?d00001 diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 5012febad1..ded2b14ca5 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -251,6 +251,47 @@ describe("hyperframes init flag rename", () => { } }); + it("converts a Theora --video to H.264 MP4", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-theora-")); + const target = join(dir, "proj"); + const clip = join(dir, "clip.ogv"); + copyFileSync(resolve(fileURLToPath(import.meta.url), "../__fixtures__/theora.ogv"), clip); + try { + const res = runInit([target, "--non-interactive", "--skip-transcribe", "--video", clip]); + expect(res.status).toBe(0); + const video = readFileSync(join(target, "index.html"), "utf-8").match(/]*>/)?.[0]; + expect(video).toContain('src="clip.mp4"'); + expect(existsSync(join(target, "clip.ogv"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("--keep-original copies a non-web --video as is instead of converting it", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-init-keep-")); + const target = join(dir, "proj"); + const clip = join(dir, "clip.mov"); + const fixture = resolve(fileURLToPath(import.meta.url), "../__fixtures__/mpeg4.mp4"); + copyFileSync(fixture, clip); + try { + const res = runInit([ + target, + "--non-interactive", + "--skip-transcribe", + "--keep-original", + "--video", + clip, + ]); + expect(res.status).toBe(0); + const video = readFileSync(join(target, "index.html"), "utf-8").match(/]*>/)?.[0]; + expect(video).toContain('src="clip.mov"'); + expect(readFileSync(join(target, "clip.mov"))).toEqual(readFileSync(fixture)); + expect(existsSync(join(target, "clip.mp4"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("wires --audio into the composition without creating a video clip", () => { const dir = mkdtempSync(join(tmpdir(), "hf-init-audio-test-")); const target = join(dir, "proj"); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2737a92c30..80f082d4cf 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -77,7 +77,7 @@ interface VideoMeta { videoCodec: string; } -const WEB_CODECS = new Set(["h264", "vp8", "vp9", "av1", "theora"]); +const WEB_CODECS = new Set(["h264", "vp8", "vp9", "av1"]); const DEFAULT_META: VideoMeta = { durationSeconds: 5, @@ -394,6 +394,7 @@ async function handleVideoFile( videoPath: string, destDir: string, interactive: boolean, + keepOriginal: boolean, ): Promise<{ meta: VideoMeta; localVideoName: string }> { const probed = probeVideo(videoPath); let meta: VideoMeta = { ...DEFAULT_META }; @@ -416,7 +417,7 @@ async function handleVideoFile( } // Check codec compatibility - if (probed && !isWebCompatible(probed.videoCodec)) { + if (probed && !keepOriginal && !isWebCompatible(probed.videoCodec)) { if (interactive) { clack.log.warn( c.warn(`Video codec "${probed.videoCodec}" is not supported by web browsers.`), @@ -717,6 +718,10 @@ export default defineCommand({ type: "boolean", description: "Skip whisper transcription", }, + "keep-original": { + type: "boolean", + description: "Copy the --video file as is, without converting it to H.264 MP4", + }, model: { type: "string", description: @@ -781,6 +786,7 @@ export default defineCommand({ const videoFlag = args.video; const audioFlag = args.audio; const skipTranscribe = args["skip-transcribe"] === true; + const keepOriginal = args["keep-original"] === true; // Temporary measure while the skills.sh registry sync lags GitHub main: the // `--skip-skills` FLAG is neutered so an agent (or user) that passes it can // NOT dodge the GitHub skills freshness check. The "don't pass --skip-skills" @@ -874,7 +880,7 @@ export default defineCommand({ // Handle video if (videoPath) { sourceFilePath = videoPath; - const result = await handleVideoFile(videoPath, destDir, false); + const result = await handleVideoFile(videoPath, destDir, false, keepOriginal); localVideoName = result.localVideoName; videoHasAudio = result.meta.hasAudio; videoDuration = result.meta.durationSeconds; @@ -1050,7 +1056,7 @@ export default defineCommand({ } mkdirSync(destDir, { recursive: true }); sourceFilePath = videoPath; - const result = await handleVideoFile(videoPath, destDir, true); + const result = await handleVideoFile(videoPath, destDir, true, keepOriginal); localVideoName = result.localVideoName; videoHasAudio = result.meta.hasAudio; videoDuration = result.meta.durationSeconds; diff --git a/packages/studio-server/src/helpers/mediaCodecMap.test.ts b/packages/studio-server/src/helpers/mediaCodecMap.test.ts index 8898faf73a..cad1265f3b 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.test.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.test.ts @@ -206,7 +206,7 @@ describe("shouldPrewarmProxy", () => { return { codecName, browserHostile: true, representativeMime: null, hasAlpha: false }; } - it.each(["hevc", "prores"])( + it.each(["hevc", "prores", "theora"])( "pre-warms %s, which has no cross-platform browser decode", (codecName) => { expect(shouldPrewarmProxy(facts(codecName))).toBe(true); diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index accec7ac9e..7650661abe 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -58,6 +58,7 @@ export const BROWSER_HOSTILE_CODECS: Record = { // Windows/Linux and Firefox everywhere need the substitute. hevc: { representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"', prewarm: true }, prores: { representativeMime: null, prewarm: true }, + theora: { representativeMime: 'video/ogg; codecs="theora"', prewarm: true }, // Every mainstream engine decodes AV1 and VP9, so canPlayType keeps the // original and only the rare browser that cannot pays for a transcode. av1: { representativeMime: 'video/mp4; codecs="av01.0.08M.08"', prewarm: false },