Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,7 @@ Quality and file size:
| `--crf` | from `--quality`| Override MP4/WebM encoder CRF, 0–51. Lower is better quality. Not with `--video-bitrate`; rejected for MOV. |
| `--video-bitrate` | from `--quality`| Target MP4/WebM bitrate, e.g. `10M` or `5000k`. Not with `--crf`; rejected for MOV. |
| `--keyframe-interval` | encoder default | MP4 only. A keyframe at least every N seconds, e.g. `2`, so a player can seek or switch rendition at that spacing. |
| `--start`, `--end` | whole film | Render only the seconds between them, as an MP4 that starts at 0 with that span's audio. MP4 at 24, 25, 30 or 60 fps, SDR only. |
| `--max-bitrate` | uncapped | MP4 only. Peak video bitrate cap, e.g. `8M`, held alongside the `--crf` or `--quality` target rather than replacing it. |
| `--buffer-size` | 2 × `--max-bitrate` | MP4 only. Buffer the cap is measured over, e.g. `16M`. Needs `--max-bitrate`. |
| `--vp9-cpu-used` | encoder default | libvpx-vp9 speed/quality trade-off for WebM, -8 to 8. Env: `PRODUCER_VP9_CPU_USED`. |
Expand Down
83 changes: 82 additions & 1 deletion packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { failCommand, requestCliExit, scheduleCliExit } from "../utils/commandRe
import { defineCommand } from "../utils/defineCommand.js";
import type { Example } from "./_examples.js";
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
import {
createRenderPlan,
resolveBrowserGpuForCli,
type RenderFormat,
type RenderRange,
} from "./render/plan.js";
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
import { deliveryFlagArgs } from "../utils/deliveryFlags.js";
import type { CatalogUsage } from "../utils/catalogUsage.js";
Expand Down Expand Up @@ -263,6 +268,16 @@ export default defineCommand({
description:
"Override MP4/WebM encoder CRF. Mutually exclusive with --video-bitrate; unsupported for MOV.",
},
start: {
type: "string",
description:
"Render from this second of the film (MP4). The clip starts at 0 and carries the audio of the span.",
},
end: {
type: "string",
description:
"Render up to this second of the film (default: the end). Needs MP4, a whole frame rate of 24, 25, 30 or 60, and SDR.",
},
"video-bitrate": {
type: "string",
description:
Expand Down Expand Up @@ -526,6 +541,7 @@ export interface RenderOptions {
vp9CpuUsed?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
range?: RenderRange;
proresProfile?: ProresProfile;
maxBitrate?: string;
bufferSize?: string;
Expand Down Expand Up @@ -861,6 +877,7 @@ async function renderDocker(
vp9CpuUsed: options.vp9CpuUsed,
videoBitrate: options.videoBitrate,
keyframeIntervalSeconds: options.keyframeIntervalSeconds,
range: options.range,
proresProfile: options.proresProfile,
maxBitrate: options.maxBitrate,
bufferSize: options.bufferSize,
Expand Down Expand Up @@ -1160,8 +1177,24 @@ async function executeLocalRender(
resumeSegments: options.resumeSegments,
keepSegments: options.keepSegments,
strictness: options.bestEffort === false ? "strict" : "best-effort",
// plan() takes the film's own size; these only satisfy the request shape.
distributed: options.range ? { width: 1920, height: 1080 } : undefined,
Comment thread
heygengenesis[bot] marked this conversation as resolved.
Resolved
},
});
if (options.range) {
return renderRange({
producer,
request,
range: options.range,
projectDir,
outputPath,
options,
logger,
startTime,
cpu,
signal: cancellation.signal,
});
}
const job = producer.createRenderJob(producer.renderConfigFromRequest(request, { logger }));

const onProgress = options.quiet
Expand Down Expand Up @@ -1261,6 +1294,54 @@ async function executeLocalRender(
};
}

async function renderRange(input: {
producer: Awaited<ReturnType<typeof loadProducer>>;
request: ReturnType<Awaited<ReturnType<typeof loadProducer>>["createRenderRequest"]>;
range: RenderRange;
projectDir: string;
outputPath: string;
options: RenderOptions;
logger: ProducerLogger;
startTime: number;
cpu: RenderCpuSampler;
signal: AbortSignal;
}): Promise<SingleRenderResult> {
const { producer, options } = input;
let framesEncoded = 0;
try {
const config = producer.distributedConfigFromRequest(input.request, {
logger: input.logger,
abortSignal: input.signal,
});
({ framesEncoded } = await producer.renderSpan(
input.projectDir,
config,
input.range,
input.outputPath,
));
} catch (error: unknown) {
handleRenderError(error, options, input.startTime, input.cpu, false, "");
}
markRenderSucceeded();
const elapsed = Date.now() - input.startTime;
input.cpu.stop();
printRenderComplete({
outputPath: input.outputPath,
projectDir: input.projectDir,
desktopHint: wantsDesktopHint(options),
elapsedMs: elapsed,
quiet: options.quiet,
format: options.format,
});
if (options.exitAfterComplete) scheduleCliExit();
return {
renderTimeMs: elapsed,
durationMs: Math.round((framesEncoded / fpsToNumber(options.fps)) * 1000),
outcome: "completed",
warnings: [],
};
}

function getMemorySnapshot() {
return {
peakMemoryMb: bytesToMb(process.memoryUsage.rss()),
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/render/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function renderOptionsFromPlan(
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
keyframeIntervalSeconds: plan.keyframeIntervalSeconds,
range: plan.range,
proresProfile: plan.proresProfile,
maxBitrate: plan.maxBitrate,
bufferSize: plan.bufferSize,
Expand Down Expand Up @@ -376,6 +377,7 @@ export function batchRowRenderOptions(
vp9CpuUsed: plan.vp9CpuUsed,
videoBitrate: plan.videoBitrate,
keyframeIntervalSeconds: plan.keyframeIntervalSeconds,
range: plan.range,
proresProfile: plan.proresProfile,
maxBitrate: plan.maxBitrate,
bufferSize: plan.bufferSize,
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/commands/render/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,30 @@ describe("createRenderPlan", () => {
}
});

it("carries --start and --end into the plan, and refuses what a range cannot render", () => {
expect(createRenderPlan({ dir: projectDir, start: "2.5", end: "6" }).range).toEqual({
startSeconds: 2.5,
endSeconds: 6,
});
expect(createRenderPlan({ dir: projectDir, end: "4" }).range).toEqual({
startSeconds: 0,
endSeconds: 4,
});
expect(createRenderPlan({ dir: projectDir }).range).toBeUndefined();
const refused: Array<Record<string, string | boolean>> = [
{ start: "-1" },
{ start: "abc" },
{ start: "5", end: "5" },
{ start: "1", format: "webm" },
{ start: "1", fps: "29.97" },
{ start: "1", fps: "50" },
{ start: "1", hdr: true },
];
for (const flags of refused) {
expect(() => createRenderPlan({ dir: projectDir, ...flags })).toThrow(CliUsageError);
}
});

it("rejects batch and single-render variables before execution", () => {
expect(() =>
createRenderPlan({ dir: projectDir, batch: "rows.json", variables: '{"name":"Ada"}' }),
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/commands/render/plan.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import {
DISTRIBUTED_FPS,
formatRenderOutputTimestamp,
fpsToNumber,
isAspectAgnosticResolutionAlias,
isDistributedFps,
normalizeResolutionFlag,
parseFps,
type CanvasResolution,
Expand Down Expand Up @@ -84,6 +86,8 @@ export interface RenderCommandArgs {
hdr?: boolean;
sdr?: boolean;
crf?: string;
start?: string;
end?: string;
"video-bitrate"?: string;
"keyframe-interval"?: string;
"max-bitrate"?: string;
Expand Down Expand Up @@ -168,6 +172,7 @@ export interface RenderPlan {
vp9CpuUsed?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
range?: RenderRange;
proresProfile?: ProresProfile;
maxBitrate?: string;
bufferSize?: string;
Expand Down Expand Up @@ -207,6 +212,53 @@ function parseRenderFormat(input: string): RenderFormat | undefined {
return RENDER_FORMATS.find((format) => format === input);
}

export interface RenderRange {
startSeconds: number;
endSeconds?: number;
}

function parseRenderRange(
args: Pick<RenderCommandArgs, "start" | "end">,
format: RenderFormat,
fps: Fps,
hdr: boolean | undefined,
): RenderRange | undefined {
if (args.start === undefined && args.end === undefined) return undefined;
const seconds = (flag: string, raw: string): number => {
const parsed = raw.trim() === "" ? Number.NaN : Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) {
errorBox(`Invalid ${flag}`, `Got "${raw}". Must be a number of seconds, 0 or more.`);
failUsage();
}
return parsed;
};
const startSeconds = args.start === undefined ? 0 : seconds("--start", args.start);
const endSeconds = args.end === undefined ? undefined : seconds("--end", args.end);
if (endSeconds !== undefined && endSeconds <= startSeconds) {
errorBox("Invalid range", `--end (${endSeconds}) must be after --start (${startSeconds}).`);
failUsage();
}
const limit = rangeLimit(format, fps, hdr);
if (limit) {
errorBox(
"A range renders what chunked renders can",
limit,
"Remove --start/--end to render the whole film.",
);
failUsage();
}
return { startSeconds, endSeconds };
}

function rangeLimit(format: RenderFormat, fps: Fps, hdr: boolean | undefined): string | undefined {
if (format !== "mp4") return `--start/--end render MP4 only, not ${format}.`;
if (fps.den !== 1 || !isDistributedFps(fps.num)) {
return `--start/--end need a whole frame rate in ${DISTRIBUTED_FPS.join(", ")}, not ${fps.num}/${fps.den}.`;
}
if (hdr) return "--start/--end render SDR only, not --hdr.";
return undefined;
}

function positiveInteger(raw: string, title: string, message: string, min = 1): number {
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < min) {
Expand Down Expand Up @@ -545,6 +597,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
failUsage();
}

const range = parseRenderRange(args, format, fps, args.hdr);
const quiet = args.quiet ?? false;
const batchJson = args.json ?? false;
return Object.freeze({
Expand Down Expand Up @@ -592,6 +645,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
vp9CpuUsed,
videoBitrate,
...delivery,
range,
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface DockerRenderOptions {
vp9CpuUsed?: number;
videoBitrate?: string;
keyframeIntervalSeconds?: number;
range?: { startSeconds: number; endSeconds?: number };
proresProfile?: string;
maxBitrate?: string;
bufferSize?: string;
Expand Down Expand Up @@ -148,6 +149,13 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.vp9CpuUsed != null ? ["--vp9-cpu-used", String(options.vp9CpuUsed)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
...(options.proresProfile ? ["--prores-profile", options.proresProfile] : []),
...(options.range
? [
"--start",
String(options.range.startSeconds),
...(options.range.endSeconds != null ? ["--end", String(options.range.endSeconds)] : []),
]
: []),
...(options.keyframeIntervalSeconds != null
? ["--keyframe-interval", String(options.keyframeIntervalSeconds)]
: []),
Expand Down
1 change: 1 addition & 0 deletions packages/producer/scripts/integration-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"src/services/distributed/planSizeCap.test.ts",
"src/services/distributed/planV2RemoteFiles.test.ts",
"src/services/distributed/renderChunk.test.ts",
"src/services/distributed/renderSpan.integration.test.ts",
"src/services/fileServer.test.ts",
"src/services/fileServer.file-race.test.ts",
"src/services/fileServer.listenError.test.ts",
Expand Down
6 changes: 6 additions & 0 deletions packages/producer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export {
type RenderPerfSummary,
type ProgressCallback,
} from "./services/renderOrchestrator.js";
export {
renderSpan,
RenderSpanUnsupportedError,
spanFrameRange,
type RenderSpanResult,
} from "./services/distributed/renderSpan.js";
export { extractSafeRenderErrorCode } from "./services/safeRenderErrorCode.js";
export {
RENDER_JOB_DIR,
Expand Down
Loading
Loading