From 7226632fc4e777e8c0c1ea2600eb50ec5f2d6d57 Mon Sep 17 00:00:00 2001 From: Hemkesh Date: Wed, 4 Mar 2026 21:37:17 -0600 Subject: [PATCH 1/4] Add precise crop controls with numeric inputs, aspect ratio presets, and drag-to-move - Add X, Y, W, H pixel input fields in the crop modal for exact positioning - Add aspect ratio preset dropdown (16:9, 9:16, 4:3, 3:4, 1:1, 21:9, Free) - Add lock/unlock button to maintain aspect ratio when resizing - Display source video resolution for reference - Add drag-to-move: click inside the crop area to pan it around - Fix dropdown styling for dark mode Co-Authored-By: Claude Opus 4.6 --- src/components/video-editor/CropControl.tsx | 21 ++- src/components/video-editor/SettingsPanel.tsx | 153 ++++++++++++++++-- 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index e95b020f..fc148944 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -16,7 +16,7 @@ interface CropControlProps { aspectRatio: AspectRatio; } -type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null; +type DragHandle = 'top' | 'right' | 'bottom' | 'left' | 'move' | null; export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { const canvasRef = useRef(null); @@ -97,6 +97,13 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont case 'right': newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x)); break; + case 'move': { + const newX = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width)); + const newY = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height)); + newCrop.x = newX; + newCrop.y = newY; + break; + } } onCropChange(newCrop); @@ -168,6 +175,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont +
handlePointerDown(e, 'move')} + /> +
(GRADIENTS[0]); const [showCropDropdown, setShowCropDropdown] = useState(false); + const [cropAspectLocked, setCropAspectLocked] = useState(false); + const [cropAspectRatio, setCropAspectRatio] = useState(''); + + const videoWidth = videoElement?.videoWidth || 1920; + const videoHeight = videoElement?.videoHeight || 1080; + + const handleCropNumericChange = useCallback((field: 'x' | 'y' | 'width' | 'height', pixelValue: number) => { + if (!cropRegion || !onCropChange) return; + const norm = { ...cropRegion }; + const maxW = videoWidth; + const maxH = videoHeight; + + switch (field) { + case 'x': + norm.x = Math.max(0, Math.min(pixelValue / maxW, 1 - norm.width)); + break; + case 'y': + norm.y = Math.max(0, Math.min(pixelValue / maxH, 1 - norm.height)); + break; + case 'width': { + const newW = Math.max(0.05, Math.min(pixelValue / maxW, 1 - norm.x)); + if (cropAspectLocked && norm.width > 0 && norm.height > 0) { + const ratio = norm.width / norm.height; + const newH = newW / ratio; + if (norm.y + newH <= 1) { + norm.width = newW; + norm.height = newH; + } + } else { + norm.width = newW; + } + break; + } + case 'height': { + const newH = Math.max(0.05, Math.min(pixelValue / maxH, 1 - norm.y)); + if (cropAspectLocked && norm.width > 0 && norm.height > 0) { + const ratio = norm.width / norm.height; + const newW = newH * ratio; + if (norm.x + newW <= 1) { + norm.height = newH; + norm.width = newW; + } + } else { + norm.height = newH; + } + break; + } + } + onCropChange(norm); + }, [cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked]); + + const applyCropAspectPreset = useCallback((preset: string) => { + if (!cropRegion || !onCropChange) return; + setCropAspectRatio(preset); + if (preset === '') { + setCropAspectLocked(false); + return; + } + const [wStr, hStr] = preset.split(':'); + const targetRatio = Number(wStr) / Number(hStr); + const norm = { ...cropRegion }; + // Keep the current width, adjust height to match ratio + const newH = (norm.width * videoWidth) / (targetRatio * videoHeight); + if (norm.y + newH <= 1 && newH >= 0.05) { + norm.height = newH; + } else { + // Keep height, adjust width + const newW = (norm.height * videoHeight * targetRatio) / videoWidth; + if (norm.x + newW <= 1 && newW >= 0.05) { + norm.width = newW; + } + } + onCropChange(norm); + setCropAspectLocked(true); + }, [cropRegion, onCropChange, videoWidth, videoHeight]); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); @@ -624,14 +699,72 @@ export function SettingsPanel({ onCropChange={onCropChange} aspectRatio={aspectRatio} /> -
- +
+
+ {[ + { label: 'X', field: 'x' as const, max: videoWidth }, + { label: 'Y', field: 'y' as const, max: videoHeight }, + { label: 'W', field: 'width' as const, max: videoWidth }, + { label: 'H', field: 'height' as const, max: videoHeight }, + ].map(({ label, field, max }) => ( +
+ + )[field] * (field === 'x' || field === 'width' ? videoWidth : videoHeight))} + onChange={(e) => handleCropNumericChange(field, Number(e.target.value))} + className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ ))} + +
+ +
+ + +
+
+ +

+ {videoWidth} × {videoHeight}px +

