mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(cli): init --video frames the project to the clip's shape, so nothing is cropped (#5984)
* fix(cli): a portrait clip passed to init --video makes a portrait project * fix(cli): keep init's comment share and the skill's citations within the checks * feat(cli): init --video snaps the frame to 9:16, 4:5 or 1:1 so a phone clip shows whole * fix(cli): init --video frames a non-square-pixel clip by the shape it plays at * fix(cli): init --video frames the project to the clip's own shape, so the whole picture shows * fix(cli): keep init's comment share at its base * test(cli): drop the ratio check of the retired talking-head skill
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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)) {
|
||||
@@ -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 });
|
||||
@@ -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"),
|
||||
@@ -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",
|
||||
@@ -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
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -932,7 +976,7 @@ export default defineCommand({
|
||||
templateId,
|
||||
media,
|
||||
tailwind,
|
||||
resolutionPreset,
|
||||
resolutionPreset ?? clipFrame(templateId, videoSize),
|
||||
args.skill,
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -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("."))) {
|
||||
@@ -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);
|
||||
@@ -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)) {
|
||||
@@ -1164,7 +1206,7 @@ export default defineCommand({
|
||||
templateId,
|
||||
media,
|
||||
tailwind,
|
||||
resolutionPreset,
|
||||
resolutionPreset ?? clipFrame(templateId, videoSize),
|
||||
args.skill,
|
||||
);
|
||||
if (!isBundled) {
|
||||
@@ -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}/`));
|
||||
|
||||
Reference in New Issue
Block a user