diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 21681fca8cb..a6542ec5f11 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1346,6 +1346,7 @@ Quality and file size: | `--video-frame-format` | `auto` | How source video frames are extracted: `auto`, `jpg`, `png`. Use `png` for UI recordings, screen captures, and other colour-sensitive footage. | | `--hdr` | off | Force HDR even with no HDR sources. MP4 only. See [HDR rendering](/guides/hdr). | | `--sdr` | off | Force SDR even when HDR sources are detected | +| `--page-motion-blur` | authored setting | Pass `off` to disable page motion blur without editing the composition. Applies to local, batch and Docker renders. Omit to preserve authored behavior. | How it uses the machine, and when it gives up: diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 47b3398c61d..9e16ae5504d 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -1220,6 +1220,23 @@ describe("renderLocal browser GPU config", () => { expect(producerState.createdJobs[0]?.variables).toBeUndefined(); }); + it.each([undefined, "off"] as const)( + "preserves page motion blur %s in the producer request", + async (pageMotionBlur) => { + await renderLocal(PROJECT_DIR, "/tmp/out.mp4", { + fps: { num: 30, den: 1 }, + quality: "standard", + format: "mp4", + gpu: false, + browserGpuMode: "software", + hdrMode: "auto", + quiet: true, + pageMotionBlur, + }); + expect(producerState.createdJobs[0]?.pageMotionBlur).toBe(pageMotionBlur); + }, + ); + it("forwards entryFile to createRenderJob when --composition is set", async () => { await renderLocal(PROJECT_DIR, "/tmp/out.mp4", { fps: { num: 30, den: 1 }, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 42f5a48f4ea..30e3fcbdfab 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -215,6 +215,11 @@ export default defineCommand({ "alpha-capable sources always extract as PNG.", default: "auto", }, + "page-motion-blur": { + type: "string", + description: + "Set off to disable authored page motion blur. Omit to keep the composition's setting.", + }, workers: { type: "string", alias: "w", @@ -503,6 +508,7 @@ export interface RenderOptions { vp9CpuUsed?: number; videoBitrate?: string; videoFrameFormat?: VideoFrameFormat; + pageMotionBlur?: "off"; quiet: boolean; debug?: boolean; /** Segmented capture: reuse a prior run's validated segments. */ @@ -832,6 +838,7 @@ async function renderDocker( vp9CpuUsed: options.vp9CpuUsed, videoBitrate: options.videoBitrate, videoFrameFormat: options.videoFrameFormat, + pageMotionBlur: options.pageMotionBlur, quiet: options.quiet, variables: options.variables, entryFile: options.entryFile, @@ -1110,6 +1117,7 @@ async function executeLocalRender( crf: options.crf, videoBitrate: options.videoBitrate, videoFrameFormat: options.videoFrameFormat, + pageMotionBlur: options.pageMotionBlur, variables: options.variables, entryFile: options.entryFile, outputResolution: options.outputResolution, diff --git a/packages/cli/src/commands/render/execute.test.ts b/packages/cli/src/commands/render/execute.test.ts index 33b05ef2422..a845543e65c 100644 --- a/packages/cli/src/commands/render/execute.test.ts +++ b/packages/cli/src/commands/render/execute.test.ts @@ -25,6 +25,15 @@ describe("renderOptionsFromPlan", () => { rmSync(projectDir, { recursive: true, force: true }); }); + it.each([undefined, "off"] as const)( + "preserves page motion blur %s for single and batch renders", + (value) => { + const plan = createRenderPlan({ dir: projectDir, "page-motion-blur": value }); + expect(renderOptionsFromPlan(plan, undefined, undefined).pageMotionBlur).toBe(value); + expect(batchRowRenderOptions(plan, undefined).pageMotionBlur).toBe(value); + }, + ); + it("carries the segmented resume flags from the plan into the render options", () => { // The hop that once dropped --resume / --keep-segments silently; the plan // -> options -> request -> config chain is otherwise only exercised by a diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index fae94c1c997..555fa85465c 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -153,6 +153,7 @@ export function renderOptionsFromPlan( vp9CpuUsed: plan.vp9CpuUsed, videoBitrate: plan.videoBitrate, videoFrameFormat: plan.videoFrameFormat, + pageMotionBlur: plan.pageMotionBlur, quiet: plan.quiet, browserPath, debug: plan.debug, @@ -357,6 +358,7 @@ export function batchRowRenderOptions( vp9CpuUsed: plan.vp9CpuUsed, videoBitrate: plan.videoBitrate, videoFrameFormat: plan.videoFrameFormat, + pageMotionBlur: plan.pageMotionBlur, quiet: batchQuiet, browserPath, entryFile: plan.entryFile, diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index 632813a9f5c..0ff9487a5cf 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -115,6 +115,18 @@ describe("createRenderPlan", () => { expect(plan.bestEffort).toBe(false); }); + it.each([undefined, "off"])("preserves page motion blur override %s", (value) => { + expect(createRenderPlan({ dir: projectDir, "page-motion-blur": value }).pageMotionBlur).toBe( + value, + ); + }); + + it.each(["", "on", "false", "OFF"])("rejects unsupported page motion blur value %s", (value) => { + expect(() => createRenderPlan({ dir: projectDir, "page-motion-blur": value })).toThrow( + CliUsageError, + ); + }); + it("preserves an aspect-agnostic resolution alias through the execution plan", () => { const plan = createRenderPlan({ dir: projectDir, resolution: "1080p" }); expect(plan.outputResolution).toBe("landscape"); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 4095ea99dd0..dfc0e0bda91 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -18,6 +18,7 @@ import { type VideoFrameFormat, } from "@hyperframes/engine"; import { errorBox } from "../../ui/format.js"; +import { parseEnumFlag } from "../../cloud/parsing.js"; import { failUsage } from "../../utils/commandResult.js"; import { resolveProject } from "../../utils/project.js"; import { @@ -73,6 +74,7 @@ export interface RenderCommandArgs { "gif-loop"?: string; "hls-segment-seconds"?: string; "video-frame-format"?: string; + "page-motion-blur"?: string; workers?: string; docker?: boolean; hdr?: boolean; @@ -126,6 +128,7 @@ export interface RenderPlan { /** HLS target segment length in seconds; only set for `format: "hls"`. */ hlsSegmentSeconds?: number; videoFrameFormat: VideoFrameFormat; + pageMotionBlur?: "off"; outputResolution?: CanvasResolution; outputResolutionAspectAgnostic: boolean; outputResolutionRaw?: string; @@ -224,6 +227,9 @@ function resolveHfEnvOverrides(): readonly string[] { /** Parse and validate command input into an immutable execution plan. */ // fallow-ignore-next-line complexity export function createRenderPlan(args: RenderCommandArgs, now = new Date()): RenderPlan { + const pageMotionBlur = parseEnumFlag(args["page-motion-blur"], ["off"] as const, { + flag: "--page-motion-blur", + }); const hfEnvOverrides = resolveHfEnvOverrides(); const hasExplicitComposition = hasExplicitCompositionArg(args.composition); const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition }); @@ -525,6 +531,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren gifFpsCapped, hlsSegmentSeconds, videoFrameFormat: videoFrameFormatRaw, + pageMotionBlur, outputResolution, outputResolutionAspectAgnostic, outputResolutionRaw: args.resolution, diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 497bad2ecb3..c3e27eeca1e 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -29,6 +29,18 @@ const FIXED_INPUT = { }; describe("buildDockerRunArgs", () => { + it("preserves an explicit page motion blur override inside Docker", () => { + const args = buildDockerRunArgs({ + ...FIXED_INPUT, + options: { ...BASE, pageMotionBlur: "off" }, + }); + expect( + args.slice(args.indexOf("--page-motion-blur"), args.indexOf("--page-motion-blur") + 2), + ).toEqual(["--page-motion-blur", "off"]); + expect(buildDockerRunArgs({ ...FIXED_INPUT, options: BASE })).not.toContain( + "--page-motion-blur", + ); + }); it("matches snapshot for the default render", () => { expect(buildDockerRunArgs({ ...FIXED_INPUT, options: BASE })).toMatchInlineSnapshot(` [ diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index a1770189ae5..7887d17bb79 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -51,6 +51,7 @@ export interface DockerRenderOptions { vp9CpuUsed?: number; videoBitrate?: string; videoFrameFormat?: "auto" | "jpg" | "png"; + pageMotionBlur?: "off"; quiet: boolean; debug?: boolean; bestEffort?: boolean; @@ -145,6 +146,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { ...(options.videoFrameFormat && options.videoFrameFormat !== "auto" ? ["--video-frame-format", options.videoFrameFormat] : []), + ...(options.pageMotionBlur !== undefined ? ["--page-motion-blur", options.pageMotionBlur] : []), ...(options.quiet ? ["--quiet"] : []), ...(options.debug ? ["--debug"] : []), // The in-container CLI is best-effort by default. Only forward the diff --git a/packages/producer/src/services/render/segmentManifest.test.ts b/packages/producer/src/services/render/segmentManifest.test.ts index 5ac7ada2680..95ed19fa406 100644 --- a/packages/producer/src/services/render/segmentManifest.test.ts +++ b/packages/producer/src/services/render/segmentManifest.test.ts @@ -31,10 +31,16 @@ const hashInput: SegmentPlanHashInput = { outputWidth: 1920, outputHeight: 1080, motionBlur: "", + pageMotionBlur: undefined, captureQuality: 80, }; describe("computeSegmentPlanHash", () => { + it("does not reuse authored-blur segments when page blur is explicitly off", () => { + expect(computeSegmentPlanHash({ ...hashInput, pageMotionBlur: "off" })).not.toBe( + computeSegmentPlanHash(hashInput), + ); + }); it("is stable and 16 hex chars", () => { const a = computeSegmentPlanHash(hashInput); expect(a).toMatch(/^[0-9a-f]{16}$/); diff --git a/packages/producer/src/services/render/segmentManifest.ts b/packages/producer/src/services/render/segmentManifest.ts index 03d2bf67953..20812534c30 100644 --- a/packages/producer/src/services/render/segmentManifest.ts +++ b/packages/producer/src/services/render/segmentManifest.ts @@ -48,6 +48,7 @@ export interface SegmentPlanHashInput { outputHeight: number; /** Serialised motion-blur options, or "" when off; sample count and shutter change every pixel. */ motionBlur: string; + pageMotionBlur: "off" | undefined; captureQuality: number | undefined; } @@ -71,6 +72,7 @@ export function computeSegmentPlanHash(input: SegmentPlanHashInput): string { input.useGpu ? "gpu" : "cpu", `${input.outputWidth}x${input.outputHeight}`, input.motionBlur, + input.pageMotionBlur ?? "", input.captureQuality === undefined ? "" : String(input.captureQuality), ].join(" "); return createHash("sha256").update(ordered).digest("hex").slice(0, 16); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 550619b47dd..daaeb93a23a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -4493,6 +4493,7 @@ async function executeRenderPipeline(input: { outputWidth: captureCompositionWidth ?? width, outputHeight: captureCompositionHeight ?? height, motionBlur: job.config.motionBlur ? JSON.stringify(job.config.motionBlur) : "", + pageMotionBlur: job.config.pageMotionBlur, captureQuality: captureOptions.quality, }); const segmentDir = segmentDirFor(join(projectDir, "renders"), segmentPlanHash);