mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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
This commit is contained in:
@@ -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. |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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(/<video\b[^>]*>/)?.[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(/<video\b[^>]*>/)?.[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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,6 +58,7 @@ export const BROWSER_HOSTILE_CODECS: Record<string, BrowserHostileCodec> = {
|
||||
// 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 },
|
||||
|
||||
Reference in New Issue
Block a user