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:
Miguel Ángel
2026-10-31 21:04:00 +00:00
committed by GitHub
parent 88f9ae8468
commit ee50cd2fc0
4 changed files with 115 additions and 9 deletions
+29 -2
View File
@@ -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 () => {
+21 -1
View File
@@ -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[],
+31 -1
View File
@@ -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);
+34 -5
View File
@@ -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(