diff --git a/bun.lock b/bun.lock index 3bf80147223..357f3542671 100644 --- a/bun.lock +++ b/bun.lock @@ -151,6 +151,25 @@ "react-dom": ">=16.8.0", }, }, + "packages/canvas-capture": { + "name": "@remotion/canvas-capture", + "version": "4.0.474", + "dependencies": { + "mediabunny": "catalog:", + "remotion": "workspace:*", + }, + "devDependencies": { + "@remotion/eslint-config-internal": "workspace:*", + "@typescript/native-preview": "catalog:", + "eslint": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + }, + }, "packages/captions": { "name": "@remotion/captions", "version": "4.0.474", @@ -675,6 +694,7 @@ "@remotion/animation-utils": "workspace:*", "@remotion/babel-loader": "workspace:*", "@remotion/bundler": "workspace:*", + "@remotion/canvas-capture": "workspace:*", "@remotion/captions": "workspace:*", "@remotion/cli": "workspace:*", "@remotion/cloudrun": "workspace:*", @@ -1551,6 +1571,7 @@ "version": "4.0.474", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", + "@remotion/canvas-capture": "workspace:*", "@remotion/media-utils": "workspace:*", "@remotion/player": "workspace:*", "@remotion/renderer": "workspace:*", @@ -3900,6 +3921,8 @@ "@remotion/bundler": ["@remotion/bundler@workspace:packages/bundler"], + "@remotion/canvas-capture": ["@remotion/canvas-capture@workspace:packages/canvas-capture"], + "@remotion/captions": ["@remotion/captions@workspace:packages/captions"], "@remotion/cli": ["@remotion/cli@workspace:packages/cli"], diff --git a/packages/canvas-capture/.npmignore b/packages/canvas-capture/.npmignore new file mode 100644 index 00000000000..eb76a5ae811 --- /dev/null +++ b/packages/canvas-capture/.npmignore @@ -0,0 +1,10 @@ +dist/test/** +src +eslint.config.mjs +tsconfig.tsbuildinfo +*.tgz +.turbo +tsconfig.json +.prettierrc.js +bundle.ts +dist/cjs/test diff --git a/packages/canvas-capture/README.md b/packages/canvas-capture/README.md new file mode 100644 index 00000000000..abbe5e85ff8 --- /dev/null +++ b/packages/canvas-capture/README.md @@ -0,0 +1,7 @@ +# @remotion/canvas-capture + +Capture HTML-in-canvas content as a video + +## Usage + +This is an internal package and has no documentation. diff --git a/packages/canvas-capture/bundle.ts b/packages/canvas-capture/bundle.ts new file mode 100644 index 00000000000..ddcee82940e --- /dev/null +++ b/packages/canvas-capture/bundle.ts @@ -0,0 +1,35 @@ +import path from 'path'; +import {build} from 'bun'; + +if (process.env.NODE_ENV !== 'production') { + throw new Error('This script must be run using NODE_ENV=production'); +} + +console.time('Generated.'); +const output = await build({ + entrypoints: ['src/index.tsx'], + naming: '[name].mjs', + external: [ + 'mediabunny', + 'remotion', + 'remotion/no-react', + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom', + ], +}); + +if (!output.success) { + console.log(output.logs.join('\n')); + process.exit(1); +} + +for (const file of output.outputs) { + const str = await file.text(); + const out = path.join('dist', 'esm', file.path); + + await Bun.write(out, str); +} + +console.timeEnd('Generated.'); diff --git a/packages/canvas-capture/eslint.config.mjs b/packages/canvas-capture/eslint.config.mjs new file mode 100644 index 00000000000..ab29acaba08 --- /dev/null +++ b/packages/canvas-capture/eslint.config.mjs @@ -0,0 +1,7 @@ +import {remotionFlatConfig} from '@remotion/eslint-config-internal'; + +const config = remotionFlatConfig({react: true}); + +export default { + ...config, +}; diff --git a/packages/canvas-capture/package.json b/packages/canvas-capture/package.json new file mode 100644 index 00000000000..2f57a6ac32a --- /dev/null +++ b/packages/canvas-capture/package.json @@ -0,0 +1,55 @@ +{ + "repository": { + "url": "https://github.com/remotion-dev/remotion/tree/main/packages/canvas-capture" + }, + "name": "@remotion/canvas-capture", + "version": "4.0.474", + "description": "Capture HTML-in-canvas content as a video", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "module": "dist/esm/index.mjs", + "type": "module", + "scripts": { + "formatting": "oxfmt src --check", + "format": "oxfmt src", + "lint": "eslint src", + "watch": "tsgo -w", + "make": "tsgo && bun --env-file=../.env.bundle bundle.ts" + }, + "author": "Jonny Burger ", + "contributors": [], + "license": "Remotion License", + "bugs": { + "url": "https://github.com/remotion-dev/remotion/issues" + }, + "dependencies": { + "mediabunny": "catalog:", + "remotion": "workspace:*" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "module": "./dist/esm/index.mjs", + "import": "./dist/esm/index.mjs" + }, + "./package.json": "./package.json" + }, + "devDependencies": { + "react": "catalog:", + "react-dom": "catalog:", + "@remotion/eslint-config-internal": "workspace:*", + "eslint": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "keywords": [ + "remotion", + "canvas-capture" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/canvas-capture/src/index.tsx b/packages/canvas-capture/src/index.tsx new file mode 100644 index 00000000000..94f16f71d28 --- /dev/null +++ b/packages/canvas-capture/src/index.tsx @@ -0,0 +1,559 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; + +type HtmlInCanvasElement = HTMLCanvasElement & { + layoutSubtree?: boolean; + requestPaint?: () => void; +}; + +type HtmlInCanvasRenderingContext2D = CanvasRenderingContext2D & { + drawElementImage?: ( + element: Element, + dx: number, + dy: number, + dWidth?: number, + dHeight?: number, + ) => DOMMatrix; + reset?: () => void; +}; + +type CanvasVideoSource = { + add: ( + timestamp: number, + duration: number, + encodeOptions?: VideoEncoderEncodeOptions, + ) => Promise; + close: () => void; +}; + +type RecordingOutput = { + start: () => Promise; + finalize: () => Promise; + cancel: () => Promise; +}; + +type RecordingTarget = { + readonly buffer: ArrayBuffer | null; +}; + +type MouseMovement = { + readonly timeInSeconds: number; + readonly clientX: number; + readonly clientY: number; + readonly pageX: number; + readonly pageY: number; + readonly canvasX: number | null; + readonly canvasY: number | null; + readonly cursor: string; +}; + +type CaptureMetadata = { + readonly density: number; + readonly contentRect: { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; + }; + readonly canvasSize: { + readonly width: number; + readonly height: number; + }; + readonly viewport: { + readonly width: number; + readonly height: number; + readonly scrollX: number; + readonly scrollY: number; + }; +}; + +type RecordingState = { + readonly output: RecordingOutput; + readonly target: RecordingTarget; + readonly source: CanvasVideoSource; + readonly startedAt: number; + readonly mouseMovements: MouseMovement[]; + lastTimestampInSeconds: number | null; + lastFramePromise: Promise; + frameCount: number; + captureMetadata: CaptureMetadata | null; + isFinalizing: boolean; +}; + +export type HtmlInCanvasCaptureHandle = { + readonly toggleRecording: () => Promise; + readonly startRecording: () => Promise; + readonly stopRecording: () => Promise; +}; + +type HtmlInCanvasCaptureProps = { + readonly children: React.ReactNode; + readonly density: number; + readonly filename: string; +}; + +type WithHtmlInCanvasCaptureProps = { + readonly density: number; + readonly filename: string; +}; + +const canvasStyle: React.CSSProperties = { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + display: 'block', +}; + +const contentStyle: React.CSSProperties = { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + transformOrigin: 'top left', +}; + +const fallbackFrameDurationInSeconds = 1 / 30; +const recordingVideoBitrate = 60_000_000; +const recordingKeyFrameIntervalInSeconds = 0.5; + +export const isHtmlInCanvasAvailable = () => { + if (typeof document === 'undefined') { + return false; + } + + const canvas = document.createElement('canvas') as HtmlInCanvasElement; + const context = canvas.getContext( + '2d', + ) as HtmlInCanvasRenderingContext2D | null; + + return ( + typeof canvas.requestPaint === 'function' && + typeof context?.drawElementImage === 'function' + ); +}; + +const resetCanvas = ( + context: HtmlInCanvasRenderingContext2D, + canvas: HTMLCanvasElement, +) => { + if (typeof context.reset === 'function') { + context.reset(); + return; + } + + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, canvas.width, canvas.height); +}; + +const roundUpToEven = (value: number) => { + const rounded = Math.max(2, Math.ceil(value)); + return rounded % 2 === 0 ? rounded : rounded + 1; +}; + +const syncCanvasSize = ( + canvas: HTMLCanvasElement, + width: number, + height: number, + density: number, +) => { + const scaledWidth = roundUpToEven(width * density); + const scaledHeight = roundUpToEven(height * density); + + if (canvas.width !== scaledWidth) { + canvas.width = scaledWidth; + } + + if (canvas.height !== scaledHeight) { + canvas.height = scaledHeight; + } +}; + +const getCursorForElement = (element: Element | null): string => { + let current: Element | null = element; + + while (current) { + const {cursor} = window.getComputedStyle(current); + if (cursor !== 'auto') { + return cursor; + } + + current = current.parentElement; + } + + return 'auto'; +}; + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +}; + +const getJsonFilename = (filename: string) => { + return filename.replace(/\.[^.]+$/, '') + '.json'; +}; + +const logCaptureError = (message: string, err: unknown) => { + // eslint-disable-next-line no-console + console.error(message, err instanceof Error ? err.message : String(err)); +}; + +const addPaintFrameToRecording = ( + recording: RecordingState, + source: CanvasVideoSource, +) => { + const elapsedInSeconds = Math.max( + 0, + (performance.now() - recording.startedAt) / 1000, + ); + const timestampInSeconds = + recording.lastTimestampInSeconds === null ? 0 : elapsedInSeconds; + const durationInSeconds = + recording.lastTimestampInSeconds === null + ? fallbackFrameDurationInSeconds + : Math.max( + fallbackFrameDurationInSeconds, + elapsedInSeconds - recording.lastTimestampInSeconds, + ); + const keyFrame = recording.lastTimestampInSeconds === null; + + recording.lastTimestampInSeconds = timestampInSeconds; + recording.lastFramePromise = recording.lastFramePromise.then(async () => { + await source.add(timestampInSeconds, durationInSeconds, {keyFrame}); + recording.frameCount++; + }); +}; + +const finalizeRecording = async ( + recording: RecordingState, + filename: string, +) => { + addPaintFrameToRecording(recording, recording.source); + await recording.lastFramePromise; + + if (recording.frameCount === 0) { + throw new Error('No frames were added to the Studio canvas recording.'); + } + + recording.source.close(); + await recording.output.finalize(); + + if (!recording.target.buffer) { + throw new Error('Mediabunny did not return an output buffer.'); + } + + downloadBlob( + new Blob([recording.target.buffer], {type: 'video/webm'}), + filename, + ); + downloadBlob( + new Blob( + [ + JSON.stringify( + { + startedAt: recording.startedAt, + endedAt: performance.now(), + captureMetadata: recording.captureMetadata, + mouseMovements: recording.mouseMovements, + }, + null, + 2, + ), + ], + {type: 'application/json'}, + ), + getJsonFilename(filename), + ); +}; + +export const HtmlInCanvasCapture = forwardRef< + HtmlInCanvasCaptureHandle, + HtmlInCanvasCaptureProps +>(({children, density, filename}, ref) => { + if (!Number.isFinite(density) || density <= 0) { + throw new Error('HTML-in-canvas capture density must be greater than 0.'); + } + + const isSupported = useMemo(() => isHtmlInCanvasAvailable(), []); + const canvasRef = useRef(null); + const contentRef = useRef(null); + const recordingRef = useRef(null); + const recordingActionRef = useRef>(Promise.resolve()); + + const requestPaint = useCallback(() => { + const canvas = canvasRef.current; + if (typeof canvas?.requestPaint !== 'function') { + return; + } + + canvas.requestPaint(); + }, []); + + const startRecording = useCallback(async () => { + const canvas = canvasRef.current; + if (!canvas || recordingRef.current) { + return; + } + + const {BufferTarget, CanvasSource, Output, WebMOutputFormat} = + await import('mediabunny'); + const target = new BufferTarget(); + const output = new Output({ + format: new WebMOutputFormat(), + target, + }); + const source = new CanvasSource(canvas, { + codec: 'vp9', + bitrate: recordingVideoBitrate, + latencyMode: 'realtime', + keyFrameInterval: recordingKeyFrameIntervalInSeconds, + }); + + output.addVideoTrack(source); + await output.start(); + + recordingRef.current = { + output, + target, + source, + startedAt: performance.now(), + mouseMovements: [], + lastTimestampInSeconds: null, + lastFramePromise: Promise.resolve(), + frameCount: 0, + captureMetadata: null, + isFinalizing: false, + }; + requestPaint(); + }, [requestPaint]); + + const stopRecording = useCallback(async () => { + const recording = recordingRef.current; + if (!recording || recording.isFinalizing) { + return; + } + + recording.isFinalizing = true; + + try { + await finalizeRecording(recording, filename); + } catch (err) { + logCaptureError('Could not finalize Studio canvas recording:', err); + await recording.output.cancel().catch((cancelErr) => { + logCaptureError('Could not cancel Studio canvas recording:', cancelErr); + }); + } finally { + recordingRef.current = null; + } + }, [filename]); + + const toggleRecording = useCallback(async () => { + recordingActionRef.current = recordingActionRef.current.then(async () => { + if (recordingRef.current) { + await stopRecording(); + return; + } + + await startRecording(); + }); + + await recordingActionRef.current; + }, [startRecording, stopRecording]); + + useImperativeHandle( + ref, + () => ({ + toggleRecording, + startRecording, + stopRecording, + }), + [startRecording, stopRecording, toggleRecording], + ); + + const drawCurrentPaint = useCallback(() => { + const canvas = canvasRef.current; + const content = contentRef.current; + if (!canvas || !content) { + return; + } + + const context = canvas.getContext( + '2d', + ) as HtmlInCanvasRenderingContext2D | null; + if (!context || typeof context.drawElementImage !== 'function') { + throw new Error('drawElementImage() is not available.'); + } + + const rect = content.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + + syncCanvasSize(canvas, rect.width, rect.height, density); + resetCanvas(context, canvas); + context.scale(density, density); + context.drawElementImage(content, 0, 0, rect.width, rect.height); + + const recording = recordingRef.current; + if (recording && !recording.isFinalizing) { + recording.captureMetadata = { + density, + contentRect: { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }, + canvasSize: { + width: canvas.width, + height: canvas.height, + }, + viewport: { + width: window.innerWidth, + height: window.innerHeight, + scrollX: window.scrollX, + scrollY: window.scrollY, + }, + }; + addPaintFrameToRecording(recording, recording.source); + } + }, [density]); + + useEffect(() => { + const onMouseMove = (event: MouseEvent) => { + const recording = recordingRef.current; + if (!recording || recording.isFinalizing) { + return; + } + + const rect = contentRef.current?.getBoundingClientRect(); + + recording.mouseMovements.push({ + timeInSeconds: (performance.now() - recording.startedAt) / 1000, + clientX: event.clientX, + clientY: event.clientY, + pageX: event.pageX, + pageY: event.pageY, + canvasX: rect ? (event.clientX - rect.left) * density : null, + canvasY: rect ? (event.clientY - rect.top) * density : null, + cursor: getCursorForElement( + document.elementFromPoint(event.clientX, event.clientY), + ), + }); + }; + + window.addEventListener('mousemove', onMouseMove); + + return () => { + window.removeEventListener('mousemove', onMouseMove); + }; + }, [density]); + + useEffect(() => { + if (!isSupported) { + return; + } + + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + canvas.setAttribute('layoutsubtree', ''); + canvas.layoutSubtree = true; + + const onPaint = () => { + try { + drawCurrentPaint(); + } catch (err) { + logCaptureError('Could not capture Studio canvas paint:', err); + } + }; + + canvas.addEventListener('paint', onPaint as EventListener); + const frame = requestAnimationFrame(requestPaint); + + return () => { + cancelAnimationFrame(frame); + canvas.removeEventListener('paint', onPaint as EventListener); + }; + }, [drawCurrentPaint, isSupported, requestPaint]); + + useEffect(() => { + if (!isSupported) { + return; + } + + const content = contentRef.current; + const canvas = canvasRef.current; + if (!content || !canvas) { + return; + } + + const resizeObserver = new ResizeObserver(([entry]) => { + const {width, height} = entry.contentRect; + syncCanvasSize(canvas, width, height, density); + requestPaint(); + }); + + resizeObserver.observe(content); + + return () => { + resizeObserver.disconnect(); + }; + }, [density, isSupported, requestPaint]); + + useEffect(() => { + return () => { + const recording = recordingRef.current; + if (!recording || recording.isFinalizing) { + return; + } + + recording.isFinalizing = true; + recording.output.cancel().catch((err) => { + logCaptureError('Could not cancel Studio canvas recording:', err); + }); + recordingRef.current = null; + }; + }, []); + + if (!isSupported) { + return children; + } + + return ( + +
+ {children} +
+
+ ); +}); + +export const withHtmlInCanvasCapture = ( + Component: React.ComponentType, +) => { + return forwardRef< + HtmlInCanvasCaptureHandle, + Props & WithHtmlInCanvasCaptureProps + >(({density, filename, ...props}, ref) => { + return ( + + + + ); + }); +}; diff --git a/packages/canvas-capture/tsconfig.json b/packages/canvas-capture/tsconfig.json new file mode 100644 index 00000000000..22b60bbe95b --- /dev/null +++ b/packages/canvas-capture/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.settings.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "jsx": "react-jsx", + "skipLibCheck": true, + "target": "ES2022", + "module": "es2020", + "moduleResolution": "bundler", + "declaration": true, + "emitDeclarationOnly": true + }, + "include": ["src"], + "references": [{"path": "../core"}] +} diff --git a/packages/cli/src/list-of-remotion-packages.ts b/packages/cli/src/list-of-remotion-packages.ts index 44f151356cd..a72aa959e46 100644 --- a/packages/cli/src/list-of-remotion-packages.ts +++ b/packages/cli/src/list-of-remotion-packages.ts @@ -8,6 +8,7 @@ export const listOfRemotionPackages = [ '@remotion/bugs', '@remotion/brand', '@remotion/bundler', + '@remotion/canvas-capture', '@remotion/cli', '@remotion/cloudrun', '@remotion/codex-plugin', diff --git a/packages/create-video/src/list-of-remotion-packages.ts b/packages/create-video/src/list-of-remotion-packages.ts index 44f151356cd..a72aa959e46 100644 --- a/packages/create-video/src/list-of-remotion-packages.ts +++ b/packages/create-video/src/list-of-remotion-packages.ts @@ -8,6 +8,7 @@ export const listOfRemotionPackages = [ '@remotion/bugs', '@remotion/brand', '@remotion/bundler', + '@remotion/canvas-capture', '@remotion/cli', '@remotion/cloudrun', '@remotion/codex-plugin', diff --git a/packages/example/package.json b/packages/example/package.json index 0aa4abe7a09..e3e90f7f0fd 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -40,6 +40,7 @@ "@remotion/animation-utils": "workspace:*", "@remotion/babel-loader": "workspace:*", "@remotion/bundler": "workspace:*", + "@remotion/canvas-capture": "workspace:*", "@remotion/effects": "workspace:*", "@remotion/captions": "workspace:*", "@remotion/cli": "workspace:*", diff --git a/packages/example/public/remotion-studio-canvas-recording.json b/packages/example/public/remotion-studio-canvas-recording.json new file mode 100644 index 00000000000..202127111b1 --- /dev/null +++ b/packages/example/public/remotion-studio-canvas-recording.json @@ -0,0 +1,1885 @@ +{ + "startedAt": 50139.40000009537, + "endedAt": 55572.90000009537, + "captureMetadata": { + "density": 2, + "contentRect": { + "left": 0, + "top": 0, + "width": 2131, + "height": 1328 + }, + "canvasSize": { + "width": 4262, + "height": 2656 + }, + "viewport": { + "width": 2131, + "height": 1328, + "scrollX": 0, + "scrollY": 0 + } + }, + "mouseMovements": [ + { + "timeInSeconds": 0.5171999998092651, + "clientX": 767, + "clientY": 557, + "pageX": 767, + "pageY": 557, + "canvasX": 1534, + "canvasY": 1114, + "cursor": "auto" + }, + { + "timeInSeconds": 0.5400999999046325, + "clientX": 772, + "clientY": 692, + "pageX": 772, + "pageY": 692, + "canvasX": 1544, + "canvasY": 1384, + "cursor": "auto" + }, + { + "timeInSeconds": 0.5575, + "clientX": 792, + "clientY": 844, + "pageX": 792, + "pageY": 844, + "canvasX": 1584, + "canvasY": 1688, + "cursor": "auto" + }, + { + "timeInSeconds": 0.5743999996185303, + "clientX": 823, + "clientY": 990, + "pageX": 823, + "pageY": 990, + "canvasX": 1646, + "canvasY": 1980, + "cursor": "auto" + }, + { + "timeInSeconds": 0.5903999996185303, + "clientX": 835, + "clientY": 1065, + "pageX": 835, + "pageY": 1065, + "canvasX": 1670, + "canvasY": 2130, + "cursor": "auto" + }, + { + "timeInSeconds": 0.6071999998092651, + "clientX": 836, + "clientY": 1094, + "pageX": 836, + "pageY": 1094, + "canvasX": 1672, + "canvasY": 2188, + "cursor": "auto" + }, + { + "timeInSeconds": 0.6565999999046326, + "clientX": 836, + "clientY": 1094, + "pageX": 836, + "pageY": 1094, + "canvasX": 1672, + "canvasY": 2188, + "cursor": "auto" + }, + { + "timeInSeconds": 0.6737999997138977, + "clientX": 831, + "clientY": 1089, + "pageX": 831, + "pageY": 1089, + "canvasX": 1662, + "canvasY": 2178, + "cursor": "auto" + }, + { + "timeInSeconds": 0.6905, + "clientX": 820, + "clientY": 1075, + "pageX": 820, + "pageY": 1075, + "canvasX": 1640, + "canvasY": 2150, + "cursor": "auto" + }, + { + "timeInSeconds": 0.7071999998092652, + "clientX": 793, + "clientY": 1040, + "pageX": 793, + "pageY": 1040, + "canvasX": 1586, + "canvasY": 2080, + "cursor": "auto" + }, + { + "timeInSeconds": 0.7237999997138977, + "clientX": 769, + "clientY": 1010, + "pageX": 769, + "pageY": 1010, + "canvasX": 1538, + "canvasY": 2020, + "cursor": "auto" + }, + { + "timeInSeconds": 0.741, + "clientX": 741, + "clientY": 964, + "pageX": 741, + "pageY": 964, + "canvasX": 1482, + "canvasY": 1928, + "cursor": "auto" + }, + { + "timeInSeconds": 0.7572999997138977, + "clientX": 735, + "clientY": 945, + "pageX": 735, + "pageY": 945, + "canvasX": 1470, + "canvasY": 1890, + "cursor": "auto" + }, + { + "timeInSeconds": 0.7738999996185303, + "clientX": 734, + "clientY": 945, + "pageX": 734, + "pageY": 945, + "canvasX": 1468, + "canvasY": 1890, + "cursor": "auto" + }, + { + "timeInSeconds": 0.8508999996185302, + "clientX": 735, + "clientY": 945, + "pageX": 735, + "pageY": 945, + "canvasX": 1470, + "canvasY": 1890, + "cursor": "auto" + }, + { + "timeInSeconds": 0.8655999999046325, + "clientX": 739, + "clientY": 944, + "pageX": 739, + "pageY": 944, + "canvasX": 1478, + "canvasY": 1888, + "cursor": "auto" + }, + { + "timeInSeconds": 0.8872999997138977, + "clientX": 744, + "clientY": 945, + "pageX": 744, + "pageY": 945, + "canvasX": 1488, + "canvasY": 1890, + "cursor": "auto" + }, + { + "timeInSeconds": 0.8988999996185303, + "clientX": 752, + "clientY": 946, + "pageX": 752, + "pageY": 946, + "canvasX": 1504, + "canvasY": 1892, + "cursor": "auto" + }, + { + "timeInSeconds": 0.9168999996185303, + "clientX": 762, + "clientY": 950, + "pageX": 762, + "pageY": 950, + "canvasX": 1524, + "canvasY": 1900, + "cursor": "auto" + }, + { + "timeInSeconds": 0.9326999998092651, + "clientX": 769, + "clientY": 953, + "pageX": 769, + "pageY": 953, + "canvasX": 1538, + "canvasY": 1906, + "cursor": "auto" + }, + { + "timeInSeconds": 0.9548999996185302, + "clientX": 774, + "clientY": 954, + "pageX": 774, + "pageY": 954, + "canvasX": 1548, + "canvasY": 1908, + "cursor": "auto" + }, + { + "timeInSeconds": 0.9658999996185302, + "clientX": 779, + "clientY": 954, + "pageX": 779, + "pageY": 954, + "canvasX": 1558, + "canvasY": 1908, + "cursor": "auto" + }, + { + "timeInSeconds": 0.9836999998092651, + "clientX": 782, + "clientY": 955, + "pageX": 782, + "pageY": 955, + "canvasX": 1564, + "canvasY": 1910, + "cursor": "auto" + }, + { + "timeInSeconds": 0.9990999999046326, + "clientX": 786, + "clientY": 958, + "pageX": 786, + "pageY": 958, + "canvasX": 1572, + "canvasY": 1916, + "cursor": "auto" + }, + { + "timeInSeconds": 1.0192999997138976, + "clientX": 789, + "clientY": 963, + "pageX": 789, + "pageY": 963, + "canvasX": 1578, + "canvasY": 1926, + "cursor": "auto" + }, + { + "timeInSeconds": 1.0335, + "clientX": 790, + "clientY": 970, + "pageX": 790, + "pageY": 970, + "canvasX": 1580, + "canvasY": 1940, + "cursor": "auto" + }, + { + "timeInSeconds": 1.051, + "clientX": 790, + "clientY": 980, + "pageX": 790, + "pageY": 980, + "canvasX": 1580, + "canvasY": 1960, + "cursor": "auto" + }, + { + "timeInSeconds": 1.0663999996185303, + "clientX": 790, + "clientY": 986, + "pageX": 790, + "pageY": 986, + "canvasX": 1580, + "canvasY": 1972, + "cursor": "auto" + }, + { + "timeInSeconds": 1.087, + "clientX": 790, + "clientY": 991, + "pageX": 790, + "pageY": 991, + "canvasX": 1580, + "canvasY": 1982, + "cursor": "auto" + }, + { + "timeInSeconds": 1.0987999997138977, + "clientX": 790, + "clientY": 994, + "pageX": 790, + "pageY": 994, + "canvasX": 1580, + "canvasY": 1988, + "cursor": "auto" + }, + { + "timeInSeconds": 1.1172999997138977, + "clientX": 790, + "clientY": 995, + "pageX": 790, + "pageY": 995, + "canvasX": 1580, + "canvasY": 1990, + "cursor": "auto" + }, + { + "timeInSeconds": 1.1320999999046326, + "clientX": 791, + "clientY": 995, + "pageX": 791, + "pageY": 995, + "canvasX": 1582, + "canvasY": 1990, + "cursor": "auto" + }, + { + "timeInSeconds": 1.153, + "clientX": 793, + "clientY": 995, + "pageX": 793, + "pageY": 995, + "canvasX": 1586, + "canvasY": 1990, + "cursor": "auto" + }, + { + "timeInSeconds": 1.1655999999046325, + "clientX": 794, + "clientY": 996, + "pageX": 794, + "pageY": 996, + "canvasX": 1588, + "canvasY": 1992, + "cursor": "auto" + }, + { + "timeInSeconds": 1.1850999999046326, + "clientX": 797, + "clientY": 996, + "pageX": 797, + "pageY": 996, + "canvasX": 1594, + "canvasY": 1992, + "cursor": "auto" + }, + { + "timeInSeconds": 1.1992999997138978, + "clientX": 802, + "clientY": 996, + "pageX": 802, + "pageY": 996, + "canvasX": 1604, + "canvasY": 1992, + "cursor": "auto" + }, + { + "timeInSeconds": 1.2192999997138978, + "clientX": 808, + "clientY": 996, + "pageX": 808, + "pageY": 996, + "canvasX": 1616, + "canvasY": 1992, + "cursor": "auto" + }, + { + "timeInSeconds": 1.2325, + "clientX": 813, + "clientY": 997, + "pageX": 813, + "pageY": 997, + "canvasX": 1626, + "canvasY": 1994, + "cursor": "auto" + }, + { + "timeInSeconds": 1.2501999998092652, + "clientX": 817, + "clientY": 998, + "pageX": 817, + "pageY": 998, + "canvasX": 1634, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.2661999998092652, + "clientX": 818, + "clientY": 998, + "pageX": 818, + "pageY": 998, + "canvasX": 1636, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.2860999999046325, + "clientX": 818, + "clientY": 998, + "pageX": 818, + "pageY": 998, + "canvasX": 1636, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.3663999996185303, + "clientX": 818, + "clientY": 998, + "pageX": 818, + "pageY": 998, + "canvasX": 1636, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.3835, + "clientX": 818, + "clientY": 998, + "pageX": 818, + "pageY": 998, + "canvasX": 1636, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.424, + "clientX": 821, + "clientY": 998, + "pageX": 821, + "pageY": 998, + "canvasX": 1642, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.4405999999046326, + "clientX": 833, + "clientY": 998, + "pageX": 833, + "pageY": 998, + "canvasX": 1666, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.4582999997138977, + "clientX": 849, + "clientY": 998, + "pageX": 849, + "pageY": 998, + "canvasX": 1698, + "canvasY": 1996, + "cursor": "auto" + }, + { + "timeInSeconds": 1.4740999999046325, + "clientX": 885, + "clientY": 999, + "pageX": 885, + "pageY": 999, + "canvasX": 1770, + "canvasY": 1998, + "cursor": "auto" + }, + { + "timeInSeconds": 1.4910999999046326, + "clientX": 919, + "clientY": 1000, + "pageX": 919, + "pageY": 1000, + "canvasX": 1838, + "canvasY": 2000, + "cursor": "auto" + }, + { + "timeInSeconds": 1.5232999997138976, + "clientX": 977, + "clientY": 1002, + "pageX": 977, + "pageY": 1002, + "canvasX": 1954, + "canvasY": 2004, + "cursor": "auto" + }, + { + "timeInSeconds": 1.5410999999046326, + "clientX": 1003, + "clientY": 1003, + "pageX": 1003, + "pageY": 1003, + "canvasX": 2006, + "canvasY": 2006, + "cursor": "auto" + }, + { + "timeInSeconds": 1.557699999809265, + "clientX": 1020, + "clientY": 1003, + "pageX": 1020, + "pageY": 1003, + "canvasX": 2040, + "canvasY": 2006, + "cursor": "auto" + }, + { + "timeInSeconds": 1.5907999997138977, + "clientX": 1112, + "clientY": 1009, + "pageX": 1112, + "pageY": 1009, + "canvasX": 2224, + "canvasY": 2018, + "cursor": "auto" + }, + { + "timeInSeconds": 1.6075999999046326, + "clientX": 1149, + "clientY": 1012, + "pageX": 1149, + "pageY": 1012, + "canvasX": 2298, + "canvasY": 2024, + "cursor": "auto" + }, + { + "timeInSeconds": 1.6243999996185303, + "clientX": 1175, + "clientY": 1013, + "pageX": 1175, + "pageY": 1013, + "canvasX": 2350, + "canvasY": 2026, + "cursor": "auto" + }, + { + "timeInSeconds": 1.6568999996185303, + "clientX": 1238, + "clientY": 1015, + "pageX": 1238, + "pageY": 1015, + "canvasX": 2476, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.6741999998092651, + "clientX": 1273, + "clientY": 1014, + "pageX": 1273, + "pageY": 1014, + "canvasX": 2546, + "canvasY": 2028, + "cursor": "auto" + }, + { + "timeInSeconds": 1.691, + "clientX": 1301, + "clientY": 1015, + "pageX": 1301, + "pageY": 1015, + "canvasX": 2602, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.7076999998092652, + "clientX": 1325, + "clientY": 1015, + "pageX": 1325, + "pageY": 1015, + "canvasX": 2650, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.7242999997138977, + "clientX": 1343, + "clientY": 1015, + "pageX": 1343, + "pageY": 1015, + "canvasX": 2686, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.7408999996185304, + "clientX": 1372, + "clientY": 1015, + "pageX": 1372, + "pageY": 1015, + "canvasX": 2744, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.757699999809265, + "clientX": 1389, + "clientY": 1015, + "pageX": 1389, + "pageY": 1015, + "canvasX": 2778, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.7907999997138977, + "clientX": 1405, + "clientY": 1015, + "pageX": 1405, + "pageY": 1015, + "canvasX": 2810, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.8075999999046326, + "clientX": 1405, + "clientY": 1015, + "pageX": 1405, + "pageY": 1015, + "canvasX": 2810, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.8850999999046325, + "clientX": 1405, + "clientY": 1015, + "pageX": 1405, + "pageY": 1015, + "canvasX": 2810, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.898699999809265, + "clientX": 1404, + "clientY": 1015, + "pageX": 1404, + "pageY": 1015, + "canvasX": 2808, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 1.9188999996185303, + "clientX": 1385, + "clientY": 1017, + "pageX": 1385, + "pageY": 1017, + "canvasX": 2770, + "canvasY": 2034, + "cursor": "auto" + }, + { + "timeInSeconds": 1.9408999996185303, + "clientX": 1332, + "clientY": 1025, + "pageX": 1332, + "pageY": 1025, + "canvasX": 2664, + "canvasY": 2050, + "cursor": "auto" + }, + { + "timeInSeconds": 1.9575999999046325, + "clientX": 1280, + "clientY": 1030, + "pageX": 1280, + "pageY": 1030, + "canvasX": 2560, + "canvasY": 2060, + "cursor": "auto" + }, + { + "timeInSeconds": 1.9740999999046325, + "clientX": 1221, + "clientY": 1033, + "pageX": 1221, + "pageY": 1033, + "canvasX": 2442, + "canvasY": 2066, + "cursor": "auto" + }, + { + "timeInSeconds": 1.9910999999046326, + "clientX": 1155, + "clientY": 1035, + "pageX": 1155, + "pageY": 1035, + "canvasX": 2310, + "canvasY": 2070, + "cursor": "auto" + }, + { + "timeInSeconds": 2.0077999997138978, + "clientX": 1071, + "clientY": 1035, + "pageX": 1071, + "pageY": 1035, + "canvasX": 2142, + "canvasY": 2070, + "cursor": "auto" + }, + { + "timeInSeconds": 2.0243999996185305, + "clientX": 947, + "clientY": 1033, + "pageX": 947, + "pageY": 1033, + "canvasX": 1894, + "canvasY": 2066, + "cursor": "auto" + }, + { + "timeInSeconds": 2.04089999961853, + "clientX": 892, + "clientY": 1031, + "pageX": 892, + "pageY": 1031, + "canvasX": 1784, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.0567999997138977, + "clientX": 815, + "clientY": 1032, + "pageX": 815, + "pageY": 1032, + "canvasX": 1630, + "canvasY": 2064, + "cursor": "auto" + }, + { + "timeInSeconds": 2.0745, + "clientX": 769, + "clientY": 1032, + "pageX": 769, + "pageY": 1032, + "canvasX": 1538, + "canvasY": 2064, + "cursor": "auto" + }, + { + "timeInSeconds": 2.0905, + "clientX": 731, + "clientY": 1031, + "pageX": 731, + "pageY": 1031, + "canvasX": 1462, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.107699999809265, + "clientX": 715, + "clientY": 1031, + "pageX": 715, + "pageY": 1031, + "canvasX": 1430, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.1232999997138977, + "clientX": 709, + "clientY": 1031, + "pageX": 709, + "pageY": 1031, + "canvasX": 1418, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.1400999999046326, + "clientX": 707, + "clientY": 1031, + "pageX": 707, + "pageY": 1031, + "canvasX": 1414, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.2240999999046327, + "clientX": 706, + "clientY": 1031, + "pageX": 706, + "pageY": 1031, + "canvasX": 1412, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.2415, + "clientX": 706, + "clientY": 1031, + "pageX": 706, + "pageY": 1031, + "canvasX": 1412, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.2575, + "clientX": 706, + "clientY": 1031, + "pageX": 706, + "pageY": 1031, + "canvasX": 1412, + "canvasY": 2062, + "cursor": "auto" + }, + { + "timeInSeconds": 2.274199999809265, + "clientX": 706, + "clientY": 1030, + "pageX": 706, + "pageY": 1030, + "canvasX": 1412, + "canvasY": 2060, + "cursor": "auto" + }, + { + "timeInSeconds": 2.284799999713898, + "clientX": 706, + "clientY": 1030, + "pageX": 706, + "pageY": 1030, + "canvasX": 1412, + "canvasY": 2060, + "cursor": "auto" + }, + { + "timeInSeconds": 2.2907999997138977, + "clientX": 706, + "clientY": 1028, + "pageX": 706, + "pageY": 1028, + "canvasX": 1412, + "canvasY": 2056, + "cursor": "auto" + }, + { + "timeInSeconds": 2.3073999996185304, + "clientX": 706, + "clientY": 1027, + "pageX": 706, + "pageY": 1027, + "canvasX": 1412, + "canvasY": 2054, + "cursor": "auto" + }, + { + "timeInSeconds": 2.3241999998092653, + "clientX": 708, + "clientY": 1025, + "pageX": 708, + "pageY": 1025, + "canvasX": 1416, + "canvasY": 2050, + "cursor": "auto" + }, + { + "timeInSeconds": 2.3407999997138975, + "clientX": 709, + "clientY": 1021, + "pageX": 709, + "pageY": 1021, + "canvasX": 1418, + "canvasY": 2042, + "cursor": "auto" + }, + { + "timeInSeconds": 2.3575, + "clientX": 711, + "clientY": 1017, + "pageX": 711, + "pageY": 1017, + "canvasX": 1422, + "canvasY": 2034, + "cursor": "auto" + }, + { + "timeInSeconds": 2.375199999809265, + "clientX": 712, + "clientY": 1015, + "pageX": 712, + "pageY": 1015, + "canvasX": 1424, + "canvasY": 2030, + "cursor": "auto" + }, + { + "timeInSeconds": 2.391, + "clientX": 712, + "clientY": 1014, + "pageX": 712, + "pageY": 1014, + "canvasX": 1424, + "canvasY": 2028, + "cursor": "auto" + }, + { + "timeInSeconds": 2.4075999999046327, + "clientX": 713, + "clientY": 1010, + "pageX": 713, + "pageY": 1010, + "canvasX": 1426, + "canvasY": 2020, + "cursor": "auto" + }, + { + "timeInSeconds": 2.424299999713898, + "clientX": 714, + "clientY": 1006, + "pageX": 714, + "pageY": 1006, + "canvasX": 1428, + "canvasY": 2012, + "cursor": "auto" + }, + { + "timeInSeconds": 2.4407999997138976, + "clientX": 715, + "clientY": 1004, + "pageX": 715, + "pageY": 1004, + "canvasX": 1430, + "canvasY": 2008, + "cursor": "auto" + }, + { + "timeInSeconds": 2.4575, + "clientX": 715, + "clientY": 1000, + "pageX": 715, + "pageY": 1000, + "canvasX": 1430, + "canvasY": 2000, + "cursor": "auto" + }, + { + "timeInSeconds": 2.474199999809265, + "clientX": 715, + "clientY": 999, + "pageX": 715, + "pageY": 999, + "canvasX": 1430, + "canvasY": 1998, + "cursor": "auto" + }, + { + "timeInSeconds": 2.4908999996185304, + "clientX": 715, + "clientY": 997, + "pageX": 715, + "pageY": 997, + "canvasX": 1430, + "canvasY": 1994, + "cursor": "auto" + }, + { + "timeInSeconds": 2.507599999904633, + "clientX": 716, + "clientY": 997, + "pageX": 716, + "pageY": 997, + "canvasX": 1432, + "canvasY": 1994, + "cursor": "auto" + }, + { + "timeInSeconds": 2.5242999997138975, + "clientX": 717, + "clientY": 994, + "pageX": 717, + "pageY": 994, + "canvasX": 1434, + "canvasY": 1988, + "cursor": "auto" + }, + { + "timeInSeconds": 2.5410999999046324, + "clientX": 718, + "clientY": 992, + "pageX": 718, + "pageY": 992, + "canvasX": 1436, + "canvasY": 1984, + "cursor": "auto" + }, + { + "timeInSeconds": 2.55789999961853, + "clientX": 719, + "clientY": 990, + "pageX": 719, + "pageY": 990, + "canvasX": 1438, + "canvasY": 1980, + "cursor": "auto" + }, + { + "timeInSeconds": 2.5745999999046325, + "clientX": 720, + "clientY": 989, + "pageX": 720, + "pageY": 989, + "canvasX": 1440, + "canvasY": 1978, + "cursor": "auto" + }, + { + "timeInSeconds": 2.5912999997138977, + "clientX": 721, + "clientY": 988, + "pageX": 721, + "pageY": 988, + "canvasX": 1442, + "canvasY": 1976, + "cursor": "auto" + }, + { + "timeInSeconds": 2.608, + "clientX": 721, + "clientY": 987, + "pageX": 721, + "pageY": 987, + "canvasX": 1442, + "canvasY": 1974, + "cursor": "auto" + }, + { + "timeInSeconds": 2.6246999998092653, + "clientX": 723, + "clientY": 985, + "pageX": 723, + "pageY": 985, + "canvasX": 1446, + "canvasY": 1970, + "cursor": "auto" + }, + { + "timeInSeconds": 2.6412999997138975, + "clientX": 723, + "clientY": 984, + "pageX": 723, + "pageY": 984, + "canvasX": 1446, + "canvasY": 1968, + "cursor": "auto" + }, + { + "timeInSeconds": 2.6577999997138977, + "clientX": 723, + "clientY": 984, + "pageX": 723, + "pageY": 984, + "canvasX": 1446, + "canvasY": 1968, + "cursor": "auto" + }, + { + "timeInSeconds": 2.6745, + "clientX": 724, + "clientY": 983, + "pageX": 724, + "pageY": 983, + "canvasX": 1448, + "canvasY": 1966, + "cursor": "auto" + }, + { + "timeInSeconds": 2.692199999809265, + "clientX": 724, + "clientY": 983, + "pageX": 724, + "pageY": 983, + "canvasX": 1448, + "canvasY": 1966, + "cursor": "auto" + }, + { + "timeInSeconds": 2.7077999997138975, + "clientX": 724, + "clientY": 982, + "pageX": 724, + "pageY": 982, + "canvasX": 1448, + "canvasY": 1964, + "cursor": "auto" + }, + { + "timeInSeconds": 2.7245999999046324, + "clientX": 724, + "clientY": 982, + "pageX": 724, + "pageY": 982, + "canvasX": 1448, + "canvasY": 1964, + "cursor": "auto" + }, + { + "timeInSeconds": 2.741199999809265, + "clientX": 724, + "clientY": 982, + "pageX": 724, + "pageY": 982, + "canvasX": 1448, + "canvasY": 1964, + "cursor": "auto" + }, + { + "timeInSeconds": 2.7578999996185303, + "clientX": 724, + "clientY": 982, + "pageX": 724, + "pageY": 982, + "canvasX": 1448, + "canvasY": 1964, + "cursor": "auto" + }, + { + "timeInSeconds": 2.7745999999046327, + "clientX": 725, + "clientY": 981, + "pageX": 725, + "pageY": 981, + "canvasX": 1450, + "canvasY": 1962, + "cursor": "auto" + }, + { + "timeInSeconds": 2.791299999713898, + "clientX": 725, + "clientY": 980, + "pageX": 725, + "pageY": 980, + "canvasX": 1450, + "canvasY": 1960, + "cursor": "auto" + }, + { + "timeInSeconds": 2.80789999961853, + "clientX": 726, + "clientY": 980, + "pageX": 726, + "pageY": 980, + "canvasX": 1452, + "canvasY": 1960, + "cursor": "auto" + }, + { + "timeInSeconds": 2.824699999809265, + "clientX": 726, + "clientY": 980, + "pageX": 726, + "pageY": 980, + "canvasX": 1452, + "canvasY": 1960, + "cursor": "auto" + }, + { + "timeInSeconds": 2.8415, + "clientX": 726, + "clientY": 979, + "pageX": 726, + "pageY": 979, + "canvasX": 1452, + "canvasY": 1958, + "cursor": "auto" + }, + { + "timeInSeconds": 2.8585999999046328, + "clientX": 726, + "clientY": 979, + "pageX": 726, + "pageY": 979, + "canvasX": 1452, + "canvasY": 1958, + "cursor": "auto" + }, + { + "timeInSeconds": 2.8745999999046328, + "clientX": 726, + "clientY": 978, + "pageX": 726, + "pageY": 978, + "canvasX": 1452, + "canvasY": 1956, + "cursor": "auto" + }, + { + "timeInSeconds": 2.8913999996185304, + "clientX": 726, + "clientY": 978, + "pageX": 726, + "pageY": 978, + "canvasX": 1452, + "canvasY": 1956, + "cursor": "auto" + }, + { + "timeInSeconds": 2.9087999997138976, + "clientX": 727, + "clientY": 978, + "pageX": 727, + "pageY": 978, + "canvasX": 1454, + "canvasY": 1956, + "cursor": "auto" + }, + { + "timeInSeconds": 2.925, + "clientX": 727, + "clientY": 978, + "pageX": 727, + "pageY": 978, + "canvasX": 1454, + "canvasY": 1956, + "cursor": "auto" + }, + { + "timeInSeconds": 2.942, + "clientX": 727, + "clientY": 978, + "pageX": 727, + "pageY": 978, + "canvasX": 1454, + "canvasY": 1956, + "cursor": "auto" + }, + { + "timeInSeconds": 2.959199999809265, + "clientX": 727, + "clientY": 978, + "pageX": 727, + "pageY": 978, + "canvasX": 1454, + "canvasY": 1956, + "cursor": "auto" + }, + { + "timeInSeconds": 2.974799999713898, + "clientX": 727, + "clientY": 977, + "pageX": 727, + "pageY": 977, + "canvasX": 1454, + "canvasY": 1954, + "cursor": "auto" + }, + { + "timeInSeconds": 2.9925, + "clientX": 727, + "clientY": 977, + "pageX": 727, + "pageY": 977, + "canvasX": 1454, + "canvasY": 1954, + "cursor": "auto" + }, + { + "timeInSeconds": 3.008199999809265, + "clientX": 727, + "clientY": 977, + "pageX": 727, + "pageY": 977, + "canvasX": 1454, + "canvasY": 1954, + "cursor": "auto" + }, + { + "timeInSeconds": 3.0250999999046324, + "clientX": 727, + "clientY": 976, + "pageX": 727, + "pageY": 976, + "canvasX": 1454, + "canvasY": 1952, + "cursor": "auto" + }, + { + "timeInSeconds": 3.041699999809265, + "clientX": 727, + "clientY": 975, + "pageX": 727, + "pageY": 975, + "canvasX": 1454, + "canvasY": 1950, + "cursor": "auto" + }, + { + "timeInSeconds": 3.0588999996185304, + "clientX": 727, + "clientY": 975, + "pageX": 727, + "pageY": 975, + "canvasX": 1454, + "canvasY": 1950, + "cursor": "auto" + }, + { + "timeInSeconds": 3.0750999999046327, + "clientX": 727, + "clientY": 975, + "pageX": 727, + "pageY": 975, + "canvasX": 1454, + "canvasY": 1950, + "cursor": "auto" + }, + { + "timeInSeconds": 3.0915, + "clientX": 727, + "clientY": 975, + "pageX": 727, + "pageY": 975, + "canvasX": 1454, + "canvasY": 1950, + "cursor": "auto" + }, + { + "timeInSeconds": 3.1082999997138976, + "clientX": 727, + "clientY": 975, + "pageX": 727, + "pageY": 975, + "canvasX": 1454, + "canvasY": 1950, + "cursor": "auto" + }, + { + "timeInSeconds": 3.125, + "clientX": 727, + "clientY": 975, + "pageX": 727, + "pageY": 975, + "canvasX": 1454, + "canvasY": 1950, + "cursor": "auto" + }, + { + "timeInSeconds": 3.3180999999046326, + "clientX": 728, + "clientY": 974, + "pageX": 728, + "pageY": 974, + "canvasX": 1456, + "canvasY": 1948, + "cursor": "auto" + }, + { + "timeInSeconds": 3.3375, + "clientX": 728, + "clientY": 974, + "pageX": 728, + "pageY": 974, + "canvasX": 1456, + "canvasY": 1948, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.358199999809265, + "clientX": 728, + "clientY": 974, + "pageX": 728, + "pageY": 974, + "canvasX": 1456, + "canvasY": 1948, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.3775999999046324, + "clientX": 728, + "clientY": 974, + "pageX": 728, + "pageY": 974, + "canvasX": 1456, + "canvasY": 1948, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.3913999996185304, + "clientX": 728, + "clientY": 973, + "pageX": 728, + "pageY": 973, + "canvasX": 1456, + "canvasY": 1946, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.408, + "clientX": 728, + "clientY": 973, + "pageX": 728, + "pageY": 973, + "canvasX": 1456, + "canvasY": 1946, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.4255999999046325, + "clientX": 728, + "clientY": 973, + "pageX": 728, + "pageY": 973, + "canvasX": 1456, + "canvasY": 1946, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.8666999998092653, + "clientX": 728, + "clientY": 973, + "pageX": 728, + "pageY": 973, + "canvasX": 1456, + "canvasY": 1946, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.8915999999046327, + "clientX": 727, + "clientY": 967, + "pageX": 727, + "pageY": 967, + "canvasX": 1454, + "canvasY": 1934, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.909, + "clientX": 723, + "clientY": 949, + "pageX": 723, + "pageY": 949, + "canvasX": 1446, + "canvasY": 1898, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.925699999809265, + "clientX": 721, + "clientY": 930, + "pageX": 721, + "pageY": 930, + "canvasX": 1442, + "canvasY": 1860, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.9422999997138977, + "clientX": 720, + "clientY": 906, + "pageX": 720, + "pageY": 906, + "canvasX": 1440, + "canvasY": 1812, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.958799999713898, + "clientX": 718, + "clientY": 881, + "pageX": 718, + "pageY": 881, + "canvasX": 1436, + "canvasY": 1762, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.9755999999046328, + "clientX": 714, + "clientY": 847, + "pageX": 714, + "pageY": 847, + "canvasX": 1428, + "canvasY": 1694, + "cursor": "row-resize" + }, + { + "timeInSeconds": 3.992199999809265, + "clientX": 713, + "clientY": 821, + "pageX": 713, + "pageY": 821, + "canvasX": 1426, + "canvasY": 1642, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.008899999618531, + "clientX": 713, + "clientY": 804, + "pageX": 713, + "pageY": 804, + "canvasX": 1426, + "canvasY": 1608, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.025199999809265, + "clientX": 713, + "clientY": 788, + "pageX": 713, + "pageY": 788, + "canvasX": 1426, + "canvasY": 1576, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.042299999713897, + "clientX": 713, + "clientY": 778, + "pageX": 713, + "pageY": 778, + "canvasX": 1426, + "canvasY": 1556, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.059, + "clientX": 713, + "clientY": 765, + "pageX": 713, + "pageY": 765, + "canvasX": 1426, + "canvasY": 1530, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.075799999713897, + "clientX": 713, + "clientY": 759, + "pageX": 713, + "pageY": 759, + "canvasX": 1426, + "canvasY": 1518, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.092099999904632, + "clientX": 713, + "clientY": 754, + "pageX": 713, + "pageY": 754, + "canvasX": 1426, + "canvasY": 1508, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.109, + "clientX": 713, + "clientY": 751, + "pageX": 713, + "pageY": 751, + "canvasX": 1426, + "canvasY": 1502, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.125699999809265, + "clientX": 713, + "clientY": 750, + "pageX": 713, + "pageY": 750, + "canvasX": 1426, + "canvasY": 1500, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.14239999961853, + "clientX": 713, + "clientY": 750, + "pageX": 713, + "pageY": 750, + "canvasX": 1426, + "canvasY": 1500, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.241799999713898, + "clientX": 713, + "clientY": 751, + "pageX": 713, + "pageY": 751, + "canvasX": 1426, + "canvasY": 1502, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.258899999618531, + "clientX": 715, + "clientY": 760, + "pageX": 715, + "pageY": 760, + "canvasX": 1430, + "canvasY": 1520, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.275799999713898, + "clientX": 717, + "clientY": 776, + "pageX": 717, + "pageY": 776, + "canvasX": 1434, + "canvasY": 1552, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.292399999618531, + "clientX": 718, + "clientY": 814, + "pageX": 718, + "pageY": 814, + "canvasX": 1436, + "canvasY": 1628, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.308299999713897, + "clientX": 718, + "clientY": 845, + "pageX": 718, + "pageY": 845, + "canvasX": 1436, + "canvasY": 1690, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.325699999809265, + "clientX": 717, + "clientY": 886, + "pageX": 717, + "pageY": 886, + "canvasX": 1434, + "canvasY": 1772, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.3423999996185305, + "clientX": 717, + "clientY": 921, + "pageX": 717, + "pageY": 921, + "canvasX": 1434, + "canvasY": 1842, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.3590999999046325, + "clientX": 717, + "clientY": 946, + "pageX": 717, + "pageY": 946, + "canvasX": 1434, + "canvasY": 1892, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.375899999618531, + "clientX": 716, + "clientY": 965, + "pageX": 716, + "pageY": 965, + "canvasX": 1432, + "canvasY": 1930, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.3925, + "clientX": 716, + "clientY": 977, + "pageX": 716, + "pageY": 977, + "canvasX": 1432, + "canvasY": 1954, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.409099999904632, + "clientX": 715, + "clientY": 993, + "pageX": 715, + "pageY": 993, + "canvasX": 1430, + "canvasY": 1986, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.425799999713898, + "clientX": 715, + "clientY": 1002, + "pageX": 715, + "pageY": 1002, + "canvasX": 1430, + "canvasY": 2004, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.442099999904633, + "clientX": 715, + "clientY": 1011, + "pageX": 715, + "pageY": 1011, + "canvasX": 1430, + "canvasY": 2022, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.459, + "clientX": 715, + "clientY": 1015, + "pageX": 715, + "pageY": 1015, + "canvasX": 1430, + "canvasY": 2030, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.475799999713898, + "clientX": 715, + "clientY": 1016, + "pageX": 715, + "pageY": 1016, + "canvasX": 1430, + "canvasY": 2032, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.4922999997138975, + "clientX": 715, + "clientY": 1018, + "pageX": 715, + "pageY": 1018, + "canvasX": 1430, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.509199999809265, + "clientX": 715, + "clientY": 1018, + "pageX": 715, + "pageY": 1018, + "canvasX": 1430, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.5255, + "clientX": 715, + "clientY": 1018, + "pageX": 715, + "pageY": 1018, + "canvasX": 1430, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.5425, + "clientX": 715, + "clientY": 1018, + "pageX": 715, + "pageY": 1018, + "canvasX": 1430, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.559299999713898, + "clientX": 715, + "clientY": 1018, + "pageX": 715, + "pageY": 1018, + "canvasX": 1430, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.5761999998092655, + "clientX": 715, + "clientY": 1018, + "pageX": 715, + "pageY": 1018, + "canvasX": 1430, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.592599999904633, + "clientX": 714, + "clientY": 1018, + "pageX": 714, + "pageY": 1018, + "canvasX": 1428, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.604599999904632, + "clientX": 714, + "clientY": 1018, + "pageX": 714, + "pageY": 1018, + "canvasX": 1428, + "canvasY": 2036, + "cursor": "row-resize" + }, + { + "timeInSeconds": 4.6085, + "clientX": 714, + "clientY": 1017, + "pageX": 714, + "pageY": 1017, + "canvasX": 1428, + "canvasY": 2034, + "cursor": "auto" + }, + { + "timeInSeconds": 4.62539999961853, + "clientX": 714, + "clientY": 1017, + "pageX": 714, + "pageY": 1017, + "canvasX": 1428, + "canvasY": 2034, + "cursor": "auto" + }, + { + "timeInSeconds": 4.641899999618531, + "clientX": 714, + "clientY": 1017, + "pageX": 714, + "pageY": 1017, + "canvasX": 1428, + "canvasY": 2034, + "cursor": "auto" + }, + { + "timeInSeconds": 4.658699999809265, + "clientX": 714, + "clientY": 1017, + "pageX": 714, + "pageY": 1017, + "canvasX": 1428, + "canvasY": 2034, + "cursor": "auto" + } + ] +} \ No newline at end of file diff --git a/packages/example/src/CanvasCapturePreview.tsx b/packages/example/src/CanvasCapturePreview.tsx new file mode 100644 index 00000000000..27ca5e38142 --- /dev/null +++ b/packages/example/src/CanvasCapturePreview.tsx @@ -0,0 +1,436 @@ +import {Video} from '@remotion/media'; +import React from 'react'; +import { + AbsoluteFill, + CalculateMetadataFunction, + Img, + Interactive, + staticFile, + useCurrentFrame, + useVideoConfig, +} from 'remotion'; +import {getMediaMetadata} from './get-media-metadata'; + +type MouseMovement = { + readonly timeInSeconds: number; + readonly clientX: number; + readonly clientY: number; + readonly pageX: number; + readonly pageY: number; + readonly canvasX?: number | null; + readonly canvasY?: number | null; + readonly cursor: string; +}; + +type CursorRecording = { + readonly startedAt: number; + readonly endedAt: number; + readonly captureMetadata?: { + readonly density: number; + readonly contentRect: { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; + }; + readonly canvasSize: { + readonly width: number; + readonly height: number; + }; + } | null; + readonly mouseMovements: MouseMovement[]; +}; + +export type CanvasCapturePreviewProps = { + readonly videoFile: string; + readonly cursorFile: string; + readonly cursorScale: number; + readonly cursorOffsetX: number; + readonly cursorOffsetY: number; + readonly cursorAssetBasePath: string | null; + readonly cursorData?: CursorRecording; + width: number | null; + height: number | null; +}; + +export const canvasCapturePreviewDefaultProps: CanvasCapturePreviewProps = { + videoFile: 'https://remotion.media/remotion-studio-canvas-recording.webm', + cursorFile: 'remotion-studio-canvas-recording.json', + cursorScale: 3, + cursorOffsetX: 0, + cursorOffsetY: 0, + cursorAssetBasePath: 'https://remotion.media/mac-cursors', +}; + +const macCursorFilenameByCssValue: Record = { + alias: 'makealias.svg', + 'all-scroll': 'move.svg', + auto: 'default.svg', + beachball: 'beachball.svg', + busy: 'busy.svg', + cell: 'cell.svg', + 'col-resize': 'resizeleftright.svg', + 'context-menu': 'contextualmenu.svg', + contextualmenu: 'contextualmenu.svg', + copy: 'copy.svg', + crosshair: 'cross.svg', + cross: 'cross.svg', + default: 'default.svg', + 'e-resize': 'resizeeast.svg', + 'ew-resize': 'resizeleftright.svg', + grab: 'handopen.svg', + grabbing: 'handgrabbing.svg', + handgrabbing: 'handgrabbing.svg', + handopen: 'handopen.svg', + handpointing: 'handpointing.svg', + help: 'help.svg', + makealias: 'makealias.svg', + move: 'move.svg', + 'n-resize': 'resizenorth.svg', + 'ne-resize': 'resizenortheast.svg', + 'nesw-resize': 'resizenortheastsouthwest.svg', + 'no-drop': 'notallowed.svg', + none: null, + 'not-allowed': 'notallowed.svg', + 'ns-resize': 'resizenorthsouth.svg', + 'nw-resize': 'resizenorthwest.svg', + 'nwse-resize': 'resizenorthwestsoutheast.svg', + pointer: 'handpointing.svg', + poof: 'poof.svg', + progress: 'busy.svg', + 'resize northsouth': 'resize northsouth.svg', + resizedown: 'resizedown.svg', + resizeeast: 'resizeeast.svg', + resizeleft: 'resizeleft.svg', + resizeleftright: 'resizeleftright.svg', + resizenorth: 'resizenorth.svg', + resizenortheast: 'resizenortheast.svg', + resizenortheastsouthwest: 'resizenortheastsouthwest.svg', + resizenorthsouth: 'resizenorthsouth.svg', + resizenorthwest: 'resizenorthwest.svg', + resizenorthwestsoutheast: 'resizenorthwestsoutheast.svg', + resizeright: 'resizeright.svg', + resizesouth: 'resizesouth.svg', + resizesoutheast: 'resizesoutheast.svg', + resizesouthwest: 'resizesouthwest.svg', + resizeup: 'resizeup.svg', + resizeupdown: 'resizeupdown.svg', + resizewest: 'resizewest.svg', + resizewesteast: 'resizewesteast.svg', + 'row-resize': 'resizenorthsouth.svg', + 's-resize': 'resizesouth.svg', + screenshotselection: 'screenshotselection.svg', + screenshotwindow: 'screenshotwindow.svg', + 'se-resize': 'resizesoutheast.svg', + 'sw-resize': 'resizesouthwest.svg', + text: 'textcursor.svg', + textcursor: 'textcursor.svg', + textcursorvertical: 'textcursorvertical.svg', + 'vertical-text': 'textcursorvertical.svg', + 'w-resize': 'resizewest.svg', + wait: 'busy.svg', + 'zoom-in': 'zoomin.svg', + 'zoom-out': 'zoomout.svg', +}; + +const resolveAsset = (src: string) => { + return src.startsWith('http://') || src.startsWith('https://') + ? src + : staticFile(src); +}; + +const resolveCursorAsset = (basePath: string, filename: string) => { + const normalizedBasePath = basePath.endsWith('/') + ? basePath.slice(0, -1) + : basePath; + + return normalizedBasePath.startsWith('http://') || + normalizedBasePath.startsWith('https://') + ? `${normalizedBasePath}/${encodeURIComponent(filename)}` + : staticFile(`${normalizedBasePath}/${filename}`); +}; + +const getNormalizedCursor = (cursor: string) => { + return cursor.split(',').at(-1)?.trim().toLowerCase() ?? 'auto'; +}; + +const getMacCursorFilename = (cursor: string) => { + return ( + macCursorFilenameByCssValue[getNormalizedCursor(cursor)] ?? 'default.svg' + ); +}; + +const findCursorAtTime = ( + mouseMovements: readonly MouseMovement[], + timeInSeconds: number, +) => { + let low = 0; + let high = mouseMovements.length - 1; + let latest: MouseMovement | null = null; + + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const movement = mouseMovements[middle]; + + if (movement.timeInSeconds <= timeInSeconds) { + latest = movement; + low = middle + 1; + } else { + high = middle - 1; + } + } + + return latest; +}; + +const ArrowCursor: React.FC<{readonly scale: number}> = ({scale}) => { + const size = 24 * scale; + + return ( + + + + ); +}; + +const TextCursor: React.FC<{readonly scale: number}> = ({scale}) => { + const height = 28 * scale; + + return ( +
+ ); +}; + +const ResizeCursor: React.FC<{ + readonly scale: number; + readonly direction: 'horizontal' | 'vertical'; +}> = ({direction, scale}) => { + const size = 26 * scale; + const rotation = direction === 'horizontal' ? 90 : 0; + + return ( + + + + ); +}; + +const CursorGlyph: React.FC<{ + readonly cursorAssetBasePath: string | null; + readonly cursor: string; + readonly scale: number; +}> = ({cursor, cursorAssetBasePath, scale}) => { + const macCursorFilename = getMacCursorFilename(cursor); + + if (macCursorFilename === null) { + return null; + } + + if (cursorAssetBasePath) { + return ( + + ); + } + + if (cursor.includes('text')) { + return ; + } + + if (cursor === 'row-resize' || cursor === 'ns-resize') { + return ; + } + + if (cursor === 'col-resize' || cursor === 'ew-resize') { + return ; + } + + return ; +}; + +const CursorOverlay: React.FC<{ + readonly cursorAssetBasePath: string | null; + readonly cursorData: CursorRecording; + readonly cursorOffsetX: number; + readonly cursorOffsetY: number; + readonly cursorScale: number; +}> = ({ + cursorAssetBasePath, + cursorData, + cursorOffsetX, + cursorOffsetY, + cursorScale, +}) => { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const cursor = findCursorAtTime(cursorData.mouseMovements, frame / fps); + + if (!cursor) { + return null; + } + + const metadata = cursorData.captureMetadata; + const scale = metadata?.density ?? cursorScale; + const x = + typeof cursor.canvasX === 'number' + ? cursor.canvasX + : metadata + ? (cursor.clientX - metadata.contentRect.left) * metadata.density + : (cursor.pageX - cursorOffsetX) * cursorScale; + const y = + typeof cursor.canvasY === 'number' + ? cursor.canvasY + : metadata + ? (cursor.clientY - metadata.contentRect.top) * metadata.density + : (cursor.pageY - cursorOffsetY) * cursorScale; + + return ( +
+ +
+ ); +}; + +export const calculateCanvasCapturePreviewMetadata: CalculateMetadataFunction< + CanvasCapturePreviewProps +> = async ({props}) => { + const fps = 30; + const videoSrc = resolveAsset(props.videoFile); + const [{durationInSeconds, dimensions}, cursorData] = await Promise.all([ + getMediaMetadata(videoSrc), + fetch(staticFile(props.cursorFile)).then((res) => { + if (!res.ok) { + throw new Error(`Could not load cursor data: ${res.status}`); + } + + return res.json() as Promise; + }), + ]); + + if (!dimensions) { + throw new Error('Could not determine canvas capture video dimensions.'); + } + + return { + durationInFrames: Math.ceil(durationInSeconds * fps), + fps, + width: 1920, + height: 1080, + props: { + ...props, + cursorData, + width: dimensions.width, + height: dimensions.height, + }, + }; +}; + +export const CanvasCapturePreview: React.FC = ({ + cursorAssetBasePath, + cursorData, + cursorFile, + cursorOffsetX, + cursorOffsetY, + cursorScale, + videoFile, + width, + height, +}) => { + if (!cursorData) { + throw new Error(`Cursor data from ${cursorFile} was not loaded.`); + } + + if (!Number.isFinite(cursorScale) || cursorScale <= 0) { + throw new Error('Cursor scale must be greater than 0.'); + } + + return ( + + + + + ); +}; diff --git a/packages/example/src/Root.tsx b/packages/example/src/Root.tsx index 10dbd0fcaad..87b67325fed 100644 --- a/packages/example/src/Root.tsx +++ b/packages/example/src/Root.tsx @@ -182,6 +182,11 @@ import {AudioSmoothnessTrimButtonComp} from './AudioSmoothness/TrimButton'; import Amplify from './AudioTesting/Amplify'; import {Issue7568} from './AudioTesting/Issue7568'; import {BrowserTest} from './BrowserTest'; +import { + CanvasCapturePreview, + calculateCanvasCapturePreviewMetadata, + canvasCapturePreviewDefaultProps, +} from './CanvasCapturePreview'; import {EdgeBlur} from './EdgeBlur/EdgeBlur'; import {EffectsTestbed} from './EffectsTestbed/EffectsTestbed'; import {HalftoneGradient} from './EffectsTestbed/HalftoneGradient'; @@ -410,6 +415,16 @@ export const Index: React.FC = () => { fps={30} durationInFrames={120} /> + void} | null = null; let lastDrawnFrame: WrappedCanvas | null = null; + let currentSeek: number | null = null; const clearLastDrawnFrame = () => { lastDrawnFrame = null; @@ -143,6 +145,7 @@ export const videoIteratorManager = async ({ videoFrameIterator?.destroy(); using delayHandle = delayPlaybackHandleIfNotPremounting(); currentDelayHandle = delayHandle; + currentSeek = timeToSeek; const iterator = await createVideoIterator( timeToSeek, @@ -176,6 +179,15 @@ export const videoIteratorManager = async ({ return; } + if ( + currentSeek !== null && + roundTo4Digits(currentSeek) === roundTo4Digits(newTime) + ) { + return; + } + + currentSeek = newTime; + if (getIsLooping()) { // If less than 1 second from the end away, we pre-warm a new iterator if (getLoopSegmentMediaEndTimestamp() - newTime < 1) { @@ -185,7 +197,8 @@ export const videoIteratorManager = async ({ } } - const videoSatisfyResult = videoFrameIterator.tryToSatisfySeek(newTime); + const videoSatisfyResult = + await videoFrameIterator.tryToSatisfySeek(newTime); // Doing this before the staleness check, because // frame might be better than what we currently have diff --git a/packages/media/src/video/video-preview-iterator.ts b/packages/media/src/video/video-preview-iterator.ts index f5ef19f1c18..211cba9a237 100644 --- a/packages/media/src/video/video-preview-iterator.ts +++ b/packages/media/src/video/video-preview-iterator.ts @@ -17,7 +17,33 @@ export const createVideoIterator = async ( : await firstAwait.wait(); let lastReturnedFrame = initialFrame; + let peekedFrame: WrappedCanvas | null = null; + + const peek = async () => { + if (peekedFrame) { + return peekedFrame; + } + + const next = iterator.next(); + if (next.type === 'ready') { + peekedFrame = next.frame; + } else { + peekedFrame = await next.wait(); + } + + return peekedFrame; + }; + const getNextOrNullIfNotAvailable = () => { + if (peekedFrame) { + const retValue = { + type: 'got-frame-or-end' as const, + frame: peekedFrame, + }; + peekedFrame = null; + return retValue; + } + const next = iterator.next(); if (next.type === 'pending') { @@ -54,9 +80,9 @@ export const createVideoIterator = async ( iterator.closeIterator().catch(() => undefined); }; - const tryToSatisfySeek = ( + const tryToSatisfySeek = async ( time: number, - ): + ): Promise< | { type: 'not-satisfied'; reason: string; @@ -64,7 +90,8 @@ export const createVideoIterator = async ( | { type: 'satisfied'; frame: WrappedCanvas; - } => { + } + > => { if (lastReturnedFrame) { const frameTimestamp = roundTo4Digits(lastReturnedFrame.timestamp); @@ -87,9 +114,18 @@ export const createVideoIterator = async ( }; } + let lastFrameDuration = lastReturnedFrame.duration; + if (lastFrameDuration === 0) { + const peeked = await peek(); + if (peeked) { + lastFrameDuration = peeked.timestamp - lastReturnedFrame.timestamp; + } + } + const frameEndTimestamp = roundTo4Digits( - lastReturnedFrame.timestamp + lastReturnedFrame.duration, + lastReturnedFrame.timestamp + lastFrameDuration, ); + const timestamp = roundTo4Digits(time); if (frameTimestamp <= timestamp && frameEndTimestamp > timestamp) { return { @@ -115,6 +151,7 @@ export const createVideoIterator = async ( while (true) { const frame = getNextOrNullIfNotAvailable(); + if (frame.type === 'need-to-wait-for-it') { return { type: 'not-satisfied' as const, diff --git a/packages/studio-shared/src/package-info.ts b/packages/studio-shared/src/package-info.ts index 1d7937b9f13..da4fdeb8bd9 100644 --- a/packages/studio-shared/src/package-info.ts +++ b/packages/studio-shared/src/package-info.ts @@ -7,6 +7,7 @@ export const packages = [ 'bugs', 'brand', 'bundler', + 'canvas-capture', 'cli', 'cloudrun', 'codex-plugin', @@ -136,6 +137,7 @@ export const descriptions: {[key in Pkgs]: string | null} = { core: 'Make videos programmatically', lambda: 'Render Remotion videos on AWS Lambda', bundler: 'Bundle Remotion compositions using Webpack', + 'canvas-capture': 'Capture HTML-in-canvas content as a video', 'studio-server': 'Run a Remotion Studio with a server backend', 'install-whisper-cpp': 'Helpers for installing and using Whisper.cpp', 'whisper-web': 'Helpers for using Whisper.cpp in browser using WASM', @@ -238,6 +240,7 @@ export const installableMap: {[key in Pkgs]: boolean} = { bugs: false, brand: false, bundler: false, + 'canvas-capture': false, cli: false, cloudrun: true, 'codex-plugin': false, @@ -336,6 +339,7 @@ export const apiDocs: {[key in Pkgs]: string | null} = { core: 'https://www.remotion.dev/docs/remotion', lambda: 'https://www.remotion.dev/docs/lambda', bundler: 'https://www.remotion.dev/docs/bundler', + 'canvas-capture': null, 'lambda-client': null, 'serverless-client': null, 'studio-server': null, diff --git a/packages/studio/bundle.ts b/packages/studio/bundle.ts index 5b3580bc740..1a26b06cd86 100644 --- a/packages/studio/bundle.ts +++ b/packages/studio/bundle.ts @@ -5,6 +5,7 @@ const external = [ 'remotion', 'react-dom', 'react', + '@remotion/canvas-capture', '@remotion/media-utils', '@remotion/studio-shared', '@remotion/timeline-utils', diff --git a/packages/studio/package.json b/packages/studio/package.json index 8b2f95e000c..2dac604e6fd 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -26,6 +26,7 @@ "dependencies": { "semver": "7.5.3", "remotion": "workspace:*", + "@remotion/canvas-capture": "workspace:*", "@remotion/player": "workspace:*", "@remotion/media-utils": "workspace:*", "@remotion/renderer": "workspace:*", diff --git a/packages/studio/src/components/Editor.tsx b/packages/studio/src/components/Editor.tsx index 78588646739..d546c1624b9 100644 --- a/packages/studio/src/components/Editor.tsx +++ b/packages/studio/src/components/Editor.tsx @@ -8,6 +8,7 @@ import {drawRef} from '../state/canvas-ref'; import {ScaleLockProvider} from '../state/scale-lock'; import {TimelineZoomContext} from '../state/timeline-zoom'; import {HigherZIndex} from '../state/z-index'; +import {CANVAS_CAPTURE_ENABLED} from './canvas-capture-enabled'; import {EditorContent} from './EditorContent'; import {ForceSpecificCursor} from './ForceSpecificCursor'; import {GlobalKeybindings} from './GlobalKeybindings'; @@ -15,6 +16,7 @@ import {Modals} from './Modals'; import {NotificationCenter} from './Notifications/NotificationCenter'; import {RenderErrorContext} from './RenderErrorContext'; import {SequencePropsSubscriptionProvider} from './SequencePropsSubscriptionProvider'; +import {StudioCanvasCapture} from './StudioCanvasCapture'; import {TopPanel} from './TopPanel'; const background: React.CSSProperties = { @@ -88,7 +90,7 @@ export const Editor: React.FC<{ [renderError], ); - return ( + const editor = ( @@ -125,4 +127,10 @@ export const Editor: React.FC<{ ); + + return CANVAS_CAPTURE_ENABLED ? ( + {editor} + ) : ( + editor + ); }; diff --git a/packages/studio/src/components/PreviewToolbar.tsx b/packages/studio/src/components/PreviewToolbar.tsx index 9138e72da3f..8265e080e7f 100644 --- a/packages/studio/src/components/PreviewToolbar.tsx +++ b/packages/studio/src/components/PreviewToolbar.tsx @@ -28,7 +28,6 @@ import {PlaybackRateSelector} from './PlaybackRateSelector'; import {PlayPause} from './PlayPause'; import {RenderButton} from './RenderButton'; import {SizeSelector} from './SizeSelector'; -import {ENABLE_OUTLINES} from './Timeline/TimelineSelection'; import {TimelineZoomControls} from './Timeline/TimelineZoomControls'; import {TimelineInOutPointToggle} from './TimelineInOutToggle'; @@ -239,11 +238,9 @@ export const PreviewToolbar: React.FC<{ - {ENABLE_OUTLINES ? ( - - - - ) : null} + + + ) : null} diff --git a/packages/studio/src/components/SelectedOutlineOverlay.tsx b/packages/studio/src/components/SelectedOutlineOverlay.tsx index 17c296b096a..15ae00a80ad 100644 --- a/packages/studio/src/components/SelectedOutlineOverlay.tsx +++ b/packages/studio/src/components/SelectedOutlineOverlay.tsx @@ -70,7 +70,6 @@ import { } from './Timeline/timeline-translate-utils'; import {getLinkedScale} from './Timeline/TimelineScaleField'; import { - ENABLE_OUTLINES, getTimelineSequenceSelectionKey, useTimelineSelection, type TimelineSelection, @@ -513,7 +512,10 @@ export const getSequencesWithSelectableOutlines = ({ return false; } - return track.nodePathInfo.auxiliaryKeys.length === 0; + return ( + track.sequence.showInTimeline && + track.nodePathInfo.auxiliaryKeys.length === 0 + ); }) .filter((track) => track.sequence.refForOutline !== null) .sort((a, b) => a.depth - b.depth) @@ -2448,7 +2450,7 @@ export const SelectedOutlineOverlay: React.FC<{ }, []); const outlineTargets = useMemo((): SelectedOutlineTarget[] => { - if (!ENABLE_OUTLINES || !editorShowOutlines) { + if (!editorShowOutlines) { return []; } diff --git a/packages/studio/src/components/StudioCanvasCapture.tsx b/packages/studio/src/components/StudioCanvasCapture.tsx new file mode 100644 index 00000000000..8d7eb1ff1f0 --- /dev/null +++ b/packages/studio/src/components/StudioCanvasCapture.tsx @@ -0,0 +1,55 @@ +import { + HtmlInCanvasCapture, + type HtmlInCanvasCaptureHandle, + isHtmlInCanvasAvailable, +} from '@remotion/canvas-capture'; +import React, {useEffect, useMemo, useRef} from 'react'; +import {useKeybinding} from '../helpers/use-keybinding'; + +const logCaptureError = (message: string, err: unknown) => { + // eslint-disable-next-line no-console + console.error(message, err instanceof Error ? err.message : String(err)); +}; + +export const StudioCanvasCapture: React.FC<{ + readonly children: React.ReactNode; + readonly density: number; +}> = ({children, density}) => { + const captureRef = useRef(null); + const isSupported = useMemo(() => isHtmlInCanvasAvailable(), []); + const keybindings = useKeybinding(); + + useEffect(() => { + if (!isSupported) { + return; + } + + const binding = keybindings.registerKeybinding({ + event: 'keydown', + key: 'p', + commandCtrlKey: true, + callback: () => { + captureRef.current?.toggleRecording().catch((err) => { + logCaptureError('Could not toggle Studio canvas recording:', err); + }); + }, + preventDefault: true, + triggerIfInputFieldFocused: true, + keepRegisteredWhenNotHighestContext: true, + }); + + return () => { + binding.unregister(); + }; + }, [isSupported, keybindings]); + + return ( + + {children} + + ); +}; diff --git a/packages/studio/src/components/Timeline/TimelineDragHandler.tsx b/packages/studio/src/components/Timeline/TimelineDragHandler.tsx index fb3c4b1ce3b..fac28eb9beb 100644 --- a/packages/studio/src/components/Timeline/TimelineDragHandler.tsx +++ b/packages/studio/src/components/Timeline/TimelineDragHandler.tsx @@ -26,11 +26,7 @@ import { getScrollPositionForCursorOnRightEdge, scrollToTimelineXOffset, } from './timeline-scroll-logic'; -import { - TIMELINE_SCRUBBER_ATTR, - TIMELINE_TOP_DRAG, - useTimelineSelection, -} from './TimelineSelection'; +import {TIMELINE_SCRUBBER_ATTR} from './TimelineSelection'; import {redrawTimelineSliderFast} from './TimelineSlider'; import {TIMELINE_TIME_INDICATOR_HEIGHT} from './TimelineTimeIndicators'; @@ -64,7 +60,6 @@ export const TimelineDragHandler: React.FC = () => { const {zoom: zoomMap} = useContext(TimelineZoomCtx); const {canvasContent} = useContext(Internals.CompositionManager); - const {canSelect, canSelectEasing} = useTimelineSelection(); const containerStyle: React.CSSProperties = useMemo(() => { if (!canvasContent || canvasContent.type !== 'composition') { @@ -75,11 +70,9 @@ export const TimelineDragHandler: React.FC = () => { return { ...container, width: 100 * zoom + '%', - ...(TIMELINE_TOP_DRAG || canSelect || canSelectEasing - ? {height: TIMELINE_TIME_INDICATOR_HEIGHT} - : {}), + height: TIMELINE_TIME_INDICATOR_HEIGHT, }; - }, [canSelect, canSelectEasing, canvasContent, zoomMap]); + }, [canvasContent, zoomMap]); if (!canvasContent || canvasContent.type !== 'composition') { return null; diff --git a/packages/studio/src/components/Timeline/TimelineEffectItem.tsx b/packages/studio/src/components/Timeline/TimelineEffectItem.tsx index 5a620cf33db..eaa22d4da63 100644 --- a/packages/studio/src/components/Timeline/TimelineEffectItem.tsx +++ b/packages/studio/src/components/Timeline/TimelineEffectItem.tsx @@ -20,7 +20,6 @@ import {TimelineRowChrome} from './TimelineRowChrome'; import { getTimelineColor, getTimelineSelectedLabelStyle, - SELECTION_ENABLED, useTimelineRowSelection, } from './TimelineSelection'; @@ -162,7 +161,6 @@ export const TimelineEffectItem: React.FC<{ !validatedLocation.source; const canReorder = - SELECTION_ENABLED && previewConnected && effectStatus.type === 'can-update-effect' && Boolean(validatedLocation.source); diff --git a/packages/studio/src/components/Timeline/TimelineExpandedKeyframeRow.tsx b/packages/studio/src/components/Timeline/TimelineExpandedKeyframeRow.tsx index 89ebe29a720..38bec3eb2d3 100644 --- a/packages/studio/src/components/Timeline/TimelineExpandedKeyframeRow.tsx +++ b/packages/studio/src/components/Timeline/TimelineExpandedKeyframeRow.tsx @@ -6,8 +6,6 @@ import type {getTimelineKeyframes} from './get-timeline-keyframes'; import {TimelineKeyframeDiamond} from './TimelineKeyframeDiamond'; import {TimelineKeyframeEasingLine} from './TimelineKeyframeEasingLine'; import { - EASING_SELECTION_ENABLED, - ENABLE_OUTLINES, getTimelineSelectedTrackHighlightStyle, useTimelineRowSelection, } from './TimelineSelection'; @@ -30,10 +28,9 @@ const TimelineExpandedKeyframeRowUnmemoized: React.FC<{ }> = ({height, keyframes, canEditEasing, nodePathInfo, showSeparator}) => { const timelineWidth = useContext(TimelineWidthContext); const {selected: rowSelected} = useTimelineRowSelection(nodePathInfo); - const easingSegments = - (ENABLE_OUTLINES || EASING_SELECTION_ENABLED) && canEditEasing - ? getTimelineEasingSegments(keyframes) - : []; + const easingSegments = canEditEasing + ? getTimelineEasingSegments(keyframes) + : []; return ( <> diff --git a/packages/studio/src/components/Timeline/TimelineItemStack.tsx b/packages/studio/src/components/Timeline/TimelineItemStack.tsx index f08cad05f5b..2a5c5147559 100644 --- a/packages/studio/src/components/Timeline/TimelineItemStack.tsx +++ b/packages/studio/src/components/Timeline/TimelineItemStack.tsx @@ -1,81 +1,15 @@ -import type {GitSource} from '@remotion/studio-shared'; -import React, {useCallback, useContext, useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import type {OriginalPosition} from '../../error-overlay/react-overlay/utils/get-source-map'; -import {StudioServerConnectionCtx} from '../../helpers/client-id'; -import {LIGHT_TEXT, VERY_LIGHT_TEXT} from '../../helpers/colors'; -import {getGitRefUrl} from '../../helpers/get-git-menu-item'; -import {openOriginalPositionInEditor} from '../../helpers/open-in-editor'; -import {Spacing} from '../layout'; -import {showNotification} from '../Notifications/NotificationCenter'; -import {Spinner} from '../Spinner'; -import {SELECTION_ENABLED} from './TimelineSelection'; +import {VERY_LIGHT_TEXT} from '../../helpers/colors'; import {getOriginalSourceAttribution} from './TimelineStack/source-attribution'; export const TimelineItemStack: React.FC<{ readonly originalLocation: OriginalPosition | null; }> = ({originalLocation}) => { - const [hovered, setHovered] = useState(false); - const [opening, setOpening] = useState(false); - - const connectionStatus = useContext(StudioServerConnectionCtx) - .previewServerState.type; - - const openEditor = useCallback(async (location: OriginalPosition) => { - if (!window.remotion_editorName) { - return; - } - - setOpening(true); - try { - await openOriginalPositionInEditor(location); - } catch (err) { - showNotification((err as Error).message, 2000); - } finally { - setOpening(false); - } - }, []); - - const canOpenInEditor = Boolean( - window.remotion_editorName && - connectionStatus === 'connected' && - originalLocation, - ); - - const canOpenInGitHub = Boolean( - window.remotion_gitSource && originalLocation, - ); - const hoverable = !SELECTION_ENABLED && (canOpenInEditor || canOpenInGitHub); - - const onClick = useCallback(() => { - if (!originalLocation) { - return; - } - - if (canOpenInEditor) { - openEditor(originalLocation); - return; - } - - if (canOpenInGitHub) { - window.open( - getGitRefUrl(window.remotion_gitSource as GitSource, originalLocation), - '_blank', - ); - } - }, [canOpenInEditor, canOpenInGitHub, openEditor, originalLocation]); - - const onPointerEnter = useCallback(() => setHovered(true), []); - const onPointerLeave = useCallback(() => setHovered(false), []); - const style = useMemo((): React.CSSProperties => { return { fontSize: 12, - color: opening - ? VERY_LIGHT_TEXT - : hovered && hoverable - ? LIGHT_TEXT - : VERY_LIGHT_TEXT, - cursor: hoverable ? 'pointer' : undefined, + color: VERY_LIGHT_TEXT, display: 'flex', flexDirection: 'row', alignItems: 'center', @@ -86,28 +20,13 @@ export const TimelineItemStack: React.FC<{ userSelect: 'none', WebkitUserSelect: 'none', }; - }, [opening, hovered, hoverable]); + }, []); if (!originalLocation) { return null; } return ( - <> -
- {getOriginalSourceAttribution(originalLocation)} -
- {opening ? ( - <> - - - - ) : null} - +
{getOriginalSourceAttribution(originalLocation)}
); }; diff --git a/packages/studio/src/components/Timeline/TimelineKeyframeControls.tsx b/packages/studio/src/components/Timeline/TimelineKeyframeControls.tsx index 8eb9c818c43..c233ff9b531 100644 --- a/packages/studio/src/components/Timeline/TimelineKeyframeControls.tsx +++ b/packages/studio/src/components/Timeline/TimelineKeyframeControls.tsx @@ -43,9 +43,8 @@ import {useTimelineKeyframeTracks} from './TimelineKeyframeTracksContext'; import { getTimelineSelectionFromNodePathInfo, getTimelineSelectionKey, - SELECTION_ENABLED, - type TimelineSelection, useTimelineSelection, + type TimelineSelection, } from './TimelineSelection'; const controlsContainerStyle: React.CSSProperties = { @@ -425,7 +424,7 @@ export const shouldShowTimelineKeyframeControls = ({ return true; } - return SELECTION_ENABLED && isKeyframedStatus(propStatus); + return isKeyframedStatus(propStatus); }; export const TimelineKeyframeControls: React.FC<{ diff --git a/packages/studio/src/components/Timeline/TimelineMediaInfo.tsx b/packages/studio/src/components/Timeline/TimelineMediaInfo.tsx index f202f702f2e..437964b8706 100644 --- a/packages/studio/src/components/Timeline/TimelineMediaInfo.tsx +++ b/packages/studio/src/components/Timeline/TimelineMediaInfo.tsx @@ -1,12 +1,7 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import {Internals} from 'remotion'; -import {LIGHT_TEXT, VERY_LIGHT_TEXT} from '../../helpers/colors'; -import {useSelectAsset} from '../use-select-asset'; -import { - getTimelineAssetLinkInfo, - openTimelineAssetLink, -} from './timeline-asset-link'; -import {SELECTION_ENABLED} from './TimelineSelection'; +import {VERY_LIGHT_TEXT} from '../../helpers/colors'; +import {getTimelineAssetLinkInfo} from './timeline-asset-link'; const lineStyle: React.CSSProperties = { whiteSpace: 'nowrap', @@ -19,34 +14,12 @@ const lineStyle: React.CSSProperties = { }; const useAssetLink = (src: string) => { - const selectAsset = useSelectAsset(); - const [hovered, setHovered] = useState(false); - const linkInfo = useMemo(() => getTimelineAssetLinkInfo(src), [src]); - const interactive = !SELECTION_ENABLED && linkInfo !== null; - - const onClick = useCallback( - (e: React.MouseEvent) => { - if (!linkInfo) { - return; - } - - e.preventDefault(); - e.stopPropagation(); - - openTimelineAssetLink(linkInfo, selectAsset); - }, - [linkInfo, selectAsset], - ); - - const onPointerEnter = useCallback(() => setHovered(true), []); - const onPointerLeave = useCallback(() => setHovered(false), []); const fileNameStyle: React.CSSProperties = useMemo( () => ({ ...lineStyle, - color: interactive && hovered ? LIGHT_TEXT : VERY_LIGHT_TEXT, - cursor: interactive ? 'pointer' : undefined, + color: VERY_LIGHT_TEXT, textDecoration: 'none', display: 'inline-block', overflow: 'hidden', @@ -55,15 +28,11 @@ const useAssetLink = (src: string) => { userSelect: 'none', WebkitUserSelect: 'none', }), - [interactive, hovered], + [], ); return { linkInfo, - interactive, - onClick, - onPointerEnter, - onPointerLeave, fileNameStyle, }; }; @@ -73,23 +42,10 @@ export const TimelineMediaInfo: React.FC<{ }> = ({src}) => { const fileName = useMemo(() => Internals.getAssetDisplayName(src), [src]); - const { - linkInfo, - interactive, - onClick, - onPointerEnter, - onPointerLeave, - fileNameStyle, - } = useAssetLink(src); + const {linkInfo, fileNameStyle} = useAssetLink(src); return ( -
+
{fileName}
); diff --git a/packages/studio/src/components/Timeline/TimelineScrollable.tsx b/packages/studio/src/components/Timeline/TimelineScrollable.tsx index 48f135e3642..92b742ea28a 100644 --- a/packages/studio/src/components/Timeline/TimelineScrollable.tsx +++ b/packages/studio/src/components/Timeline/TimelineScrollable.tsx @@ -1,7 +1,10 @@ import React, {useMemo} from 'react'; import {HORIZONTAL_SCROLLBAR_CLASSNAME} from '../Menu/is-menu-item'; import {scrollableRef} from './timeline-refs'; -import {TIMELINE_BACKGROUND} from './TimelineSelection'; +import { + TIMELINE_BACKGROUND, + useTimelineMarqueeSelection, +} from './TimelineSelection'; const outer: React.CSSProperties = { width: '100%', @@ -12,9 +15,19 @@ const outer: React.CSSProperties = { backgroundColor: TIMELINE_BACKGROUND, }; +const marqueeStyle: React.CSSProperties = { + backgroundColor: 'rgba(70, 130, 255, 0.16)', + border: '1px solid rgba(70, 130, 255, 0.75)', + boxSizing: 'border-box', + pointerEvents: 'none', + position: 'fixed', + zIndex: 10, +}; + export const TimelineScrollable: React.FC<{ readonly children: React.ReactNode; }> = ({children}) => { + const {marqueeRect, onPointerDownCapture} = useTimelineMarqueeSelection(); const containerStyle: React.CSSProperties = useMemo(() => { return { width: '100%', @@ -27,8 +40,20 @@ export const TimelineScrollable: React.FC<{ ref={scrollableRef} style={outer} className={HORIZONTAL_SCROLLBAR_CLASSNAME} + onPointerDownCapture={onPointerDownCapture} >
{children}
+ {marqueeRect === null ? null : ( +
+ )}
); }; diff --git a/packages/studio/src/components/Timeline/TimelineSelection.tsx b/packages/studio/src/components/Timeline/TimelineSelection.tsx index f13fedb1273..0a645a7d909 100644 --- a/packages/studio/src/components/Timeline/TimelineSelection.tsx +++ b/packages/studio/src/components/Timeline/TimelineSelection.tsx @@ -18,6 +18,7 @@ import type { import {TIMELINE_PADDING} from '../../helpers/timeline-layout'; import {timelineNodePathInfoToKey} from '../../helpers/timeline-node-path-key'; import {useKeybinding} from '../../helpers/use-keybinding'; +import {useZIndex} from '../../state/z-index'; import {TimelineClipboardKeybindings} from './TimelineClipboardKeybindings'; import {TimelineDeleteKeybindings} from './TimelineDeleteKeybindings'; @@ -61,14 +62,8 @@ export const getTimelineSelectedTrackHighlightStyle = ( width: timelineWidth, }); -export const SELECTION_ENABLED = false; -export const TIMELINE_TOP_DRAG = false; -export const ENABLE_OUTLINES = false; -export const TIMELINE_BACKGROUND = ENABLE_OUTLINES ? '#0F1113' : '#111'; -export const TIMELINE_TICKS_BACKGROUND = ENABLE_OUTLINES - ? BACKGROUND - : TIMELINE_BACKGROUND; -export const EASING_SELECTION_ENABLED = false; +export const TIMELINE_BACKGROUND = '#0F1113'; +export const TIMELINE_TICKS_BACKGROUND = BACKGROUND; type TimelineSelectionBase = { readonly nodePathInfo: SequenceNodePathInfo; @@ -655,11 +650,9 @@ export const TimelineSelectionProvider: React.FC<{ }> = ({children}) => { const {previewServerState} = useContext(StudioServerConnectionCtx); const canSelect = - (SELECTION_ENABLED || ENABLE_OUTLINES) && previewServerState.type === 'connected' && !window.remotion_isReadOnlyStudio; const canSelectEasing = - EASING_SELECTION_ENABLED && previewServerState.type === 'connected' && !window.remotion_isReadOnlyStudio; const [selectedItems, setSelectedItems] = useState< @@ -902,12 +895,17 @@ export const useCurrentTimelineSelectionStateAsRef = () => { export const useTimelineMarqueeSelection = () => { const {canSelect, canSelectEasing, getMarqueeSelection, selectItems} = useTimelineSelection(); + const {isHighestContext} = useZIndex(); const [marqueeRect, setMarqueeRect] = useState( null, ); const onPointerDownCapture = useCallback( (event: React.PointerEvent) => { + if (!isHighestContext) { + return; + } + if (event.button !== 0 || (!canSelect && !canSelectEasing)) { return; } @@ -1011,7 +1009,13 @@ export const useTimelineMarqueeSelection = () => { window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerCancel); }, - [canSelect, canSelectEasing, getMarqueeSelection, selectItems], + [ + canSelect, + canSelectEasing, + getMarqueeSelection, + isHighestContext, + selectItems, + ], ); return {marqueeRect, onPointerDownCapture}; diff --git a/packages/studio/src/components/Timeline/TimelineSequence.tsx b/packages/studio/src/components/Timeline/TimelineSequence.tsx index 655d1d14a1c..88a5005dbbe 100644 --- a/packages/studio/src/components/Timeline/TimelineSequence.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequence.tsx @@ -16,9 +16,8 @@ import {AudioWaveform} from '../AudioWaveform'; import {LoopedTimelineIndicator} from './LoopedTimelineIndicators'; import {TimelineImageInfo} from './TimelineImageInfo'; import { - TIMELINE_MARQUEE_ITEM_ATTR, - TIMELINE_TOP_DRAG, shouldSelectTimelineRowOnPointerDown, + TIMELINE_MARQUEE_ITEM_ATTR, useTimelineMarqueeSelectableItem, useTimelineRowSelection, } from './TimelineSelection'; @@ -104,7 +103,7 @@ const TimelineSequenceCurrentFrame: React.FC<{ }); } - if (TIMELINE_TOP_DRAG && fromCanUpdate) { + if (fromCanUpdate) { onMoveDragPointerDown(e); } } @@ -309,7 +308,6 @@ const TimelineSequenceInner: React.FC<{ }, [marginLeft, s.type, width]); const showRightEdgeDragHandle = - TIMELINE_TOP_DRAG && (s.type === 'sequence' || s.type === 'image') && !s.loopDisplay && !s.isInsideSeries && diff --git a/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx b/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx index 00852463b9f..0df05bd94a3 100644 --- a/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequenceItem.tsx @@ -17,8 +17,8 @@ import {ContextMenu} from '../ContextMenu'; import { addEffectFromDragData, getEffectDragData, - hasExplicitEffectDragType, hasEffectDragType, + hasExplicitEffectDragType, } from '../effect-drag-and-drop'; import { ExpandedTracksGetterContext, @@ -45,7 +45,6 @@ import {TimelineMediaInfo} from './TimelineMediaInfo'; import {TimelineRowChrome} from './TimelineRowChrome'; import { isTimelineSelectionModifierEvent, - SELECTION_ENABLED, useTimelineRowContainsSelection, useTimelineRowSelection, } from './TimelineSelection'; @@ -236,11 +235,10 @@ export const TimelineSequenceItem: React.FC<{ ); const parentId = sequence.parent ?? null; const canReorderSequence = - SELECTION_ENABLED && previewConnected && Boolean(nodePath && nodePathKey && validatedLocation?.source) && nodePathInfo?.numberOfSequencesWithThisNodePath === 1; - const canHandleSequenceDrag = SELECTION_ENABLED && previewConnected; + const canHandleSequenceDrag = previewConnected; const confirm = useConfirmationDialog(); const deleteDisabled = useMemo( @@ -671,7 +669,7 @@ export const TimelineSequenceItem: React.FC<{ const onShowInEditorDoubleClick = useCallback( (e: React.MouseEvent) => { - if (!SELECTION_ENABLED || !canOpenInEditor) { + if (!canOpenInEditor) { return; } @@ -913,11 +911,7 @@ export const TimelineSequenceItem: React.FC<{ onDragLeave={canDropEffect ? onEffectDragLeave : undefined} onDragOver={canDropEffect ? onEffectDragOver : undefined} onDrop={canDropEffect ? onEffectDrop : undefined} - onDoubleClick={ - SELECTION_ENABLED && canOpenInEditor - ? onShowInEditorDoubleClick - : undefined - } + onDoubleClick={canOpenInEditor ? onShowInEditorDoubleClick : undefined} >
= ({sequence, selected, containsSelection}) => { - const [hovered, setHovered] = useState(false); - const {documentationLink} = sequence; - const hoverable = !SELECTION_ENABLED && documentationLink !== null; - - const onClick = useCallback(() => { - if (documentationLink === null) { - return; - } - - window.open(documentationLink, '_blank', 'noopener,noreferrer'); - }, [documentationLink]); - - const onPointerEnter = useCallback(() => setHovered(true), []); - const onPointerLeave = useCallback(() => setHovered(false), []); - const style = useMemo((): React.CSSProperties => { - const hoverEffect = hovered && hoverable; return { alignItems: 'center', alignSelf: 'stretch', @@ -50,27 +33,18 @@ export const TimelineSequenceName: React.FC<{ color: getTimelineColor(selected, false), userSelect: 'none', WebkitUserSelect: 'none', - textDecoration: hoverEffect ? 'underline' : 'none', - cursor: hoverEffect ? 'pointer' : undefined, + textDecoration: 'none', boxShadow: containsSelection && !selected ? `inset 0 0 0 2px ${TIMELINE_SELECTED_LABEL_BACKGROUND}` : undefined, }; - }, [hovered, hoverable, selected, containsSelection]); + }, [selected, containsSelection]); const text = getTruncatedDisplayName(sequence.displayName) || ''; return ( -
+
{text}
); diff --git a/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx b/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx index 5970e525f28..781a0a7f4df 100644 --- a/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx +++ b/packages/studio/src/components/Timeline/TimelineTimeIndicators.tsx @@ -13,7 +13,7 @@ import {renderFrame} from '../../state/render-frame'; import {TimeValue} from '../TimeValue'; import {timelineVerticalScroll} from './timeline-refs'; import {getFrameIncrementFromWidth} from './timeline-scroll-logic'; -import {ENABLE_OUTLINES, TIMELINE_TICKS_BACKGROUND} from './TimelineSelection'; +import {TIMELINE_TICKS_BACKGROUND} from './TimelineSelection'; import {TimelineWidthContext} from './TimelineWidthProvider'; export const TIMELINE_TIME_INDICATOR_HEIGHT = 39; @@ -23,9 +23,7 @@ const container: React.CSSProperties = { position: 'absolute', backgroundColor: TIMELINE_TICKS_BACKGROUND, top: 0, - borderBottom: ENABLE_OUTLINES - ? `${TIMELINE_ITEM_BORDER_BOTTOM}px solid ${TIMELINE_TRACK_SEPARATOR}` - : undefined, + borderBottom: `${TIMELINE_ITEM_BORDER_BOTTOM}px solid ${TIMELINE_TRACK_SEPARATOR}`, }; const tick: React.CSSProperties = { diff --git a/packages/studio/src/components/Timeline/TimelineTracks.tsx b/packages/studio/src/components/Timeline/TimelineTracks.tsx index 738430cc4f7..415b7c931f4 100644 --- a/packages/studio/src/components/Timeline/TimelineTracks.tsx +++ b/packages/studio/src/components/Timeline/TimelineTracks.tsx @@ -2,7 +2,6 @@ import React, {useMemo} from 'react'; import type {TrackWithHash} from '../../helpers/get-timeline-sequence-sort-key'; import {TIMELINE_PADDING} from '../../helpers/timeline-layout'; import {MaxTimelineTracksReached} from './MaxTimelineTracks'; -import {useTimelineMarqueeSelection} from './TimelineSelection'; import {TimelineTimePadding} from './TimelineTimeIndicators'; import {TimelineTrack} from './TimelineTrack'; @@ -15,20 +14,10 @@ const timelineContent: React.CSSProperties = { minHeight: '100%', }; -const marqueeStyle: React.CSSProperties = { - backgroundColor: 'rgba(70, 130, 255, 0.16)', - border: '1px solid rgba(70, 130, 255, 0.75)', - boxSizing: 'border-box', - pointerEvents: 'none', - position: 'fixed', - zIndex: 10, -}; - const TimelineTracksInner: React.FC<{ readonly timeline: TrackWithHash[]; readonly hasBeenCut: boolean; }> = ({timeline, hasBeenCut}) => { - const {marqueeRect, onPointerDownCapture} = useTimelineMarqueeSelection(); const timelineStyle: React.CSSProperties = useMemo(() => { return { ...timelineContent, @@ -37,7 +26,7 @@ const TimelineTracksInner: React.FC<{ }, []); return ( -
+
{timeline.map((track) => ( @@ -45,17 +34,6 @@ const TimelineTracksInner: React.FC<{ ))}
{hasBeenCut ? : null} - {marqueeRect === null ? null : ( -
- )}
); }; diff --git a/packages/studio/src/components/canvas-capture-enabled.ts b/packages/studio/src/components/canvas-capture-enabled.ts new file mode 100644 index 00000000000..3732eb5fbba --- /dev/null +++ b/packages/studio/src/components/canvas-capture-enabled.ts @@ -0,0 +1 @@ +export const CANVAS_CAPTURE_ENABLED = false; diff --git a/packages/studio/src/test/canvas-capture-enabled.test.ts b/packages/studio/src/test/canvas-capture-enabled.test.ts new file mode 100644 index 00000000000..bdfab6ea3ae --- /dev/null +++ b/packages/studio/src/test/canvas-capture-enabled.test.ts @@ -0,0 +1,6 @@ +import {expect, test} from 'bun:test'; +import {CANVAS_CAPTURE_ENABLED} from '../components/canvas-capture-enabled'; + +test('Canvas capture should be disabled by default', () => { + expect(CANVAS_CAPTURE_ENABLED).toBe(false); +}); diff --git a/packages/studio/src/test/timeline-selection.test.ts b/packages/studio/src/test/timeline-selection.test.ts index 8fccd9a0e5a..04e59d2bfab 100644 --- a/packages/studio/src/test/timeline-selection.test.ts +++ b/packages/studio/src/test/timeline-selection.test.ts @@ -18,6 +18,7 @@ import { } from '../components/selected-outline-uv'; import { applySelectedOutlineDragAxisLock, + compensateTranslateForTransformOrigin, getOutlineSelectionInteraction, getSelectedEffectFieldsBySequenceKey, getSelectedOutlineDragChanges, @@ -31,7 +32,6 @@ import { getSelectedOutlineScaleEdgeInfo, getSelectedSequenceKeys, getSequencesWithSelectableOutlines, - compensateTranslateForTransformOrigin, type SelectedOutlineDragState, type SelectedOutlineRotationDragState, type SelectedOutlineScaleDragState, @@ -49,7 +49,6 @@ import { } from '../components/Timeline/TimelineClipboardKeybindings'; import {getSelectedKeyframeControlNodePathInfos} from '../components/Timeline/TimelineKeyframeControls'; import { - ENABLE_OUTLINES, getClampedTimelineMarqueePoint, getSelectableTimelineSequenceSelections, getTimelineMarqueeSelection, @@ -57,11 +56,9 @@ import { getTimelineSelectionFromNodePathInfo, getTimelineSequenceSelectionKey, isTimelineSelectionModifierEvent, - SELECTION_ENABLED, shouldSelectTimelineRowOnPointerDown, TIMELINE_BACKGROUND, TIMELINE_TICKS_BACKGROUND, - TIMELINE_TOP_DRAG, timelineMarqueeRectsIntersect, } from '../components/Timeline/TimelineSelection'; import { @@ -73,8 +70,8 @@ import { getTimelineSequenceFromDragValue, } from '../components/Timeline/TimelineSequenceRightEdgeDragHandle'; import { - parseTransformOrigin, parsedTransformOriginToUv, + parseTransformOrigin, serializeTransformOrigin, } from '../components/Timeline/transform-origin-utils'; import type {SequenceNodePathInfo} from '../helpers/get-timeline-sequence-sort-key'; @@ -149,6 +146,7 @@ const makeTimelineSequence = ({ duration = 100, from = 0, type = 'sequence', + showInTimeline = true, }: { readonly schema: SequenceSchema; readonly effects?: readonly {readonly schema: SequenceSchema}[]; @@ -159,6 +157,7 @@ const makeTimelineSequence = ({ readonly duration?: number; readonly from?: number; readonly type?: TSequence['type']; + readonly showInTimeline?: boolean; }): TSequence => ({ type, @@ -169,7 +168,7 @@ const makeTimelineSequence = ({ documentationLink: null, parent: parentId, rootId: 'root', - showInTimeline: true, + showInTimeline, nonce: [[0, 0]], loopDisplay: undefined, getStack: () => null, @@ -220,10 +219,6 @@ const makeFromPropStatuses = ( return propStatuses; }; -test('Timeline selection should stay disabled until released publicly', () => { - expect(SELECTION_ENABLED).toBe(false); -}); - test('timeline marquee rectangle intersection detects overlapping targets', () => { expect( timelineMarqueeRectsIntersect( @@ -757,10 +752,6 @@ test('pasting an effect prop requires the same effect type and prop key', () => ).toEqual({type: 'effect-type-mismatch'} satisfies PasteEffectPropTarget); }); -test('Timeline top drag should not be enabled', () => { - expect(TIMELINE_TOP_DRAG).toBe(false); -}); - test('Timeline duration drag applies the same delta to selected sequences', () => { const schema = {} satisfies SequenceSchema; const firstNodePathInfo = makeNodePathInfo(['body', 0], []); @@ -1178,13 +1169,9 @@ test('Timeline from drag removes the prop at the default value', () => { }); }); -test('Timeline outlines should not be enabled', () => { - expect(ENABLE_OUTLINES).toBe(false); -}); - -test('Timeline colors should use the old palette when outlines are disabled', () => { - expect(TIMELINE_BACKGROUND).toBe('#111'); - expect(TIMELINE_TICKS_BACKGROUND).toBe('#111'); +test('Timeline colors use the outlines palette', () => { + expect(TIMELINE_BACKGROUND).toBe('#0F1113'); + expect(TIMELINE_TICKS_BACKGROUND).not.toBe(TIMELINE_BACKGROUND); }); test('Timeline outlines visibility is enabled by default and persisted', () => { @@ -1256,6 +1243,50 @@ test('Canvas outline hit targets render nested sequences above parents', () => { ]); }); +test('Canvas outline hit targets exclude sequences hidden from the timeline', () => { + const schema = {} satisfies SequenceSchema; + const refForOutline = {current: null}; + const hiddenNodePathInfo = makeNodePathInfo(['body', 0], []); + const visibleNodePathInfo = makeNodePathInfo(['body', 1], []); + const childNodePathInfo = makeNodePathInfo(['body', 0, 'children', 0], []); + const outlines = getSequencesWithSelectableOutlines({ + sequences: [ + makeTimelineSequence({ + schema, + id: 'hidden', + overrideId: 'hidden', + refForOutline, + showInTimeline: false, + }), + makeTimelineSequence({ + schema, + id: 'visible', + overrideId: 'visible', + refForOutline, + }), + makeTimelineSequence({ + schema, + id: 'child', + overrideId: 'child', + parentId: 'hidden', + refForOutline, + }), + ], + overrideIdsToNodePaths: { + hidden: hiddenNodePathInfo.sequenceSubscriptionKey, + visible: visibleNodePathInfo.sequenceSubscriptionKey, + child: childNodePathInfo.sequenceSubscriptionKey, + }, + }); + + expect(outlines.map((outline) => outline.key).sort()).toEqual( + [ + getTimelineSequenceSelectionKey(visibleNodePathInfo), + getTimelineSequenceSelectionKey(childNodePathInfo), + ].sort(), + ); +}); + test('UV handles project semantic outline corners', () => { const points = [ {x: 200, y: 200}, diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index f33441fa87d..d46897976ad 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../core" }, + { + "path": "../canvas-capture" + }, { "path": "../media-utils" }, diff --git a/packages/template-blank/public/Screenshot 2026-05-28 at 14.02.20.png b/packages/template-blank/public/Screenshot 2026-05-28 at 14.02.20.png deleted file mode 100644 index f078728c2e6..00000000000 Binary files a/packages/template-blank/public/Screenshot 2026-05-28 at 14.02.20.png and /dev/null differ diff --git a/packages/template-blank/public/withtitle.png b/packages/template-blank/public/withtitle.png deleted file mode 100644 index 265c3408257..00000000000 Binary files a/packages/template-blank/public/withtitle.png and /dev/null differ diff --git a/tsconfig.json b/tsconfig.json index d42d90231d1..d620fecfb69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -255,6 +255,9 @@ { "path": "./packages/rounded-text-box" }, + { + "path": "./packages/canvas-capture" + }, { "path": "./packages/light-leaks" },