diff --git a/packages/cli/src/cloud/offload.test.ts b/packages/cli/src/cloud/offload.test.ts
index 1c90689e9e..8914782f9e 100644
--- a/packages/cli/src/cloud/offload.test.ts
+++ b/packages/cli/src/cloud/offload.test.ts
@@ -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(``),
+ "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(``), "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(``),
"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 () => {
diff --git a/packages/cli/src/cloud/offload.ts b/packages/cli/src/cloud/offload.ts
index b96128f6dc..4a560f2dbb 100644
--- a/packages/cli/src/cloud/offload.ts
+++ b/packages/cli/src/cloud/offload.ts
@@ -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();
const report: OffloadedMedia[] = [];
+ let referenced = new Set();
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,
+ 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,
report: OffloadedMedia[],
diff --git a/packages/cli/src/commands/cloud/render.test.ts b/packages/cli/src/commands/cloud/render.test.ts
index 959af83f03..5c42b65266 100644
--- a/packages/cli/src/commands/cloud/render.test.ts
+++ b/packages/cli/src/commands/cloud/render.test.ts
@@ -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"),
+ ``,
+ );
+ 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"),
+ `${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);
diff --git a/packages/cli/src/commands/cloud/render.ts b/packages/cli/src/commands/cloud/render.ts
index 576f499860..2d6fbbcb83 100644
--- a/packages/cli/src/commands/cloud/render.ts
+++ b/packages/cli/src/commands/cloud/render.ts
@@ -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 {
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 {
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(