feat(cli): preserve page motion blur off in local renders (#6081)

This commit is contained in:
Miguel Ángel
2026-10-22 19:30:16 +00:00
committed by GitHub
parent 3d67fb9d10
commit 10c724bf55
12 changed files with 79 additions and 0 deletions
+1
View File
@@ -1448,6 +1448,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:
+17
View File
@@ -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 },
+8
View File
@@ -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,
@@ -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
@@ -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,
@@ -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");
+7
View File
@@ -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,
@@ -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(`
[
+2
View File
@@ -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
@@ -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}$/);
@@ -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);
@@ -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);