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 0000000000..66a56c3148 Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/mpeg4.mp4 differ diff --git a/packages/cli/src/commands/__fixtures__/theora.ogv b/packages/cli/src/commands/__fixtures__/theora.ogv new file mode 100644 index 0000000000..f8442efff0 Binary files /dev/null and b/packages/cli/src/commands/__fixtures__/theora.ogv differ 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 },