+
+ +
+ +
From c8ebef026bdefd18ee54a7e31c4c6b8ce9eff91b Mon Sep 17 00:00:00 2001 From: Hemkesh Date: Wed, 4 Mar 2026 21:48:47 -0600 Subject: [PATCH 2/4] Add "Native" aspect ratio option to export at cropped video dimensions Adds a "Native" option to the aspect ratio dropdown that uses the cropped video's actual aspect ratio, so the video fills the entire frame with no background visible. Selecting Native also sets padding to 0 automatically. Co-Authored-By: Claude Opus 4.6 --- src/components/video-editor/VideoEditor.tsx | 13 +++++++--- src/components/video-editor/VideoPlayback.tsx | 4 +-- src/utils/aspectRatioUtils.ts | 25 +++++++++++++++---- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 769e88e2..9666c33c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -42,7 +42,7 @@ import { type PlaybackSpeed, } from "./types"; import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter"; -import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; import { getAssetPath } from "@/lib/assetPath"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { matchesShortcut } from "@/lib/shortcuts"; @@ -833,9 +833,11 @@ export default function VideoEditor() { videoPlaybackRef.current?.pause(); } - const aspectRatioValue = getAspectRatioValue(aspectRatio); const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; + const aspectRatioValue = aspectRatio === 'native' + ? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion) + : getAspectRatioValue(aspectRatio); // Get preview CONTAINER dimensions for scaling const playbackRef = videoPlaybackRef.current; @@ -1130,7 +1132,7 @@ export default function VideoEditor() {
{/* Video preview */}
-
+
{ + setAspectRatio(ratio); + if (ratio === 'native') setPadding(0); + }} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 2801f1ae..7ca784c6 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -11,7 +11,7 @@ import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { applyZoomTransform } from "./videoPlayback/zoomTransform"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; -import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; +import { type AspectRatio, formatAspectRatioForCSS, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; interface VideoPlaybackProps { @@ -797,7 +797,7 @@ const VideoPlayback = forwardRef(({ : { background: resolvedWallpaper || '' }; return ( -
+
{/* Background layer - always render as DOM element with blur */}
Date: Wed, 4 Mar 2026 21:51:09 -0600 Subject: [PATCH 3/4] Default to Windows tab when no screens available and show source counts On Linux (e.g. Ubuntu), screen sources are often empty. This defaults the source selector to the Windows tab when there are no screens, and shows the count of each source type in the tab labels. Co-Authored-By: Claude Opus 4.6 --- src/components/launch/SourceSelector.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 33c67c1e..74165607 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -70,10 +70,10 @@ export function SourceSelector() { return (
- + - Screens - Windows + Screens ({screenSources.length}) + Windows ({windowSources.length})
From 7a8d0f449a1449ebcf13f7f0cad5b26949d9dd12 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 15 Mar 2026 10:29:23 +0100 Subject: [PATCH 4/4] feat: narrow PR to zoom transitions and motion blur --- package-lock.json | 97 +++++--- package.json | 1 + src/components/video-editor/VideoPlayback.tsx | 204 +++++++++++----- .../video-editor/videoPlayback/constants.ts | 7 +- .../video-editor/videoPlayback/focusUtils.ts | 89 ++++++- .../video-editor/videoPlayback/mathUtils.ts | 78 ++++++ .../videoPlayback/zoomRegionUtils.ts | 225 +++++++++++++++-- .../videoPlayback/zoomTransform.ts | 228 ++++++++++++++++-- src/lib/exporter/frameRenderer.ts | 196 +++++++++------ 9 files changed, 907 insertions(+), 218 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9edd82e..86295865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "mediabunny": "^1.25.1", "motion": "^12.23.24", "mp4box": "^2.2.0", + "pixi-filters": "^6.1.5", "pixi.js": "^8.14.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -116,7 +117,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -345,7 +345,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1320,6 +1319,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1341,6 +1341,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1357,6 +1358,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1371,6 +1373,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2054,7 +2057,6 @@ "integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/core": "^0.16.13" @@ -2097,7 +2099,6 @@ "integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2112,7 +2113,6 @@ "integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2141,7 +2141,6 @@ "integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13", @@ -2191,7 +2190,6 @@ "integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2335,7 +2333,6 @@ "integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2350,7 +2347,6 @@ "integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2368,7 +2364,6 @@ "integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.7.2", "@jimp/utils": "^0.16.13" @@ -2795,6 +2790,7 @@ "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2809,7 +2805,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2836,7 +2833,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2863,19 +2861,22 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2887,6 +2888,7 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2898,6 +2900,7 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2912,19 +2915,22 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/utils/node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@pixi/utils/node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4380,6 +4386,12 @@ "@types/events": "*" } }, + "node_modules/@types/gradient-parser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@types/gradient-parser/-/gradient-parser-0.1.5.tgz", + "integrity": "sha512-r7K3NkJz3A95WkVVmjs0NcchhHstC2C/VIYNX4JC6tieviUNo774FFeOHjThr3Vw/WCeMP9kAT77MKbIRlO/4w==", + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", @@ -4439,7 +4451,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4451,7 +4462,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4760,7 +4770,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5581,7 +5590,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5894,6 +5902,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -6343,7 +6352,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6639,7 +6649,6 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7066,6 +7075,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7086,6 +7096,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8768,7 +8779,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10331,6 +10341,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -10846,6 +10857,18 @@ "node": ">=4.0.0" } }, + "node_modules/pixi-filters": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz", + "integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==", + "license": "MIT", + "dependencies": { + "@types/gradient-parser": "^0.1.2" + }, + "peerDependencies": { + "pixi.js": ">=8.0.0-0" + } + }, "node_modules/pixi.js": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz", @@ -10920,7 +10943,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11065,6 +11087,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11082,6 +11105,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11230,6 +11254,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -11288,7 +11313,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11301,7 +11325,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12092,6 +12115,7 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -12111,6 +12135,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -12127,6 +12152,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12145,6 +12171,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -12792,7 +12819,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12865,6 +12891,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12928,6 +12955,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12942,6 +12970,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12955,7 +12984,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -13108,7 +13136,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13342,6 +13369,7 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", + "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -13354,7 +13382,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -13468,7 +13497,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13543,8 +13571,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vitest": { "version": "4.0.16", @@ -14108,7 +14135,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14122,7 +14148,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index ef2c1975..fbb618a9 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "mediabunny": "^1.25.1", "motion": "^12.23.24", "mp4box": "^2.2.0", + "pixi-filters": "^6.1.5", "pixi.js": "^8.14.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 98a4c236..9280cc3f 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -7,6 +7,7 @@ import { Texture, VideoSource, } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; import type React from "react"; import { forwardRef, @@ -29,14 +30,24 @@ import { type ZoomFocus, type ZoomRegion, } from "./types"; -import { DEFAULT_FOCUS, MIN_DELTA, SMOOTHING_FACTOR } from "./videoPlayback/constants"; +import { + DEFAULT_FOCUS, + ZOOM_SCALE_DEADZONE, + ZOOM_TRANSLATION_DEADZONE_PX, +} from "./videoPlayback/constants"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; -import { applyZoomTransform } from "./videoPlayback/zoomTransform"; +import { + applyZoomTransform, + computeFocusFromTransform, + computeZoomTransform, + createMotionBlurState, + type MotionBlurState, +} from "./videoPlayback/zoomTransform"; interface VideoPlaybackProps { videoPath: string; @@ -113,6 +124,7 @@ const VideoPlayback = forwardRef( }, ref, ) => { + const ZOOM_MOTION_BLUR_AMOUNT = 0.35; const videoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -131,8 +143,13 @@ const VideoPlayback = forwardRef( scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + appliedScale: 1, }); const blurFilterRef = useRef(null); + const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); const stageSizeRef = useRef({ width: 0, height: 0 }); const videoSizeRef = useRef({ width: 0, height: 0 }); @@ -149,6 +166,7 @@ const VideoPlayback = forwardRef( const trimRegionsRef = useRef([]); const speedRegionsRef = useRef([]); const motionBlurEnabledRef = useRef(motionBlurEnabled); + const motionBlurStateRef = useRef(createMotionBlurState()); const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); const videoReadyRafRef = useRef(null); @@ -412,8 +430,15 @@ const VideoPlayback = forwardRef( scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + appliedScale: 1, }; + // Reset motion blur state for clean transitions + motionBlurStateRef.current = createMotionBlurState(); + if (blurFilterRef.current) { blurFilterRef.current.blur = 0; } @@ -446,7 +471,7 @@ const VideoPlayback = forwardRef( focusY: DEFAULT_FOCUS.cy, motionIntensity: 0, isPlaying: false, - motionBlurEnabled: motionBlurEnabledRef.current, + motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0, }); requestAnimationFrame(() => { @@ -605,14 +630,20 @@ const VideoPlayback = forwardRef( scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + appliedScale: 1, }; const blurFilter = new BlurFilter(); blurFilter.quality = 3; blurFilter.resolution = app.renderer.resolution; blurFilter.blur = 0; - videoContainer.filters = [blurFilter]; + const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + videoContainer.filters = [blurFilter, motionBlurFilter]; blurFilterRef.current = blurFilter; + motionBlurFilterRef.current = motionBlurFilter; layoutVideoContentRef.current?.(); video.pause(); @@ -662,6 +693,10 @@ const VideoPlayback = forwardRef( blurFilterRef.current.destroy(); blurFilterRef.current = null; } + if (motionBlurFilterRef.current) { + motionBlurFilterRef.current.destroy(); + motionBlurFilterRef.current = null; + } videoTexture.destroy(true); videoSpriteRef.current = null; @@ -676,97 +711,154 @@ const VideoPlayback = forwardRef( const videoContainer = videoContainerRef.current; if (!app || !videoSprite || !videoContainer) return; - const applyTransform = (motionIntensity: number) => { + const applyTransformFn = ( + transform: { scale: number; x: number; y: number }, + targetFocus: ZoomFocus, + motionIntensity: number, + motionVector: { x: number; y: number }, + ) => { const cameraContainer = cameraContainerRef.current; if (!cameraContainer) return; const state = animationStateRef.current; - applyZoomTransform({ + const appliedTransform = applyZoomTransform({ cameraContainer, blurFilter: blurFilterRef.current, + motionBlurFilter: motionBlurFilterRef.current, stageSize: stageSizeRef.current, baseMask: baseMaskRef.current, zoomScale: state.scale, - focusX: state.focusX, - focusY: state.focusY, + zoomProgress: state.progress, + focusX: targetFocus.cx, + focusY: targetFocus.cy, motionIntensity, + motionVector, isPlaying: isPlayingRef.current, - motionBlurEnabled: motionBlurEnabledRef.current, + motionBlurAmount: motionBlurEnabledRef.current ? ZOOM_MOTION_BLUR_AMOUNT : 0, + transformOverride: transform, + motionBlurState: motionBlurStateRef.current, + frameTimeMs: performance.now(), }); + + state.x = appliedTransform.x; + state.y = appliedTransform.y; + state.appliedScale = appliedTransform.scale; }; const ticker = () => { - const { region, strength } = findDominantRegion( + const { region, strength, blendedScale, transition } = findDominantRegion( zoomRegionsRef.current, currentTimeRef.current, + { connectZooms: true }, ); const defaultFocus = DEFAULT_FOCUS; let targetScaleFactor = 1; let targetFocus = defaultFocus; + let targetProgress = 0; // If a zoom is selected but video is not playing, show default unzoomed view - // (the overlay will show where the zoom will be) const selectedId = selectedZoomIdRef.current; const hasSelectedZoom = selectedId !== null; const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = clampFocusToStage(region.focus, region.depth); - - // Interpolate scale and focus based on region strength - targetScaleFactor = 1 + (zoomScale - 1) * strength; - targetFocus = { - cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength, - cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength, - }; + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + const regionFocus = region.focus; + + targetScaleFactor = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + + // Handle connected zoom transitions (pan between adjacent zoom regions) + if (transition) { + const startTransform = computeZoomTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: transition.startScale, + zoomProgress: 1, + focusX: transition.startFocus.cx, + focusY: transition.startFocus.cy, + }); + const endTransform = computeZoomTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: transition.endScale, + zoomProgress: 1, + focusX: transition.endFocus.cx, + focusY: transition.endFocus.cy, + }); + + const interpolatedTransform = { + scale: + startTransform.scale + + (endTransform.scale - startTransform.scale) * transition.progress, + x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress, + y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress, + }; + + targetScaleFactor = interpolatedTransform.scale; + targetFocus = computeFocusFromTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: interpolatedTransform.scale, + x: interpolatedTransform.x, + y: interpolatedTransform.y, + }); + targetProgress = 1; + } } const state = animationStateRef.current; + const prevScale = state.appliedScale; + const prevX = state.x; + const prevY = state.y; - const prevScale = state.scale; - const prevFocusX = state.focusX; - const prevFocusY = state.focusY; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; - - if (Math.abs(scaleDelta) > MIN_DELTA) { - nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; - } else { - nextScale = targetScaleFactor; - } - - if (Math.abs(focusXDelta) > MIN_DELTA) { - nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; - } else { - nextFocusX = targetFocus.cx; - } - - if (Math.abs(focusYDelta) > MIN_DELTA) { - nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; - } else { - nextFocusY = targetFocus.cy; - } + const projectedTransform = computeZoomTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); - state.scale = nextScale; - state.focusX = nextFocusX; - state.focusY = nextFocusY; + const appliedScale = + Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE + ? projectedTransform.scale + : projectedTransform.scale; + const appliedX = + Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX + ? projectedTransform.x + : projectedTransform.x; + const appliedY = + Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX + ? projectedTransform.y + : projectedTransform.y; const motionIntensity = Math.max( - Math.abs(nextScale - prevScale), - Math.abs(nextFocusX - prevFocusX), - Math.abs(nextFocusY - prevFocusY), + Math.abs(appliedScale - prevScale), + Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width), + Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height), ); - applyTransform(motionIntensity); + const motionVector = { + x: appliedX - prevX, + y: appliedY - prevY, + }; + + applyTransformFn( + { scale: appliedScale, x: appliedX, y: appliedY }, + targetFocus, + motionIntensity, + motionVector, + ); }; app.ticker.add(ticker); @@ -775,7 +867,7 @@ const VideoPlayback = forwardRef( app.ticker.remove(ticker); } }; - }, [pixiReady, videoReady, clampFocusToStage]); + }, [pixiReady, videoReady]); const handleLoadedMetadata = (e: React.SyntheticEvent) => { const video = e.currentTarget; diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts index e0dbf6ba..83ff8d22 100644 --- a/src/components/video-editor/videoPlayback/constants.ts +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -1,7 +1,10 @@ import type { ZoomFocus } from "../types"; export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 }; -export const TRANSITION_WINDOW_MS = 320; -export const SMOOTHING_FACTOR = 0.12; +export const TRANSITION_WINDOW_MS = 1015.05; +export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5; export const MIN_DELTA = 0.0001; export const VIEWPORT_SCALE = 0.8; +export const SMOOTHING_FACTOR = 0.12; +export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25; +export const ZOOM_SCALE_DEADZONE = 0.002; diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts index cb519e67..5a56f0ee 100644 --- a/src/components/video-editor/videoPlayback/focusUtils.ts +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -5,28 +5,93 @@ interface StageSize { height: number; } +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +function easeIntoBoundary(normalized: number) { + const t = clamp(normalized, 0, 1); + return -t * t * t + 2 * t * t; +} + +function softClampToRange(value: number, min: number, max: number, softness: number) { + const clamped = clamp(value, min, max); + + if (softness <= 0 || max <= min) { + return clamped; + } + + if (clamped < min + softness) { + const normalized = (clamped - min) / softness; + return min + softness * easeIntoBoundary(normalized); + } + + if (clamped > max - softness) { + const normalized = (max - clamped) / softness; + return max - softness * easeIntoBoundary(normalized); + } + + return clamped; +} + +function getFocusBounds(depth: ZoomDepth) { + const zoomScale = ZOOM_DEPTH_SCALES[depth]; + return getFocusBoundsForScale(zoomScale); +} + +function getFocusBoundsForScale(zoomScale: number) { + const marginX = 1 / (2 * zoomScale); + const marginY = 1 / (2 * zoomScale); + + return { + minX: marginX, + maxX: 1 - marginX, + minY: marginY, + maxY: 1 - marginY, + }; +} + export function clampFocusToStage( focus: ZoomFocus, depth: ZoomDepth, - stageSize: StageSize, + _stageSize: StageSize, ): ZoomFocus { - if (!stageSize.width || !stageSize.height) { - return clampFocusToDepth(focus, depth); - } + const baseFocus = clampFocusToDepth(focus, depth); + const bounds = getFocusBounds(depth); - const zoomScale = ZOOM_DEPTH_SCALES[depth]; + return { + cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX), + cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY), + }; +} - const windowWidth = stageSize.width / zoomScale; - const windowHeight = stageSize.height / zoomScale; +export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus { + const baseFocus = { + cx: clamp(focus.cx, 0, 1), + cy: clamp(focus.cy, 0, 1), + }; + const bounds = getFocusBoundsForScale(zoomScale); - const marginX = windowWidth / (2 * stageSize.width); - const marginY = windowHeight / (2 * stageSize.height); + return { + cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX), + cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY), + }; +} - const baseFocus = clampFocusToDepth(focus, depth); +export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus { + const baseFocus = { + cx: clamp(focus.cx, 0, 1), + cy: clamp(focus.cy, 0, 1), + }; + const bounds = getFocusBoundsForScale(zoomScale); + const horizontalRange = bounds.maxX - bounds.minX; + const verticalRange = bounds.maxY - bounds.minY; + const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35); + const verticalSoftness = Math.min(0.12, verticalRange * 0.35); return { - cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)), - cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)), + cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness), + cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness), }; } diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts index 5995b903..78c9414f 100644 --- a/src/components/video-editor/videoPlayback/mathUtils.ts +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -2,7 +2,85 @@ export function clamp01(value: number) { return Math.max(0, Math.min(1, value)); } +function sampleCubicBezier(a1: number, a2: number, t: number) { + const oneMinusT = 1 - t; + return 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t; +} + +function sampleCubicBezierDerivative(a1: number, a2: number, t: number) { + const oneMinusT = 1 - t; + return 3 * a1 * oneMinusT * oneMinusT + 6 * (a2 - a1) * oneMinusT * t + 3 * (1 - a2) * t * t; +} + +export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) { + const targetX = clamp01(t); + let solvedT = targetX; + + for (let i = 0; i < 8; i += 1) { + const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX; + const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT); + + if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) { + break; + } + + solvedT -= currentX / currentDerivative; + } + + let lower = 0; + let upper = 1; + solvedT = clamp01(solvedT); + + for (let i = 0; i < 10; i += 1) { + const currentX = sampleCubicBezier(x1, x2, solvedT); + if (Math.abs(currentX - targetX) < 1e-6) { + break; + } + + if (currentX < targetX) { + lower = solvedT; + } else { + upper = solvedT; + } + + solvedT = (lower + upper) / 2; + } + + return sampleCubicBezier(y1, y2, solvedT); +} + +export function easeOutExpo(t: number) { + const clamped = clamp01(t); + if (clamped === 1) { + return 1; + } + + return 1 - Math.pow(2, -7 * clamped); +} + +export function easeOutScreenStudio(t: number) { + return cubicBezier(0.16, 1, 0.3, 1, t); +} + export function smoothStep(t: number) { const clamped = clamp01(t); return clamped * clamped * (3 - 2 * clamped); } + +/** + * Gentle ease-in-out cubic — slow start, smooth middle, gentle landing. + * Used for zoom-in transitions. + */ +export function easeInOutCubic(t: number) { + const x = clamp01(t); + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; +} + +/** + * Ease-out cubic — starts at speed, then decelerates to a gentle stop. + * Used for zoom-out transitions so strength eases smoothly to zero. + */ +export function easeOutCubic(t: number) { + const x = clamp01(t); + return 1 - Math.pow(1 - x, 3); +} diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index b87ba12b..3ceace87 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -1,31 +1,224 @@ -import type { ZoomRegion } from "../types"; -import { TRANSITION_WINDOW_MS } from "./constants"; -import { smoothStep } from "./mathUtils"; +import type { ZoomFocus, ZoomRegion } from "../types"; +import { ZOOM_DEPTH_SCALES } from "../types"; +import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants"; +import { clampFocusToScale } from "./focusUtils"; +import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils"; + +const CHAINED_ZOOM_PAN_GAP_MS = 1500; +const CONNECTED_ZOOM_PAN_DURATION_MS = 1000; +const ZOOM_IN_OVERLAP_MS = 500; + +type DominantRegionOptions = { + connectZooms?: boolean; +}; + +type ConnectedRegionPair = { + currentRegion: ZoomRegion; + nextRegion: ZoomRegion; + transitionStart: number; + transitionEnd: number; +}; + +type ConnectedPanTransition = { + progress: number; + startFocus: ZoomFocus; + endFocus: ZoomFocus; + startScale: number; + endScale: number; +}; + +function lerp(start: number, end: number, amount: number) { + return start + (end - start) * amount; +} + +function easeConnectedPan(value: number) { + return cubicBezier(0.1, 0.0, 0.2, 1.0, value); +} export function computeRegionStrength(region: ZoomRegion, timeMs: number) { - const leadInStart = region.startMs - TRANSITION_WINDOW_MS; + const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS; + const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS; const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS; if (timeMs < leadInStart || timeMs > leadOutEnd) { return 0; } - const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS); - const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS); - return Math.min(fadeIn, fadeOut); + if (timeMs < zoomInEnd) { + const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS; + return easeOutScreenStudio(progress); + } + + if (timeMs <= region.endMs) { + return 1; + } + + const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS); + return 1 - easeOutScreenStudio(progress); +} + +function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus { + return { + cx: lerp(start.cx, end.cx, amount), + cy: lerp(start.cy, end.cy, amount), + }; +} + +function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus { + return clampFocusToScale(region.focus, zoomScale); +} + +function getConnectedRegionPairs(regions: ZoomRegion[]) { + const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs); + const pairs: ConnectedRegionPair[] = []; + + for (let index = 0; index < sortedRegions.length - 1; index += 1) { + const currentRegion = sortedRegions[index]; + const nextRegion = sortedRegions[index + 1]; + const gapMs = nextRegion.startMs - currentRegion.endMs; + + if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) { + continue; + } + + pairs.push({ + currentRegion, + nextRegion, + transitionStart: currentRegion.endMs, + transitionEnd: currentRegion.endMs + CONNECTED_ZOOM_PAN_DURATION_MS, + }); + } + + return pairs; } -export function findDominantRegion(regions: ZoomRegion[], timeMs: number) { - let bestRegion: ZoomRegion | null = null; - let bestStrength = 0; +function getActiveRegion( + regions: ZoomRegion[], + timeMs: number, + connectedPairs: ConnectedRegionPair[], +) { + const activeRegions = regions + .map((region) => { + const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id); + if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) { + return { region, strength: 0 }; + } + + const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id); + if (incomingPair && timeMs < incomingPair.transitionEnd) { + return { region, strength: 0 }; + } + + return { region, strength: computeRegionStrength(region, timeMs) }; + }) + .filter((entry) => entry.strength > 0) + .sort((left, right) => { + if (right.strength !== left.strength) { + return right.strength - left.strength; + } + + return right.region.startMs - left.region.startMs; + }); + + if (activeRegions.length === 0) { + return null; + } + + const activeRegion = activeRegions[0].region; + const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth]; + + return { + region: { + ...activeRegion, + focus: getResolvedFocus(activeRegion, activeScale), + }, + strength: activeRegions[0].strength, + blendedScale: null, + }; +} + +function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) { + for (const pair of connectedPairs) { + if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) { + const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth]; + return { + region: { + ...pair.nextRegion, + focus: getResolvedFocus(pair.nextRegion, nextScale), + }, + strength: 1, + blendedScale: null, + }; + } + } + + return null; +} + +function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) { + for (const pair of connectedPairs) { + const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair; + + if (timeMs < transitionStart || timeMs > transitionEnd) { + continue; + } + + const transitionProgress = easeConnectedPan( + clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)), + ); + const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth]; + const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth]; + const transitionScale = lerp(currentScale, nextScale, transitionProgress); + const currentFocus = getResolvedFocus(currentRegion, currentScale); + const nextFocus = getResolvedFocus(nextRegion, nextScale); + const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress); + + return { + region: { + ...nextRegion, + focus: transitionFocus, + }, + strength: 1, + blendedScale: transitionScale, + transition: { + progress: transitionProgress, + startFocus: currentFocus, + endFocus: nextFocus, + startScale: currentScale, + endScale: nextScale, + }, + }; + } + + return null; +} + +export function findDominantRegion( + regions: ZoomRegion[], + timeMs: number, + options: DominantRegionOptions = {}, +): { + region: ZoomRegion | null; + strength: number; + blendedScale: number | null; + transition: ConnectedPanTransition | null; +} { + const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : []; + + if (options.connectZooms) { + const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs); + if (connectedTransition) { + return connectedTransition; + } - for (const region of regions) { - const strength = computeRegionStrength(region, timeMs); - if (strength > bestStrength) { - bestStrength = strength; - bestRegion = region; + const connectedHold = getConnectedRegionHold(timeMs, connectedPairs); + if (connectedHold) { + return { ...connectedHold, transition: null }; } } - return { region: bestRegion, strength: bestStrength }; + const activeRegion = getActiveRegion(regions, timeMs, connectedPairs); + return activeRegion + ? { ...activeRegion, transition: null } + : { region: null, strength: 0, blendedScale: null, transition: null }; } diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index 8dbb5b3a..8fcf397f 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -1,61 +1,243 @@ import { BlurFilter, Container } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; + +const PEAK_VELOCITY_PPS = 2000; +const MAX_BLUR_PX = 8; +const VELOCITY_THRESHOLD_PPS = 15; + +export interface MotionBlurState { + lastFrameTimeMs: number; + prevCamX: number; + prevCamY: number; + prevCamScale: number; + initialized: boolean; +} + +export function createMotionBlurState(): MotionBlurState { + return { + lastFrameTimeMs: 0, + prevCamX: 0, + prevCamY: 0, + prevCamScale: 1, + initialized: false, + }; +} interface TransformParams { cameraContainer: Container; blurFilter: BlurFilter | null; + motionBlurFilter?: MotionBlurFilter | null; stageSize: { width: number; height: number }; baseMask: { x: number; y: number; width: number; height: number }; zoomScale: number; + zoomProgress?: number; focusX: number; focusY: number; motionIntensity: number; + motionVector?: { x: number; y: number }; isPlaying: boolean; - motionBlurEnabled?: boolean; + motionBlurAmount?: number; + transformOverride?: AppliedTransform; + motionBlurState?: MotionBlurState; + frameTimeMs?: number; +} + +interface AppliedTransform { + scale: number; + x: number; + y: number; +} + +interface FocusFromTransformGeometry { + stageSize: { width: number; height: number }; + baseMask: { x: number; y: number; width: number; height: number }; + zoomScale: number; + x: number; + y: number; +} + +interface ZoomTransformGeometry { + stageSize: { width: number; height: number }; + baseMask: { x: number; y: number; width: number; height: number }; + zoomScale: number; + zoomProgress?: number; + focusX: number; + focusY: number; +} + +export function computeZoomTransform({ + stageSize, + baseMask, + zoomScale, + zoomProgress = 1, + focusX, + focusY, +}: ZoomTransformGeometry): AppliedTransform { + if ( + stageSize.width <= 0 || + stageSize.height <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 + ) { + return { scale: 1, x: 0, y: 0 }; + } + + const progress = Math.min(1, Math.max(0, zoomProgress)); + const focusStagePxX = baseMask.x + focusX * baseMask.width; + const focusStagePxY = baseMask.y + focusY * baseMask.height; + const stageCenterX = stageSize.width / 2; + const stageCenterY = stageSize.height / 2; + const scale = 1 + (zoomScale - 1) * progress; + const finalX = stageCenterX - focusStagePxX * zoomScale; + const finalY = stageCenterY - focusStagePxY * zoomScale; + + return { + scale, + x: finalX * progress, + y: finalY * progress, + }; +} + +export function computeFocusFromTransform({ + stageSize, + baseMask, + zoomScale, + x, + y, +}: FocusFromTransformGeometry) { + if ( + stageSize.width <= 0 || + stageSize.height <= 0 || + baseMask.width <= 0 || + baseMask.height <= 0 || + zoomScale <= 0 + ) { + return { cx: 0.5, cy: 0.5 }; + } + + const stageCenterX = stageSize.width / 2; + const stageCenterY = stageSize.height / 2; + const focusStagePxX = (stageCenterX - x) / zoomScale; + const focusStagePxY = (stageCenterY - y) / zoomScale; + + return { + cx: (focusStagePxX - baseMask.x) / baseMask.width, + cy: (focusStagePxY - baseMask.y) / baseMask.height, + }; } export function applyZoomTransform({ cameraContainer, blurFilter, + motionBlurFilter, stageSize, baseMask, zoomScale, + zoomProgress = 1, focusX, focusY, - motionIntensity, + motionIntensity: _motionIntensity, + motionVector: _motionVector, isPlaying, - motionBlurEnabled = false, -}: TransformParams) { + motionBlurAmount = 0, + transformOverride, + motionBlurState, + frameTimeMs, +}: TransformParams): AppliedTransform { if ( stageSize.width <= 0 || stageSize.height <= 0 || baseMask.width <= 0 || baseMask.height <= 0 ) { - return; + return { scale: 1, x: 0, y: 0 }; } - // The focus point in stage coordinates (where the user clicked/selected) - const focusStagePxX = focusX * stageSize.width; - const focusStagePxY = focusY * stageSize.height; + const transform = + transformOverride ?? + computeZoomTransform({ + stageSize, + baseMask, + zoomScale, + zoomProgress, + focusX, + focusY, + }); - // Stage center (where we want the focus to end up after zoom) - const stageCenterX = stageSize.width / 2; - const stageCenterY = stageSize.height / 2; + // Apply position & scale to camera container + cameraContainer.scale.set(transform.scale); + cameraContainer.position.set(transform.x, transform.y); + + if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) { + const now = frameTimeMs ?? performance.now(); + + if (!motionBlurState.initialized) { + motionBlurState.prevCamX = transform.x; + motionBlurState.prevCamY = transform.y; + motionBlurState.prevCamScale = transform.scale; + motionBlurState.lastFrameTimeMs = now; + motionBlurState.initialized = true; + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = 5; + motionBlurFilter.offset = 0; + if (blurFilter) blurFilter.blur = 0; + } else { + const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs)); + const dtSeconds = dtMs / 1000; + motionBlurState.lastFrameTimeMs = now; - // Apply zoom scale to camera container - cameraContainer.scale.set(zoomScale); + // Camera displacement this frame (stage-px) + const dx = transform.x - motionBlurState.prevCamX; + const dy = transform.y - motionBlurState.prevCamY; + const dScale = transform.scale - motionBlurState.prevCamScale; - // Calculate camera position to keep focus point centered - // After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale) - // We want it at stage center, so offset = center - (focus * scale) - const cameraX = stageCenterX - focusStagePxX * zoomScale; - const cameraY = stageCenterY - focusStagePxY * zoomScale; + motionBlurState.prevCamX = transform.x; + motionBlurState.prevCamY = transform.y; + motionBlurState.prevCamScale = transform.scale; - cameraContainer.position.set(cameraX, cameraY); + // Velocity in px/s (translation + scale-change contribution) + const velocityX = dx / dtSeconds; + const velocityY = dy / dtSeconds; + const scaleVelocity = + Math.abs(dScale / dtSeconds) * Math.max(stageSize.width, stageSize.height) * 0.5; + const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) + scaleVelocity; - if (blurFilter) { - const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005; - const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0; - blurFilter.blur = motionBlur; + const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS); + const targetBlur = + speed < VELOCITY_THRESHOLD_PPS + ? 0 + : normalised * normalised * MAX_BLUR_PX * motionBlurAmount; + + const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1; + const velocityScale = targetBlur * 1.2; + motionBlurFilter.velocity = + targetBlur > 0 + ? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale } + : { x: 0, y: 0 }; + motionBlurFilter.kernelSize = targetBlur > 4 ? 11 : targetBlur > 1.5 ? 9 : 5; + motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0; + + if (blurFilter) { + blurFilter.blur = 0; + } + } + } else { + if (motionBlurFilter) { + motionBlurFilter.velocity = { x: 0, y: 0 }; + motionBlurFilter.kernelSize = 5; + motionBlurFilter.offset = 0; + } + if (blurFilter) { + blurFilter.blur = 0; + } + if (motionBlurState) { + motionBlurState.initialized = false; + } } + + return { + scale: transform.scale, + x: transform.x, + y: transform.y, + }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 7ad52042..a4003efc 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -7,6 +7,7 @@ import { Texture, type TextureSourceLike, } from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; import type { AnnotationRegion, CropRegion, @@ -17,13 +18,18 @@ import type { import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; import { DEFAULT_FOCUS, - MIN_DELTA, - SMOOTHING_FACTOR, + ZOOM_SCALE_DEADZONE, + ZOOM_TRANSLATION_DEADZONE_PX, } from "@/components/video-editor/videoPlayback/constants"; import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils"; import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; -import { applyZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform"; -import { getAssetPath } from "@/lib/assetPath"; +import { + applyZoomTransform, + computeFocusFromTransform, + computeZoomTransform, + createMotionBlurState, + type MotionBlurState, +} from "@/components/video-editor/videoPlayback/zoomTransform"; import { renderAnnotations } from "./annotationRenderer"; interface FrameRenderConfig { @@ -50,6 +56,10 @@ interface AnimationState { scale: number; focusX: number; focusY: number; + progress: number; + x: number; + y: number; + appliedScale: number; } interface LayoutCache { @@ -70,6 +80,7 @@ export class FrameRenderer { private backgroundSprite: HTMLCanvasElement | null = null; private maskGraphics: Graphics | null = null; private blurFilter: BlurFilter | null = null; + private motionBlurFilter: MotionBlurFilter | null = null; private shadowCanvas: HTMLCanvasElement | null = null; private shadowCtx: CanvasRenderingContext2D | null = null; private compositeCanvas: HTMLCanvasElement | null = null; @@ -78,6 +89,7 @@ export class FrameRenderer { private animationState: AnimationState; private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; + private motionBlurState: MotionBlurState = createMotionBlurState(); constructor(config: FrameRenderConfig) { this.config = config; @@ -85,6 +97,10 @@ export class FrameRenderer { scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy, + progress: 0, + x: 0, + y: 0, + appliedScale: 1, }; } @@ -130,7 +146,8 @@ export class FrameRenderer { this.blurFilter.quality = 5; this.blurFilter.resolution = this.app.renderer.resolution; this.blurFilter.blur = 0; - this.videoContainer.filters = [this.blurFilter]; + this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter]; // Setup composite canvas for final output with shadows this.compositeCanvas = document.createElement("canvas"); @@ -179,14 +196,18 @@ export class FrameRenderer { ) { // Image background const img = new Image(); - const imageUrl = await this.resolveWallpaperImageUrl(wallpaper); - // Don't set crossOrigin for same-origin images to avoid CORS taint. - if ( - imageUrl.startsWith("http") && - window.location.origin && - !imageUrl.startsWith(window.location.origin) - ) { - img.crossOrigin = "anonymous"; + // Don't set crossOrigin for same-origin images to avoid CORS taint + // Only set it for cross-origin URLs + let imageUrl: string; + if (wallpaper.startsWith("http")) { + imageUrl = wallpaper; + if (!imageUrl.startsWith(window.location.origin)) { + img.crossOrigin = "anonymous"; + } + } else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) { + imageUrl = wallpaper; + } else { + imageUrl = window.location.origin + wallpaper; } await new Promise((resolve, reject) => { @@ -280,23 +301,6 @@ export class FrameRenderer { this.backgroundSprite = bgCanvas; } - private async resolveWallpaperImageUrl(wallpaper: string): Promise { - if ( - wallpaper.startsWith("file://") || - wallpaper.startsWith("data:") || - wallpaper.startsWith("http") - ) { - return wallpaper; - } - - const resolved = await getAssetPath(wallpaper.replace(/^\/+/, "")); - if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) { - return `${window.location.origin}${resolved}`; - } - - return resolved; - } - async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise { if (!this.app || !this.videoContainer || !this.cameraContainer) { throw new Error("Renderer not initialized"); @@ -338,14 +342,18 @@ export class FrameRenderer { applyZoomTransform({ cameraContainer: this.cameraContainer, blurFilter: this.blurFilter, + motionBlurFilter: this.motionBlurFilter, stageSize: layoutCache.stageSize, baseMask: layoutCache.maskRect, zoomScale: this.animationState.scale, + zoomProgress: this.animationState.progress, focusX: this.animationState.focusX, focusY: this.animationState.focusY, motionIntensity: maxMotionIntensity, isPlaying: true, - motionBlurEnabled: this.config.motionBlurEnabled ?? false, + motionBlurAmount: this.config.motionBlurEnabled ? 0.35 : 0, + motionBlurState: this.motionBlurState, + frameTimeMs: timeMs, }); // Render the PixiJS stage to its canvas (video only, transparent background) @@ -456,63 +464,104 @@ export class FrameRenderer { private updateAnimationState(timeMs: number): number { if (!this.cameraContainer || !this.layoutCache) return 0; - const { region, strength } = findDominantRegion(this.config.zoomRegions, timeMs); + const { region, strength, blendedScale, transition } = findDominantRegion( + this.config.zoomRegions, + timeMs, + { connectZooms: true }, + ); const defaultFocus = DEFAULT_FOCUS; let targetScaleFactor = 1; let targetFocus = { ...defaultFocus }; + let targetProgress = 0; if (region && strength > 0) { - const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; + const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; const regionFocus = this.clampFocusToStage(region.focus, region.depth); - targetScaleFactor = 1 + (zoomScale - 1) * strength; - targetFocus = { - cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength, - cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength, - }; - } - - const state = this.animationState; - - const prevScale = state.scale; - const prevFocusX = state.focusX; - const prevFocusY = state.focusY; - - const scaleDelta = targetScaleFactor - state.scale; - const focusXDelta = targetFocus.cx - state.focusX; - const focusYDelta = targetFocus.cy - state.focusY; - - let nextScale = prevScale; - let nextFocusX = prevFocusX; - let nextFocusY = prevFocusY; + targetScaleFactor = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + + if (transition) { + const startTransform = computeZoomTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: transition.startScale, + zoomProgress: 1, + focusX: transition.startFocus.cx, + focusY: transition.startFocus.cy, + }); + const endTransform = computeZoomTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: transition.endScale, + zoomProgress: 1, + focusX: transition.endFocus.cx, + focusY: transition.endFocus.cy, + }); - if (Math.abs(scaleDelta) > MIN_DELTA) { - nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR; - } else { - nextScale = targetScaleFactor; + const interpolatedTransform = { + scale: + startTransform.scale + + (endTransform.scale - startTransform.scale) * transition.progress, + x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress, + y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress, + }; + + targetScaleFactor = interpolatedTransform.scale; + targetFocus = computeFocusFromTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: interpolatedTransform.scale, + x: interpolatedTransform.x, + y: interpolatedTransform.y, + }); + targetProgress = 1; + } } - if (Math.abs(focusXDelta) > MIN_DELTA) { - nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR; - } else { - nextFocusX = targetFocus.cx; - } + const state = this.animationState; - if (Math.abs(focusYDelta) > MIN_DELTA) { - nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR; - } else { - nextFocusY = targetFocus.cy; - } + const prevScale = state.appliedScale; + const prevX = state.x; + const prevY = state.y; + + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; + + const projectedTransform = computeZoomTransform({ + stageSize: this.layoutCache.stageSize, + baseMask: this.layoutCache.maskRect, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); - state.scale = nextScale; - state.focusX = nextFocusX; - state.focusY = nextFocusY; + const appliedScale = + Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE + ? projectedTransform.scale + : projectedTransform.scale; + const appliedX = + Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX + ? projectedTransform.x + : projectedTransform.x; + const appliedY = + Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX + ? projectedTransform.y + : projectedTransform.y; + + state.x = appliedX; + state.y = appliedY; + state.appliedScale = appliedScale; return Math.max( - Math.abs(nextScale - prevScale), - Math.abs(nextFocusX - prevFocusX), - Math.abs(nextFocusY - prevFocusY), + Math.abs(appliedScale - prevScale), + Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width), + Math.abs(appliedY - prevY) / Math.max(1, this.layoutCache.stageSize.height), ); } @@ -594,6 +643,7 @@ export class FrameRenderer { this.videoContainer = null; this.maskGraphics = null; this.blurFilter = null; + this.motionBlurFilter = null; this.shadowCanvas = null; this.shadowCtx = null; this.compositeCanvas = null;