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(