mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 04:07:03 +00:00
fix(cli): cloud render uses the composition's data-fps and skips unused heavy files (#6895)
* fix(cli): cloud render uses the composition's data-fps and skips unused heavy files * fix(cli): satisfy typecheck and comment ratchet for cloud render fps
This commit is contained in:
@@ -158,6 +158,30 @@ describe("offloadHeavyMedia", () => {
|
||||
expect(second.get("index.html")!.toString()).toContain("https://assets.example/1/clip.mp4");
|
||||
});
|
||||
|
||||
it("leaves heavy files nothing references out of the archive, but keeps one a variable names", async () => {
|
||||
const dir = project({
|
||||
"index.html": html(`<video src="clip.mp4"></video>`),
|
||||
"clip.mp4": HEAVY,
|
||||
"assets/guide.mov": HEAVY,
|
||||
"assets/plate.png": HEAVY,
|
||||
"assets/small.txt": "notes",
|
||||
});
|
||||
const fileMap = stagePublishFiles(dir, OFFLOAD_MIN_BYTES);
|
||||
|
||||
await offloadHeavyMedia(dir, fileMap, {
|
||||
apiBaseUrl: "https://api.example",
|
||||
upload: fakeUpload(),
|
||||
cachePath: cachePath(),
|
||||
variablesText: JSON.stringify({ photo: "assets/plate.png" }),
|
||||
});
|
||||
|
||||
expect([...fileMap.keys()].sort()).toEqual([
|
||||
"assets/plate.png",
|
||||
"assets/small.txt",
|
||||
"index.html",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("a dry run (no upload)", () => {
|
||||
const opts = { apiBaseUrl: "https://api.example" };
|
||||
const files = { "index.html": html(`<video src="clip.mp4"></video>`), "clip.mp4": HEAVY };
|
||||
@@ -456,7 +480,7 @@ describe("offloadHeavyMedia", () => {
|
||||
|
||||
expect(prepared?.equals(nested)).toBe(true);
|
||||
expect(fileMap.has("compositions/logo.gif")).toBe(false);
|
||||
expect(fileMap.has("logo.gif")).toBe(true);
|
||||
expect(fileMap.has("logo.gif")).toBe(false);
|
||||
});
|
||||
|
||||
it("hashes and encodes media over 4 GiB from disk, never reading it whole", async () => {
|
||||
@@ -704,11 +728,12 @@ describe("offloadHeavyMedia", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a heavy file no reference names in the zip, since --variables can pick it at render time", async () => {
|
||||
it("keeps a heavy file only a --variables value names, and drops the one nothing names", async () => {
|
||||
const dir = project({
|
||||
"index.html": html(`<video data-var-src="clip" src="media/a.mp4"></video>`),
|
||||
"media/a.mp4": HEAVY,
|
||||
"media/b.mp4": HEAVY,
|
||||
"media/c.mp4": HEAVY,
|
||||
});
|
||||
const fileMap = buildPublishFileMap(dir);
|
||||
const upload = fakeUpload();
|
||||
@@ -717,11 +742,13 @@ describe("offloadHeavyMedia", () => {
|
||||
apiBaseUrl: "https://api.example",
|
||||
upload,
|
||||
cachePath: cachePath(),
|
||||
variablesText: JSON.stringify({ clip: "media/b.mp4" }),
|
||||
});
|
||||
|
||||
expect(report.map((item) => item.path)).toEqual(["media/a.mp4"]);
|
||||
expect(upload).toHaveBeenCalledTimes(1);
|
||||
expect(fileMap.has("media/b.mp4")).toBe(true);
|
||||
expect(fileMap.has("media/c.mp4")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a heavy file a script builds from split strings in the zip", async () => {
|
||||
|
||||
@@ -165,6 +165,7 @@ export interface OffloadOptions {
|
||||
/** Overrides the renderer's GIF preparation for tests. */
|
||||
gifPrep?: GifPrep;
|
||||
onFile?: (item: OffloadedMedia) => void;
|
||||
variablesText?: string;
|
||||
}
|
||||
|
||||
type MediaKind = "video" | "audio" | "image";
|
||||
@@ -714,10 +715,11 @@ export async function offloadHeavyMedia(
|
||||
};
|
||||
const urls = new Map<string, string>();
|
||||
const report: OffloadedMedia[] = [];
|
||||
let referenced = new Set<string>();
|
||||
try {
|
||||
await prepareEntryGifs(ctx, fileMap);
|
||||
// Only files a reference points at: one used through render-time --variables must stay in the zip.
|
||||
const referenced = referencedFiles(ctx.absDir, fileMap);
|
||||
referenced = referencedFiles(ctx.absDir, fileMap);
|
||||
for (const [path, file] of [...fileMap].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!referenced.has(path)) continue;
|
||||
const item = await offloadFile(ctx, path, file);
|
||||
@@ -733,9 +735,27 @@ export async function offloadHeavyMedia(
|
||||
|
||||
if (urls.size > 0) rewriteReferences(ctx.absDir, fileMap, (key) => urls.get(key) ?? null);
|
||||
dropReplacedFiles(fileMap, report, ctx.preparedGifSources, [...urls.values()]);
|
||||
dropUnusedHeavyFiles(ctx, referenced, [...urls.values()]);
|
||||
return report;
|
||||
}
|
||||
|
||||
function dropUnusedHeavyFiles(
|
||||
ctx: OffloadContext,
|
||||
referenced: Set<string>,
|
||||
assetUrls: string[],
|
||||
): void {
|
||||
const { fileMap } = ctx;
|
||||
const withoutUrls = (text: string) =>
|
||||
assetUrls.reduce((acc, url) => acc.replaceAll(url, ""), text);
|
||||
const code = withoutUrls(scriptCode(fileMap));
|
||||
const variables = ctx.opts.variablesText ?? "";
|
||||
for (const [path, file] of [...fileMap]) {
|
||||
if (referenced.has(path) || stagedSize(file) < OFFLOAD_MIN_BYTES) continue;
|
||||
if (variables.includes(basename(path))) continue;
|
||||
if (!isStillMentioned(fileMap, path, code, withoutUrls)) fileMap.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
function dropReplacedFiles(
|
||||
fileMap: Map<string, StagedFile>,
|
||||
report: OffloadedMedia[],
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
assertArchiveFits,
|
||||
resolveCloudFpsArg,
|
||||
resolveAspectRatioForSubmit,
|
||||
validateDryRunSource,
|
||||
type ProjectInputSource,
|
||||
@@ -35,6 +36,30 @@ function writeComposition(width: number, height: number): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("resolveCloudFpsArg", () => {
|
||||
const withFps = (fps: string) => {
|
||||
const dir = writeComposition(1920, 1080);
|
||||
writeFileSync(
|
||||
join(dir, "index.html"),
|
||||
`<!doctype html><html><body><div data-composition-id="main" data-fps="${fps}" data-width="1920" data-height="1080"></div></body></html>`,
|
||||
);
|
||||
return { kind: "dir", dir } as const;
|
||||
};
|
||||
|
||||
it("reads the root data-fps when --fps is absent", () => {
|
||||
expect(resolveCloudFpsArg(withFps("24"), undefined, undefined)).toBe("24");
|
||||
});
|
||||
it("lets an explicit --fps win", () => {
|
||||
expect(resolveCloudFpsArg(withFps("24"), "60", undefined)).toBe("60");
|
||||
});
|
||||
it("leaves the server default when data-fps is absent or fractional", () => {
|
||||
expect(
|
||||
resolveCloudFpsArg({ kind: "dir", dir: writeComposition(1, 1) }, undefined, undefined),
|
||||
).toBeUndefined();
|
||||
expect(resolveCloudFpsArg(withFps("29.97"), undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDryRunSource", () => {
|
||||
it("accepts a local directory and rejects already-uploaded sources", () => {
|
||||
expect(() => validateDryRunSource({ kind: "dir", dir: "." }, true)).not.toThrow();
|
||||
@@ -147,6 +172,11 @@ describe("cloud render --dry-run with heavy media", () => {
|
||||
describe("cloud render --dry-run with a file over 4 GiB left in the zip", () => {
|
||||
it("reports it as over the limit without reading it", () => {
|
||||
const dir = writeComposition(1920, 1080);
|
||||
// A mention nothing can rewrite keeps the file in the zip.
|
||||
writeFileSync(
|
||||
join(dir, "index.html"),
|
||||
`<!-- raw/take.mov -->${readFileSync(join(dir, "index.html"), "utf-8")}`,
|
||||
);
|
||||
mkdirSync(join(dir, "raw"));
|
||||
writeFileSync(join(dir, "raw", "take.mov"), "");
|
||||
truncateSync(join(dir, "raw", "take.mov"), 2 ** 32 + 1);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "../../cloud/detectAspectRatio.js";
|
||||
import { c } from "../../ui/colors.js";
|
||||
import { errorBox, formatBytes, formatDuration } from "../../ui/format.js";
|
||||
import { resolveDefaultFpsArg } from "../../utils/renderArgs.js";
|
||||
import { resolveProject, type ProjectDir } from "../../utils/project.js";
|
||||
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
||||
import {
|
||||
@@ -119,7 +120,10 @@ export default defineCommand({
|
||||
meta: { name: "render", description: "Render a HyperFrames composition in the cloud" },
|
||||
args: {
|
||||
dir: { type: "positional", required: false, description: "Project directory (default: .)" },
|
||||
fps: { type: "string", description: "Frames per second (1-240). Default: 30." },
|
||||
fps: {
|
||||
type: "string",
|
||||
description: "Frames per second (1-240). Default: the composition's root data-fps, else 30.",
|
||||
},
|
||||
quality: { type: "string", description: "draft | standard | high (default: standard)" },
|
||||
format: { type: "string", description: "mp4 | webm | mov (default: mp4)" },
|
||||
"page-motion-blur": {
|
||||
@@ -220,7 +224,6 @@ export default defineCommand({
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const asJson = Boolean(args.json);
|
||||
const fps = parseIntFlag(args.fps, { flag: "--fps", min: 1, max: 240 });
|
||||
const quality = parseEnumFlag(args.quality, VALID_QUALITY, { flag: "--quality" });
|
||||
const format = parseEnumFlag(args.format, VALID_FORMAT, { flag: "--format" });
|
||||
const pageMotionBlur = parseEnumFlag(args["page-motion-blur"], ["off"] as const, {
|
||||
@@ -246,6 +249,12 @@ export default defineCommand({
|
||||
});
|
||||
validateDryRunSource(project, args["dry-run"] ?? false);
|
||||
|
||||
const fps = parseIntFlag(resolveCloudFpsArg(project, args.fps, args.composition), {
|
||||
flag: "--fps",
|
||||
min: 1,
|
||||
max: 240,
|
||||
});
|
||||
|
||||
// Aspect ratio is derived from the composition's authored dimensions: for
|
||||
// a local dir we parse the entry HTML and auto-detect, so the user rarely
|
||||
// needs --aspect-ratio at all. When they DO pass it, we validate it
|
||||
@@ -277,8 +286,12 @@ export default defineCommand({
|
||||
const client = await createCloudClient();
|
||||
const preparedArchive =
|
||||
project.kind === "dir"
|
||||
? await prepareLocalArchive(project, args.composition, asJson, (file) =>
|
||||
uploadAssetViaDirectUpload({ client, ...file }),
|
||||
? await prepareLocalArchive(
|
||||
project,
|
||||
args.composition,
|
||||
asJson,
|
||||
(file) => uploadAssetViaDirectUpload({ client, ...file }),
|
||||
variables && JSON.stringify(variables),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
@@ -590,6 +603,7 @@ async function prepareLocalArchive(
|
||||
composition: string | undefined,
|
||||
asJson: boolean,
|
||||
upload?: OffloadOptions["upload"],
|
||||
variablesText?: string,
|
||||
): Promise<PreparedLocalArchive> {
|
||||
const project = resolveProject(source.dir);
|
||||
if (!asJson) {
|
||||
@@ -597,7 +611,7 @@ async function prepareLocalArchive(
|
||||
console.log(`${c.accent("◆")} Zipping ${c.accent(project.name)}`);
|
||||
}
|
||||
const { fileMap, entryPath } = stageProjectFiles(project, composition);
|
||||
const offloaded = await offloadMedia(project, fileMap, entryPath, asJson, upload);
|
||||
const offloaded = await offloadMedia(project, fileMap, entryPath, asJson, upload, variablesText);
|
||||
|
||||
// Media left on disk barely compresses: past the limit, its size is the report and nothing is read.
|
||||
const onDiskBytes = [...fileMap.values()]
|
||||
@@ -656,6 +670,7 @@ async function offloadMedia(
|
||||
entryPath: string,
|
||||
asJson: boolean,
|
||||
upload: OffloadOptions["upload"],
|
||||
variablesText?: string,
|
||||
): Promise<OffloadedMedia[]> {
|
||||
let offloaded: OffloadedMedia[];
|
||||
try {
|
||||
@@ -663,6 +678,7 @@ async function offloadMedia(
|
||||
apiBaseUrl: resolveCloudBaseUrl(),
|
||||
upload,
|
||||
entryPath,
|
||||
variablesText,
|
||||
onFile: (item) =>
|
||||
(asJson ? console.error : console.log)(c.dim(` ${describeOffload(item, !upload)}`)),
|
||||
});
|
||||
@@ -681,6 +697,19 @@ async function offloadMedia(
|
||||
return offloaded;
|
||||
}
|
||||
|
||||
/** --fps, else the entry's root data-fps (the owner local render uses), else the server default. */
|
||||
export function resolveCloudFpsArg(
|
||||
source: ProjectInputSource,
|
||||
fps: string | undefined,
|
||||
composition: string | undefined,
|
||||
): string | undefined {
|
||||
if (source.kind !== "dir") return fps;
|
||||
const { dir } = resolveProject(source.dir);
|
||||
const resolved = resolveDefaultFpsArg(fps, dir, resolvePath(dir, "index.html"), composition);
|
||||
// The cloud API takes whole frame rates; a fractional data-fps (29.97) leaves it to the server.
|
||||
return resolved !== undefined && /^\d+$/.test(resolved) ? resolved : fps;
|
||||
}
|
||||
|
||||
export function assertArchiveFits(sizeBytes: number, largestFiles: ArchiveFileSummary[]): void {
|
||||
if (sizeBytes <= DIRECT_UPLOAD_LIMIT_BYTES) return;
|
||||
errorBox(
|
||||
|
||||
Reference in New Issue
Block a user