+# Revideo Fork: Professional Motion Blur
+
+**This fork adds professional-grade motion blur to Revideo** using temporal sub-frame accumulation - the same technique used in film and professional video production.
+
+
+
+
+
+## Motion Blur Features
+
+### Temporal Sub-Frame Accumulation
+
+The implementation renders multiple sub-frames within each output frame's time window and blends them together with configurable weights. This approach:
+
+- Produces physically accurate motion blur matching real camera behavior
+- Supports any frame rate and resolution
+- Works with all Revideo animations and components
+
+### Quality Presets
+
+| Preset | Samples | Use Case |
+|--------|---------|----------|
+| `low` | 4 | Fast previews, draft renders |
+| `medium` | 8 | Good balance of quality and speed |
+| `high` | 16 | High quality production renders |
+| `ultra` | 32 | Maximum quality |
+
+### Shutter Angle
+
+Controls exposure time as a fraction of frame duration, simulating a rotary disc shutter:
+
+| Angle | Exposure | Effect |
+|-------|----------|--------|
+| 90 degrees | 25% | Minimal blur, staccato motion |
+| 180 degrees | 50% | Film standard, natural motion |
+| 270 degrees | 75% | Pronounced blur |
+| 360 degrees | 100% | Maximum blur, dreamy effect |
+
+### Shutter Curves
+
+Sample weighting distribution for different blur characteristics:
+
+- **Box**: Equal weight for all samples (sharp, defined edges)
+- **Triangle**: Linear falloff from center (softer blur)
+- **Gaussian**: Bell curve distribution (most natural, mimics real cameras)
+
+### Shutter Position
+
+Controls when the blur occurs relative to the frame:
+
+- **Center**: Blur straddles the frame (recommended)
+- **Start**: Blur trails behind (forward/leading motion)
+- **End**: Blur leads ahead (backward/trailing motion)
+
+### Per-Element Motion Blur Control
+
+Individual elements can override the global motion blur setting. This is essential for:
+
+- Keeping text and UI elements sharp
+- Creating visual contrast between static and moving elements
+- Performance optimization
+
+```tsx
+// This element will be blurred (inherits from scene)
+
+
+// This element stays sharp (blur disabled)
+
+```
+
+### Implementation Architecture
+
+The renderer uses a **two-pass approach** for per-element control:
+
+```
+Frame N:
++-- Pass 1: Motion Blur (sub-frame accumulation)
+| +-- Subframe 0: Render blur-enabled elements, accumulate with weight
+| +-- Subframe 1: Advance time, render, accumulate
+| +-- ...
+| +-- Finalize: Normalize accumulated values
+|
++-- Pass 2: Static Elements
+ +-- Render blur-disabled elements on top (no accumulation)
+```
+
+### Usage
+
+```typescript
+// Enable motion blur in your project
+export default makeProject({
+ scenes: [scene],
+ settings: {
+ rendering: {
+ motionBlur: {
+ enabled: true,
+ quality: 'high', // or samples: 16
+ shutterAngle: 180, // degrees (0-720)
+ shutterCurve: 'gaussian', // 'box' | 'triangle' | 'gaussian'
+ shutterPosition: 'center', // 'center' | 'start' | 'end'
+ },
+ },
+ },
+});
+```
+
+See full documentation: [Motion Blur Guide](./docs/MOTION_BLUR.md)
+
+---
+
# Revideo - Create Videos with Code
Revideo is an open source framework for programmatic video editing. It is forked
@@ -129,6 +239,9 @@ Concretely, some of the differences to Motion Canvas are the following ones:
- **Better Audio Support:** We have enabled audio export from `` tags
during rendering, and have also added an `` tag that makes it easy to
synchronize audio with your animations.
+- **Professional Motion Blur:** Realistic motion blur using temporal sub-frame
+ accumulation with quality presets, shutter curves (box/triangle/gaussian),
+ and per-element control. See [Motion Blur Documentation](./docs/MOTION_BLUR.md).
diff --git a/docs/MOTION_BLUR.md b/docs/MOTION_BLUR.md
new file mode 100644
index 000000000..5d7f3e442
--- /dev/null
+++ b/docs/MOTION_BLUR.md
@@ -0,0 +1,345 @@
+# Motion Blur for Revideo
+
+Professional-grade motion blur for programmatic video rendering using temporal sub-frame accumulation.
+
+
+
+
+
+*Left side: Motion blur ON | Right side: Motion blur OFF (per-element control)*
+
+## Overview
+
+Motion blur simulates the blur that occurs in real cameras when objects move during the exposure time. This implementation uses **temporal sub-frame accumulation** - rendering multiple sub-frames at different points in time and blending them together to create realistic motion blur effects.
+
+### Key Features
+
+- **Quality presets** - Low (4), medium (8), high (16), ultra (32) samples
+- **Shutter curves** - Box (uniform), triangle (linear falloff), gaussian (bell curve)
+- **Shutter position** - Center, start, or end alignment
+- **Per-element control** - Enable/disable blur on individual elements
+- **Professional shutter angle** - 0-720° support (film standard is 180°)
+
+## Quick Start
+
+### Basic Usage
+
+Enable motion blur in your project configuration:
+
+```typescript
+import {makeProject} from '@revideo/core';
+import scene from './scenes/my-scene';
+
+export default makeProject({
+ scenes: [scene],
+ settings: {
+ rendering: {
+ motionBlur: {
+ enabled: true,
+ quality: 'high', // 16 samples
+ },
+ },
+ },
+});
+```
+
+### Advanced Configuration
+
+```typescript
+motionBlur: {
+ enabled: true,
+
+ // Quality preset: 'low' (4), 'medium' (8), 'high' (16), 'ultra' (32)
+ // Or set 'samples' directly for custom count
+ quality: 'high',
+
+ // Shutter angle: exposure time in degrees
+ // 180° = standard film (50% of frame exposed)
+ // 360° = full frame exposure (maximum blur)
+ shutterAngle: 180,
+
+ // Shutter curve: weight distribution for samples
+ // 'box' = equal weight (sharp, can show stepping)
+ // 'triangle' = linear falloff from center (softer)
+ // 'gaussian' = bell curve (most natural, like real cameras)
+ shutterCurve: 'gaussian',
+
+ // Shutter position: when blur occurs relative to frame
+ // 'center' = blur straddles the frame (recommended)
+ // 'start' = blur trails behind (forward blur)
+ // 'end' = blur leads ahead (backward blur)
+ shutterPosition: 'center',
+}
+```
+
+## Per-Element Motion Blur Control
+
+You can override motion blur on individual elements. This is useful for:
+- Keeping text and UI elements sharp
+- Creating visual contrast between static and moving elements
+- Performance optimization by only blurring what needs it
+
+### Disabling Blur on Specific Elements
+
+```tsx
+import {Circle, Txt, makeScene2D} from '@revideo/2d';
+
+export default makeScene2D('scene', function* (view) {
+ view.add(
+ <>
+ {/* This circle will have motion blur applied */}
+
+
+ {/* This text will stay sharp (no motion blur) */}
+
+ >
+ );
+});
+```
+
+### Split-Screen Comparison Example
+
+```tsx
+import {Circle, Line, Txt, makeScene2D} from '@revideo/2d';
+import {all, createRef} from '@revideo/core';
+
+export default makeScene2D('comparison', function* (view) {
+ const blurredCircle = createRef();
+ const sharpCircle = createRef();
+
+ view.add(
+ <>
+ {/* Divider line - stays sharp */}
+
+
+ {/* Left side: Motion blur ON (default) */}
+
+
+
+ {/* Right side: Motion blur OFF */}
+
+
+ >
+ );
+
+ // Animate both identically - only left will blur
+ yield* all(
+ blurredCircle().position.x(-100, 0.5).to(-500, 0.5),
+ sharpCircle().position.x(500, 0.5).to(100, 0.5),
+ );
+});
+```
+
+## Configuration Reference
+
+### MotionBlurConfig
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `enabled` | `boolean` | `false` | Enable/disable motion blur |
+| `samples` | `number` | `8` | Number of sub-frames (1-64) |
+| `quality` | `'low' \| 'medium' \| 'high' \| 'ultra'` | - | Quality preset (sets samples) |
+| `shutterAngle` | `number` | `180` | Shutter angle in degrees (0-720) |
+| `shutterCurve` | `'box' \| 'triangle' \| 'gaussian'` | `'box'` | Sample weight distribution |
+| `shutterPosition` | `'center' \| 'start' \| 'end'` | `'center'` | Blur alignment relative to frame |
+| `adaptiveSampling` | `boolean` | `false` | Enable velocity-based sample scaling |
+| `adaptiveMinSamples` | `number` | `2` | Minimum samples when adaptive |
+
+### Quality Presets
+
+| Preset | Samples | Use Case |
+|--------|---------|----------|
+| `low` | 4 | Fast previews, draft renders |
+| `medium` | 8 | Good balance of quality/speed |
+| `high` | 16 | High quality production renders |
+| `ultra` | 32 | Maximum quality, slow |
+
+### Shutter Angle Guide
+
+| Angle | Exposure | Effect |
+|-------|----------|--------|
+| 90° | 25% | Minimal blur, staccato motion |
+| 180° | 50% | Film standard, natural motion |
+| 270° | 75% | Pronounced blur |
+| 360° | 100% | Maximum blur, dreamy effect |
+
+### Shutter Curves
+
+| Curve | Description | Best For |
+|-------|-------------|----------|
+| `box` | Equal weight for all samples | Sharp, defined blur edges |
+| `triangle` | Linear falloff from center | Softer blur, good balance |
+| `gaussian` | Bell curve distribution | Most natural, mimics real cameras |
+
+### Shutter Position
+
+| Position | Description | Visual Effect |
+|----------|-------------|---------------|
+| `center` | Blur straddles the frame | Balanced, recommended |
+| `start` | Blur trails behind | Forward/leading motion |
+| `end` | Blur leads ahead | Backward/trailing motion |
+
+## How It Works
+
+### Temporal Sub-Frame Accumulation
+
+1. **Begin accumulation**: Initialize a high-precision float buffer
+2. **Two-pass rendering**:
+ - **Pass 1 (blur)**: Render blur-enabled elements multiple times at sub-frame positions
+ - **Pass 2 (static)**: Render blur-disabled elements once, composited on top
+3. **Weight and blend**: Each sub-frame is weighted according to the shutter curve
+4. **Finalize**: Convert accumulated values back to standard pixel format
+
+### Per-Element Control Architecture
+
+The renderer uses a two-pass approach:
+
+```
+Frame N:
+├── Pass 1: Motion Blur (sub-frame accumulation)
+│ ├── Subframe 0: Render blur-enabled elements, accumulate
+│ ├── Subframe 1: Advance time, render, accumulate
+│ ├── ...
+│ └── Subframe N: Final accumulation, normalize
+│
+└── Pass 2: Static Elements
+ └── Render blur-disabled elements on top (no accumulation)
+```
+
+This ensures:
+- Elements with `motionBlur={{enabled: false}}` remain perfectly sharp
+- Blurred elements composite naturally behind static elements
+- UI, text, and reference elements stay crisp
+
+## Rendering
+
+### Using the Render Script
+
+```bash
+# From the examples package
+npm run render:motion-blur
+
+# Or with custom output
+node dist/render-motion-blur.js --output my-video.mp4
+```
+
+### Programmatic Rendering
+
+```typescript
+import {renderVideo} from '@revideo/renderer';
+
+await renderVideo({
+ projectFile: './src/motion-blur.ts',
+ output: {
+ file: 'output.mp4',
+ codec: 'h264',
+ },
+ settings: {
+ motionBlur: {
+ enabled: true,
+ quality: 'high',
+ shutterCurve: 'gaussian',
+ },
+ },
+});
+```
+
+## Performance Considerations
+
+- **Sample count directly affects render time**: 16 samples = 16x more scene renders
+- **Use quality presets**: Start with `medium`, increase if needed
+- **Per-element control**: Disable blur on static elements for better performance
+- **Adaptive sampling** (experimental): Reduces samples for slow-moving areas
+
+### Performance Tips
+
+1. Use `quality: 'low'` for previews during development
+2. Only apply motion blur to elements that actually move
+3. Disable blur on text and UI elements
+4. Consider `shutterCurve: 'gaussian'` - looks better with fewer samples
+
+## Troubleshooting
+
+### Motion blur not appearing
+
+1. Ensure `enabled: true` is set in project settings
+2. Check that rendering (not preview) mode is being used
+3. Verify the element is moving fast enough to show visible blur
+
+### Black or missing elements
+
+1. Check that parent containers don't have `motionBlur={{enabled: false}}`
+2. Ensure elements have valid opacity (> 0)
+3. Verify z-order of static vs. blurred elements
+
+### Stepping artifacts visible
+
+1. Increase sample count (`quality: 'high'` or `'ultra'`)
+2. Switch to `shutterCurve: 'gaussian'` for smoother blending
+3. Try `shutterCurve: 'triangle'` as a compromise
+
+## API Reference
+
+### Types
+
+```typescript
+type ShutterCurve = 'box' | 'triangle' | 'gaussian';
+type ShutterPosition = 'center' | 'start' | 'end';
+type MotionBlurQuality = 'low' | 'medium' | 'high' | 'ultra';
+
+interface MotionBlurConfig {
+ enabled: boolean;
+ samples: number;
+ shutterAngle: number;
+ shutterCurve: ShutterCurve;
+ shutterPosition: ShutterPosition;
+ adaptiveSampling: boolean;
+ adaptiveMinSamples: number;
+}
+```
+
+### Utility Functions
+
+```typescript
+import {
+ resolveMotionBlurConfig,
+ calculateSubframeOffsets,
+ calculateSubframeWeights,
+ calculateAdaptiveSamples,
+ describeMotionBlurConfig,
+} from '@revideo/core';
+
+// Resolve partial config to full config
+const config = resolveMotionBlurConfig({quality: 'high', shutterCurve: 'gaussian'});
+
+// Get time offsets for sub-frames
+const offsets = calculateSubframeOffsets(config, 1/60); // 60fps
+
+// Get weights for each sub-frame
+const weights = calculateSubframeWeights(config);
+
+// Get human-readable description
+console.log(describeMotionBlurConfig(config));
+// "Motion blur: 16 samples, 180° shutter, gaussian curve"
+```
+
+## License
+
+This feature is part of Revideo and is licensed under the MIT License.
diff --git a/docs/motion-blur-demo.gif b/docs/motion-blur-demo.gif
new file mode 100644
index 000000000..ae8fd0d6c
Binary files /dev/null and b/docs/motion-blur-demo.gif differ
diff --git a/docs/motion-blur-demo.mp4 b/docs/motion-blur-demo.mp4
new file mode 100644
index 000000000..49f2e6a65
Binary files /dev/null and b/docs/motion-blur-demo.mp4 differ
diff --git a/package-lock.json b/package-lock.json
index 27c798e2f..e7d494516 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17592,7 +17592,8 @@
"dependencies": {
"@revideo/2d": "*",
"@revideo/core": "*",
- "@revideo/ffmpeg": "*"
+ "@revideo/ffmpeg": "*",
+ "@revideo/renderer": "*"
},
"devDependencies": {
"@revideo/ui": "*",
diff --git a/packages/2d/src/lib/components/View2D.ts b/packages/2d/src/lib/components/View2D.ts
index 01f679543..d65b095b0 100644
--- a/packages/2d/src/lib/components/View2D.ts
+++ b/packages/2d/src/lib/components/View2D.ts
@@ -48,6 +48,31 @@ export class View2D extends Rect {
@signal()
public declare readonly assetHash: SimpleSignal;
+ /**
+ * Current motion blur subframe index during rendering.
+ * -1 = not in motion blur mode (normal rendering)
+ * 0+ = current subframe index
+ */
+ @initial(-1)
+ @signal()
+ public declare readonly motionBlurSubframe: SimpleSignal;
+
+ /**
+ * Total number of motion blur subframes being rendered.
+ * 0 = not in motion blur mode
+ */
+ @initial(0)
+ @signal()
+ public declare readonly motionBlurTotalSubframes: SimpleSignal;
+
+ /**
+ * Weight for the current motion blur subframe.
+ * Used for weighting samples in accumulation.
+ */
+ @initial(1)
+ @signal()
+ public declare readonly motionBlurSubframeWeight: SimpleSignal;
+
public constructor(props: View2DProps) {
super({
composite: true,
diff --git a/packages/2d/src/lib/scenes/Scene2D.ts b/packages/2d/src/lib/scenes/Scene2D.ts
index e32a56f3f..43e17746d 100644
--- a/packages/2d/src/lib/scenes/Scene2D.ts
+++ b/packages/2d/src/lib/scenes/Scene2D.ts
@@ -239,4 +239,22 @@ export class Scene2D extends GeneratorScene implements Inspectable {
});
});
}
+
+ /**
+ * Set motion blur subframe context for rendering.
+ *
+ * @param subframeIndex - Current subframe index (0 to totalSubframes-1), or -1 to reset
+ * @param totalSubframes - Total number of subframes being rendered
+ * @param weight - Weight for this subframe in accumulation
+ */
+ public setMotionBlurSubframe(
+ subframeIndex: number,
+ totalSubframes: number,
+ weight: number,
+ ): void {
+ const view = this.getView();
+ view.motionBlurSubframe(subframeIndex);
+ view.motionBlurTotalSubframes(totalSubframes);
+ view.motionBlurSubframeWeight(weight);
+ }
}
diff --git a/packages/cli/src/server/render.ts b/packages/cli/src/server/render.ts
index f1556bdd7..3a6264a9d 100644
--- a/packages/cli/src/server/render.ts
+++ b/packages/cli/src/server/render.ts
@@ -4,8 +4,20 @@ import type {Request, Response} from 'express';
import path from 'path';
import {v4 as uuidv4} from 'uuid';
import {scheduleCleanup} from '../utils';
+import {validateRenderRequest} from './validation';
export async function render(req: Request, res: Response) {
+ // Validate request body
+ const validation = validateRenderRequest(req);
+ if (!validation.valid) {
+ res.status(400).json({
+ status: 'error',
+ message: 'Invalid request body',
+ errors: validation.errors,
+ });
+ return;
+ }
+
const {callbackUrl} = req.body;
if (callbackUrl) {
await renderWithCallback(req, res);
@@ -15,7 +27,6 @@ export async function render(req: Request, res: Response) {
}
async function renderWithCallback(req: Request, res: Response) {
- // TODO: validate request body
const {variables, callbackUrl, settings} = req.body;
const tempProjectName = uuidv4();
const outputFileName = `${tempProjectName}.mp4`;
@@ -75,7 +86,6 @@ async function renderWithCallback(req: Request, res: Response) {
}
async function renderWithoutCallback(req: Request, res: Response) {
- // TODO: validate request body
const {variables, streamProgress, settings} = req.body;
const tempProjectName: `${string}.mp4` = `${uuidv4()}.mp4`;
const resultFilePath = path.join(process.cwd(), `output/${tempProjectName}`);
diff --git a/packages/cli/src/server/validation.test.ts b/packages/cli/src/server/validation.test.ts
new file mode 100644
index 000000000..55ec373a8
--- /dev/null
+++ b/packages/cli/src/server/validation.test.ts
@@ -0,0 +1,336 @@
+import type {Request} from 'express';
+import {describe, expect, test} from 'vitest';
+import {validateRenderRequest} from './validation';
+
+// Helper to create a mock request
+function mockRequest(body: unknown): Request {
+ return {body} as Request;
+}
+
+describe('validateRenderRequest', () => {
+ describe('body validation', () => {
+ test('accepts empty body', () => {
+ const result = validateRenderRequest(mockRequest({}));
+ expect(result.valid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ test('rejects null body', () => {
+ const result = validateRenderRequest(mockRequest(null));
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('body');
+ });
+
+ test('rejects non-object body', () => {
+ const result = validateRenderRequest(mockRequest('not an object'));
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('body');
+ });
+ });
+
+ describe('callbackUrl validation', () => {
+ test('accepts valid http URL', () => {
+ const result = validateRenderRequest(
+ mockRequest({callbackUrl: 'http://example.com/callback'}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('accepts valid https URL', () => {
+ const result = validateRenderRequest(
+ mockRequest({callbackUrl: 'https://example.com/callback'}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects non-string callbackUrl', () => {
+ const result = validateRenderRequest(mockRequest({callbackUrl: 123}));
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('callbackUrl');
+ expect(result.errors[0].message).toContain('must be a string');
+ });
+
+ test('rejects invalid URL', () => {
+ const result = validateRenderRequest(
+ mockRequest({callbackUrl: 'not-a-valid-url'}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('callbackUrl');
+ expect(result.errors[0].message).toContain('must be a valid URL');
+ });
+
+ test('rejects non-http URLs', () => {
+ const result = validateRenderRequest(
+ mockRequest({callbackUrl: 'ftp://example.com'}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('callbackUrl');
+ });
+ });
+
+ describe('variables validation', () => {
+ test('accepts object variables', () => {
+ const result = validateRenderRequest(
+ mockRequest({variables: {key: 'value', num: 42}}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects non-object variables', () => {
+ const result = validateRenderRequest(mockRequest({variables: 'string'}));
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('variables');
+ });
+
+ test('rejects null variables', () => {
+ const result = validateRenderRequest(mockRequest({variables: null}));
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('variables');
+ });
+ });
+
+ describe('streamProgress validation', () => {
+ test('accepts boolean streamProgress', () => {
+ const result = validateRenderRequest(mockRequest({streamProgress: true}));
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects non-boolean streamProgress', () => {
+ const result = validateRenderRequest(
+ mockRequest({streamProgress: 'yes'}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('streamProgress');
+ });
+ });
+
+ describe('settings validation', () => {
+ test('accepts valid settings object', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ outFile: 'output.mp4',
+ outDir: './output',
+ workers: 4,
+ },
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects non-object settings', () => {
+ const result = validateRenderRequest(mockRequest({settings: 'invalid'}));
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings');
+ });
+
+ describe('outFile validation', () => {
+ test('accepts .mp4 extension', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {outFile: 'video.mp4'}}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('accepts .webm extension', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {outFile: 'video.webm'}}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('accepts .mov extension', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {outFile: 'video.mov'}}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects invalid extension', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {outFile: 'video.avi'}}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.outFile');
+ });
+ });
+
+ describe('workers validation', () => {
+ test('accepts valid worker count', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {workers: 8}}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects non-integer workers', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {workers: 2.5}}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.workers');
+ });
+
+ test('rejects workers less than 1', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {workers: 0}}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.workers');
+ });
+
+ test('rejects workers greater than 32', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {workers: 100}}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.workers');
+ });
+ });
+
+ describe('viteBasePort validation', () => {
+ test('accepts valid port', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {viteBasePort: 9000}}),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects port below 1', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {viteBasePort: 0}}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.viteBasePort');
+ });
+
+ test('rejects port above 65535', () => {
+ const result = validateRenderRequest(
+ mockRequest({settings: {viteBasePort: 70000}}),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.viteBasePort');
+ });
+ });
+ });
+
+ describe('projectSettings validation', () => {
+ test('accepts valid exporter settings', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ exporter: {
+ name: '@revideo/core/wasm',
+ },
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects invalid exporter name', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ exporter: {
+ name: 'invalid-exporter',
+ },
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe(
+ 'settings.projectSettings.exporter.name',
+ );
+ });
+
+ test('accepts valid size', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ size: {x: 1920, y: 1080},
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects invalid size', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ size: {x: -100, y: 1080},
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.projectSettings.size.x');
+ });
+
+ test('accepts valid range', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ range: [0, 10],
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('accepts Infinity as range end', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ range: [0, Infinity],
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(true);
+ });
+
+ test('rejects range with end < start', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ settings: {
+ projectSettings: {
+ range: [10, 5],
+ },
+ },
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].field).toBe('settings.projectSettings.range');
+ });
+ });
+
+ describe('multiple errors', () => {
+ test('collects multiple validation errors', () => {
+ const result = validateRenderRequest(
+ mockRequest({
+ callbackUrl: 123,
+ variables: null,
+ streamProgress: 'yes',
+ settings: {
+ outFile: 'video.avi',
+ workers: 100,
+ },
+ }),
+ );
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(3);
+ });
+ });
+});
diff --git a/packages/cli/src/server/validation.ts b/packages/cli/src/server/validation.ts
new file mode 100644
index 000000000..8fad30515
--- /dev/null
+++ b/packages/cli/src/server/validation.ts
@@ -0,0 +1,321 @@
+import type {Request} from 'express';
+
+export interface ValidationError {
+ field: string;
+ message: string;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ errors: ValidationError[];
+}
+
+/**
+ * Validates the render request body for both callback and non-callback modes.
+ */
+export function validateRenderRequest(req: Request): ValidationResult {
+ const errors: ValidationError[] = [];
+ const body = req.body;
+
+ // Body must be an object
+ if (!body || typeof body !== 'object') {
+ return {
+ valid: false,
+ errors: [{field: 'body', message: 'Request body must be a JSON object'}],
+ };
+ }
+
+ const {callbackUrl, variables, settings, streamProgress} = body;
+
+ // Validate callbackUrl if provided
+ if (callbackUrl !== undefined) {
+ if (typeof callbackUrl !== 'string') {
+ errors.push({
+ field: 'callbackUrl',
+ message: 'callbackUrl must be a string',
+ });
+ } else if (!isValidUrl(callbackUrl)) {
+ errors.push({
+ field: 'callbackUrl',
+ message: 'callbackUrl must be a valid URL',
+ });
+ }
+ }
+
+ // Validate variables if provided
+ if (variables !== undefined) {
+ if (typeof variables !== 'object' || variables === null) {
+ errors.push({
+ field: 'variables',
+ message: 'variables must be an object',
+ });
+ }
+ }
+
+ // Validate streamProgress if provided
+ if (streamProgress !== undefined && typeof streamProgress !== 'boolean') {
+ errors.push({
+ field: 'streamProgress',
+ message: 'streamProgress must be a boolean',
+ });
+ }
+
+ // Validate settings if provided
+ if (settings !== undefined) {
+ const settingsErrors = validateSettings(settings);
+ errors.push(...settingsErrors);
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ };
+}
+
+/**
+ * Validates the settings object for render requests.
+ */
+function validateSettings(settings: unknown): ValidationError[] {
+ const errors: ValidationError[] = [];
+
+ if (typeof settings !== 'object' || settings === null) {
+ errors.push({
+ field: 'settings',
+ message: 'settings must be an object',
+ });
+ return errors;
+ }
+
+ const s = settings as Record;
+
+ // Validate outFile if provided
+ if (s.outFile !== undefined) {
+ if (typeof s.outFile !== 'string') {
+ errors.push({
+ field: 'settings.outFile',
+ message: 'outFile must be a string',
+ });
+ } else if (!isValidOutFile(s.outFile)) {
+ errors.push({
+ field: 'settings.outFile',
+ message: 'outFile must end with .mp4, .webm, or .mov',
+ });
+ }
+ }
+
+ // Validate outDir if provided
+ if (s.outDir !== undefined && typeof s.outDir !== 'string') {
+ errors.push({
+ field: 'settings.outDir',
+ message: 'outDir must be a string',
+ });
+ }
+
+ // Validate workers if provided
+ if (s.workers !== undefined) {
+ if (typeof s.workers !== 'number' || !Number.isInteger(s.workers)) {
+ errors.push({
+ field: 'settings.workers',
+ message: 'workers must be an integer',
+ });
+ } else if (s.workers < 1 || s.workers > 32) {
+ errors.push({
+ field: 'settings.workers',
+ message: 'workers must be between 1 and 32',
+ });
+ }
+ }
+
+ // Validate logProgress if provided
+ if (s.logProgress !== undefined && typeof s.logProgress !== 'boolean') {
+ errors.push({
+ field: 'settings.logProgress',
+ message: 'logProgress must be a boolean',
+ });
+ }
+
+ // Validate viteBasePort if provided
+ if (s.viteBasePort !== undefined) {
+ if (
+ typeof s.viteBasePort !== 'number' ||
+ !Number.isInteger(s.viteBasePort)
+ ) {
+ errors.push({
+ field: 'settings.viteBasePort',
+ message: 'viteBasePort must be an integer',
+ });
+ } else if (s.viteBasePort < 1 || s.viteBasePort > 65535) {
+ errors.push({
+ field: 'settings.viteBasePort',
+ message: 'viteBasePort must be a valid port number (1-65535)',
+ });
+ }
+ }
+
+ // Validate projectSettings if provided
+ if (s.projectSettings !== undefined) {
+ const projectSettingsErrors = validateProjectSettings(s.projectSettings);
+ errors.push(...projectSettingsErrors);
+ }
+
+ return errors;
+}
+
+/**
+ * Validates the projectSettings object.
+ */
+function validateProjectSettings(projectSettings: unknown): ValidationError[] {
+ const errors: ValidationError[] = [];
+
+ if (typeof projectSettings !== 'object' || projectSettings === null) {
+ errors.push({
+ field: 'settings.projectSettings',
+ message: 'projectSettings must be an object',
+ });
+ return errors;
+ }
+
+ const ps = projectSettings as Record;
+
+ // Validate exporter if provided
+ if (ps.exporter !== undefined) {
+ if (typeof ps.exporter !== 'object' || ps.exporter === null) {
+ errors.push({
+ field: 'settings.projectSettings.exporter',
+ message: 'exporter must be an object',
+ });
+ } else {
+ const exporter = ps.exporter as Record;
+
+ // Validate exporter name
+ if (exporter.name !== undefined) {
+ const validExporters = [
+ '@revideo/core/wasm',
+ '@revideo/core/ffmpeg',
+ '@revideo/core/image-sequence',
+ ];
+ if (
+ typeof exporter.name !== 'string' ||
+ !validExporters.includes(exporter.name)
+ ) {
+ errors.push({
+ field: 'settings.projectSettings.exporter.name',
+ message: `exporter.name must be one of: ${validExporters.join(', ')}`,
+ });
+ }
+ }
+
+ // Validate exporter options if provided
+ if (exporter.options !== undefined) {
+ if (typeof exporter.options !== 'object' || exporter.options === null) {
+ errors.push({
+ field: 'settings.projectSettings.exporter.options',
+ message: 'exporter.options must be an object',
+ });
+ } else {
+ const options = exporter.options as Record;
+
+ // Validate format if provided
+ if (options.format !== undefined) {
+ const validFormats = ['mp4', 'webm', 'proRes'];
+ if (
+ typeof options.format !== 'string' ||
+ !validFormats.includes(options.format)
+ ) {
+ errors.push({
+ field: 'settings.projectSettings.exporter.options.format',
+ message: `format must be one of: ${validFormats.join(', ')}`,
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Validate background if provided
+ if (ps.background !== undefined && typeof ps.background !== 'string') {
+ errors.push({
+ field: 'settings.projectSettings.background',
+ message: 'background must be a string (color value)',
+ });
+ }
+
+ // Validate size if provided
+ if (ps.size !== undefined) {
+ if (typeof ps.size !== 'object' || ps.size === null) {
+ errors.push({
+ field: 'settings.projectSettings.size',
+ message: 'size must be an object with x and y properties',
+ });
+ } else {
+ const size = ps.size as Record;
+ if (typeof size.x !== 'number' || size.x <= 0) {
+ errors.push({
+ field: 'settings.projectSettings.size.x',
+ message: 'size.x must be a positive number',
+ });
+ }
+ if (typeof size.y !== 'number' || size.y <= 0) {
+ errors.push({
+ field: 'settings.projectSettings.size.y',
+ message: 'size.y must be a positive number',
+ });
+ }
+ }
+ }
+
+ // Validate range if provided
+ if (ps.range !== undefined) {
+ if (!Array.isArray(ps.range) || ps.range.length !== 2) {
+ errors.push({
+ field: 'settings.projectSettings.range',
+ message: 'range must be an array of two numbers [start, end]',
+ });
+ } else {
+ const [start, end] = ps.range;
+ if (typeof start !== 'number' || start < 0) {
+ errors.push({
+ field: 'settings.projectSettings.range[0]',
+ message: 'range start must be a non-negative number',
+ });
+ }
+ if (typeof end !== 'number') {
+ errors.push({
+ field: 'settings.projectSettings.range[1]',
+ message: 'range end must be a number',
+ });
+ } else if (isFinite(end) && typeof start === 'number' && end < start) {
+ errors.push({
+ field: 'settings.projectSettings.range',
+ message: 'range end must be greater than or equal to start',
+ });
+ }
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * Validates if a string is a valid URL.
+ */
+function isValidUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Validates if the output file has a valid extension.
+ */
+function isValidOutFile(outFile: string): boolean {
+ return (
+ outFile.endsWith('.mp4') ||
+ outFile.endsWith('.webm') ||
+ outFile.endsWith('.mov')
+ );
+}
diff --git a/packages/core/src/app/PlaybackStatus.ts b/packages/core/src/app/PlaybackStatus.ts
index 750015c5b..974a016f8 100644
--- a/packages/core/src/app/PlaybackStatus.ts
+++ b/packages/core/src/app/PlaybackStatus.ts
@@ -4,8 +4,36 @@ import type {PlaybackManager, PlaybackState} from './PlaybackManager';
* A read-only representation of the playback.
*/
export class PlaybackStatus {
+ /**
+ * Temporary time offset for motion blur subframe rendering.
+ * This is added to the current time when rendering subframes.
+ */
+ private subframeOffsetValue = 0;
+
public constructor(private readonly playback: PlaybackManager) {}
+ /**
+ * Set the subframe time offset for motion blur rendering.
+ * @param offset - Time offset in seconds
+ */
+ public setSubframeOffset(offset: number): void {
+ this.subframeOffsetValue = offset;
+ }
+
+ /**
+ * Reset the subframe offset to 0.
+ */
+ public resetSubframeOffset(): void {
+ this.subframeOffsetValue = 0;
+ }
+
+ /**
+ * Get the current subframe offset.
+ */
+ public get subframeOffset(): number {
+ return this.subframeOffsetValue;
+ }
+
/**
* Convert seconds to frames using the current framerate.
*
@@ -24,8 +52,11 @@ export class PlaybackStatus {
return frames / this.playback.fps;
}
+ /**
+ * Get the current time in seconds, including any subframe offset.
+ */
public get time(): number {
- return this.framesToSeconds(this.playback.frame);
+ return this.framesToSeconds(this.playback.frame) + this.subframeOffsetValue;
}
public get frame(): number {
diff --git a/packages/core/src/app/Project.ts b/packages/core/src/app/Project.ts
index 24481a8fa..a85b4a380 100644
--- a/packages/core/src/app/Project.ts
+++ b/packages/core/src/app/Project.ts
@@ -1,7 +1,7 @@
import type {FfmpegExporterOptions, ImageExporterOptions} from '../exporter';
import type {Plugin} from '../plugin';
import type {SceneDescription} from '../scenes';
-import type {CanvasColorSpace, Color, Vector2} from '../types';
+import type {CanvasColorSpace, Color, MotionBlurConfig, Vector2} from '../types';
import type {Logger} from './Logger';
// TODO(refactor): check if we can get rid of this
@@ -47,6 +47,7 @@ export interface ProjectSettings {
fps: number;
resolutionScale: number;
colorSpace: CanvasColorSpace;
+ motionBlur?: Partial;
};
preview: {
fps: number;
@@ -66,6 +67,7 @@ export interface UserProjectSettings {
fps: number;
resolutionScale: number;
colorSpace: CanvasColorSpace;
+ motionBlur?: Partial;
};
preview: {
fps: number;
@@ -83,6 +85,7 @@ export type RenderVideoUserProjectSettings = {
size?: UserProjectSettings['shared']['size'];
exporter?: UserProjectSettings['rendering']['exporter'];
+ motionBlur?: UserProjectSettings['rendering']['motionBlur'];
};
/**
diff --git a/packages/core/src/app/Renderer.ts b/packages/core/src/app/Renderer.ts
index 12873a4d2..57e648107 100644
--- a/packages/core/src/app/Renderer.ts
+++ b/packages/core/src/app/Renderer.ts
@@ -8,7 +8,11 @@ import {
} from '../exporter';
import type {Scene} from '../scenes';
import {clampRemap} from '../tweening';
-import {Vector2} from '../types';
+import {
+ calculateSubframeOffsets,
+ calculateSubframeWeights,
+ Vector2,
+} from '../types';
import {Semaphore} from '../utils';
import {PlaybackManager, PlaybackState} from './PlaybackManager';
import {PlaybackStatus} from './PlaybackStatus';
@@ -291,6 +295,19 @@ export class Renderer {
// Main rendering loop
await this.playback.seek(from);
+
+ // Calculate motion blur frame advancement to compensate in main loop
+ const motionBlurConfig = this.stage.getMotionBlurConfig();
+ let motionBlurAdvancement = 0;
+ if (motionBlurConfig.enabled) {
+ const shutterFraction = motionBlurConfig.shutterAngle / 360;
+ const subframeStep = shutterFraction / motionBlurConfig.samples;
+ // Motion blur advances (samples - 1) times
+ motionBlurAdvancement = (motionBlurConfig.samples - 1) * subframeStep;
+ }
+ // Speed needed after motion blur to reach next integer frame
+ const compensatedSpeed = 1 - motionBlurAdvancement;
+
try {
this.estimator.reset(1 / (to - from));
await this.exportFrame(signal);
@@ -302,7 +319,11 @@ export class Renderer {
} else {
let finished = false;
while (!finished) {
+ // Advance to next frame, compensating for motion blur's advancement
+ this.playback.speed = motionBlurConfig.enabled ? compensatedSpeed : 1;
await this.playback.progress();
+ this.playback.speed = 1; // Reset for motion blur's internal use
+
await this.exportFrame(signal);
this.estimator.update(
clampRemap(from, to, 0, 1, this.playback.frame),
@@ -363,10 +384,27 @@ export class Renderer {
private async exportFrame(signal: AbortSignal) {
this.frame.current = this.playback.frame;
- await this.stage.render(
- this.playback.currentScene!,
- this.playback.previousScene,
- );
+
+ const motionBlurConfig = this.stage.getMotionBlurConfig();
+
+ // Debug: log motion blur config on first frame
+ if (this.playback.frame === 0) {
+ console.log('[Motion Blur Debug] Config:', JSON.stringify(motionBlurConfig));
+ }
+
+ if (motionBlurConfig.enabled) {
+ // Render with motion blur using subframe accumulation
+ if (this.playback.frame === 0) {
+ console.log('[Motion Blur Debug] Using motion blur rendering path');
+ }
+ await this.exportFrameWithMotionBlur(signal, motionBlurConfig);
+ } else {
+ // Standard single-frame rendering
+ await this.stage.render(
+ this.playback.currentScene!,
+ this.playback.previousScene,
+ );
+ }
const sceneFrame =
this.playback.frame - this.playback.currentScene.firstFrame;
@@ -380,6 +418,93 @@ export class Renderer {
);
}
+ /**
+ * Export a frame with motion blur by rendering sub-frames and blending.
+ *
+ * Motion Canvas uses generator-based animations that evaluate during progress(),
+ * not during render. So we must actually advance the scene through each subframe
+ * to get proper motion blur.
+ *
+ * Strategy:
+ * 1. Save current frame position
+ * 2. Advance through subframe positions using progress() with small speed
+ * 3. Render and accumulate each subframe
+ * 4. Frame will naturally be at the right position for next iteration
+ */
+ private async exportFrameWithMotionBlur(
+ signal: AbortSignal,
+ config: ReturnType,
+ ) {
+ const samples = config.samples;
+
+ // Calculate weights based on shutter curve (box/triangle/gaussian)
+ const weights = calculateSubframeWeights(config);
+
+ // Calculate shutter window in frames
+ // shutterAngle 360° = 1 full frame, 180° = 0.5 frames
+ const shutterFraction = config.shutterAngle / 360;
+ const subframeStep = shutterFraction / samples;
+
+ // Begin accumulation
+ this.stage.beginMotionBlurAccumulation();
+
+ // Debug: log on first frame
+ if (this.playback.frame === 0) {
+ console.log(
+ `[Motion Blur] samples=${samples}, shutterAngle=${config.shutterAngle}°, curve=${config.shutterCurve}, position=${config.shutterPosition}`,
+ );
+ console.log(`[Motion Blur] Subframe step: ${subframeStep.toFixed(4)} frames, total: ${(subframeStep * samples).toFixed(4)} frames`);
+ }
+
+ // Save original speed
+ const originalSpeed = this.playback.speed;
+
+ // Temporarily modify playback speed for sub-frame steps
+ this.playback.speed = subframeStep;
+
+ // Render and accumulate each subframe
+ for (let i = 0; i < samples; i++) {
+ if (signal.aborted) {
+ this.playback.speed = originalSpeed;
+ // Reset motion blur context
+ this.playback.currentScene?.setMotionBlurSubframe?.(-1, 0, 1);
+ return;
+ }
+
+ // Set motion blur subframe context
+ this.playback.currentScene?.setMotionBlurSubframe?.(
+ i,
+ samples,
+ weights[i],
+ );
+
+ // Render at current subframe position
+ await this.stage.render(
+ this.playback.currentScene!,
+ this.playback.previousScene,
+ );
+
+ // Accumulate this sample with curve-based weight
+ this.stage.accumulateMotionBlurSample(weights[i]);
+
+ // Advance by subframe step to next sample position
+ // Skip advance on last sample - main loop will handle frame progression
+ if (i < samples - 1) {
+ this.playback.speed = subframeStep;
+ await this.playback.progress();
+ }
+ }
+
+ // Restore original speed
+ this.playback.speed = originalSpeed;
+
+ // Finalize - write blurred result to canvas
+ this.stage.finalizeMotionBlur();
+
+ // Reset motion blur context
+ this.playback.currentScene?.setMotionBlurSubframe?.(-1, 0, 1);
+ }
+
private async getMediaByFrames(settings: RendererSettings) {
this.stage.configure(settings);
this.playback.fps = settings.fps;
diff --git a/packages/core/src/app/Stage.ts b/packages/core/src/app/Stage.ts
index 07910d010..2d7774350 100644
--- a/packages/core/src/app/Stage.ts
+++ b/packages/core/src/app/Stage.ts
@@ -1,7 +1,11 @@
import type {Scene} from '../scenes';
import {unwrap} from '../signals';
-import type {CanvasColorSpace, Color} from '../types';
-import {Vector2} from '../types';
+import type {CanvasColorSpace, Color, MotionBlurConfig} from '../types';
+import {
+ DEFAULT_MOTION_BLUR_CONFIG,
+ resolveMotionBlurConfig,
+ Vector2,
+} from '../types';
import {getContext} from '../utils';
export interface StageSettings {
@@ -9,6 +13,7 @@ export interface StageSettings {
resolutionScale: number;
colorSpace: CanvasColorSpace;
background: Color | string | null;
+ motionBlur?: Partial;
}
/**
@@ -21,11 +26,19 @@ export class Stage {
private resolutionScale = 1;
private colorSpace: CanvasColorSpace = 'srgb';
private size = Vector2.zero;
+ private motionBlurConfig: MotionBlurConfig = {...DEFAULT_MOTION_BLUR_CONFIG};
public readonly finalBuffer: HTMLCanvasElement;
private readonly currentBuffer: HTMLCanvasElement;
private readonly previousBuffer: HTMLCanvasElement;
+ /**
+ * Accumulation buffer for motion blur.
+ * Uses Float32Array for high precision during accumulation.
+ */
+ private accumulationBuffer: Float32Array | null = null;
+ private accumulationSamples = 0;
+
private context: CanvasRenderingContext2D;
private currentContext: CanvasRenderingContext2D;
private previousContext: CanvasRenderingContext2D;
@@ -45,11 +58,19 @@ export class Stage {
this.previousContext = getContext({colorSpace}, this.previousBuffer);
}
+ /**
+ * Get the current motion blur configuration.
+ */
+ public getMotionBlurConfig(): MotionBlurConfig {
+ return this.motionBlurConfig;
+ }
+
public configure({
colorSpace = this.colorSpace,
size = this.size,
resolutionScale = this.resolutionScale,
background = this.background,
+ motionBlur,
}: Partial) {
if (colorSpace !== this.colorSpace) {
this.colorSpace = colorSpace;
@@ -67,15 +88,27 @@ export class Stage {
this.resizeCanvas(this.context);
this.resizeCanvas(this.currentContext);
this.resizeCanvas(this.previousContext);
+ // Reset accumulation buffer on resize
+ this.accumulationBuffer = null;
}
this.background =
typeof background === 'string'
? background
: (background?.serialize() ?? null);
+
+ // Update motion blur configuration
+ if (motionBlur !== undefined) {
+ this.motionBlurConfig = resolveMotionBlurConfig(motionBlur);
+ }
}
- public async render(currentScene: Scene, previousScene: Scene | null) {
+ public async render(
+ currentScene: Scene,
+ previousScene: Scene | null,
+ options: {clearCanvas?: boolean} = {},
+ ) {
+ const {clearCanvas = true} = options;
const previousOnTop = previousScene
? unwrap(currentScene.previousOnTop)
: false;
@@ -87,12 +120,14 @@ export class Stage {
await currentScene.render(this.currentContext);
const size = this.canvasSize;
- this.context.clearRect(0, 0, size.width, size.height);
- if (this.background) {
- this.context.save();
- this.context.fillStyle = this.background;
- this.context.fillRect(0, 0, size.width, size.height);
- this.context.restore();
+ if (clearCanvas) {
+ this.context.clearRect(0, 0, size.width, size.height);
+ if (this.background) {
+ this.context.save();
+ this.context.fillStyle = this.background;
+ this.context.fillRect(0, 0, size.width, size.height);
+ this.context.restore();
+ }
}
if (previousScene && !previousOnTop) {
@@ -109,4 +144,106 @@ export class Stage {
context.canvas.width = size.width;
context.canvas.height = size.height;
}
+
+ /**
+ * Begin accumulating samples for motion blur.
+ * Call this before the first subframe render.
+ */
+ public beginMotionBlurAccumulation(): void {
+ const size = this.canvasSize;
+ const bufferSize = size.width * size.height * 4;
+
+ // Create or reset accumulation buffer
+ if (
+ !this.accumulationBuffer ||
+ this.accumulationBuffer.length !== bufferSize
+ ) {
+ this.accumulationBuffer = new Float32Array(bufferSize);
+ } else {
+ this.accumulationBuffer.fill(0);
+ }
+
+ this.accumulationSamples = 0;
+ }
+
+ /**
+ * Accumulate the current frame into the motion blur buffer.
+ * Call this after each subframe render.
+ *
+ * @param weight - Weight for this sample (typically 1/samples)
+ */
+ public accumulateMotionBlurSample(weight: number): void {
+ if (!this.accumulationBuffer) {
+ return;
+ }
+
+ const size = this.canvasSize;
+ const imageData = this.context.getImageData(0, 0, size.width, size.height);
+ const pixels = imageData.data;
+
+ // Accumulate weighted pixel values
+ for (let i = 0; i < pixels.length; i++) {
+ this.accumulationBuffer[i] += pixels[i] * weight;
+ }
+
+ this.accumulationSamples++;
+ }
+
+ /**
+ * Finalize motion blur accumulation and write to finalBuffer.
+ * Call this after all subframe renders are complete.
+ */
+ public finalizeMotionBlur(): void {
+ if (!this.accumulationBuffer || this.accumulationSamples === 0) {
+ return;
+ }
+
+ const size = this.canvasSize;
+ const imageData = this.context.createImageData(size.width, size.height);
+ const pixels = imageData.data;
+
+ // Convert accumulated values back to 8-bit pixels
+ for (let i = 0; i < pixels.length; i++) {
+ // Clamp to 0-255 range (accumulated values are already weighted)
+ pixels[i] = Math.round(
+ Math.min(255, Math.max(0, this.accumulationBuffer[i])),
+ );
+ }
+
+ // Write the blurred result to the final buffer
+ this.context.putImageData(imageData, 0, 0);
+ }
+
+ /**
+ * Render with motion blur using the provided render callback.
+ * This method handles the accumulation loop internally.
+ *
+ * @param renderCallback - Async function that renders a single subframe
+ * @param subframeOffsets - Time offsets for each subframe in seconds
+ * @param weights - Weight for each subframe (should sum to 1)
+ */
+ public async renderWithMotionBlur(
+ renderCallback: (timeOffset: number) => Promise,
+ subframeOffsets: number[],
+ weights: number[],
+ ): Promise {
+ if (
+ subframeOffsets.length === 0 ||
+ subframeOffsets.length !== weights.length
+ ) {
+ return;
+ }
+
+ this.beginMotionBlurAccumulation();
+
+ for (let i = 0; i < subframeOffsets.length; i++) {
+ // Render subframe (callback should call stage.render() internally)
+ await renderCallback(subframeOffsets[i]);
+
+ // Accumulate this sample
+ this.accumulateMotionBlurSample(weights[i]);
+ }
+
+ this.finalizeMotionBlur();
+ }
}
diff --git a/packages/core/src/scenes/Scene.ts b/packages/core/src/scenes/Scene.ts
index aaa57a187..5cedf1662 100644
--- a/packages/core/src/scenes/Scene.ts
+++ b/packages/core/src/scenes/Scene.ts
@@ -334,6 +334,21 @@ export interface Scene {
adjustVolume(volumeScale: number): void;
+ /**
+ * Set motion blur subframe context for per-element motion blur control.
+ *
+ * @param subframeIndex - Current subframe index (0 to totalSubframes-1), or -1 to reset
+ * @param totalSubframes - Total number of subframes being rendered
+ * @param weight - Weight for this subframe (for compensation calculations)
+ * @param renderPass - Which elements to render: 'blur', 'static', or 'all'
+ */
+ setMotionBlurSubframe?(
+ subframeIndex: number,
+ totalSubframes: number,
+ weight: number,
+ renderPass?: 'blur' | 'static' | 'all',
+ ): void;
+
/**
* Should this scene be rendered below the previous scene during a transition?
*/
diff --git a/packages/core/src/types/MotionBlur.test.ts b/packages/core/src/types/MotionBlur.test.ts
new file mode 100644
index 000000000..2b4e1ea0e
--- /dev/null
+++ b/packages/core/src/types/MotionBlur.test.ts
@@ -0,0 +1,338 @@
+import {describe, expect, test} from 'vitest';
+import {
+ calculateAdaptiveSamples,
+ calculateSubframeOffsets,
+ calculateSubframeWeights,
+ DEFAULT_MOTION_BLUR_CONFIG,
+ describeMotionBlurConfig,
+ MotionBlurConfig,
+ QUALITY_SAMPLES,
+ resolveMotionBlurConfig,
+} from './MotionBlur';
+
+describe('MotionBlur', () => {
+ describe('DEFAULT_MOTION_BLUR_CONFIG', () => {
+ test('has correct default values', () => {
+ expect(DEFAULT_MOTION_BLUR_CONFIG).toEqual({
+ enabled: false,
+ samples: 8,
+ shutterAngle: 180,
+ shutterCurve: 'box',
+ shutterPosition: 'center',
+ adaptiveSampling: false,
+ adaptiveMinSamples: 2,
+ shutterPhase: -90,
+ });
+ });
+ });
+
+ describe('QUALITY_SAMPLES', () => {
+ test('defines correct sample counts for each preset', () => {
+ expect(QUALITY_SAMPLES.low).toBe(4);
+ expect(QUALITY_SAMPLES.medium).toBe(8);
+ expect(QUALITY_SAMPLES.high).toBe(16);
+ expect(QUALITY_SAMPLES.ultra).toBe(32);
+ });
+ });
+
+ describe('resolveMotionBlurConfig', () => {
+ test('returns defaults when no options provided', () => {
+ const config = resolveMotionBlurConfig();
+ expect(config).toEqual(DEFAULT_MOTION_BLUR_CONFIG);
+ });
+
+ test('returns defaults when undefined provided', () => {
+ const config = resolveMotionBlurConfig(undefined);
+ expect(config).toEqual(DEFAULT_MOTION_BLUR_CONFIG);
+ });
+
+ test('merges partial options with defaults', () => {
+ const config = resolveMotionBlurConfig({enabled: true});
+ expect(config).toEqual({
+ ...DEFAULT_MOTION_BLUR_CONFIG,
+ enabled: true,
+ });
+ });
+
+ test('allows overriding all options', () => {
+ const custom = {
+ enabled: true,
+ samples: 16,
+ shutterAngle: 270,
+ shutterCurve: 'gaussian' as const,
+ shutterPosition: 'start' as const,
+ adaptiveSampling: true,
+ adaptiveMinSamples: 4,
+ shutterPhase: -135,
+ };
+ const config = resolveMotionBlurConfig(custom);
+ expect(config).toEqual(custom);
+ });
+
+ test('resolves quality preset to samples', () => {
+ expect(resolveMotionBlurConfig({quality: 'low'}).samples).toBe(4);
+ expect(resolveMotionBlurConfig({quality: 'medium'}).samples).toBe(8);
+ expect(resolveMotionBlurConfig({quality: 'high'}).samples).toBe(16);
+ expect(resolveMotionBlurConfig({quality: 'ultra'}).samples).toBe(32);
+ });
+
+ test('samples takes precedence over quality', () => {
+ const config = resolveMotionBlurConfig({quality: 'ultra', samples: 10});
+ expect(config.samples).toBe(10);
+ });
+
+ test('clamps samples to valid range', () => {
+ expect(resolveMotionBlurConfig({samples: 0}).samples).toBe(1);
+ expect(resolveMotionBlurConfig({samples: -5}).samples).toBe(1);
+ expect(resolveMotionBlurConfig({samples: 100}).samples).toBe(64);
+ expect(resolveMotionBlurConfig({samples: 2.5}).samples).toBe(3);
+ });
+
+ test('clamps shutter angle to valid range', () => {
+ expect(resolveMotionBlurConfig({shutterAngle: -10}).shutterAngle).toBe(0);
+ expect(resolveMotionBlurConfig({shutterAngle: 800}).shutterAngle).toBe(
+ 720,
+ );
+ });
+
+ test('clamps shutter phase to valid range', () => {
+ expect(resolveMotionBlurConfig({shutterPhase: -400}).shutterPhase).toBe(
+ -360,
+ );
+ expect(resolveMotionBlurConfig({shutterPhase: 400}).shutterPhase).toBe(
+ 360,
+ );
+ });
+
+ test('converts shutterPosition to shutterPhase', () => {
+ // center: -shutterAngle/2 = -90
+ expect(
+ resolveMotionBlurConfig({shutterPosition: 'center'}).shutterPhase,
+ ).toBe(-90);
+ // start: 0
+ expect(
+ resolveMotionBlurConfig({shutterPosition: 'start'}).shutterPhase,
+ ).toBe(0);
+ // end: -shutterAngle = -180
+ expect(
+ resolveMotionBlurConfig({shutterPosition: 'end'}).shutterPhase,
+ ).toBe(-180);
+ });
+ });
+
+ describe('calculateSubframeOffsets', () => {
+ const fps24FrameDuration = 1 / 24;
+
+ const makeConfig = (
+ overrides: Partial = {},
+ ): MotionBlurConfig => ({
+ ...DEFAULT_MOTION_BLUR_CONFIG,
+ enabled: true,
+ ...overrides,
+ });
+
+ test('returns correct number of offsets', () => {
+ const config = makeConfig({samples: 8});
+ const offsets = calculateSubframeOffsets(config, fps24FrameDuration);
+ expect(offsets).toHaveLength(8);
+ });
+
+ test('returns single centered offset for 1 sample', () => {
+ const config = makeConfig({samples: 1, shutterPhase: 0});
+ const offsets = calculateSubframeOffsets(config, fps24FrameDuration);
+ expect(offsets).toHaveLength(1);
+ });
+
+ test('offsets span the expected exposure window', () => {
+ const config = makeConfig({samples: 4});
+ const offsets = calculateSubframeOffsets(config, fps24FrameDuration);
+
+ // Exposure time is 180/360 = 0.5 of frame duration
+ const exposureTime = fps24FrameDuration * 0.5;
+
+ // Check that the offsets span approximately the exposure window
+ const minOffset = Math.min(...offsets);
+ const maxOffset = Math.max(...offsets);
+ const range = maxOffset - minOffset;
+
+ expect(range).toBeCloseTo(exposureTime, 5);
+ });
+
+ test('higher shutter angle produces wider offset range', () => {
+ const config180 = makeConfig({samples: 4, shutterAngle: 180});
+ const config360 = makeConfig({samples: 4, shutterAngle: 360});
+
+ const offsets180 = calculateSubframeOffsets(
+ config180,
+ fps24FrameDuration,
+ );
+ const offsets360 = calculateSubframeOffsets(
+ config360,
+ fps24FrameDuration,
+ );
+
+ const range180 = Math.max(...offsets180) - Math.min(...offsets180);
+ const range360 = Math.max(...offsets360) - Math.min(...offsets360);
+
+ expect(range360).toBeGreaterThan(range180);
+ expect(range360).toBeCloseTo(range180 * 2, 5);
+ });
+
+ test('zero shutter angle produces zero offsets', () => {
+ const config = makeConfig({samples: 4, shutterAngle: 0, shutterPhase: 0});
+ const offsets = calculateSubframeOffsets(config, fps24FrameDuration);
+
+ for (const offset of offsets) {
+ expect(offset).toBeCloseTo(0, 10);
+ }
+ });
+ });
+
+ describe('calculateSubframeWeights', () => {
+ const makeConfig = (
+ overrides: Partial = {},
+ ): MotionBlurConfig => ({
+ ...DEFAULT_MOTION_BLUR_CONFIG,
+ enabled: true,
+ ...overrides,
+ });
+
+ test('returns correct number of weights', () => {
+ const config = makeConfig({samples: 8});
+ const weights = calculateSubframeWeights(config);
+ expect(weights).toHaveLength(8);
+ });
+
+ test('weights sum to 1 for box curve', () => {
+ const config = makeConfig({samples: 16, shutterCurve: 'box'});
+ const weights = calculateSubframeWeights(config);
+ const sum = weights.reduce((a, b) => a + b, 0);
+ expect(sum).toBeCloseTo(1, 10);
+ });
+
+ test('weights sum to 1 for triangle curve', () => {
+ const config = makeConfig({samples: 16, shutterCurve: 'triangle'});
+ const weights = calculateSubframeWeights(config);
+ const sum = weights.reduce((a, b) => a + b, 0);
+ expect(sum).toBeCloseTo(1, 10);
+ });
+
+ test('weights sum to 1 for gaussian curve', () => {
+ const config = makeConfig({samples: 16, shutterCurve: 'gaussian'});
+ const weights = calculateSubframeWeights(config);
+ const sum = weights.reduce((a, b) => a + b, 0);
+ expect(sum).toBeCloseTo(1, 10);
+ });
+
+ test('all weights are equal for box weighting', () => {
+ const config = makeConfig({samples: 4, shutterCurve: 'box'});
+ const weights = calculateSubframeWeights(config);
+
+ const expectedWeight = 0.25;
+ for (const weight of weights) {
+ expect(weight).toBeCloseTo(expectedWeight, 10);
+ }
+ });
+
+ test('triangle weights are highest at center', () => {
+ const config = makeConfig({samples: 5, shutterCurve: 'triangle'});
+ const weights = calculateSubframeWeights(config);
+
+ // Middle element should be highest
+ expect(weights[2]).toBeGreaterThan(weights[0]);
+ expect(weights[2]).toBeGreaterThan(weights[4]);
+ });
+
+ test('gaussian weights are highest at center', () => {
+ const config = makeConfig({samples: 5, shutterCurve: 'gaussian'});
+ const weights = calculateSubframeWeights(config);
+
+ // Middle element should be highest
+ expect(weights[2]).toBeGreaterThan(weights[0]);
+ expect(weights[2]).toBeGreaterThan(weights[4]);
+ });
+
+ test('single sample has weight of 1', () => {
+ const config = makeConfig({samples: 1});
+ const weights = calculateSubframeWeights(config);
+ expect(weights).toHaveLength(1);
+ expect(weights[0]).toBe(1);
+ });
+ });
+
+ describe('calculateAdaptiveSamples', () => {
+ const makeConfig = (
+ overrides: Partial = {},
+ ): MotionBlurConfig => ({
+ ...DEFAULT_MOTION_BLUR_CONFIG,
+ enabled: true,
+ samples: 16,
+ adaptiveSampling: true,
+ adaptiveMinSamples: 2,
+ ...overrides,
+ });
+
+ test('returns full samples when adaptive disabled', () => {
+ const config = makeConfig({adaptiveSampling: false});
+ expect(calculateAdaptiveSamples(config, 100)).toBe(16);
+ });
+
+ test('returns full samples at high velocity', () => {
+ const config = makeConfig();
+ expect(calculateAdaptiveSamples(config, 100, 50)).toBe(16);
+ });
+
+ test('returns minimum samples at zero velocity', () => {
+ const config = makeConfig();
+ expect(calculateAdaptiveSamples(config, 0)).toBe(2);
+ });
+
+ test('scales samples based on velocity', () => {
+ const config = makeConfig();
+ const halfVelocitySamples = calculateAdaptiveSamples(config, 25, 50);
+ expect(halfVelocitySamples).toBeGreaterThan(2);
+ expect(halfVelocitySamples).toBeLessThan(16);
+ });
+
+ test('respects minimum samples', () => {
+ const config = makeConfig({adaptiveMinSamples: 4});
+ expect(calculateAdaptiveSamples(config, 0)).toBe(4);
+ });
+ });
+
+ describe('describeMotionBlurConfig', () => {
+ const makeConfig = (
+ overrides: Partial = {},
+ ): MotionBlurConfig => ({
+ ...DEFAULT_MOTION_BLUR_CONFIG,
+ ...overrides,
+ });
+
+ test('returns disabled message when disabled', () => {
+ const config = makeConfig({enabled: false});
+ expect(describeMotionBlurConfig(config)).toBe('Motion blur disabled');
+ });
+
+ test('includes basic info when enabled', () => {
+ const config = makeConfig({enabled: true, samples: 8, shutterAngle: 180});
+ const desc = describeMotionBlurConfig(config);
+ expect(desc).toContain('8 samples');
+ expect(desc).toContain('180° shutter');
+ });
+
+ test('includes curve when not box', () => {
+ const config = makeConfig({enabled: true, shutterCurve: 'gaussian'});
+ expect(describeMotionBlurConfig(config)).toContain('gaussian curve');
+ });
+
+ test('includes position when not center', () => {
+ const config = makeConfig({enabled: true, shutterPosition: 'start'});
+ expect(describeMotionBlurConfig(config)).toContain('start position');
+ });
+
+ test('includes adaptive when enabled', () => {
+ const config = makeConfig({enabled: true, adaptiveSampling: true});
+ expect(describeMotionBlurConfig(config)).toContain('adaptive');
+ });
+ });
+});
diff --git a/packages/core/src/types/MotionBlur.ts b/packages/core/src/types/MotionBlur.ts
new file mode 100644
index 000000000..9902e3491
--- /dev/null
+++ b/packages/core/src/types/MotionBlur.ts
@@ -0,0 +1,430 @@
+/**
+ * Motion Blur Configuration and Utilities
+ *
+ * This module provides configuration types and utility functions for
+ * motion blur rendering using temporal sub-frame accumulation.
+ *
+ * @packageDocumentation
+ */
+
+/**
+ * Shutter curve types that determine how samples are weighted.
+ *
+ * - `box`: Equal weight for all samples (default, sharp edges)
+ * - `triangle`: Linear falloff from center (softer)
+ * - `gaussian`: Bell curve falloff (most natural, like real cameras)
+ */
+export type ShutterCurve = 'box' | 'triangle' | 'gaussian';
+
+/**
+ * Shutter position relative to the frame time.
+ *
+ * - `center`: Blur is centered on the frame (recommended)
+ * - `start`: Blur starts at frame time (forward/trailing blur)
+ * - `end`: Blur ends at frame time (backward/leading blur)
+ */
+export type ShutterPosition = 'center' | 'start' | 'end';
+
+/**
+ * Quality presets for motion blur.
+ *
+ * - `low`: 4 samples (fast, draft quality)
+ * - `medium`: 8 samples (balanced)
+ * - `high`: 16 samples (high quality)
+ * - `ultra`: 32 samples (maximum quality, slow)
+ */
+export type MotionBlurQuality = 'low' | 'medium' | 'high' | 'ultra';
+
+/**
+ * Sample counts for each quality preset.
+ */
+export const QUALITY_SAMPLES: Record = {
+ low: 4,
+ medium: 8,
+ high: 16,
+ ultra: 32,
+};
+
+/**
+ * Configuration for motion blur rendering.
+ *
+ * Motion blur simulates the blur that occurs in real cameras when objects
+ * move during the exposure time. This implementation uses temporal sub-frame
+ * accumulation - rendering multiple sub-frames at different points in time
+ * and blending them together.
+ *
+ * @example
+ * ```typescript
+ * // Basic usage with quality preset
+ * motionBlur: {
+ * enabled: true,
+ * quality: 'high',
+ * }
+ *
+ * // Advanced usage with custom settings
+ * motionBlur: {
+ * enabled: true,
+ * samples: 16,
+ * shutterAngle: 180,
+ * shutterCurve: 'gaussian',
+ * shutterPosition: 'center',
+ * }
+ * ```
+ */
+export interface MotionBlurConfig {
+ /**
+ * Whether motion blur is enabled.
+ *
+ * @defaultValue `false`
+ */
+ enabled: boolean;
+
+ /**
+ * Number of samples (sub-frames) to render per frame.
+ *
+ * Higher values produce smoother blur but require more render time.
+ * Use `quality` preset for convenience, or set this directly.
+ *
+ * @defaultValue `8`
+ */
+ samples: number;
+
+ /**
+ * Shutter angle in degrees (0-720).
+ *
+ * This simulates a rotary disc shutter like those used in film cameras.
+ * - 180 degrees: Standard film motion blur (50% of frame time exposed)
+ * - 360 degrees: Maximum blur (100% of frame time exposed)
+ * - 90 degrees: Minimal blur (25% of frame time exposed)
+ *
+ * @defaultValue `180`
+ */
+ shutterAngle: number;
+
+ /**
+ * Shutter curve type for sample weighting.
+ *
+ * - `box`: Equal weight (sharp, can show stepping artifacts)
+ * - `triangle`: Linear falloff from center (softer)
+ * - `gaussian`: Bell curve (most natural, recommended)
+ *
+ * @defaultValue `'box'`
+ */
+ shutterCurve: ShutterCurve;
+
+ /**
+ * Shutter position relative to frame time.
+ *
+ * - `center`: Blur straddles the frame (half before, half after)
+ * - `start`: Blur trails behind (forward blur)
+ * - `end`: Blur leads ahead (backward blur)
+ *
+ * @defaultValue `'center'`
+ */
+ shutterPosition: ShutterPosition;
+
+ /**
+ * Enable adaptive sampling based on motion speed.
+ *
+ * When enabled, fast-moving areas get more samples while
+ * slow/static areas get fewer, improving performance.
+ *
+ * @defaultValue `false`
+ */
+ adaptiveSampling: boolean;
+
+ /**
+ * Minimum samples when adaptive sampling is enabled.
+ *
+ * @defaultValue `2`
+ */
+ adaptiveMinSamples: number;
+
+ /**
+ * Legacy: Shutter phase in degrees (-360 to 360).
+ *
+ * @deprecated Use `shutterPosition` instead. This is kept for
+ * backward compatibility.
+ *
+ * @defaultValue `-90`
+ */
+ shutterPhase: number;
+}
+
+/**
+ * Default motion blur configuration.
+ */
+export const DEFAULT_MOTION_BLUR_CONFIG: MotionBlurConfig = {
+ enabled: false,
+ samples: 8,
+ shutterAngle: 180,
+ shutterCurve: 'box',
+ shutterPosition: 'center',
+ adaptiveSampling: false,
+ adaptiveMinSamples: 2,
+ shutterPhase: -90,
+};
+
+/**
+ * Partial motion blur configuration for user input.
+ * Also supports `quality` preset for convenience.
+ */
+export type MotionBlurOptions = Partial & {
+ /**
+ * Quality preset that sets the sample count.
+ * If both `quality` and `samples` are provided, `samples` takes precedence.
+ */
+ quality?: MotionBlurQuality;
+};
+
+/**
+ * Merges user motion blur options with defaults.
+ *
+ * @param options - User-provided options
+ * @returns Complete motion blur configuration
+ */
+export function resolveMotionBlurConfig(
+ options?: MotionBlurOptions,
+): MotionBlurConfig {
+ if (!options) {
+ return {...DEFAULT_MOTION_BLUR_CONFIG};
+ }
+
+ // Resolve samples from quality preset or direct value
+ let samples = options.samples ?? DEFAULT_MOTION_BLUR_CONFIG.samples;
+ if (options.quality && !options.samples) {
+ samples = QUALITY_SAMPLES[options.quality];
+ }
+
+ // Convert shutterPosition to shutterPhase for backward compatibility
+ let shutterPhase =
+ options.shutterPhase ?? DEFAULT_MOTION_BLUR_CONFIG.shutterPhase;
+ if (options.shutterPosition && !options.shutterPhase) {
+ const shutterAngle =
+ options.shutterAngle ?? DEFAULT_MOTION_BLUR_CONFIG.shutterAngle;
+ shutterPhase = calculatePhaseFromPosition(
+ options.shutterPosition,
+ shutterAngle,
+ );
+ }
+
+ return {
+ enabled: options.enabled ?? DEFAULT_MOTION_BLUR_CONFIG.enabled,
+ samples: clampSamples(samples),
+ shutterAngle: clampShutterAngle(
+ options.shutterAngle ?? DEFAULT_MOTION_BLUR_CONFIG.shutterAngle,
+ ),
+ shutterCurve:
+ options.shutterCurve ?? DEFAULT_MOTION_BLUR_CONFIG.shutterCurve,
+ shutterPosition:
+ options.shutterPosition ?? DEFAULT_MOTION_BLUR_CONFIG.shutterPosition,
+ adaptiveSampling:
+ options.adaptiveSampling ?? DEFAULT_MOTION_BLUR_CONFIG.adaptiveSampling,
+ adaptiveMinSamples: Math.max(
+ 1,
+ options.adaptiveMinSamples ?? DEFAULT_MOTION_BLUR_CONFIG.adaptiveMinSamples,
+ ),
+ shutterPhase: clampShutterPhase(shutterPhase),
+ };
+}
+
+/**
+ * Calculate shutter phase from position preset.
+ */
+function calculatePhaseFromPosition(
+ position: ShutterPosition,
+ shutterAngle: number,
+): number {
+ switch (position) {
+ case 'center':
+ return -shutterAngle / 2;
+ case 'start':
+ return 0;
+ case 'end':
+ return -shutterAngle;
+ }
+}
+
+/**
+ * Clamp samples to valid range (1-64).
+ */
+function clampSamples(samples: number): number {
+ return Math.max(1, Math.min(64, Math.round(samples)));
+}
+
+/**
+ * Clamp shutter angle to valid range (0-720).
+ * 720 allows double exposure for artistic effects.
+ */
+function clampShutterAngle(angle: number): number {
+ return Math.max(0, Math.min(720, angle));
+}
+
+/**
+ * Clamp shutter phase to valid range (-360 to 360).
+ */
+function clampShutterPhase(phase: number): number {
+ return Math.max(-360, Math.min(360, phase));
+}
+
+/**
+ * Calculate the time offsets for sub-frame samples.
+ *
+ * @param config - Motion blur configuration
+ * @param frameDuration - Duration of one frame in seconds
+ * @returns Array of time offsets in seconds for each sample
+ */
+export function calculateSubframeOffsets(
+ config: MotionBlurConfig,
+ frameDuration: number,
+): number[] {
+ const {samples, shutterAngle, shutterPhase} = config;
+
+ // Convert shutter angle to fraction of frame time
+ const shutterFraction = shutterAngle / 360;
+
+ // Total exposure time
+ const exposureTime = frameDuration * shutterFraction;
+
+ // Phase offset (convert degrees to fraction of frame time)
+ const phaseOffset = (shutterPhase / 360) * frameDuration;
+
+ const offsets: number[] = [];
+
+ for (let i = 0; i < samples; i++) {
+ // Distribute samples evenly across the exposure window
+ const samplePosition = samples === 1 ? 0.5 : i / (samples - 1);
+ const timeOffset =
+ phaseOffset + samplePosition * exposureTime - exposureTime / 2;
+ offsets.push(timeOffset);
+ }
+
+ return offsets;
+}
+
+/**
+ * Calculate weight for each sub-frame sample based on shutter curve.
+ *
+ * @param config - Motion blur configuration
+ * @returns Array of weights for each sample (normalized to sum to 1)
+ */
+export function calculateSubframeWeights(config: MotionBlurConfig): number[] {
+ const {samples, shutterCurve} = config;
+
+ let weights: number[];
+
+ switch (shutterCurve) {
+ case 'triangle':
+ weights = calculateTriangleWeights(samples);
+ break;
+ case 'gaussian':
+ weights = calculateGaussianWeights(samples);
+ break;
+ case 'box':
+ default:
+ weights = calculateBoxWeights(samples);
+ break;
+ }
+
+ // Normalize weights to sum to 1
+ const sum = weights.reduce((a, b) => a + b, 0);
+ return weights.map(w => w / sum);
+}
+
+/**
+ * Box (uniform) weighting - equal weight for all samples.
+ */
+function calculateBoxWeights(samples: number): number[] {
+ return Array(samples).fill(1);
+}
+
+/**
+ * Triangle weighting - linear falloff from center.
+ * Creates a softer blur than box.
+ */
+function calculateTriangleWeights(samples: number): number[] {
+ const weights: number[] = [];
+ const center = (samples - 1) / 2;
+
+ for (let i = 0; i < samples; i++) {
+ // Distance from center, normalized to 0-1
+ const distance = Math.abs(i - center) / (samples / 2);
+ // Triangle: weight = 1 at center, 0 at edges
+ weights.push(1 - distance);
+ }
+
+ return weights;
+}
+
+/**
+ * Gaussian weighting - bell curve falloff from center.
+ * Most natural looking, mimics real camera shutter behavior.
+ */
+function calculateGaussianWeights(samples: number): number[] {
+ const weights: number[] = [];
+ const center = (samples - 1) / 2;
+ // Sigma controls the spread - 0.4 gives nice falloff within the sample range
+ const sigma = samples / 4;
+
+ for (let i = 0; i < samples; i++) {
+ const x = i - center;
+ // Gaussian: e^(-x^2 / (2 * sigma^2))
+ const weight = Math.exp(-(x * x) / (2 * sigma * sigma));
+ weights.push(weight);
+ }
+
+ return weights;
+}
+
+/**
+ * Calculate adaptive sample count based on motion velocity.
+ *
+ * @param config - Motion blur configuration
+ * @param velocity - Object velocity in pixels per frame
+ * @param threshold - Velocity threshold for full samples (default: 50px/frame)
+ * @returns Adjusted sample count
+ */
+export function calculateAdaptiveSamples(
+ config: MotionBlurConfig,
+ velocity: number,
+ threshold = 50,
+): number {
+ if (!config.adaptiveSampling) {
+ return config.samples;
+ }
+
+ // Scale samples based on velocity
+ // At threshold velocity, use full samples
+ // Below threshold, scale down proportionally
+ const scale = Math.min(1, velocity / threshold);
+ const adaptedSamples = Math.round(
+ config.adaptiveMinSamples +
+ (config.samples - config.adaptiveMinSamples) * scale,
+ );
+
+ return Math.max(config.adaptiveMinSamples, adaptedSamples);
+}
+
+/**
+ * Get a human-readable description of the motion blur configuration.
+ *
+ * @param config - Motion blur configuration
+ * @returns Description string
+ */
+export function describeMotionBlurConfig(config: MotionBlurConfig): string {
+ if (!config.enabled) {
+ return 'Motion blur disabled';
+ }
+
+ const parts = [
+ `${config.samples} samples`,
+ `${config.shutterAngle}° shutter`,
+ config.shutterCurve !== 'box' ? `${config.shutterCurve} curve` : null,
+ config.shutterPosition !== 'center'
+ ? `${config.shutterPosition} position`
+ : null,
+ config.adaptiveSampling ? 'adaptive' : null,
+ ].filter(Boolean);
+
+ return `Motion blur: ${parts.join(', ')}`;
+}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 3f060afd9..dc0f3bbe5 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -15,3 +15,4 @@ export * from './Spacing';
export * from './Type';
export * from './Vector';
export * from './vector-transformations';
+export * from './MotionBlur';
diff --git a/packages/examples/package.json b/packages/examples/package.json
index 4d55acff0..98c89e6fc 100644
--- a/packages/examples/package.json
+++ b/packages/examples/package.json
@@ -4,12 +4,14 @@
"version": "0.0.0",
"scripts": {
"dev": "vite",
- "build": "tsc && vite build --base /examples/"
+ "build": "tsc && vite build --base /examples/",
+ "render": "tsc -p tsconfig.render.json && node dist/render-motion-blur.js"
},
"dependencies": {
"@revideo/2d": "*",
"@revideo/core": "*",
- "@revideo/ffmpeg": "*"
+ "@revideo/ffmpeg": "*",
+ "@revideo/renderer": "*"
},
"devDependencies": {
"@revideo/ui": "*",
diff --git a/packages/examples/src/motion-blur.ts b/packages/examples/src/motion-blur.ts
new file mode 100644
index 000000000..4ca530d9a
--- /dev/null
+++ b/packages/examples/src/motion-blur.ts
@@ -0,0 +1,37 @@
+import {makeProject} from '@revideo/core';
+
+import scene from './scenes/motion-blur';
+
+export default makeProject({
+ scenes: [scene],
+ settings: {
+ rendering: {
+ // Motion blur configuration
+ // Uses temporal sub-frame accumulation for realistic motion blur
+ motionBlur: {
+ enabled: true,
+
+ // Quality preset: 'low' (4), 'medium' (8), 'high' (16), 'ultra' (32)
+ // Or set 'samples' directly for custom count
+ quality: 'high', // 16 samples
+
+ // Shutter angle: exposure time in degrees
+ // 180° = standard film (50% of frame exposed)
+ // 360° = full frame exposure (maximum blur)
+ shutterAngle: 180,
+
+ // Shutter curve: weight distribution for samples
+ // 'box' = equal weight (sharp, can show stepping)
+ // 'triangle' = linear falloff from center (softer)
+ // 'gaussian' = bell curve (most natural, like real cameras)
+ shutterCurve: 'gaussian',
+
+ // Shutter position: when blur occurs relative to frame
+ // 'center' = blur straddles the frame (recommended)
+ // 'start' = blur trails behind (forward blur)
+ // 'end' = blur leads ahead (backward blur)
+ shutterPosition: 'center',
+ },
+ },
+ },
+});
diff --git a/packages/examples/src/render-motion-blur.ts b/packages/examples/src/render-motion-blur.ts
new file mode 100644
index 000000000..a2ecb7a86
--- /dev/null
+++ b/packages/examples/src/render-motion-blur.ts
@@ -0,0 +1,19 @@
+import {renderVideo} from '@revideo/renderer';
+
+async function render() {
+ console.log('Rendering motion blur test video...');
+ console.log('This will render a side-by-side comparison of motion blur ON vs OFF');
+
+ const file = await renderVideo({
+ projectFile: './src/motion-blur.ts',
+ settings: {
+ logProgress: true,
+ outFile: 'motion-blur-demo.mp4',
+ outDir: './output',
+ },
+ });
+
+ console.log(`Rendered video to ${file}`);
+}
+
+render().catch(console.error);
diff --git a/packages/examples/src/scenes/motion-blur.tsx b/packages/examples/src/scenes/motion-blur.tsx
new file mode 100644
index 000000000..657b47948
--- /dev/null
+++ b/packages/examples/src/scenes/motion-blur.tsx
@@ -0,0 +1,79 @@
+import {Circle, Rect, Txt, makeScene2D} from '@revideo/2d';
+import {all, createRef, waitFor} from '@revideo/core';
+
+/**
+ * Motion Blur Demo Scene
+ *
+ * Demonstrates motion blur applied to all elements.
+ * Motion blur is configured at the scene/project level.
+ */
+export default makeScene2D('motion-blur', function* (view) {
+ // Create refs for animated elements
+ const circle = createRef();
+ const rect = createRef();
+
+ // Add all elements
+ view.add(
+ <>
+ {/* Title */}
+
+
+
+ {/* Circle - fast horizontal movement */}
+
+
+ {/* Rectangle - spinning */}
+
+ >,
+ );
+
+ // Animate elements
+ yield* all(
+ // Circle - fast horizontal sweep
+ circle().position.x(300, 0.4).to(-300, 0.4),
+ // Rectangle - continuous spin
+ rect().rotation(720, 2),
+ );
+
+ yield* waitFor(0.3);
+
+ // Second pass - faster for more pronounced blur
+ yield* all(
+ circle().position.x(300, 0.25).to(-300, 0.25),
+ );
+
+ yield* waitFor(0.3);
+
+ // Third pass
+ yield* all(
+ circle().position.x(300, 0.2).to(-300, 0.2),
+ );
+});
diff --git a/packages/examples/tsconfig.render.json b/packages/examples/tsconfig.render.json
new file mode 100644
index 000000000..3673e8f9c
--- /dev/null
+++ b/packages/examples/tsconfig.render.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@revideo/2d/tsconfig.project.json",
+ "compilerOptions": {
+ "types": ["node"],
+ "noEmit": false,
+ "outDir": "dist",
+ "module": "CommonJS",
+ "skipLibCheck": true
+ },
+ "include": ["src/render-motion-blur.ts"]
+}
diff --git a/packages/renderer/client/render.ts b/packages/renderer/client/render.ts
index e23c35224..31f4af96f 100644
--- a/packages/renderer/client/render.ts
+++ b/packages/renderer/client/render.ts
@@ -69,6 +69,10 @@ export const render = async (
renderer.frameToTime(firstWorkerFrame),
renderer.frameToTime(lastWorkerFrame),
] as [number, number],
+ // Ensure motion blur is passed through (user settings take precedence)
+ motionBlur:
+ overwriteRenderSettings.motionBlur ??
+ renderSettingsFromProject.motionBlur,
};
await renderer.render(combinedSettings);