From 0f2bd49af19f635e8131541a4bcf8f9b1e54fa03 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 3 Jan 2026 05:47:13 +0000
Subject: [PATCH 1/4] feat(cli): add request body validation for render
endpoints
Add upfront validation of request bodies for the /render API endpoint
to improve security and provide better error messages to API consumers.
The validation module checks:
- callbackUrl: must be valid http/https URL if provided
- variables: must be an object if provided
- streamProgress: must be boolean if provided
- settings: validates outFile extension, workers count, port range,
exporter configuration, size dimensions, and time range
This addresses the TODO comments in render.ts and improves API robustness.
---
packages/cli/src/server/render.ts | 14 +-
packages/cli/src/server/validation.test.ts | 336 +++++++++++++++++++++
packages/cli/src/server/validation.ts | 321 ++++++++++++++++++++
3 files changed, 669 insertions(+), 2 deletions(-)
create mode 100644 packages/cli/src/server/validation.test.ts
create mode 100644 packages/cli/src/server/validation.ts
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')
+ );
+}
From 1554627c7dbe676400567b3c13927c905dada21f Mon Sep 17 00:00:00 2001
From: Tim Scarfe
Date: Sat, 3 Jan 2026 15:01:35 +0700
Subject: [PATCH 2/4] feat: professional motion blur with per-element control
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
⚠️ AI-ASSISTED CODE - PLEASE REVIEW CAREFULLY ⚠️
This feature was vibecoded with Claude Code (Opus 4.5). While functional
and tested, it should be carefully reviewed before merging to production.
## Summary
Adds professional-grade motion blur to Revideo using temporal sub-frame
accumulation - the same technique used in film and professional video
production.
## Features
- **Quality presets**: low (4), medium (8), high (16), ultra (32) samples
- **Shutter curves**: box (uniform), triangle (linear falloff), gaussian (bell curve)
- **Shutter angle**: 0-720° (film standard is 180°)
- **Shutter position**: center, start, or end alignment
- **Per-element control**: Enable/disable blur on individual elements
## Implementation
Two-pass rendering for per-element motion blur:
1. Pass 1 (blur): Render blur-enabled elements with sub-frame accumulation
- Advance scene through sub-frame time offsets
- Accumulate each render with curve-based weights
- Normalize using high-precision Float32 buffer
2. Pass 2 (static): Render blur-disabled elements on top
- Single render at frame time
- Composited over the blurred result
## Usage
```typescript
motionBlur: {
enabled: true,
quality: 'high',
shutterAngle: 180,
shutterCurve: 'gaussian',
}
```
Per-element: ``
## Key Files
- packages/core/src/types/MotionBlur.ts - Config types and weight calculations
- packages/core/src/app/Renderer.ts - Two-pass rendering implementation
- packages/core/src/app/Stage.ts - Accumulation buffer
- packages/2d/src/lib/components/Node.ts - Per-element render pass filtering
- packages/2d/src/lib/components/View2D.ts - Motion blur subframe signals
🤖 Vibecoded with Claude Code (Opus 4.5) - https://claude.com/claude-code
Co-Authored-By: Claude
---
README.md | 113 +++++
docs/MOTION_BLUR.md | 345 +++++++++++++++
docs/motion-blur-demo.gif | Bin 0 -> 135765 bytes
docs/motion-blur-demo.mp4 | Bin 0 -> 203407 bytes
package-lock.json | 3 +-
packages/2d/src/lib/components/Node.ts | 65 +++
packages/2d/src/lib/components/View2D.ts | 38 ++
packages/2d/src/lib/scenes/Scene2D.ts | 21 +
packages/core/src/app/PlaybackStatus.ts | 33 +-
packages/core/src/app/Project.ts | 5 +-
packages/core/src/app/Renderer.ts | 125 +++++-
packages/core/src/app/Stage.ts | 155 ++++++-
packages/core/src/scenes/Scene.ts | 15 +
packages/core/src/types/MotionBlur.test.ts | 338 +++++++++++++++
packages/core/src/types/MotionBlur.ts | 430 +++++++++++++++++++
packages/core/src/types/index.ts | 1 +
packages/examples/package.json | 6 +-
packages/examples/src/motion-blur.ts | 37 ++
packages/examples/src/render-motion-blur.ts | 19 +
packages/examples/src/scenes/motion-blur.tsx | 141 ++++++
packages/examples/tsconfig.render.json | 11 +
packages/renderer/client/render.ts | 4 +
22 files changed, 1886 insertions(+), 19 deletions(-)
create mode 100644 docs/MOTION_BLUR.md
create mode 100644 docs/motion-blur-demo.gif
create mode 100644 docs/motion-blur-demo.mp4
create mode 100644 packages/core/src/types/MotionBlur.test.ts
create mode 100644 packages/core/src/types/MotionBlur.ts
create mode 100644 packages/examples/src/motion-blur.ts
create mode 100644 packages/examples/src/render-motion-blur.ts
create mode 100644 packages/examples/src/scenes/motion-blur.tsx
create mode 100644 packages/examples/tsconfig.render.json
diff --git a/README.md b/README.md
index fde6865b6..e8b1576b9 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,116 @@
+# 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.
+
+