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 Demo - Left side shows blur, right side shows sharp reference +

+ +## 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 `