From 4b3f057389160d47afe12daa470d9ddcbae512ab Mon Sep 17 00:00:00 2001 From: KBCats Date: Sun, 15 Mar 2026 17:01:44 -0700 Subject: [PATCH] feat(editor): add cursor sway effect Add a cursor sway control that carries through preview, export, and saved projects, and scale the effect so the editor slider has more usable range. --- src/components/video-editor/SettingsPanel.tsx | 561 +++-- src/components/video-editor/VideoEditor.tsx | 1789 ++++++++------ src/components/video-editor/VideoPlayback.tsx | 2195 +++++++++-------- .../video-editor/projectPersistence.ts | 256 +- src/components/video-editor/types.ts | 77 +- .../videoPlayback/cursorRenderer.ts | 382 ++- .../videoPlayback/cursorSway.test.ts | 31 + .../video-editor/videoPlayback/cursorSway.ts | 49 + src/i18n/locales/en/settings.json | 117 +- src/i18n/locales/es/settings.json | 117 +- src/i18n/locales/zh-CN/settings.json | 117 +- src/lib/exporter/frameRenderer.ts | 347 ++- src/lib/exporter/gifExporter.ts | 85 +- src/lib/exporter/videoExporter.ts | 154 +- 14 files changed, 3818 insertions(+), 2459 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/cursorSway.test.ts create mode 100644 src/components/video-editor/videoPlayback/cursorSway.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1cbf39b7..131e974d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,25 +1,67 @@ import { cn } from "@/lib/utils"; import { useEffect, useRef } from "react"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; -import { BUILT_IN_WALLPAPERS, WALLPAPER_PATHS, WALLPAPER_RELATIVE_PATHS } from "@/lib/wallpapers"; +import { + BUILT_IN_WALLPAPERS, + WALLPAPER_PATHS, + WALLPAPER_RELATIVE_PATHS, +} from "@/lib/wallpapers"; import { SliderControl } from "./SliderControl"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { useState } from "react"; -import Block from '@uiw/react-color-block'; -import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react"; +import Block from "@uiw/react-color-block"; +import { + Trash2, + Download, + Crop, + X, + Bug, + Upload, + Star, + Film, + Image, + Sparkles, + Palette, + Save, + FolderOpen, +} from "lucide-react"; import { toast } from "sonner"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; -import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types"; -import { SPEED_OPTIONS, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_ZOOM_MOTION_BLUR } from "./types"; +import type { + ZoomDepth, + CropRegion, + AnnotationRegion, + AnnotationType, + PlaybackSpeed, +} from "./types"; +import { + SPEED_OPTIONS, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_SWAY, + DEFAULT_ZOOM_MOTION_BLUR, +} from "./types"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; -import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import type { + ExportQuality, + ExportFormat, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; const GRADIENTS = [ "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", @@ -76,6 +118,8 @@ interface SettingsPanelProps { onCursorMotionBlurChange?: (amount: number) => void; cursorClickBounce?: number; onCursorClickBounceChange?: (amount: number) => void; + cursorSway?: number; + onCursorSwayChange?: (amount: number) => void; borderRadius?: number; onBorderRadiusChange?: (radius: number) => void; padding?: number; @@ -103,7 +147,10 @@ interface SettingsPanelProps { annotationRegions?: AnnotationRegion[]; onAnnotationContentChange?: (id: string, content: string) => void; onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; - onAnnotationStyleChange?: (id: string, style: Partial) => void; + onAnnotationStyleChange?: ( + id: string, + style: Partial, + ) => void; onAnnotationFigureDataChange?: (id: string, figureData: any) => void; onAnnotationDelete?: (id: string) => void; selectedSpeedId?: string | null; @@ -123,17 +170,17 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; -export function SettingsPanel({ - selected, - onWallpaperChange, - selectedZoomDepth, - onZoomDepthChange, - selectedZoomId, - onZoomDelete, +export function SettingsPanel({ + selected, + onWallpaperChange, + selectedZoomDepth, + onZoomDepthChange, + selectedZoomId, + onZoomDelete, selectedTrimId, onTrimDelete, - shadowIntensity = 0.67, - onShadowChange, + shadowIntensity = 0.67, + onShadowChange, backgroundBlur = 0, onBackgroundBlurChange, zoomMotionBlur = 0, @@ -152,23 +199,25 @@ export function SettingsPanel({ onCursorMotionBlurChange, cursorClickBounce = 1, onCursorClickBounceChange, - borderRadius = 12.5, - onBorderRadiusChange, - padding = 50, - onPaddingChange, - cropRegion, - onCropChange, - aspectRatio, - videoElement, - exportQuality = 'good', + cursorSway = DEFAULT_CURSOR_SWAY, + onCursorSwayChange, + borderRadius = 12.5, + onBorderRadiusChange, + padding = 50, + onPaddingChange, + cropRegion, + onCropChange, + aspectRatio, + videoElement, + exportQuality = "good", onExportQualityChange, - exportFormat = 'mp4', + exportFormat = "mp4", onExportFormatChange, gifFrameRate = 15, onGifFrameRateChange, gifLoop = true, onGifLoopChange, - gifSizePreset = 'medium', + gifSizePreset = "medium", onGifSizePresetChange, gifOutputDimensions = { width: 1280, height: 720 }, onSaveProject, @@ -186,40 +235,59 @@ export function SettingsPanel({ onSpeedChange, onSpeedDelete, }: SettingsPanelProps) { - const tSettings = useScopedT('settings'); + const tSettings = useScopedT("settings"); const { t } = useI18n(); - const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); + const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState( + [], + ); const [customImages, setCustomImages] = useState([]); const fileInputRef = useRef(null); useEffect(() => { - let mounted = true - ;(async () => { + let mounted = true; + (async () => { try { const resolved = await Promise.all( - WALLPAPER_RELATIVE_PATHS.map(async (path) => getRenderableAssetUrl(await getAssetPath(path))) - ) - if (mounted) setWallpaperPreviewPaths(resolved) + WALLPAPER_RELATIVE_PATHS.map(async (path) => + getRenderableAssetUrl(await getAssetPath(path)), + ), + ); + if (mounted) setWallpaperPreviewPaths(resolved); } catch (err) { - if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS) + if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); } - })() - return () => { mounted = false } - }, []) + })(); + return () => { + mounted = false; + }; + }, []); const colorPalette = [ - '#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00', - '#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107', - '#2563EB', '#000000', '#607D8B', '#795548', + "#FF0000", + "#FFD700", + "#00FF00", + "#FFFFFF", + "#0000FF", + "#FF6B00", + "#9B59B6", + "#E91E63", + "#00BCD4", + "#FF5722", + "#8BC34A", + "#FFC107", + "#2563EB", + "#000000", + "#607D8B", + "#795548", ]; - - const [selectedColor, setSelectedColor] = useState('#ADADAD'); + + const [selectedColor, setSelectedColor] = useState("#ADADAD"); const [gradient, setGradient] = useState(GRADIENTS[0]); const [showCropModal, setShowCropModal] = useState(false); const cropSnapshotRef = useRef(null); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); - + const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { onZoomDelete(selectedZoomId); @@ -251,14 +319,14 @@ export function SettingsPanel({ if (!files || files.length === 0) return; const file = files[0]; - + // Validate file type - only allow JPG/JPEG - const validTypes = ['image/jpeg', 'image/jpg']; + const validTypes = ["image/jpeg", "image/jpg"]; if (!validTypes.includes(file.type)) { - toast.error(tSettings('background.uploadError'), { - description: tSettings('background.uploadErrorDescription'), + toast.error(tSettings("background.uploadError"), { + description: tSettings("background.uploadErrorDescription"), }); - event.target.value = ''; + event.target.value = ""; return; } @@ -267,26 +335,29 @@ export function SettingsPanel({ reader.onload = (e) => { const dataUrl = e.target?.result as string; if (dataUrl) { - setCustomImages(prev => [...prev, dataUrl]); + setCustomImages((prev) => [...prev, dataUrl]); onWallpaperChange(dataUrl); - toast.success(tSettings('background.uploadSuccess')); + toast.success(tSettings("background.uploadSuccess")); } }; reader.onerror = () => { - toast.error(t('common.failedToUploadImage'), { - description: t('common.errorReadingFile'), + toast.error(t("common.failedToUploadImage"), { + description: t("common.errorReadingFile"), }); }; reader.readAsDataURL(file); // Reset input so the same file can be selected again - event.target.value = ''; + event.target.value = ""; }; - const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + const handleRemoveCustomImage = ( + imageUrl: string, + event: React.MouseEvent, + ) => { event.stopPropagation(); - setCustomImages(prev => prev.filter(img => img !== imageUrl)); + setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); // If the removed image was selected, clear selection if (selected === imageUrl) { onWallpaperChange(WALLPAPER_PATHS[0]); @@ -294,19 +365,36 @@ export function SettingsPanel({ }; // Find selected annotation - const selectedAnnotation = selectedAnnotationId - ? annotationRegions.find(a => a.id === selectedAnnotationId) + const selectedAnnotation = selectedAnnotationId + ? annotationRegions.find((a) => a.id === selectedAnnotationId) : null; // If an annotation is selected, show annotation settings instead - if (selectedAnnotation && onAnnotationContentChange && onAnnotationTypeChange && onAnnotationStyleChange && onAnnotationDelete) { + if ( + selectedAnnotation && + onAnnotationContentChange && + onAnnotationTypeChange && + onAnnotationStyleChange && + onAnnotationDelete + ) { return ( onAnnotationContentChange(selectedAnnotation.id, content)} - onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} - onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} - onFigureDataChange={onAnnotationFigureDataChange ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) : undefined} + onContentChange={(content) => + onAnnotationContentChange(selectedAnnotation.id, content) + } + onTypeChange={(type) => + onAnnotationTypeChange(selectedAnnotation.id, type) + } + onStyleChange={(style) => + onAnnotationStyleChange(selectedAnnotation.id, style) + } + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => + onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } onDelete={() => onAnnotationDelete(selectedAnnotation.id)} /> ); @@ -317,11 +405,17 @@ export function SettingsPanel({
- {tSettings('zoom.level')} + + {tSettings("zoom.level")} +
{zoomEnabled && selectedZoomDepth && ( - {ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} + { + ZOOM_DEPTH_OPTIONS.find( + (o) => o.depth === selectedZoomDepth, + )?.label + } )} @@ -339,10 +433,12 @@ export function SettingsPanel({ className={cn( "h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all", "duration-200 ease-out", - zoomEnabled ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed", + zoomEnabled + ? "opacity-100 cursor-pointer" + : "opacity-40 cursor-not-allowed", isActive ? "border-[#2563EB] bg-[#2563EB] text-white shadow-[#2563EB]/20" - : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200" + : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200", )} > {option.label} @@ -351,7 +447,9 @@ export function SettingsPanel({ })}
{!zoomEnabled && ( -

{tSettings('zoom.selectRegion')}

+

+ {tSettings("zoom.selectRegion")} +

)} {zoomEnabled && ( )}
@@ -375,17 +473,20 @@ export function SettingsPanel({ className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs" > - {tSettings('trim.deleteRegion')} + {tSettings("trim.deleteRegion")}
)}
- {tSettings('speed.playbackSpeed')} + + {tSettings("speed.playbackSpeed")} + {selectedSpeedId && selectedSpeedValue && ( - {SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`} + {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue) + ?.label ?? `${selectedSpeedValue}×`} )}
@@ -401,10 +502,12 @@ export function SettingsPanel({ className={cn( "h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all", "duration-200 ease-out", - selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed", + selectedSpeedId + ? "opacity-100 cursor-pointer" + : "opacity-40 cursor-not-allowed", isActive ? "border-[#d97706] bg-[#d97706] text-white shadow-[#d97706]/20" - : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200" + : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200", )} > {option.label} @@ -413,33 +516,48 @@ export function SettingsPanel({ })}
{!selectedSpeedId && ( -

{tSettings('speed.selectRegion')}

+

+ {tSettings("speed.selectRegion")} +

)} {selectedSpeedId && ( )}
- - + +
- {tSettings('effects.title')} + + {tSettings("effects.title")} +
-
{tSettings('effects.showCursor')}
+
+ {tSettings("effects.showCursor")} +
-
{tSettings('effects.loopCursor')}
+
+ {tSettings("effects.loopCursor")} +
onBackgroundBlurChange?.(v)} formatValue={(v) => `${v.toFixed(1)}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ''))} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} />
@@ -474,7 +594,7 @@ export function SettingsPanel({
onZoomMotionBlurChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ''))} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} />
-
{tSettings('effects.connectZooms')}
+
+ {tSettings("effects.connectZooms")} +
onCursorSizeChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ''))} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} />
onCursorSmoothingChange?.(v)} - formatValue={(v) => v <= 0 ? 'Off' : v.toFixed(2)} + formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} parseInput={(t) => parseFloat(t)} />
@@ -528,7 +650,7 @@ export function SettingsPanel({
onCursorMotionBlurChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ''))} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} />
onCursorClickBounceChange?.(v)} formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ''))} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} />
- +
onCursorSwayChange?.(v)} + formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} + parseInput={(t) => { + const normalized = t.trim().toLowerCase(); + if (normalized === "off") { + return 0; + } + + return parseFloat(t.replace(/×$/, "")); + }} + /> +
+
+ onShadowChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, '')) / 100} + parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} />
onBorderRadiusChange?.(v)} formatValue={(v) => `${v}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ''))} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} />
onPaddingChange?.(v)} formatValue={(v) => `${v}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, ''))} + parseInput={(t) => parseFloat(t.replace(/%$/, ""))} />
@@ -602,26 +744,46 @@ export function SettingsPanel({ className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all" > - {tSettings('crop.title')} + {tSettings("crop.title")} - +
- {tSettings('background.title')} + + {tSettings("background.title")} +
- {tSettings('background.image')} - {tSettings('background.color')} - {tSettings('background.gradient')} + + {tSettings("background.image")} + + + {tSettings("background.color")} + + + {tSettings("background.gradient")} + - +
- {tSettings('background.uploadCustom')} + {tSettings("background.uploadCustom")}
@@ -650,14 +812,20 @@ export function SettingsPanel({ "aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group shadow-sm", isSelected ? "border-[#2563EB] ring-1 ring-[#2563EB]/30" - : "border-white/10 hover:border-[#2563EB]/40 opacity-80 hover:opacity-100 bg-white/5" + : "border-white/10 hover:border-[#2563EB]/40 opacity-80 hover:opacity-100 bg-white/5", )} - style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: "cover", backgroundPosition: "center" }} + style={{ + backgroundImage: `url(${imageUrl})`, + backgroundSize: "cover", + backgroundPosition: "center", + }} onClick={() => onWallpaperChange(imageUrl)} role="button" >
- +
- +
{GRADIENTS.map((g, idx) => ( @@ -725,13 +912,16 @@ export function SettingsPanel({ key={g} className={cn( "aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm", - gradient === g - ? "border-[#2563EB] ring-1 ring-[#2563EB]/30" - : "border-white/10 hover:border-[#2563EB]/40 opacity-80 hover:opacity-100 bg-white/5" + gradient === g + ? "border-[#2563EB] ring-1 ring-[#2563EB]/30" + : "border-white/10 hover:border-[#2563EB]/40 opacity-80 hover:opacity-100 bg-white/5", )} style={{ background: g }} aria-label={`Gradient ${idx + 1}`} - onClick={() => { setGradient(g); onWallpaperChange(g); }} + onClick={() => { + setGradient(g); + onWallpaperChange(g); + }} role="button" /> ))} @@ -746,15 +936,19 @@ export function SettingsPanel({ {showCropModal && cropRegion && onCropChange && ( <> -
- {tSettings('crop.title')} -

{tSettings('crop.instruction')}

+ + {tSettings("crop.title")} + +

+ {tSettings("crop.instruction")} +

@@ -787,64 +981,70 @@ export function SettingsPanel({
- {exportFormat === 'mp4' && ( + {exportFormat === "mp4" && (
)} - {exportFormat === 'gif' && ( + {exportFormat === "gif" && (
@@ -854,7 +1054,9 @@ export function SettingsPanel({ onClick={() => onGifFrameRateChange?.(rate.value)} className={cn( "rounded-md transition-all text-[10px] font-medium", - gifFrameRate === rate.value ? "bg-white text-black" : "text-slate-400 hover:text-slate-200" + gifFrameRate === rate.value + ? "bg-white text-black" + : "text-slate-400 hover:text-slate-200", )} > {rate.value} @@ -865,21 +1067,31 @@ export function SettingsPanel({ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( ))}
- {gifOutputDimensions.width} × {gifOutputDimensions.height}px + + {gifOutputDimensions.width} × {gifOutputDimensions.height}px +
- {tSettings('export.loop')} + + {tSettings("export.loop")} +
)} - +
@@ -918,33 +1130,38 @@ export function SettingsPanel({ className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#2563EB] text-white rounded-xl shadow-lg shadow-[#2563EB]/20 hover:bg-[#2563EB]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200" > - {tSettings('export.exportVideo', undefined, { format: exportFormat === 'gif' ? 'GIF' : 'Video' })} + {tSettings("export.exportVideo", undefined, { + format: exportFormat === "gif" ? "GIF" : "Video", + })}
); } - diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f410b42b..ab0df9cd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -10,91 +10,106 @@ import type { AppLocale } from "@/i18n/config"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { getAssetPath } from "@/lib/assetPath"; import { - calculateOutputDimensions, - type ExportFormat, - type ExportProgress, - type ExportQuality, - type ExportSettings, - GIF_SIZE_PRESETS, - GifExporter, - type GifFrameRate, - type GifSizePreset, - VideoExporter, + calculateOutputDimensions, + type ExportFormat, + type ExportProgress, + type ExportQuality, + type ExportSettings, + GIF_SIZE_PRESETS, + GifExporter, + type GifFrameRate, + type GifSizePreset, + VideoExporter, } from "@/lib/exporter"; import { matchesShortcut } from "@/lib/shortcuts"; -import { DEFAULT_WALLPAPER_RELATIVE_PATH, WALLPAPER_PATHS } from "@/lib/wallpapers"; -import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; +import { + DEFAULT_WALLPAPER_RELATIVE_PATH, + WALLPAPER_PATHS, +} from "@/lib/wallpapers"; +import { + type AspectRatio, + getAspectRatioValue, +} from "@/utils/aspectRatioUtils"; import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { - createProjectData, - deriveNextId, - fromFileUrl, - normalizeProjectEditor, - toFileUrl, - validateProjectData, + createProjectData, + deriveNextId, + fromFileUrl, + normalizeProjectEditor, + toFileUrl, + validateProjectData, } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; import TimelineEditor from "./timeline/TimelineEditor"; import { - detectInteractionCandidates, - normalizeCursorTelemetry, + detectInteractionCandidates, + normalizeCursorTelemetry, } from "./timeline/zoomSuggestionUtils"; import { - type AnnotationRegion, - type CropRegion, - type CursorTelemetryPoint, - clampFocusToDepth, - DEFAULT_ANNOTATION_POSITION, - DEFAULT_ANNOTATION_SIZE, - DEFAULT_ANNOTATION_STYLE, - DEFAULT_CROP_REGION, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, - DEFAULT_FIGURE_DATA, - DEFAULT_PLAYBACK_SPEED, - DEFAULT_ZOOM_DEPTH, - DEFAULT_ZOOM_MOTION_BLUR, - type FigureData, - type PlaybackSpeed, - type SpeedRegion, - type TrimRegion, - type ZoomDepth, - type ZoomFocus, - type ZoomRegion, + type AnnotationRegion, + type CropRegion, + type CursorTelemetryPoint, + clampFocusToDepth, + DEFAULT_ANNOTATION_POSITION, + DEFAULT_ANNOTATION_SIZE, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_CROP_REGION, + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_SWAY, + DEFAULT_FIGURE_DATA, + DEFAULT_PLAYBACK_SPEED, + DEFAULT_ZOOM_DEPTH, + DEFAULT_ZOOM_MOTION_BLUR, + type FigureData, + type PlaybackSpeed, + type SpeedRegion, + type TrimRegion, + type ZoomDepth, + type ZoomFocus, + type ZoomRegion, } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; import { - buildLoopedCursorTelemetry, - getDisplayedTimelineWindowMs, + buildLoopedCursorTelemetry, + getDisplayedTimelineWindowMs, } from "./videoPlayback/cursorLoopTelemetry"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; const LOOP_CURSOR_END_WINDOW_MS = 670; type EditorHistorySnapshot = { - zoomRegions: ZoomRegion[]; - trimRegions: TrimRegion[]; - speedRegions: SpeedRegion[]; - annotationRegions: AnnotationRegion[]; - selectedZoomId: string | null; - selectedTrimId: string | null; - selectedSpeedId: string | null; - selectedAnnotationId: string | null; + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; + annotationRegions: AnnotationRegion[]; + selectedZoomId: string | null; + selectedTrimId: string | null; + selectedSpeedId: string | null; + selectedAnnotationId: string | null; }; type PendingExportSave = { - fileName: string; - arrayBuffer: ArrayBuffer; + fileName: string; + arrayBuffer: ArrayBuffer; }; function LanguageSwitcher() { const { locale, setLocale, t } = useI18n(); - const idx = SUPPORTED_LOCALES.indexOf(locale as typeof SUPPORTED_LOCALES[number]); - const next = SUPPORTED_LOCALES[(idx + 1) % SUPPORTED_LOCALES.length] as AppLocale; - const labels: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; + const idx = SUPPORTED_LOCALES.indexOf( + locale as (typeof SUPPORTED_LOCALES)[number], + ); + const next = SUPPORTED_LOCALES[ + (idx + 1) % SUPPORTED_LOCALES.length + ] as AppLocale; + const labels: Record = { + en: "EN", + es: "ES", + "zh-CN": "中文", + }; return ( ); } @@ -113,7 +130,9 @@ export default function VideoEditor() { const { t } = useI18n(); const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); - const [currentProjectPath, setCurrentProjectPath] = useState(null); + const [currentProjectPath, setCurrentProjectPath] = useState( + null, + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -122,39 +141,60 @@ export default function VideoEditor() { const [wallpaper, setWallpaper] = useState(WALLPAPER_PATHS[0]); const [shadowIntensity, setShadowIntensity] = useState(0.67); const [backgroundBlur, setBackgroundBlur] = useState(0); - const [zoomMotionBlur, setZoomMotionBlur] = useState(DEFAULT_ZOOM_MOTION_BLUR); + const [zoomMotionBlur, setZoomMotionBlur] = useState( + DEFAULT_ZOOM_MOTION_BLUR, + ); const [connectZooms, setConnectZooms] = useState(true); const [showCursor, setShowCursor] = useState(true); const [loopCursor, setLoopCursor] = useState(false); const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE); - const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING); - const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR); - const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE); + const [cursorSmoothing, setCursorSmoothing] = useState( + DEFAULT_CURSOR_SMOOTHING, + ); + const [cursorMotionBlur, setCursorMotionBlur] = useState( + DEFAULT_CURSOR_MOTION_BLUR, + ); + const [cursorClickBounce, setCursorClickBounce] = useState( + DEFAULT_CURSOR_CLICK_BOUNCE, + ); + const [cursorSway, setCursorSway] = useState(DEFAULT_CURSOR_SWAY); const [borderRadius, setBorderRadius] = useState(12.5); const [padding, setPadding] = useState(50); const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = useState([]); - const [cursorTelemetry, setCursorTelemetry] = useState([]); + const [cursorTelemetry, setCursorTelemetry] = useState< + CursorTelemetryPoint[] + >([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); const [selectedTrimId, setSelectedTrimId] = useState(null); const [speedRegions, setSpeedRegions] = useState([]); const [selectedSpeedId, setSelectedSpeedId] = useState(null); - const [annotationRegions, setAnnotationRegions] = useState([]); - const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [annotationRegions, setAnnotationRegions] = useState< + AnnotationRegion[] + >([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState< + string | null + >(null); const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(null); + const [exportProgress, setExportProgress] = useState( + null, + ); const [exportError, setExportError] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); - const [aspectRatio, setAspectRatio] = useState('16:9'); - const [exportQuality, setExportQuality] = useState('good'); - const [exportFormat, setExportFormat] = useState('mp4'); + const [aspectRatio, setAspectRatio] = useState("16:9"); + const [exportQuality, setExportQuality] = useState("good"); + const [exportFormat, setExportFormat] = useState("mp4"); const [gifFrameRate, setGifFrameRate] = useState(15); const [gifLoop, setGifLoop] = useState(true); - const [gifSizePreset, setGifSizePreset] = useState('medium'); - const [exportedFilePath, setExportedFilePath] = useState(undefined); + const [gifSizePreset, setGifSizePreset] = useState("medium"); + const [exportedFilePath, setExportedFilePath] = useState( + undefined, + ); const [hasPendingExportSave, setHasPendingExportSave] = useState(false); - const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null); + const [lastSavedSnapshot, setLastSavedSnapshot] = useState( + null, + ); const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); @@ -172,18 +212,23 @@ export default function VideoEditor() { const applyingHistoryRef = useRef(false); const pendingExportSaveRef = useRef(null); - const cloneSnapshot = useCallback((snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { - return { - zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)), - trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)), - speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)), - annotationRegions: JSON.parse(JSON.stringify(snapshot.annotationRegions)), - selectedZoomId: snapshot.selectedZoomId, - selectedTrimId: snapshot.selectedTrimId, - selectedSpeedId: snapshot.selectedSpeedId, - selectedAnnotationId: snapshot.selectedAnnotationId, - }; - }, []); + const cloneSnapshot = useCallback( + (snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { + return { + zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)), + trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)), + speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)), + annotationRegions: JSON.parse( + JSON.stringify(snapshot.annotationRegions), + ), + selectedZoomId: snapshot.selectedZoomId, + selectedTrimId: snapshot.selectedTrimId, + selectedSpeedId: snapshot.selectedSpeedId, + selectedAnnotationId: snapshot.selectedAnnotationId, + }; + }, + [], + ); const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => { return { @@ -207,30 +252,49 @@ export default function VideoEditor() { selectedAnnotationId, ]); - const applyHistorySnapshot = useCallback((snapshot: EditorHistorySnapshot) => { - applyingHistoryRef.current = true; - const cloned = cloneSnapshot(snapshot); - setZoomRegions(cloned.zoomRegions); - setTrimRegions(cloned.trimRegions); - setSpeedRegions(cloned.speedRegions); - setAnnotationRegions(cloned.annotationRegions); - setSelectedZoomId(cloned.selectedZoomId); - setSelectedTrimId(cloned.selectedTrimId); - setSelectedSpeedId(cloned.selectedSpeedId); - setSelectedAnnotationId(cloned.selectedAnnotationId); + const applyHistorySnapshot = useCallback( + (snapshot: EditorHistorySnapshot) => { + applyingHistoryRef.current = true; + const cloned = cloneSnapshot(snapshot); + setZoomRegions(cloned.zoomRegions); + setTrimRegions(cloned.trimRegions); + setSpeedRegions(cloned.speedRegions); + setAnnotationRegions(cloned.annotationRegions); + setSelectedZoomId(cloned.selectedZoomId); + setSelectedTrimId(cloned.selectedTrimId); + setSelectedSpeedId(cloned.selectedSpeedId); + setSelectedAnnotationId(cloned.selectedAnnotationId); - nextZoomIdRef.current = deriveNextId("zoom", cloned.zoomRegions.map((region) => region.id)); - nextTrimIdRef.current = deriveNextId("trim", cloned.trimRegions.map((region) => region.id)); - nextSpeedIdRef.current = deriveNextId("speed", cloned.speedRegions.map((region) => region.id)); - nextAnnotationIdRef.current = deriveNextId("annotation", cloned.annotationRegions.map((region) => region.id)); - nextAnnotationZIndexRef.current = - cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; - }, [cloneSnapshot]); + nextZoomIdRef.current = deriveNextId( + "zoom", + cloned.zoomRegions.map((region) => region.id), + ); + nextTrimIdRef.current = deriveNextId( + "trim", + cloned.trimRegions.map((region) => region.id), + ); + nextSpeedIdRef.current = deriveNextId( + "speed", + cloned.speedRegions.map((region) => region.id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + cloned.annotationRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + cloned.annotationRegions.reduce( + (max, region) => Math.max(max, region.zIndex), + 0, + ) + 1; + }, + [cloneSnapshot], + ); const handleUndo = useCallback(() => { if (historyPastRef.current.length === 0) return; - const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); + const current = + historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); const previous = historyPastRef.current.pop(); if (!previous) return; @@ -242,7 +306,8 @@ export default function VideoEditor() { const handleRedo = useCallback(() => { if (historyFutureRef.current.length === 0) return; - const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); + const current = + historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); const next = historyFutureRef.current.pop(); if (!next) return; @@ -251,75 +316,94 @@ export default function VideoEditor() { applyHistorySnapshot(next); }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot]); - const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { - if (!validateProjectData(candidate)) { - return false; - } + const applyLoadedProject = useCallback( + async (candidate: unknown, path?: string | null) => { + if (!validateProjectData(candidate)) { + return false; + } - const project = candidate; - const sourcePath = fromFileUrl(project.videoPath); - const normalizedEditor = normalizeProjectEditor(project.editor); + const project = candidate; + const sourcePath = fromFileUrl(project.videoPath); + const normalizedEditor = normalizeProjectEditor(project.editor); - try { - videoPlaybackRef.current?.pause(); - } catch { - // no-op - } - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); - setError(null); - setVideoSourcePath(sourcePath); - setVideoPath(toFileUrl(sourcePath)); - setCurrentProjectPath(path ?? null); + setError(null); + setVideoSourcePath(sourcePath); + setVideoPath(toFileUrl(sourcePath)); + setCurrentProjectPath(path ?? null); - setWallpaper(normalizedEditor.wallpaper); - setShadowIntensity(normalizedEditor.shadowIntensity); - setBackgroundBlur(normalizedEditor.backgroundBlur); - setZoomMotionBlur(normalizedEditor.zoomMotionBlur); - setConnectZooms(normalizedEditor.connectZooms); - setShowCursor(normalizedEditor.showCursor); - setLoopCursor(normalizedEditor.loopCursor); - setCursorSize(normalizedEditor.cursorSize); - setCursorSmoothing(normalizedEditor.cursorSmoothing); - setCursorMotionBlur(normalizedEditor.cursorMotionBlur); - setCursorClickBounce(normalizedEditor.cursorClickBounce); - setBorderRadius(normalizedEditor.borderRadius); - setPadding(normalizedEditor.padding); - setCropRegion(normalizedEditor.cropRegion); - setZoomRegions(normalizedEditor.zoomRegions); - setTrimRegions(normalizedEditor.trimRegions); - setSpeedRegions(normalizedEditor.speedRegions); - setAnnotationRegions(normalizedEditor.annotationRegions); - setAspectRatio(normalizedEditor.aspectRatio); - setExportQuality(normalizedEditor.exportQuality); - setExportFormat(normalizedEditor.exportFormat); - setGifFrameRate(normalizedEditor.gifFrameRate); - setGifLoop(normalizedEditor.gifLoop); - setGifSizePreset(normalizedEditor.gifSizePreset); + setWallpaper(normalizedEditor.wallpaper); + setShadowIntensity(normalizedEditor.shadowIntensity); + setBackgroundBlur(normalizedEditor.backgroundBlur); + setZoomMotionBlur(normalizedEditor.zoomMotionBlur); + setConnectZooms(normalizedEditor.connectZooms); + setShowCursor(normalizedEditor.showCursor); + setLoopCursor(normalizedEditor.loopCursor); + setCursorSize(normalizedEditor.cursorSize); + setCursorSmoothing(normalizedEditor.cursorSmoothing); + setCursorMotionBlur(normalizedEditor.cursorMotionBlur); + setCursorClickBounce(normalizedEditor.cursorClickBounce); + setCursorSway(normalizedEditor.cursorSway); + setBorderRadius(normalizedEditor.borderRadius); + setPadding(normalizedEditor.padding); + setCropRegion(normalizedEditor.cropRegion); + setZoomRegions(normalizedEditor.zoomRegions); + setTrimRegions(normalizedEditor.trimRegions); + setSpeedRegions(normalizedEditor.speedRegions); + setAnnotationRegions(normalizedEditor.annotationRegions); + setAspectRatio(normalizedEditor.aspectRatio); + setExportQuality(normalizedEditor.exportQuality); + setExportFormat(normalizedEditor.exportFormat); + setGifFrameRate(normalizedEditor.gifFrameRate); + setGifLoop(normalizedEditor.gifLoop); + setGifSizePreset(normalizedEditor.gifSizePreset); - setSelectedZoomId(null); - setSelectedTrimId(null); - setSelectedSpeedId(null); - setSelectedAnnotationId(null); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); - nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id)); - nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id)); - nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.map((region) => region.id)); - nextAnnotationIdRef.current = deriveNextId( - "annotation", - normalizedEditor.annotationRegions.map((region) => region.id), - ); - nextAnnotationZIndexRef.current = - normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; + nextZoomIdRef.current = deriveNextId( + "zoom", + normalizedEditor.zoomRegions.map((region) => region.id), + ); + nextTrimIdRef.current = deriveNextId( + "trim", + normalizedEditor.trimRegions.map((region) => region.id), + ); + nextSpeedIdRef.current = deriveNextId( + "speed", + normalizedEditor.speedRegions.map((region) => region.id), + ); + nextAnnotationIdRef.current = deriveNextId( + "annotation", + normalizedEditor.annotationRegions.map((region) => region.id), + ); + nextAnnotationZIndexRef.current = + normalizedEditor.annotationRegions.reduce( + (max, region) => Math.max(max, region.zIndex), + 0, + ) + 1; - setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor))); - return true; - }, []); + setLastSavedSnapshot( + JSON.stringify(createProjectData(sourcePath, normalizedEditor)), + ); + return true; + }, + [], + ); const currentProjectSnapshot = useMemo(() => { - const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); + const sourcePath = + videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); if (!sourcePath) { return null; } @@ -336,6 +420,7 @@ export default function VideoEditor() { cursorSmoothing, cursorMotionBlur, cursorClickBounce, + cursorSway, borderRadius, padding, cropRegion, @@ -365,6 +450,7 @@ export default function VideoEditor() { cursorSmoothing, cursorMotionBlur, cursorClickBounce, + cursorSway, borderRadius, padding, cropRegion, @@ -410,15 +496,16 @@ export default function VideoEditor() { const hasUnsavedChanges = Boolean( currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - currentProjectSnapshot !== lastSavedSnapshot, + currentProjectSnapshot && + lastSavedSnapshot && + currentProjectSnapshot !== lastSavedSnapshot, ); useEffect(() => { async function loadInitialData() { try { - const currentProjectResult = await window.electronAPI.loadCurrentProjectFile(); + const currentProjectResult = + await window.electronAPI.loadCurrentProjectFile(); if (currentProjectResult.success && currentProjectResult.project) { const restored = await applyLoadedProject( currentProjectResult.project, @@ -449,19 +536,80 @@ export default function VideoEditor() { loadInitialData(); }, [applyLoadedProject]); - const saveProject = useCallback(async (forceSaveAs: boolean) => { - if (!videoPath) { - toast.error('No video loaded'); - return; - } + const saveProject = useCallback( + async (forceSaveAs: boolean) => { + if (!videoPath) { + toast.error("No video loaded"); + return; + } - const sourcePath = videoSourcePath ?? fromFileUrl(videoPath); - if (!sourcePath) { - toast.error('Unable to determine source video path'); - return; - } + const sourcePath = videoSourcePath ?? fromFileUrl(videoPath); + if (!sourcePath) { + toast.error("Unable to determine source video path"); + return; + } - const projectData = createProjectData(sourcePath, { + const projectData = createProjectData(sourcePath, { + wallpaper, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + showCursor, + loopCursor, + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + borderRadius, + padding, + cropRegion, + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + aspectRatio, + exportQuality, + exportFormat, + gifFrameRate, + gifLoop, + gifSizePreset, + }); + + const fileNameBase = + sourcePath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; + const projectSnapshot = JSON.stringify(projectData); + const result = await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + forceSaveAs ? undefined : (currentProjectPath ?? undefined), + ); + + if (result.canceled) { + toast.info("Project save canceled"); + return; + } + + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return; + } + + if (result.path) { + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot(projectSnapshot); + + toast.success(`Project saved to ${result.path}`); + }, + [ + videoPath, + videoSourcePath, + currentProjectPath, wallpaper, shadowIntensity, backgroundBlur, @@ -473,6 +621,7 @@ export default function VideoEditor() { cursorSmoothing, cursorMotionBlur, cursorClickBounce, + cursorSway, borderRadius, padding, cropRegion, @@ -486,61 +635,8 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, - }); - - const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`; - const projectSnapshot = JSON.stringify(projectData); - const result = await window.electronAPI.saveProjectFile( - projectData, - fileNameBase, - forceSaveAs ? undefined : currentProjectPath ?? undefined, - ); - - if (result.canceled) { - toast.info("Project save canceled"); - return; - } - - if (!result.success) { - toast.error(result.message || 'Failed to save project'); - return; - } - - if (result.path) { - setCurrentProjectPath(result.path); - } - setLastSavedSnapshot(projectSnapshot); - - toast.success(`Project saved to ${result.path}`); - }, [ - videoPath, - videoSourcePath, - currentProjectPath, - wallpaper, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - connectZooms, - showCursor, - loopCursor, - cursorSize, - cursorSmoothing, - cursorMotionBlur, - cursorClickBounce, - borderRadius, - padding, - cropRegion, - zoomRegions, - trimRegions, - speedRegions, - annotationRegions, - aspectRatio, - exportQuality, - exportFormat, - gifFrameRate, - gifLoop, - gifSizePreset, - ]); + ], + ); useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { @@ -549,11 +645,11 @@ export default function VideoEditor() { } event.preventDefault(); - event.returnValue = ''; + event.returnValue = ""; }; - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [hasUnsavedChanges]); useEffect(() => { @@ -584,13 +680,16 @@ export default function VideoEditor() { } if (!result.success) { - toast.error(result.message || 'Failed to load project'); + toast.error(result.message || "Failed to load project"); return; } - const restored = await applyLoadedProject(result.project, result.path ?? null); + const restored = await applyLoadedProject( + result.project, + result.path ?? null, + ); if (!restored) { - toast.error('Invalid project file format'); + toast.error("Invalid project file format"); return; } @@ -598,9 +697,12 @@ export default function VideoEditor() { }, [applyLoadedProject]); useEffect(() => { - const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); - const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); - const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); + const removeLoadListener = + window.electronAPI.onMenuLoadProject(handleLoadProject); + const removeSaveListener = + window.electronAPI.onMenuSaveProject(handleSaveProject); + const removeSaveAsListener = + window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); return () => { removeLoadListener?.(); @@ -621,12 +723,14 @@ export default function VideoEditor() { } try { - const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath)); + const result = await window.electronAPI.getCursorTelemetry( + fromFileUrl(videoPath), + ); if (mounted) { setCursorTelemetry(result.success ? result.samples : []); } } catch (telemetryError) { - console.warn('Unable to load cursor telemetry:', telemetryError); + console.warn("Unable to load cursor telemetry:", telemetryError); if (mounted) { setCursorTelemetry([]); } @@ -646,7 +750,10 @@ export default function VideoEditor() { } const totalMs = Math.max(0, Math.round(duration * 1000)); - return normalizeCursorTelemetry(cursorTelemetry, totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER); + return normalizeCursorTelemetry( + cursorTelemetry, + totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER, + ); }, [cursorTelemetry, duration]); const displayedTimelineWindow = useMemo(() => { @@ -659,7 +766,10 @@ export default function VideoEditor() { return normalizedCursorTelemetry; } - if (normalizedCursorTelemetry.length < 2 || displayedTimelineWindow.endMs <= displayedTimelineWindow.startMs) { + if ( + normalizedCursorTelemetry.length < 2 || + displayedTimelineWindow.endMs <= displayedTimelineWindow.startMs + ) { return normalizedCursorTelemetry; } @@ -679,12 +789,19 @@ export default function VideoEditor() { return zoomRegions; } - const dominantAtStart = findDominantRegion(zoomRegions, displayedTimelineWindow.startMs, { connectZooms }).region; + const dominantAtStart = findDominantRegion( + zoomRegions, + displayedTimelineWindow.startMs, + { connectZooms }, + ).region; if (!dominantAtStart) { return zoomRegions; } - const endWindowStartMs = Math.max(displayedTimelineWindow.startMs, displayedTimelineWindow.endMs - LOOP_CURSOR_END_WINDOW_MS); + const endWindowStartMs = Math.max( + displayedTimelineWindow.startMs, + displayedTimelineWindow.endMs - LOOP_CURSOR_END_WINDOW_MS, + ); const loopEndRegion: ZoomRegion = { id: `${dominantAtStart.id}__loop-end-sync`, startMs: endWindowStartMs, @@ -703,7 +820,12 @@ export default function VideoEditor() { }, [loopCursor, zoomRegions, displayedTimelineWindow, connectZooms]); useEffect(() => { - if (!videoPath || duration <= 0 || zoomRegions.length > 0 || normalizedCursorTelemetry.length < 2) { + if ( + !videoPath || + duration <= 0 || + zoomRegions.length > 0 || + normalizedCursorTelemetry.length < 2 + ) { return; } @@ -724,7 +846,9 @@ export default function VideoEditor() { const DEFAULT_DURATION_MS = 1100; const MIN_SPACING_MS = 1800; - const sortedCandidates = [...candidates].sort((a, b) => b.strength - a.strength); + const sortedCandidates = [...candidates].sort( + (a, b) => b.strength - a.strength, + ); const acceptedCenters: number[] = []; setZoomRegions((prev) => { @@ -738,14 +862,20 @@ export default function VideoEditor() { sortedCandidates.forEach((candidate) => { const tooCloseToAccepted = acceptedCenters.some( - (center) => Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS, + (center) => + Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS, ); if (tooCloseToAccepted) { return; } - const centeredStart = Math.round(candidate.centerTimeMs - DEFAULT_DURATION_MS / 2); - const startMs = Math.max(0, Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS)); + const centeredStart = Math.round( + candidate.centerTimeMs - DEFAULT_DURATION_MS / 2, + ); + const startMs = Math.max( + 0, + Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS), + ); const endMs = Math.min(totalMs, startMs + DEFAULT_DURATION_MS); const hasOverlap = reservedSpans.some( @@ -782,16 +912,20 @@ export default function VideoEditor() { let mounted = true; (async () => { try { - const resolvedPath = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH); + const resolvedPath = await getAssetPath( + DEFAULT_WALLPAPER_RELATIVE_PATH, + ); if (mounted) { setWallpaper(resolvedPath); } } catch (err) { // If resolution fails, keep the fallback - console.warn('Failed to resolve default wallpaper path:', err); + console.warn("Failed to resolve default wallpaper path:", err); } })(); - return () => { mounted = false }; + return () => { + mounted = false; + }; }, []); function togglePlayPause() { @@ -802,7 +936,7 @@ export default function VideoEditor() { if (isPlaying) { playback.pause(); } else { - playback.play().catch(err => console.error('Video play failed:', err)); + playback.play().catch((err) => console.error("Video play failed:", err)); } } @@ -917,34 +1051,43 @@ export default function VideoEditor() { ); }, []); - const handleZoomDepthChange = useCallback((depth: ZoomDepth) => { - if (!selectedZoomId) return; - setZoomRegions((prev) => - prev.map((region) => - region.id === selectedZoomId - ? { - ...region, - depth, - focus: clampFocusToDepth(region.focus, depth), - } - : region, - ), - ); - }, [selectedZoomId]); + const handleZoomDepthChange = useCallback( + (depth: ZoomDepth) => { + if (!selectedZoomId) return; + setZoomRegions((prev) => + prev.map((region) => + region.id === selectedZoomId + ? { + ...region, + depth, + focus: clampFocusToDepth(region.focus, depth), + } + : region, + ), + ); + }, + [selectedZoomId], + ); - const handleZoomDelete = useCallback((id: string) => { - setZoomRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedZoomId === id) { - setSelectedZoomId(null); - } - }, [selectedZoomId]); + const handleZoomDelete = useCallback( + (id: string) => { + setZoomRegions((prev) => prev.filter((region) => region.id !== id)); + if (selectedZoomId === id) { + setSelectedZoomId(null); + } + }, + [selectedZoomId], + ); - const handleTrimDelete = useCallback((id: string) => { - setTrimRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedTrimId === id) { - setSelectedTrimId(null); - } - }, [selectedTrimId]); + const handleTrimDelete = useCallback( + (id: string) => { + setTrimRegions((prev) => prev.filter((region) => region.id !== id)); + if (selectedTrimId === id) { + setSelectedTrimId(null); + } + }, + [selectedTrimId], + ); const handleSelectSpeed = useCallback((id: string | null) => { setSelectedSpeedId(id); @@ -984,21 +1127,27 @@ export default function VideoEditor() { ); }, []); - const handleSpeedDelete = useCallback((id: string) => { - setSpeedRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedSpeedId === id) { - setSelectedSpeedId(null); - } - }, [selectedSpeedId]); + const handleSpeedDelete = useCallback( + (id: string) => { + setSpeedRegions((prev) => prev.filter((region) => region.id !== id)); + if (selectedSpeedId === id) { + setSelectedSpeedId(null); + } + }, + [selectedSpeedId], + ); - const handleSpeedChange = useCallback((speed: PlaybackSpeed) => { - if (!selectedSpeedId) return; - setSpeedRegions((prev) => - prev.map((region) => - region.id === selectedSpeedId ? { ...region, speed } : region, - ), - ); - }, [selectedSpeedId]); + const handleSpeedChange = useCallback( + (speed: PlaybackSpeed) => { + if (!selectedSpeedId) return; + setSpeedRegions((prev) => + prev.map((region) => + region.id === selectedSpeedId ? { ...region, speed } : region, + ), + ); + }, + [selectedSpeedId], + ); const handleAnnotationAdded = useCallback((span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; @@ -1007,8 +1156,8 @@ export default function VideoEditor() { id, startMs: Math.round(span.start), endMs: Math.round(span.end), - type: 'text', - content: 'Enter text...', + type: "text", + content: "Enter text...", position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, @@ -1034,109 +1183,122 @@ export default function VideoEditor() { ); }, []); - const handleAnnotationDelete = useCallback((id: string) => { - setAnnotationRegions((prev) => prev.filter((region) => region.id !== id)); - if (selectedAnnotationId === id) { - setSelectedAnnotationId(null); - } - }, [selectedAnnotationId]); + const handleAnnotationDelete = useCallback( + (id: string) => { + setAnnotationRegions((prev) => prev.filter((region) => region.id !== id)); + if (selectedAnnotationId === id) { + setSelectedAnnotationId(null); + } + }, + [selectedAnnotationId], + ); - const handleAnnotationContentChange = useCallback((id: string, content: string) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { - if (region.id !== id) return region; - - // Store content in type-specific fields - if (region.type === 'text') { - return { ...region, content, textContent: content }; - } else if (region.type === 'image') { - return { ...region, content, imageContent: content }; - } else { - return { ...region, content }; - } - }); - return updated; - }); - }, []); + const handleAnnotationContentChange = useCallback( + (id: string, content: string) => { + setAnnotationRegions((prev) => { + const updated = prev.map((region) => { + if (region.id !== id) return region; - const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => { - setAnnotationRegions((prev) => { - const updated = prev.map((region) => { - if (region.id !== id) return region; - - const updatedRegion = { ...region, type }; - - // Restore content from type-specific storage - if (type === 'text') { - updatedRegion.content = region.textContent || 'Enter text...'; - } else if (type === 'image') { - updatedRegion.content = region.imageContent || ''; - } else if (type === 'figure') { - updatedRegion.content = ''; - if (!region.figureData) { - updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; + // Store content in type-specific fields + if (region.type === "text") { + return { ...region, content, textContent: content }; + } else if (region.type === "image") { + return { ...region, content, imageContent: content }; + } else { + return { ...region, content }; } - } - - return updatedRegion; + }); + return updated; }); - return updated; - }); - }, []); + }, + [], + ); - const handleAnnotationStyleChange = useCallback((id: string, style: Partial) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, style: { ...region.style, ...style } } - : region, - ), - ); - }, []); + const handleAnnotationTypeChange = useCallback( + (id: string, type: AnnotationRegion["type"]) => { + setAnnotationRegions((prev) => { + const updated = prev.map((region) => { + if (region.id !== id) return region; - const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, figureData } - : region, - ), - ); - }, []); + const updatedRegion = { ...region, type }; - const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, position } - : region, - ), - ); - }, []); + // Restore content from type-specific storage + if (type === "text") { + updatedRegion.content = region.textContent || "Enter text..."; + } else if (type === "image") { + updatedRegion.content = region.imageContent || ""; + } else if (type === "figure") { + updatedRegion.content = ""; + if (!region.figureData) { + updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA }; + } + } + + return updatedRegion; + }); + return updated; + }); + }, + [], + ); + + const handleAnnotationStyleChange = useCallback( + (id: string, style: Partial) => { + setAnnotationRegions((prev) => + prev.map((region) => + region.id === id + ? { ...region, style: { ...region.style, ...style } } + : region, + ), + ); + }, + [], + ); + + const handleAnnotationFigureDataChange = useCallback( + (id: string, figureData: FigureData) => { + setAnnotationRegions((prev) => + prev.map((region) => + region.id === id ? { ...region, figureData } : region, + ), + ); + }, + [], + ); + + const handleAnnotationPositionChange = useCallback( + (id: string, position: { x: number; y: number }) => { + setAnnotationRegions((prev) => + prev.map((region) => + region.id === id ? { ...region, position } : region, + ), + ); + }, + [], + ); + + const handleAnnotationSizeChange = useCallback( + (id: string, size: { width: number; height: number }) => { + setAnnotationRegions((prev) => + prev.map((region) => (region.id === id ? { ...region, size } : region)), + ); + }, + [], + ); - const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { ...region, size } - : region, - ), - ); - }, []); - // Global Tab prevention useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; const isEditableTarget = - target instanceof HTMLInputElement - || target instanceof HTMLTextAreaElement - || target?.isContentEditable; + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable; const usesPrimaryModifier = isMac ? e.metaKey : e.ctrlKey; const key = e.key.toLowerCase(); - if (usesPrimaryModifier && !e.altKey && key === 'z') { + if (usesPrimaryModifier && !e.altKey && key === "z") { if (!isEditableTarget) { e.preventDefault(); if (e.shiftKey) { @@ -1148,7 +1310,7 @@ export default function VideoEditor() { return; } - if (!isMac && e.ctrlKey && !e.metaKey && !e.altKey && key === 'y') { + if (!isMac && e.ctrlKey && !e.metaKey && !e.altKey && key === "y") { if (!isEditableTarget) { e.preventDefault(); handleRedo(); @@ -1156,7 +1318,7 @@ export default function VideoEditor() { return; } - if (e.key === 'Tab') { + if (e.key === "Tab") { // Allow tab only in inputs/textareas if (isEditableTarget) { return; @@ -1170,7 +1332,7 @@ export default function VideoEditor() { return; } e.preventDefault(); - + const playback = videoPlaybackRef.current; if (playback?.video) { if (playback.video.paused) { @@ -1181,31 +1343,44 @@ export default function VideoEditor() { } } }; - - window.addEventListener('keydown', handleKeyDown, { capture: true }); - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => + window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [shortcuts, isMac, handleUndo, handleRedo]); useEffect(() => { - if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { + if ( + selectedZoomId && + !zoomRegions.some((region) => region.id === selectedZoomId) + ) { setSelectedZoomId(null); } }, [selectedZoomId, zoomRegions]); useEffect(() => { - if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { + if ( + selectedTrimId && + !trimRegions.some((region) => region.id === selectedTrimId) + ) { setSelectedTrimId(null); } }, [selectedTrimId, trimRegions]); useEffect(() => { - if (selectedAnnotationId && !annotationRegions.some((region) => region.id === selectedAnnotationId)) { + if ( + selectedAnnotationId && + !annotationRegions.some((region) => region.id === selectedAnnotationId) + ) { setSelectedAnnotationId(null); } }, [selectedAnnotationId, annotationRegions]); useEffect(() => { - if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) { + if ( + selectedSpeedId && + !speedRegions.some((region) => region.id === selectedSpeedId) + ) { setSelectedSpeedId(null); } }, [selectedSpeedId, speedRegions]); @@ -1213,321 +1388,391 @@ export default function VideoEditor() { const showExportSuccessToast = useCallback((filePath: string) => { toast.success(`Exported successfully to ${filePath}`, { action: { - label: 'Show in Folder', + label: "Show in Folder", onClick: async () => { try { const result = await window.electronAPI.revealInFolder(filePath); if (!result.success) { - const errorMessage = result.error || result.message || 'Failed to reveal item in folder.'; + const errorMessage = + result.error || + result.message || + "Failed to reveal item in folder."; toast.error(errorMessage); } } catch (err) { toast.error(`Error revealing in folder: ${String(err)}`); } - } - } + }, + }, }); }, []); - const handleExport = useCallback(async (settings: ExportSettings) => { - if (!videoPath) { - toast.error('No video loaded'); - return; - } - - const video = videoPlaybackRef.current?.video; - if (!video) { - toast.error('Video not ready'); - return; - } - - setIsExporting(true); - setExportProgress(null); - setExportError(null); - pendingExportSaveRef.current = null; - setHasPendingExportSave(false); - - let keepExportDialogOpen = false; - - try { - const wasPlaying = isPlaying; - const restoreTime = video.currentTime; - if (wasPlaying) { - videoPlaybackRef.current?.pause(); + const handleExport = useCallback( + async (settings: ExportSettings) => { + if (!videoPath) { + toast.error("No video loaded"); + return; } - const sourceWidth = video.videoWidth || 1920; - const sourceHeight = video.videoHeight || 1080; - const sourceAspectRatio = sourceHeight > 0 ? sourceWidth / sourceHeight : 16 / 9; - const aspectRatioValue = getAspectRatioValue(aspectRatio, sourceAspectRatio); - - // Get preview CONTAINER dimensions for scaling - const playbackRef = videoPlaybackRef.current; - const containerElement = playbackRef?.containerRef?.current; - const previewWidth = containerElement?.clientWidth || 1920; - const previewHeight = containerElement?.clientHeight || 1080; - - if (settings.format === 'gif' && settings.gifConfig) { - // GIF Export - const gifExporter = new GifExporter({ - videoUrl: videoPath, - width: settings.gifConfig.width, - height: settings.gifConfig.height, - frameRate: settings.gifConfig.frameRate, - loop: settings.gifConfig.loop, - sizePreset: settings.gifConfig.sizePreset, - wallpaper, - trimRegions, - speedRegions, - showShadow: shadowIntensity > 0, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - connectZooms, - borderRadius, - padding, - videoPadding: padding, - cropRegion, - annotationRegions, - zoomRegions: effectiveZoomRegions, - cursorTelemetry: effectiveCursorTelemetry, - showCursor, - cursorSize, - cursorSmoothing, - cursorMotionBlur, - cursorClickBounce, - previewWidth, - previewHeight, - onProgress: (progress: ExportProgress) => { - setExportProgress(progress); - }, - }); - - exporterRef.current = gifExporter as unknown as VideoExporter; - const result = await gifExporter.export(); - - if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.gif`; - - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - - if (saveResult.canceled) { - pendingExportSaveRef.current = { arrayBuffer, fileName }; - setHasPendingExportSave(true); - setExportError('Save dialog canceled. Click Save Again to save without re-rendering.'); - toast.info('Save canceled. You can save again without re-exporting.'); - keepExportDialogOpen = true; - } else if (saveResult.success && saveResult.path) { - showExportSuccessToast(saveResult.path); - setExportedFilePath(saveResult.path); - } else { - setExportError(saveResult.message || 'Failed to save GIF'); - toast.error(saveResult.message || 'Failed to save GIF'); - } - } else { - setExportError(result.error || 'GIF export failed'); - toast.error(result.error || 'GIF export failed'); - } - } else { - // MP4 Export - const quality = settings.quality || exportQuality; - let exportWidth: number; - let exportHeight: number; - let bitrate: number; - - if (quality === 'source') { - // Use source resolution - exportWidth = sourceWidth; - exportHeight = sourceHeight; - - if (aspectRatio === 'native') { - exportWidth = Math.floor(sourceWidth / 2) * 2; - exportHeight = Math.floor(sourceHeight / 2) * 2; - } else if (aspectRatioValue === 1) { - // Square (1:1): use smaller dimension to avoid codec limits - const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; - exportWidth = baseDimension; - exportHeight = baseDimension; - } else if (aspectRatioValue > 1) { - // Landscape: find largest even dimensions that exactly match aspect ratio - const baseWidth = Math.floor(sourceWidth / 2) * 2; - let found = false; - for (let w = baseWidth; w >= 100 && !found; w -= 2) { - const h = Math.round(w / aspectRatioValue); - if (h % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportWidth = baseWidth; - exportHeight = Math.floor((baseWidth / aspectRatioValue) / 2) * 2; - } - } else { - // Portrait: find largest even dimensions that exactly match aspect ratio - const baseHeight = Math.floor(sourceHeight / 2) * 2; - let found = false; - for (let h = baseHeight; h >= 100 && !found; h -= 2) { - const w = Math.round(h * aspectRatioValue); - if (w % 2 === 0 && Math.abs((w / h) - aspectRatioValue) < 0.0001) { - exportWidth = w; - exportHeight = h; - found = true; - } - } - if (!found) { - exportHeight = baseHeight; - exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2; - } - } - - // Calculate visually lossless bitrate matching screen recording optimization - const totalPixels = exportWidth * exportHeight; - bitrate = 30_000_000; - if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { - bitrate = 50_000_000; - } else if (totalPixels > 2560 * 1440) { - bitrate = 80_000_000; - } - } else { - // Use quality-based target resolution - const targetHeight = quality === 'medium' ? 720 : 1080; - - // Calculate dimensions maintaining aspect ratio - exportHeight = Math.floor(targetHeight / 2) * 2; - exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; - - // Adjust bitrate for lower resolutions - const totalPixels = exportWidth * exportHeight; - if (totalPixels <= 1280 * 720) { - bitrate = 10_000_000; - } else if (totalPixels <= 1920 * 1080) { - bitrate = 20_000_000; - } else { - bitrate = 30_000_000; - } - } - - const exporter = new VideoExporter({ - videoUrl: videoPath, - width: exportWidth, - height: exportHeight, - frameRate: 60, - bitrate, - codec: 'avc1.640033', - wallpaper, - trimRegions, - speedRegions, - showShadow: shadowIntensity > 0, - shadowIntensity, - backgroundBlur, - zoomMotionBlur, - connectZooms, - borderRadius, - padding, - cropRegion, - annotationRegions, - zoomRegions: effectiveZoomRegions, - cursorTelemetry: effectiveCursorTelemetry, - showCursor, - cursorSize, - cursorSmoothing, - cursorMotionBlur, - cursorClickBounce, - previewWidth, - previewHeight, - onProgress: (progress: ExportProgress) => { - setExportProgress(progress); - }, - }); - - exporterRef.current = exporter; - const result = await exporter.export(); - - if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.mp4`; - - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); - - if (saveResult.canceled) { - pendingExportSaveRef.current = { arrayBuffer, fileName }; - setHasPendingExportSave(true); - setExportError('Save dialog canceled. Click Save Again to save without re-rendering.'); - toast.info('Save canceled. You can save again without re-exporting.'); - keepExportDialogOpen = true; - } else if (saveResult.success && saveResult.path) { - showExportSuccessToast(saveResult.path); - setExportedFilePath(saveResult.path); - } else { - setExportError(saveResult.message || 'Failed to save video'); - toast.error(saveResult.message || 'Failed to save video'); - } - } else { - setExportError(result.error || 'Export failed'); - toast.error(result.error || 'Export failed'); - } + const video = videoPlaybackRef.current?.video; + if (!video) { + toast.error("Video not ready"); + return; } - if (wasPlaying) { - videoPlaybackRef.current?.play(); - } else { - video.currentTime = restoreTime; - await videoPlaybackRef.current?.refreshFrame(); - } - } catch (error) { - console.error('Export error:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - setExportError(errorMessage); - toast.error(`Export failed: ${errorMessage}`); - } finally { - if (!isPlaying) { - await videoPlaybackRef.current?.refreshFrame().catch(() => undefined); - } - setIsExporting(false); - exporterRef.current = null; - setShowExportDialog(keepExportDialogOpen); + setIsExporting(true); setExportProgress(null); - } - }, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, connectZooms, showCursor, effectiveCursorTelemetry, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality, showExportSuccessToast]); + setExportError(null); + pendingExportSaveRef.current = null; + setHasPendingExportSave(false); + + let keepExportDialogOpen = false; + + try { + const wasPlaying = isPlaying; + const restoreTime = video.currentTime; + if (wasPlaying) { + videoPlaybackRef.current?.pause(); + } + + const sourceWidth = video.videoWidth || 1920; + const sourceHeight = video.videoHeight || 1080; + const sourceAspectRatio = + sourceHeight > 0 ? sourceWidth / sourceHeight : 16 / 9; + const aspectRatioValue = getAspectRatioValue( + aspectRatio, + sourceAspectRatio, + ); + + // Get preview CONTAINER dimensions for scaling + const playbackRef = videoPlaybackRef.current; + const containerElement = playbackRef?.containerRef?.current; + const previewWidth = containerElement?.clientWidth || 1920; + const previewHeight = containerElement?.clientHeight || 1080; + + if (settings.format === "gif" && settings.gifConfig) { + // GIF Export + const gifExporter = new GifExporter({ + videoUrl: videoPath, + width: settings.gifConfig.width, + height: settings.gifConfig.height, + frameRate: settings.gifConfig.frameRate, + loop: settings.gifConfig.loop, + sizePreset: settings.gifConfig.sizePreset, + wallpaper, + trimRegions, + speedRegions, + showShadow: shadowIntensity > 0, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + borderRadius, + padding, + videoPadding: padding, + cropRegion, + annotationRegions, + zoomRegions: effectiveZoomRegions, + cursorTelemetry: effectiveCursorTelemetry, + showCursor, + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + previewWidth, + previewHeight, + onProgress: (progress: ExportProgress) => { + setExportProgress(progress); + }, + }); + + exporterRef.current = gifExporter as unknown as VideoExporter; + const result = await gifExporter.export(); + + if (result.success && result.blob) { + const arrayBuffer = await result.blob.arrayBuffer(); + const timestamp = Date.now(); + const fileName = `export-${timestamp}.gif`; + + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + ); + + if (saveResult.canceled) { + pendingExportSaveRef.current = { arrayBuffer, fileName }; + setHasPendingExportSave(true); + setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + toast.info( + "Save canceled. You can save again without re-exporting.", + ); + keepExportDialogOpen = true; + } else if (saveResult.success && saveResult.path) { + showExportSuccessToast(saveResult.path); + setExportedFilePath(saveResult.path); + } else { + setExportError(saveResult.message || "Failed to save GIF"); + toast.error(saveResult.message || "Failed to save GIF"); + } + } else { + setExportError(result.error || "GIF export failed"); + toast.error(result.error || "GIF export failed"); + } + } else { + // MP4 Export + const quality = settings.quality || exportQuality; + let exportWidth: number; + let exportHeight: number; + let bitrate: number; + + if (quality === "source") { + // Use source resolution + exportWidth = sourceWidth; + exportHeight = sourceHeight; + + if (aspectRatio === "native") { + exportWidth = Math.floor(sourceWidth / 2) * 2; + exportHeight = Math.floor(sourceHeight / 2) * 2; + } else if (aspectRatioValue === 1) { + // Square (1:1): use smaller dimension to avoid codec limits + const baseDimension = + Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; + exportWidth = baseDimension; + exportHeight = baseDimension; + } else if (aspectRatioValue > 1) { + // Landscape: find largest even dimensions that exactly match aspect ratio + const baseWidth = Math.floor(sourceWidth / 2) * 2; + let found = false; + for (let w = baseWidth; w >= 100 && !found; w -= 2) { + const h = Math.round(w / aspectRatioValue); + if ( + h % 2 === 0 && + Math.abs(w / h - aspectRatioValue) < 0.0001 + ) { + exportWidth = w; + exportHeight = h; + found = true; + } + } + if (!found) { + exportWidth = baseWidth; + exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; + } + } else { + // Portrait: find largest even dimensions that exactly match aspect ratio + const baseHeight = Math.floor(sourceHeight / 2) * 2; + let found = false; + for (let h = baseHeight; h >= 100 && !found; h -= 2) { + const w = Math.round(h * aspectRatioValue); + if ( + w % 2 === 0 && + Math.abs(w / h - aspectRatioValue) < 0.0001 + ) { + exportWidth = w; + exportHeight = h; + found = true; + } + } + if (!found) { + exportHeight = baseHeight; + exportWidth = + Math.floor((baseHeight * aspectRatioValue) / 2) * 2; + } + } + + // Calculate visually lossless bitrate matching screen recording optimization + const totalPixels = exportWidth * exportHeight; + bitrate = 30_000_000; + if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { + bitrate = 50_000_000; + } else if (totalPixels > 2560 * 1440) { + bitrate = 80_000_000; + } + } else { + // Use quality-based target resolution + const targetHeight = quality === "medium" ? 720 : 1080; + + // Calculate dimensions maintaining aspect ratio + exportHeight = Math.floor(targetHeight / 2) * 2; + exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; + + // Adjust bitrate for lower resolutions + const totalPixels = exportWidth * exportHeight; + if (totalPixels <= 1280 * 720) { + bitrate = 10_000_000; + } else if (totalPixels <= 1920 * 1080) { + bitrate = 20_000_000; + } else { + bitrate = 30_000_000; + } + } + + const exporter = new VideoExporter({ + videoUrl: videoPath, + width: exportWidth, + height: exportHeight, + frameRate: 60, + bitrate, + codec: "avc1.640033", + wallpaper, + trimRegions, + speedRegions, + showShadow: shadowIntensity > 0, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + borderRadius, + padding, + cropRegion, + annotationRegions, + zoomRegions: effectiveZoomRegions, + cursorTelemetry: effectiveCursorTelemetry, + showCursor, + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + previewWidth, + previewHeight, + onProgress: (progress: ExportProgress) => { + setExportProgress(progress); + }, + }); + + exporterRef.current = exporter; + const result = await exporter.export(); + + if (result.success && result.blob) { + const arrayBuffer = await result.blob.arrayBuffer(); + const timestamp = Date.now(); + const fileName = `export-${timestamp}.mp4`; + + const saveResult = await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + ); + + if (saveResult.canceled) { + pendingExportSaveRef.current = { arrayBuffer, fileName }; + setHasPendingExportSave(true); + setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + toast.info( + "Save canceled. You can save again without re-exporting.", + ); + keepExportDialogOpen = true; + } else if (saveResult.success && saveResult.path) { + showExportSuccessToast(saveResult.path); + setExportedFilePath(saveResult.path); + } else { + setExportError(saveResult.message || "Failed to save video"); + toast.error(saveResult.message || "Failed to save video"); + } + } else { + setExportError(result.error || "Export failed"); + toast.error(result.error || "Export failed"); + } + } + + if (wasPlaying) { + videoPlaybackRef.current?.play(); + } else { + video.currentTime = restoreTime; + await videoPlaybackRef.current?.refreshFrame(); + } + } catch (error) { + console.error("Export error:", error); + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + setExportError(errorMessage); + toast.error(`Export failed: ${errorMessage}`); + } finally { + if (!isPlaying) { + await videoPlaybackRef.current?.refreshFrame().catch(() => undefined); + } + setIsExporting(false); + exporterRef.current = null; + setShowExportDialog(keepExportDialogOpen); + setExportProgress(null); + } + }, + [ + videoPath, + wallpaper, + zoomRegions, + trimRegions, + speedRegions, + shadowIntensity, + backgroundBlur, + zoomMotionBlur, + connectZooms, + showCursor, + effectiveCursorTelemetry, + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + borderRadius, + padding, + cropRegion, + annotationRegions, + isPlaying, + aspectRatio, + exportQuality, + showExportSuccessToast, + ], + ); const handleOpenExportDialog = useCallback(() => { if (!videoPath) { - toast.error('No video loaded'); + toast.error("No video loaded"); return; } if (hasPendingExportSave) { setShowExportDialog(true); - setExportError('Save dialog canceled. Click Save Again to save without re-rendering.'); + setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); return; } const video = videoPlaybackRef.current?.video; if (!video) { - toast.error('Video not ready'); + toast.error("Video not ready"); return; } // Build export settings from current state const sourceWidth = video.videoWidth || 1920; const sourceHeight = video.videoHeight || 1080; - const gifDimensions = calculateOutputDimensions(sourceWidth, sourceHeight, gifSizePreset, GIF_SIZE_PRESETS); + const gifDimensions = calculateOutputDimensions( + sourceWidth, + sourceHeight, + gifSizePreset, + GIF_SIZE_PRESETS, + ); const settings: ExportSettings = { format: exportFormat, - quality: exportFormat === 'mp4' ? exportQuality : undefined, - gifConfig: exportFormat === 'gif' ? { - frameRate: gifFrameRate, - loop: gifLoop, - sizePreset: gifSizePreset, - width: gifDimensions.width, - height: gifDimensions.height, - } : undefined, + quality: exportFormat === "mp4" ? exportQuality : undefined, + gifConfig: + exportFormat === "gif" + ? { + frameRate: gifFrameRate, + loop: gifLoop, + sizePreset: gifSizePreset, + width: gifDimensions.width, + height: gifDimensions.height, + } + : undefined, }; setShowExportDialog(true); @@ -1535,12 +1780,21 @@ export default function VideoEditor() { // Start export immediately handleExport(settings); - }, [videoPath, hasPendingExportSave, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]); + }, [ + videoPath, + hasPendingExportSave, + exportFormat, + exportQuality, + gifFrameRate, + gifLoop, + gifSizePreset, + handleExport, + ]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { exporterRef.current.cancel(); - toast.info('Export canceled'); + toast.info("Export canceled"); setShowExportDialog(false); setIsExporting(false); setExportProgress(null); @@ -1560,11 +1814,16 @@ export default function VideoEditor() { return; } - const saveResult = await window.electronAPI.saveExportedVideo(pendingSave.arrayBuffer, pendingSave.fileName); + const saveResult = await window.electronAPI.saveExportedVideo( + pendingSave.arrayBuffer, + pendingSave.fileName, + ); if (saveResult.canceled) { - setExportError('Save dialog canceled. Click Save Again to save without re-rendering.'); - toast.info('Save canceled. You can try again.'); + setExportError( + "Save dialog canceled. Click Save Again to save without re-rendering.", + ); + toast.info("Save canceled. You can try again."); return; } @@ -1578,7 +1837,7 @@ export default function VideoEditor() { return; } - const errorMessage = saveResult.message || 'Failed to save video'; + const errorMessage = saveResult.message || "Failed to save video"; setExportError(errorMessage); toast.error(errorMessage); }, [showExportSuccessToast]); @@ -1587,7 +1846,9 @@ export default function VideoEditor() { try { const result = await window.electronAPI.openRecordingsFolder(); if (!result.success) { - toast.error(result.message || result.error || 'Failed to open recordings folder.'); + toast.error( + result.message || result.error || "Failed to open recordings folder.", + ); } } catch (error) { toast.error(`Failed to open recordings folder: ${String(error)}`); @@ -1618,17 +1879,18 @@ export default function VideoEditor() { ); } - return (
-
- Recordly + + Recordly +
@@ -1652,19 +1919,37 @@ export default function VideoEditor() {
{/* Video preview */} -
-
{ - const previewVideo = videoPlaybackRef.current?.video; - if (previewVideo && previewVideo.videoHeight > 0) { - return previewVideo.videoWidth / previewVideo.videoHeight; - } - return 16 / 9; - })()), maxWidth: '100%', margin: '0 auto', boxSizing: 'border-box' }}> +
+
{ + const previewVideo = videoPlaybackRef.current?.video; + if (previewVideo && previewVideo.videoHeight > 0) { + return ( + previewVideo.videoWidth / previewVideo.videoHeight + ); + } + return 16 / 9; + })(), + ), + maxWidth: "100%", + margin: "0 auto", + boxSizing: "border-box", + }} + >
{/* Playback controls */} -
-
+
+
+ videoDuration={duration} + currentTime={currentTime} + onSeek={handleSeek} + cursorTelemetry={effectiveCursorTelemetry} + zoomRegions={effectiveZoomRegions} + onZoomAdded={handleZoomAdded} + onZoomSuggested={handleZoomSuggested} + onZoomSpanChange={handleZoomSpanChange} + onZoomDelete={handleZoomDelete} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + trimRegions={trimRegions} + onTrimAdded={handleTrimAdded} + onTrimSpanChange={handleTrimSpanChange} + onTrimDelete={handleTrimDelete} + selectedTrimId={selectedTrimId} + onSelectTrim={handleSelectTrim} + speedRegions={speedRegions} + onSpeedAdded={handleSpeedAdded} + onSpeedSpanChange={handleSpeedSpanChange} + onSpeedDelete={handleSpeedDelete} + selectedSpeedId={selectedSpeedId} + onSelectSpeed={handleSelectSpeed} + annotationRegions={annotationRegions} + onAnnotationAdded={handleAnnotationAdded} + onAnnotationSpanChange={handleAnnotationSpanChange} + onAnnotationDelete={handleAnnotationDelete} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + aspectRatio={aspectRatio} + onAspectRatioChange={setAspectRatio} + />
- {/* Right section: settings panel */} - z.id === selectedZoomId)?.depth : null} - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomDepth={ + selectedZoomId + ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth + : null + } + onZoomDepthChange={(depth) => + selectedZoomId && handleZoomDepthChange(depth) + } selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} selectedTrimId={selectedTrimId} @@ -1790,6 +2092,8 @@ export default function VideoEditor() { onCursorMotionBlurChange={setCursorMotionBlur} cursorClickBounce={cursorClickBounce} onCursorClickBounceChange={setCursorClickBounce} + cursorSway={cursorSway} + onCursorSwayChange={setCursorSway} borderRadius={borderRadius} onBorderRadiusChange={setBorderRadius} padding={padding} @@ -1812,7 +2116,7 @@ export default function VideoEditor() { videoPlaybackRef.current?.video?.videoWidth || 1920, videoPlaybackRef.current?.video?.videoHeight || 1080, gifSizePreset, - GIF_SIZE_PRESETS + GIF_SIZE_PRESETS, )} onExport={handleOpenExportDialog} selectedAnnotationId={selectedAnnotationId} @@ -1825,14 +2129,19 @@ export default function VideoEditor() { onSaveProject={handleSaveProject} onLoadProject={handleLoadProject} selectedSpeedId={selectedSpeedId} - selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? + null) + : null + } onSpeedChange={handleSpeedChange} onSpeedDelete={handleSpeedDelete} />
- + void; - onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; - onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + onAnnotationPositionChange?: ( + id: string, + position: { x: number; y: number }, + ) => void; + onAnnotationSizeChange?: ( + id: string, + size: { width: number; height: number }, + ) => void; cursorTelemetry?: CursorTelemetryPoint[]; showCursor?: boolean; cursorSize?: number; cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorSway?: number; } export interface VideoPlaybackRef { @@ -93,1052 +146,1180 @@ export interface VideoPlaybackRef { refreshFrame: () => Promise; } -const VideoPlayback = forwardRef(({ - videoPath, - onDurationChange, - onTimeUpdate, - currentTime, - onPlayStateChange, - onError, - wallpaper, - zoomRegions, - selectedZoomId, - onSelectZoom, - onZoomFocusChange, - isPlaying, - showShadow, - shadowIntensity = 0, - backgroundBlur = 0, - zoomMotionBlur = 0, - connectZooms = true, - borderRadius = 0, - padding = 50, - cropRegion, - trimRegions = [], - speedRegions = [], - aspectRatio, - annotationRegions = [], - selectedAnnotationId, - onSelectAnnotation, - onAnnotationPositionChange, - onAnnotationSizeChange, - cursorTelemetry = [], - showCursor = false, - cursorSize = DEFAULT_CURSOR_SIZE, - cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, - cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, - cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, -}, ref) => { - const videoRef = useRef(null); - const containerRef = useRef(null); - const appRef = useRef(null); - const videoSpriteRef = useRef(null); - const videoContainerRef = useRef(null); - const cursorContainerRef = useRef(null); - const cameraContainerRef = useRef(null); - const timeUpdateAnimationRef = useRef(null); - const [pixiReady, setPixiReady] = useState(false); - const [videoReady, setVideoReady] = useState(false); - const overlayRef = useRef(null); - const focusIndicatorRef = useRef(null); - const currentTimeRef = useRef(0); - const zoomRegionsRef = useRef([]); - const selectedZoomIdRef = useRef(null); - const animationStateRef = useRef(createPlaybackAnimationState()); - 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 }); - const baseScaleRef = useRef(1); - const baseOffsetRef = useRef({ x: 0, y: 0 }); - const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); - const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); - const maskGraphicsRef = useRef(null); - const isPlayingRef = useRef(isPlaying); - const isSeekingRef = useRef(false); - const allowPlaybackRef = useRef(false); - const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); - const layoutVideoContentRef = useRef<(() => void) | null>(null); - const trimRegionsRef = useRef([]); - const speedRegionsRef = useRef([]); - const zoomMotionBlurRef = useRef(zoomMotionBlur); - const connectZoomsRef = useRef(connectZooms); - const videoReadyRafRef = useRef(null); - const cursorOverlayRef = useRef(null); - const cursorTelemetryRef = useRef([]); - const showCursorRef = useRef(showCursor); - const cursorSizeRef = useRef(cursorSize); - const cursorSmoothingRef = useRef(cursorSmoothing); - const cursorMotionBlurRef = useRef(cursorMotionBlur); - const cursorClickBounceRef = useRef(cursorClickBounce); - const motionBlurStateRef = useRef(createMotionBlurState()); - - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); - }, []); - - const updateOverlayForRegion = useCallback((region: ZoomRegion | null, focusOverride?: ZoomFocus) => { - const overlayEl = overlayRef.current; - const indicatorEl = focusIndicatorRef.current; - - if (!overlayEl || !indicatorEl) { - return; - } - - // Update stage size from overlay dimensions - const stageWidth = overlayEl.clientWidth; - const stageHeight = overlayEl.clientHeight; - if (stageWidth && stageHeight) { - stageSizeRef.current = { width: stageWidth, height: stageHeight }; - } - - updateOverlayIndicator({ - overlayEl, - indicatorEl, - region, - focusOverride, - baseMask: baseMaskRef.current, - isPlaying: isPlayingRef.current, - }); - }, []); - - const layoutVideoContent = useCallback(() => { - const container = containerRef.current; - const app = appRef.current; - const videoSprite = videoSpriteRef.current; - const maskGraphics = maskGraphicsRef.current; - const videoElement = videoRef.current; - const cameraContainer = cameraContainerRef.current; - - if (!container || !app || !videoSprite || !maskGraphics || !videoElement || !cameraContainer) { - return; - } - - // Lock video dimensions on first layout to prevent resize issues - if (!lockedVideoDimensionsRef.current && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { - lockedVideoDimensionsRef.current = { - width: videoElement.videoWidth, - height: videoElement.videoHeight, - }; - } - - const result = layoutVideoContentUtil({ - container, - app, - videoSprite, - maskGraphics, - videoElement, +const VideoPlayback = forwardRef( + ( + { + videoPath, + onDurationChange, + onTimeUpdate, + currentTime, + onPlayStateChange, + onError, + wallpaper, + zoomRegions, + selectedZoomId, + onSelectZoom, + onZoomFocusChange, + isPlaying, + showShadow, + shadowIntensity = 0, + backgroundBlur = 0, + zoomMotionBlur = 0, + connectZooms = true, + borderRadius = 0, + padding = 50, cropRegion, - lockedVideoDimensions: lockedVideoDimensionsRef.current, - borderRadius, - padding, - }); + trimRegions = [], + speedRegions = [], + aspectRatio, + annotationRegions = [], + selectedAnnotationId, + onSelectAnnotation, + onAnnotationPositionChange, + onAnnotationSizeChange, + cursorTelemetry = [], + showCursor = false, + cursorSize = DEFAULT_CURSOR_SIZE, + cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, + cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, + cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, + cursorSway = DEFAULT_CURSOR_SWAY, + }, + ref, + ) => { + const videoRef = useRef(null); + const containerRef = useRef(null); + const appRef = useRef(null); + const videoSpriteRef = useRef(null); + const videoContainerRef = useRef(null); + const cursorContainerRef = useRef(null); + const cameraContainerRef = useRef(null); + const timeUpdateAnimationRef = useRef(null); + const [pixiReady, setPixiReady] = useState(false); + const [videoReady, setVideoReady] = useState(false); + const overlayRef = useRef(null); + const focusIndicatorRef = useRef(null); + const currentTimeRef = useRef(0); + const zoomRegionsRef = useRef([]); + const selectedZoomIdRef = useRef(null); + const animationStateRef = useRef( + createPlaybackAnimationState(), + ); + 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 }); + const baseScaleRef = useRef(1); + const baseOffsetRef = useRef({ x: 0, y: 0 }); + const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); + const maskGraphicsRef = useRef(null); + const isPlayingRef = useRef(isPlaying); + const isSeekingRef = useRef(false); + const allowPlaybackRef = useRef(false); + const lockedVideoDimensionsRef = useRef<{ + width: number; + height: number; + } | null>(null); + const layoutVideoContentRef = useRef<(() => void) | null>(null); + const trimRegionsRef = useRef([]); + const speedRegionsRef = useRef([]); + const zoomMotionBlurRef = useRef(zoomMotionBlur); + const connectZoomsRef = useRef(connectZooms); + const videoReadyRafRef = useRef(null); + const cursorOverlayRef = useRef(null); + const cursorTelemetryRef = useRef([]); + const showCursorRef = useRef(showCursor); + const cursorSizeRef = useRef(cursorSize); + const cursorSmoothingRef = useRef(cursorSmoothing); + const cursorMotionBlurRef = useRef(cursorMotionBlur); + const cursorClickBounceRef = useRef(cursorClickBounce); + const cursorSwayRef = useRef(cursorSway); + const motionBlurStateRef = useRef(createMotionBlurState()); - if (result) { - stageSizeRef.current = result.stageSize; - videoSizeRef.current = result.videoSize; - baseScaleRef.current = result.baseScale; - baseOffsetRef.current = result.baseOffset; - baseMaskRef.current = result.maskRect; - cropBoundsRef.current = result.cropBounds; + const clampFocusToStage = useCallback( + (focus: ZoomFocus, depth: ZoomDepth) => { + return clampFocusToStageUtil(focus, depth, stageSizeRef.current); + }, + [], + ); - // Reset camera container to identity - cameraContainer.scale.set(1); - cameraContainer.position.set(0, 0); + const updateOverlayForRegion = useCallback( + (region: ZoomRegion | null, focusOverride?: ZoomFocus) => { + const overlayEl = overlayRef.current; + const indicatorEl = focusIndicatorRef.current; - const selectedId = selectedZoomIdRef.current; - const activeRegion = selectedId - ? zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null - : null; + if (!overlayEl || !indicatorEl) { + return; + } - updateOverlayForRegion(activeRegion); - } - }, [updateOverlayForRegion, cropRegion, borderRadius, padding]); + // Update stage size from overlay dimensions + const stageWidth = overlayEl.clientWidth; + const stageHeight = overlayEl.clientHeight; + if (stageWidth && stageHeight) { + stageSizeRef.current = { width: stageWidth, height: stageHeight }; + } - useEffect(() => { - layoutVideoContentRef.current = layoutVideoContent; - }, [layoutVideoContent]); + updateOverlayIndicator({ + overlayEl, + indicatorEl, + region, + focusOverride, + baseMask: baseMaskRef.current, + isPlaying: isPlayingRef.current, + }); + }, + [], + ); - const selectedZoom = useMemo(() => { - if (!selectedZoomId) return null; - return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; - }, [zoomRegions, selectedZoomId]); + const layoutVideoContent = useCallback(() => { + const container = containerRef.current; + const app = appRef.current; + const videoSprite = videoSpriteRef.current; + const maskGraphics = maskGraphicsRef.current; + const videoElement = videoRef.current; + const cameraContainer = cameraContainerRef.current; - useImperativeHandle(ref, () => ({ - video: videoRef.current, - app: appRef.current, - videoSprite: videoSpriteRef.current, - videoContainer: videoContainerRef.current, - containerRef, - play: async () => { - const vid = videoRef.current; - if (!vid) return; - try { - allowPlaybackRef.current = true; - await vid.play(); - } catch (error) { + if ( + !container || + !app || + !videoSprite || + !maskGraphics || + !videoElement || + !cameraContainer + ) { + return; + } + + // Lock video dimensions on first layout to prevent resize issues + if ( + !lockedVideoDimensionsRef.current && + videoElement.videoWidth > 0 && + videoElement.videoHeight > 0 + ) { + lockedVideoDimensionsRef.current = { + width: videoElement.videoWidth, + height: videoElement.videoHeight, + }; + } + + const result = layoutVideoContentUtil({ + container, + app, + videoSprite, + maskGraphics, + videoElement, + cropRegion, + lockedVideoDimensions: lockedVideoDimensionsRef.current, + borderRadius, + padding, + }); + + if (result) { + stageSizeRef.current = result.stageSize; + videoSizeRef.current = result.videoSize; + baseScaleRef.current = result.baseScale; + baseOffsetRef.current = result.baseOffset; + baseMaskRef.current = result.maskRect; + cropBoundsRef.current = result.cropBounds; + + // Reset camera container to identity + cameraContainer.scale.set(1); + cameraContainer.position.set(0, 0); + + const selectedId = selectedZoomIdRef.current; + const activeRegion = selectedId + ? (zoomRegionsRef.current.find( + (region) => region.id === selectedId, + ) ?? null) + : null; + + updateOverlayForRegion(activeRegion); + } + }, [updateOverlayForRegion, cropRegion, borderRadius, padding]); + + useEffect(() => { + layoutVideoContentRef.current = layoutVideoContent; + }, [layoutVideoContent]); + + const selectedZoom = useMemo(() => { + if (!selectedZoomId) return null; + return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; + }, [zoomRegions, selectedZoomId]); + + useImperativeHandle(ref, () => ({ + video: videoRef.current, + app: appRef.current, + videoSprite: videoSpriteRef.current, + videoContainer: videoContainerRef.current, + containerRef, + play: async () => { + const vid = videoRef.current; + if (!vid) return; + try { + allowPlaybackRef.current = true; + await vid.play(); + } catch (error) { + allowPlaybackRef.current = false; + throw error; + } + }, + pause: () => { + const video = videoRef.current; allowPlaybackRef.current = false; - throw error; - } - }, - pause: () => { - const video = videoRef.current; - allowPlaybackRef.current = false; - if (!video) { - return; - } - video.pause(); - }, - refreshFrame: async () => { - const video = videoRef.current; - if (!video || Number.isNaN(video.currentTime)) { - return; - } + if (!video) { + return; + } + video.pause(); + }, + refreshFrame: async () => { + const video = videoRef.current; + if (!video || Number.isNaN(video.currentTime)) { + return; + } - const restoreTime = video.currentTime; - const duration = Number.isFinite(video.duration) ? video.duration : 0; - const epsilon = duration > 0 ? Math.min(1 / 120, duration / 1000 || 1 / 120) : 1 / 120; - const nudgeTarget = restoreTime > epsilon - ? restoreTime - epsilon - : Math.min(duration || restoreTime + epsilon, restoreTime + epsilon); + const restoreTime = video.currentTime; + const duration = Number.isFinite(video.duration) ? video.duration : 0; + const epsilon = + duration > 0 + ? Math.min(1 / 120, duration / 1000 || 1 / 120) + : 1 / 120; + const nudgeTarget = + restoreTime > epsilon + ? restoreTime - epsilon + : Math.min( + duration || restoreTime + epsilon, + restoreTime + epsilon, + ); - if (Math.abs(nudgeTarget - restoreTime) < 0.000001) { - return; - } + if (Math.abs(nudgeTarget - restoreTime) < 0.000001) { + return; + } - await new Promise((resolve) => { - const handleFirstSeeked = () => { - video.removeEventListener('seeked', handleFirstSeeked); - const handleSecondSeeked = () => { - video.removeEventListener('seeked', handleSecondSeeked); - video.pause(); - resolve(); + await new Promise((resolve) => { + const handleFirstSeeked = () => { + video.removeEventListener("seeked", handleFirstSeeked); + const handleSecondSeeked = () => { + video.removeEventListener("seeked", handleSecondSeeked); + video.pause(); + resolve(); + }; + + video.addEventListener("seeked", handleSecondSeeked, { + once: true, + }); + video.currentTime = restoreTime; }; - video.addEventListener('seeked', handleSecondSeeked, { once: true }); - video.currentTime = restoreTime; - }; + video.addEventListener("seeked", handleFirstSeeked, { once: true }); + video.currentTime = nudgeTarget; + }); + }, + })); - video.addEventListener('seeked', handleFirstSeeked, { once: true }); - video.currentTime = nudgeTarget; - }); - }, - })); + const updateFocusFromClientPoint = (clientX: number, clientY: number) => { + const overlayEl = overlayRef.current; + if (!overlayEl) return; - const updateFocusFromClientPoint = (clientX: number, clientY: number) => { - const overlayEl = overlayRef.current; - if (!overlayEl) return; + const regionId = selectedZoomIdRef.current; + if (!regionId) return; - const regionId = selectedZoomIdRef.current; - if (!regionId) return; + const region = zoomRegionsRef.current.find((r) => r.id === regionId); + if (!region) return; - const region = zoomRegionsRef.current.find((r) => r.id === regionId); - if (!region) return; + const rect = overlayEl.getBoundingClientRect(); + const stageWidth = rect.width; + const stageHeight = rect.height; - const rect = overlayEl.getBoundingClientRect(); - const stageWidth = rect.width; - const stageHeight = rect.height; - - if (!stageWidth || !stageHeight) { - return; - } - - stageSizeRef.current = { width: stageWidth, height: stageHeight }; - - const localX = clientX - rect.left; - const localY = clientY - rect.top; - const baseMask = baseMaskRef.current; - - const unclampedFocus: ZoomFocus = { - cx: clamp01((localX - baseMask.x) / Math.max(1, baseMask.width)), - cy: clamp01((localY - baseMask.y) / Math.max(1, baseMask.height)), - }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); - - onZoomFocusChange(region.id, clampedFocus); - updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); - }; - - const handleOverlayPointerDown = (event: React.PointerEvent) => { - if (isPlayingRef.current) return; - const regionId = selectedZoomIdRef.current; - if (!regionId) return; - const region = zoomRegionsRef.current.find((r) => r.id === regionId); - if (!region) return; - onSelectZoom(region.id); - event.preventDefault(); - isDraggingFocusRef.current = true; - event.currentTarget.setPointerCapture(event.pointerId); - updateFocusFromClientPoint(event.clientX, event.clientY); - }; - - const handleOverlayPointerMove = (event: React.PointerEvent) => { - if (!isDraggingFocusRef.current) return; - event.preventDefault(); - updateFocusFromClientPoint(event.clientX, event.clientY); - }; - - const endFocusDrag = (event: React.PointerEvent) => { - if (!isDraggingFocusRef.current) return; - isDraggingFocusRef.current = false; - try { - event.currentTarget.releasePointerCapture(event.pointerId); - } catch { - - } - }; - - const handleOverlayPointerUp = (event: React.PointerEvent) => { - endFocusDrag(event); - }; - - const handleOverlayPointerLeave = (event: React.PointerEvent) => { - endFocusDrag(event); - }; - - useEffect(() => { - zoomRegionsRef.current = zoomRegions; - }, [zoomRegions]); - - useEffect(() => { - selectedZoomIdRef.current = selectedZoomId; - }, [selectedZoomId]); - - useEffect(() => { - isPlayingRef.current = isPlaying; - }, [isPlaying]); - - useEffect(() => { - trimRegionsRef.current = trimRegions; - }, [trimRegions]); - - useEffect(() => { - speedRegionsRef.current = speedRegions; - }, [speedRegions]); - - useEffect(() => { - zoomMotionBlurRef.current = zoomMotionBlur; - }, [zoomMotionBlur]); - - useEffect(() => { - connectZoomsRef.current = connectZooms; - }, [connectZooms]); - - useEffect(() => { - cursorTelemetryRef.current = cursorTelemetry; - }, [cursorTelemetry]); - - useEffect(() => { - showCursorRef.current = showCursor; - }, [showCursor]); - - useEffect(() => { - cursorSizeRef.current = cursorSize; - }, [cursorSize]); - - useEffect(() => { - cursorSmoothingRef.current = cursorSmoothing; - }, [cursorSmoothing]); - - useEffect(() => { - cursorMotionBlurRef.current = cursorMotionBlur; - }, [cursorMotionBlur]); - - useEffect(() => { - cursorClickBounceRef.current = cursorClickBounce; - }, [cursorClickBounce]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const app = appRef.current; - const cameraContainer = cameraContainerRef.current; - const video = videoRef.current; - - if (!app || !cameraContainer || !video) return; - - const tickerWasStarted = app.ticker?.started || false; - if (tickerWasStarted && app.ticker) { - app.ticker.stop(); - } - - const wasPlaying = !video.paused; - if (wasPlaying) { - video.pause(); - } - - animationStateRef.current = createPlaybackAnimationState(); - - // Reset cursor overlay smoothing on layout change - cursorOverlayRef.current?.reset(); - - // Reset motion blur state for clean transitions - motionBlurStateRef.current = createMotionBlurState(); - - if (blurFilterRef.current) { - blurFilterRef.current.blur = 0; - } - - requestAnimationFrame(() => { - const container = cameraContainerRef.current; - const videoStage = videoContainerRef.current; - const sprite = videoSpriteRef.current; - const currentApp = appRef.current; - if (!container || !videoStage || !sprite || !currentApp) { + if (!stageWidth || !stageHeight) { return; } - container.scale.set(1); - container.position.set(0, 0); - videoStage.scale.set(1); - videoStage.position.set(0, 0); - sprite.scale.set(1); - sprite.position.set(0, 0); + stageSizeRef.current = { width: stageWidth, height: stageHeight }; - layoutVideoContent(); + const localX = clientX - rect.left; + const localY = clientY - rect.top; + const baseMask = baseMaskRef.current; - applyZoomTransform({ - cameraContainer: container, - blurFilter: blurFilterRef.current, - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: 1, - focusX: DEFAULT_FOCUS.cx, - focusY: DEFAULT_FOCUS.cy, - motionIntensity: 0, - isPlaying: false, - motionBlurAmount: zoomMotionBlurRef.current, - }); + const unclampedFocus: ZoomFocus = { + cx: clamp01((localX - baseMask.x) / Math.max(1, baseMask.width)), + cy: clamp01((localY - baseMask.y) / Math.max(1, baseMask.height)), + }; + const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + + onZoomFocusChange(region.id, clampedFocus); + updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); + }; + + const handleOverlayPointerDown = ( + event: React.PointerEvent, + ) => { + if (isPlayingRef.current) return; + const regionId = selectedZoomIdRef.current; + if (!regionId) return; + const region = zoomRegionsRef.current.find((r) => r.id === regionId); + if (!region) return; + onSelectZoom(region.id); + event.preventDefault(); + isDraggingFocusRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const handleOverlayPointerMove = ( + event: React.PointerEvent, + ) => { + if (!isDraggingFocusRef.current) return; + event.preventDefault(); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const endFocusDrag = (event: React.PointerEvent) => { + if (!isDraggingFocusRef.current) return; + isDraggingFocusRef.current = false; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch {} + }; + + const handleOverlayPointerUp = ( + event: React.PointerEvent, + ) => { + endFocusDrag(event); + }; + + const handleOverlayPointerLeave = ( + event: React.PointerEvent, + ) => { + endFocusDrag(event); + }; + + useEffect(() => { + zoomRegionsRef.current = zoomRegions; + }, [zoomRegions]); + + useEffect(() => { + selectedZoomIdRef.current = selectedZoomId; + }, [selectedZoomId]); + + useEffect(() => { + isPlayingRef.current = isPlaying; + }, [isPlaying]); + + useEffect(() => { + trimRegionsRef.current = trimRegions; + }, [trimRegions]); + + useEffect(() => { + speedRegionsRef.current = speedRegions; + }, [speedRegions]); + + useEffect(() => { + zoomMotionBlurRef.current = zoomMotionBlur; + }, [zoomMotionBlur]); + + useEffect(() => { + connectZoomsRef.current = connectZooms; + }, [connectZooms]); + + useEffect(() => { + cursorTelemetryRef.current = cursorTelemetry; + }, [cursorTelemetry]); + + useEffect(() => { + showCursorRef.current = showCursor; + }, [showCursor]); + + useEffect(() => { + cursorSizeRef.current = cursorSize; + }, [cursorSize]); + + useEffect(() => { + cursorSmoothingRef.current = cursorSmoothing; + }, [cursorSmoothing]); + + useEffect(() => { + cursorMotionBlurRef.current = cursorMotionBlur; + }, [cursorMotionBlur]); + + useEffect(() => { + cursorClickBounceRef.current = cursorClickBounce; + }, [cursorClickBounce]); + + useEffect(() => { + cursorSwayRef.current = cursorSway; + }, [cursorSway]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const app = appRef.current; + const cameraContainer = cameraContainerRef.current; + const video = videoRef.current; + + if (!app || !cameraContainer || !video) return; + + const tickerWasStarted = app.ticker?.started || false; + if (tickerWasStarted && app.ticker) { + app.ticker.stop(); + } + + const wasPlaying = !video.paused; + if (wasPlaying) { + video.pause(); + } + + animationStateRef.current = createPlaybackAnimationState(); + + // Reset cursor overlay smoothing on layout change + cursorOverlayRef.current?.reset(); + + // Reset motion blur state for clean transitions + motionBlurStateRef.current = createMotionBlurState(); + + if (blurFilterRef.current) { + blurFilterRef.current.blur = 0; + } requestAnimationFrame(() => { - const finalApp = appRef.current; - if (wasPlaying && video) { - video.play().catch(() => { - }); + const container = cameraContainerRef.current; + const videoStage = videoContainerRef.current; + const sprite = videoSpriteRef.current; + const currentApp = appRef.current; + if (!container || !videoStage || !sprite || !currentApp) { + return; } - if (tickerWasStarted && finalApp?.ticker) { - finalApp.ticker.start(); - } - }); - }); - }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); - useEffect(() => { - if (!pixiReady || !videoReady) return; - const container = containerRef.current; - if (!container) return; + container.scale.set(1); + container.position.set(0, 0); + videoStage.scale.set(1); + videoStage.position.set(0, 0); + sprite.scale.set(1); + sprite.position.set(0, 0); - if (typeof ResizeObserver === 'undefined') { - return; - } + layoutVideoContent(); - const observer = new ResizeObserver(() => { - layoutVideoContent(); - }); - - observer.observe(container); - return () => { - observer.disconnect(); - }; - }, [pixiReady, videoReady, layoutVideoContent]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - updateOverlayForRegion(selectedZoom); - }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); - - useEffect(() => { - const overlayEl = overlayRef.current; - if (!overlayEl) return; - if (!selectedZoom) { - overlayEl.style.cursor = 'default'; - overlayEl.style.pointerEvents = 'none'; - return; - } - overlayEl.style.cursor = isPlaying ? 'not-allowed' : 'grab'; - overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; - }, [selectedZoom, isPlaying]); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let mounted = true; - let app: Application | null = null; - - (async () => { - let cursorOverlayEnabled = true; - try { - await preloadCursorAssets(); - } catch (error) { - cursorOverlayEnabled = false; - console.warn('Native cursor assets are unavailable in preview; continuing without cursor overlay.', error); - } - - app = new Application(); - - await app.init({ - width: container.clientWidth, - height: container.clientHeight, - backgroundAlpha: 0, - antialias: true, - resolution: window.devicePixelRatio || 1, - autoDensity: true, - }); - - app.ticker.maxFPS = 60; - - if (!mounted) { - app.destroy(true, { children: true, texture: false, textureSource: false }); - return; - } - - appRef.current = app; - container.appendChild(app.canvas); - - // Camera container - this will be scaled/positioned for zoom - const cameraContainer = new Container(); - cameraContainerRef.current = cameraContainer; - app.stage.addChild(cameraContainer); - - // Video container - holds the masked video sprite - const videoContainer = new Container(); - videoContainerRef.current = videoContainer; - cameraContainer.addChild(videoContainer); - - const cursorContainer = new Container(); - cursorContainerRef.current = cursorContainer; - cameraContainer.addChild(cursorContainer); - - // Cursor overlay - rendered above the masked video so it can sit in front - // of the content without getting clipped. - if (cursorOverlayEnabled) { - const cursorOverlay = new PixiCursorOverlay({ - dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current, - smoothingFactor: cursorSmoothingRef.current, - motionBlur: cursorMotionBlurRef.current, - clickBounce: cursorClickBounceRef.current, + applyZoomTransform({ + cameraContainer: container, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + motionIntensity: 0, + isPlaying: false, + motionBlurAmount: zoomMotionBlurRef.current, }); - cursorOverlayRef.current = cursorOverlay; - cursorContainer.addChild(cursorOverlay.container); - } else { - cursorOverlayRef.current = null; - } - - setPixiReady(true); - })().catch((error) => { - console.error('Failed to initialize preview renderer:', error); - onError(error instanceof Error ? error.message : 'Failed to initialize preview renderer'); - }); - return () => { - mounted = false; - setPixiReady(false); - if (cursorOverlayRef.current) { - cursorOverlayRef.current.destroy(); - cursorOverlayRef.current = null; - } - if (app && app.renderer) { - app.destroy(true, { children: true, texture: false, textureSource: false }); - } - appRef.current = null; - cameraContainerRef.current = null; - videoContainerRef.current = null; - cursorContainerRef.current = null; - videoSpriteRef.current = null; - }; - }, [onError]); - - useEffect(() => { - const video = videoRef.current; - if (!video) return; - video.pause(); - video.currentTime = 0; - allowPlaybackRef.current = false; - lockedVideoDimensionsRef.current = null; - setVideoReady(false); - if (videoReadyRafRef.current) { - cancelAnimationFrame(videoReadyRafRef.current); - videoReadyRafRef.current = null; - } - }, [videoPath]); - - - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const video = videoRef.current; - const app = appRef.current; - const videoContainer = videoContainerRef.current; - const cursorContainer = cursorContainerRef.current; - - if (!video || !app || !videoContainer || !cursorContainer) return; - if (video.videoWidth === 0 || video.videoHeight === 0) return; - - const source = VideoSource.from(video); - if ('autoPlay' in source) { - (source as { autoPlay?: boolean }).autoPlay = false; - } - if ('autoUpdate' in source) { - (source as { autoUpdate?: boolean }).autoUpdate = true; - } - const videoTexture = Texture.from(source); - - const videoSprite = new Sprite(videoTexture); - videoSpriteRef.current = videoSprite; - - const maskGraphics = new Graphics(); - videoContainer.addChild(videoSprite); - videoContainer.addChild(maskGraphics); - videoContainer.mask = maskGraphics; - maskGraphicsRef.current = maskGraphics; - if (cursorOverlayRef.current) { - cursorContainer.addChild(cursorOverlayRef.current.container); - } - - animationStateRef.current = createPlaybackAnimationState(); - - const blurFilter = new BlurFilter(); - blurFilter.quality = 3; - blurFilter.resolution = app.renderer.resolution; - blurFilter.blur = 0; - const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); - videoContainer.filters = [blurFilter, motionBlurFilter]; - blurFilterRef.current = blurFilter; - motionBlurFilterRef.current = motionBlurFilter; - - layoutVideoContent(); - video.pause(); - - const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({ - video, - isSeekingRef, - isPlayingRef, - allowPlaybackRef, - currentTimeRef, - timeUpdateAnimationRef, - onPlayStateChange, - onTimeUpdate, - trimRegionsRef, - speedRegionsRef, - }); - - video.addEventListener('play', handlePlay); - video.addEventListener('pause', handlePause); - video.addEventListener('ended', handlePause); - video.addEventListener('seeked', handleSeeked); - video.addEventListener('seeking', handleSeeking); - - return () => { - video.removeEventListener('play', handlePlay); - video.removeEventListener('pause', handlePause); - video.removeEventListener('ended', handlePause); - video.removeEventListener('seeked', handleSeeked); - video.removeEventListener('seeking', handleSeeking); - - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - } - - if (videoSprite) { - videoContainer.removeChild(videoSprite); - videoSprite.destroy(); - } - if (maskGraphics) { - videoContainer.removeChild(maskGraphics); - maskGraphics.destroy(); - } - videoContainer.mask = null; - maskGraphicsRef.current = null; - if (blurFilterRef.current) { - videoContainer.filters = []; - blurFilterRef.current.destroy(); - blurFilterRef.current = null; - } - if (motionBlurFilterRef.current) { - motionBlurFilterRef.current.destroy(); - motionBlurFilterRef.current = null; - } - videoTexture.destroy(false); - - videoSpriteRef.current = null; - }; - }, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]); - - useEffect(() => { - if (!pixiReady || !videoReady) return; - - const app = appRef.current; - const videoSprite = videoSpriteRef.current; - const videoContainer = videoContainerRef.current; - if (!app || !videoSprite || !videoContainer) return; - - const applyTransform = ( - transform: { scale: number; x: number; y: number }, - focus: ZoomFocus, - motionIntensity: number, - motionVector: { x: number; y: number }, - ) => { - const cameraContainer = cameraContainerRef.current; - if (!cameraContainer) return; - - const state = animationStateRef.current; - - const appliedTransform = applyZoomTransform({ - cameraContainer, - blurFilter: blurFilterRef.current, - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: state.scale, - zoomProgress: state.progress, - focusX: focus.cx, - focusY: focus.cy, - motionIntensity, - motionVector, - isPlaying: isPlayingRef.current, - motionBlurAmount: zoomMotionBlurRef.current, - motionBlurFilter: motionBlurFilterRef.current, - transformOverride: transform, - motionBlurState: motionBlurStateRef.current, - frameTimeMs: performance.now(), + requestAnimationFrame(() => { + const finalApp = appRef.current; + if (wasPlaying && video) { + video.play().catch(() => {}); + } + if (tickerWasStarted && finalApp?.ticker) { + finalApp.ticker.start(); + } + }); }); + }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); - state.x = appliedTransform.x; - state.y = appliedTransform.y; - state.appliedScale = appliedTransform.scale; - }; + useEffect(() => { + if (!pixiReady || !videoReady) return; + const container = containerRef.current; + if (!container) return; - const ticker = () => { - const { region, strength, blendedScale, transition } = findDominantRegion( - zoomRegionsRef.current, - currentTimeRef.current, - { - connectZooms: connectZoomsRef.current, - }, - ); - - 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 = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = region.focus; - - targetScaleFactor = zoomScale; - targetFocus = regionFocus; - targetProgress = strength; - - 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; - - state.scale = targetScaleFactor; - state.focusX = targetFocus.cx; - state.focusY = targetFocus.cy; - state.progress = targetProgress; - - const projectedTransform = computeZoomTransform({ - stageSize: stageSizeRef.current, - baseMask: baseMaskRef.current, - zoomScale: state.scale, - zoomProgress: state.progress, - focusX: state.focusX, - focusY: state.focusY, - }); - - 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(appliedScale - prevScale), - Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width), - Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height), - ); - - const motionVector = { - x: appliedX - prevX, - y: appliedY - prevY, - }; - - applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, targetFocus, motionIntensity, motionVector); - - // Update cursor overlay - const cursorOverlay = cursorOverlayRef.current; - if (cursorOverlay) { - const timeMs = currentTimeRef.current; - cursorOverlay.update( - cursorTelemetryRef.current, - timeMs, - baseMaskRef.current, - showCursorRef.current, - !isPlayingRef.current || isSeekingRef.current, - ); - } - }; - - app.ticker.add(ticker); - return () => { - if (app && app.ticker) { - app.ticker.remove(ticker); - } - }; - }, [pixiReady, videoReady, clampFocusToStage]); - - useEffect(() => { - const overlay = cursorOverlayRef.current; - if (!overlay) { - return; - } - - overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize); - overlay.setSmoothingFactor(cursorSmoothing); - overlay.setMotionBlur(cursorMotionBlur); - overlay.setClickBounce(cursorClickBounce); - overlay.reset(); - }, [cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce]); - - const handleLoadedMetadata = (e: React.SyntheticEvent) => { - const video = e.currentTarget; - onDurationChange(video.duration); - video.currentTime = 0; - video.pause(); - allowPlaybackRef.current = false; - currentTimeRef.current = 0; - - if (videoReadyRafRef.current) { - cancelAnimationFrame(videoReadyRafRef.current); - videoReadyRafRef.current = null; - } - - const waitForRenderableFrame = () => { - const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0; - const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; - if (hasDimensions && hasData) { - videoReadyRafRef.current = null; - setVideoReady(true); + if (typeof ResizeObserver === "undefined") { return; } - videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); - }; - videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); - }; + const observer = new ResizeObserver(() => { + layoutVideoContent(); + }); - const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [pixiReady, videoReady, layoutVideoContent]); - useEffect(() => { - let mounted = true - ;(async () => { - try { - if (!wallpaper) { - const def = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH) - if (mounted) setResolvedWallpaper(def) - return - } + useEffect(() => { + if (!pixiReady || !videoReady) return; + updateOverlayForRegion(selectedZoom); + }, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]); - if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { - if (mounted) setResolvedWallpaper(wallpaper) - return - } - - // If it's a data URL (custom uploaded image), use as-is - if (wallpaper.startsWith('data:')) { - if (mounted) setResolvedWallpaper(wallpaper) - return - } - - if (wallpaper.startsWith('http') || wallpaper.startsWith('file://') || wallpaper.startsWith('/')) { - const renderable = await getRenderableAssetUrl(wallpaper) - if (mounted) setResolvedWallpaper(renderable) - return - } - const p = await getRenderableAssetUrl(await getAssetPath(wallpaper.replace(/^\//, ''))) - if (mounted) setResolvedWallpaper(p) - } catch (err) { - if (mounted) setResolvedWallpaper(wallpaper || DEFAULT_WALLPAPER_PATH) + useEffect(() => { + const overlayEl = overlayRef.current; + if (!overlayEl) return; + if (!selectedZoom) { + overlayEl.style.cursor = "default"; + overlayEl.style.pointerEvents = "none"; + return; } - })() - return () => { mounted = false } - }, [wallpaper]) + overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab"; + overlayEl.style.pointerEvents = isPlaying ? "none" : "auto"; + }, [selectedZoom, isPlaying]); - useEffect(() => { - return () => { + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let mounted = true; + let app: Application | null = null; + + (async () => { + let cursorOverlayEnabled = true; + try { + await preloadCursorAssets(); + } catch (error) { + cursorOverlayEnabled = false; + console.warn( + "Native cursor assets are unavailable in preview; continuing without cursor overlay.", + error, + ); + } + + app = new Application(); + + await app.init({ + width: container.clientWidth, + height: container.clientHeight, + backgroundAlpha: 0, + antialias: true, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + }); + + app.ticker.maxFPS = 60; + + if (!mounted) { + app.destroy(true, { + children: true, + texture: false, + textureSource: false, + }); + return; + } + + appRef.current = app; + container.appendChild(app.canvas); + + // Camera container - this will be scaled/positioned for zoom + const cameraContainer = new Container(); + cameraContainerRef.current = cameraContainer; + app.stage.addChild(cameraContainer); + + // Video container - holds the masked video sprite + const videoContainer = new Container(); + videoContainerRef.current = videoContainer; + cameraContainer.addChild(videoContainer); + + const cursorContainer = new Container(); + cursorContainerRef.current = cursorContainer; + cameraContainer.addChild(cursorContainer); + + // Cursor overlay - rendered above the masked video so it can sit in front + // of the content without getting clipped. + if (cursorOverlayEnabled) { + const cursorOverlay = new PixiCursorOverlay({ + dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current, + smoothingFactor: cursorSmoothingRef.current, + motionBlur: cursorMotionBlurRef.current, + clickBounce: cursorClickBounceRef.current, + sway: cursorSwayRef.current, + }); + cursorOverlayRef.current = cursorOverlay; + cursorContainer.addChild(cursorOverlay.container); + } else { + cursorOverlayRef.current = null; + } + + setPixiReady(true); + })().catch((error) => { + console.error("Failed to initialize preview renderer:", error); + onError( + error instanceof Error + ? error.message + : "Failed to initialize preview renderer", + ); + }); + + return () => { + mounted = false; + setPixiReady(false); + if (cursorOverlayRef.current) { + cursorOverlayRef.current.destroy(); + cursorOverlayRef.current = null; + } + if (app && app.renderer) { + app.destroy(true, { + children: true, + texture: false, + textureSource: false, + }); + } + appRef.current = null; + cameraContainerRef.current = null; + videoContainerRef.current = null; + cursorContainerRef.current = null; + videoSpriteRef.current = null; + }; + }, [onError]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.pause(); + video.currentTime = 0; + allowPlaybackRef.current = false; + lockedVideoDimensionsRef.current = null; + setVideoReady(false); if (videoReadyRafRef.current) { cancelAnimationFrame(videoReadyRafRef.current); videoReadyRafRef.current = null; } - }; - }, []) + }, [videoPath]); - const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/') || resolvedWallpaper.startsWith('data:'))) - const backgroundStyle = isImageUrl - ? { backgroundImage: `url(${resolvedWallpaper || ''})` } - : { background: resolvedWallpaper || '' }; + useEffect(() => { + if (!pixiReady || !videoReady) return; - const nativeAspectRatio = (() => { - const locked = lockedVideoDimensionsRef.current; - if (locked && locked.height > 0) { - return locked.width / locked.height; - } - const video = videoRef.current; - if (video && video.videoHeight > 0) { - return video.videoWidth / video.videoHeight; - } - return 16 / 9; - })(); + const video = videoRef.current; + const app = appRef.current; + const videoContainer = videoContainerRef.current; + const cursorContainer = cursorContainerRef.current; - return ( -
- {/* Background layer */} -
0 ? `blur(${backgroundBlur}px)` : 'none', - }} - /> -
0) - ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` - : 'none', - }} - /> - {/* Only render overlay after PIXI and video are fully initialized */} - {pixiReady && videoReady && ( -
-
- {(() => { - const filtered = (annotationRegions || []).filter((annotation) => { - if (typeof annotation.startMs !== 'number' || typeof annotation.endMs !== 'number') return false; - - if (annotation.id === selectedAnnotationId) return true; - - const timeMs = Math.round(currentTime * 1000); - return timeMs >= annotation.startMs && timeMs <= annotation.endMs; + if (!video || !app || !videoContainer || !cursorContainer) return; + if (video.videoWidth === 0 || video.videoHeight === 0) return; + + const source = VideoSource.from(video); + if ("autoPlay" in source) { + (source as { autoPlay?: boolean }).autoPlay = false; + } + if ("autoUpdate" in source) { + (source as { autoUpdate?: boolean }).autoUpdate = true; + } + const videoTexture = Texture.from(source); + + const videoSprite = new Sprite(videoTexture); + videoSpriteRef.current = videoSprite; + + const maskGraphics = new Graphics(); + videoContainer.addChild(videoSprite); + videoContainer.addChild(maskGraphics); + videoContainer.mask = maskGraphics; + maskGraphicsRef.current = maskGraphics; + if (cursorOverlayRef.current) { + cursorContainer.addChild(cursorOverlayRef.current.container); + } + + animationStateRef.current = createPlaybackAnimationState(); + + const blurFilter = new BlurFilter(); + blurFilter.quality = 3; + blurFilter.resolution = app.renderer.resolution; + blurFilter.blur = 0; + const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0); + videoContainer.filters = [blurFilter, motionBlurFilter]; + blurFilterRef.current = blurFilter; + motionBlurFilterRef.current = motionBlurFilter; + + layoutVideoContent(); + video.pause(); + + const { handlePlay, handlePause, handleSeeked, handleSeeking } = + createVideoEventHandlers({ + video, + isSeekingRef, + isPlayingRef, + allowPlaybackRef, + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + trimRegionsRef, + speedRegionsRef, + }); + + video.addEventListener("play", handlePlay); + video.addEventListener("pause", handlePause); + video.addEventListener("ended", handlePause); + video.addEventListener("seeked", handleSeeked); + video.addEventListener("seeking", handleSeeking); + + return () => { + video.removeEventListener("play", handlePlay); + video.removeEventListener("pause", handlePause); + video.removeEventListener("ended", handlePause); + video.removeEventListener("seeked", handleSeeked); + video.removeEventListener("seeking", handleSeeking); + + if (timeUpdateAnimationRef.current) { + cancelAnimationFrame(timeUpdateAnimationRef.current); + } + + if (videoSprite) { + videoContainer.removeChild(videoSprite); + videoSprite.destroy(); + } + if (maskGraphics) { + videoContainer.removeChild(maskGraphics); + maskGraphics.destroy(); + } + videoContainer.mask = null; + maskGraphicsRef.current = null; + if (blurFilterRef.current) { + videoContainer.filters = []; + blurFilterRef.current.destroy(); + blurFilterRef.current = null; + } + if (motionBlurFilterRef.current) { + motionBlurFilterRef.current.destroy(); + motionBlurFilterRef.current = null; + } + videoTexture.destroy(false); + + videoSpriteRef.current = null; + }; + }, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + + const app = appRef.current; + const videoSprite = videoSpriteRef.current; + const videoContainer = videoContainerRef.current; + if (!app || !videoSprite || !videoContainer) return; + + const applyTransform = ( + transform: { scale: number; x: number; y: number }, + focus: ZoomFocus, + motionIntensity: number, + motionVector: { x: number; y: number }, + ) => { + const cameraContainer = cameraContainerRef.current; + if (!cameraContainer) return; + + const state = animationStateRef.current; + + const appliedTransform = applyZoomTransform({ + cameraContainer, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: focus.cx, + focusY: focus.cy, + motionIntensity, + motionVector, + isPlaying: isPlayingRef.current, + motionBlurAmount: zoomMotionBlurRef.current, + motionBlurFilter: motionBlurFilterRef.current, + 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, blendedScale, transition } = + findDominantRegion(zoomRegionsRef.current, currentTimeRef.current, { + connectZooms: connectZoomsRef.current, + }); + + 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 = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + const regionFocus = region.focus; + + targetScaleFactor = zoomScale; + targetFocus = regionFocus; + targetProgress = strength; + + 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, }); - - // Sort by z-index (lowest to highest) so higher z-index renders on top - const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); - - // Handle click-through cycling: when clicking same annotation, cycle to next - const handleAnnotationClick = (clickedId: string) => { - if (!onSelectAnnotation) return; - - // If clicking on already selected annotation and there are multiple overlapping - if (clickedId === selectedAnnotationId && sorted.length > 1) { - // Find current index and cycle to next - const currentIndex = sorted.findIndex(a => a.id === clickedId); - const nextIndex = (currentIndex + 1) % sorted.length; - onSelectAnnotation(sorted[nextIndex].id); - } else { - // First click or clicking different annotation - onSelectAnnotation(clickedId); - } - }; - - return sorted.map((annotation) => ( - onAnnotationPositionChange?.(id, position)} - onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)} - onClick={handleAnnotationClick} - zIndex={annotation.zIndex} - isSelectedBoost={annotation.id === selectedAnnotationId} - /> - )); - })()} -
- )} -
- ); -}); -VideoPlayback.displayName = 'VideoPlayback'; + 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; + + state.scale = targetScaleFactor; + state.focusX = targetFocus.cx; + state.focusY = targetFocus.cy; + state.progress = targetProgress; + + const projectedTransform = computeZoomTransform({ + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: state.scale, + zoomProgress: state.progress, + focusX: state.focusX, + focusY: state.focusY, + }); + + 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(appliedScale - prevScale), + Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width), + Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height), + ); + + const motionVector = { + x: appliedX - prevX, + y: appliedY - prevY, + }; + + applyTransform( + { scale: appliedScale, x: appliedX, y: appliedY }, + targetFocus, + motionIntensity, + motionVector, + ); + + // Update cursor overlay + const cursorOverlay = cursorOverlayRef.current; + if (cursorOverlay) { + const timeMs = currentTimeRef.current; + cursorOverlay.update( + cursorTelemetryRef.current, + timeMs, + baseMaskRef.current, + showCursorRef.current, + !isPlayingRef.current || isSeekingRef.current, + ); + } + }; + + app.ticker.add(ticker); + return () => { + if (app && app.ticker) { + app.ticker.remove(ticker); + } + }; + }, [pixiReady, videoReady, clampFocusToStage]); + + useEffect(() => { + const overlay = cursorOverlayRef.current; + if (!overlay) { + return; + } + + overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize); + overlay.setSmoothingFactor(cursorSmoothing); + overlay.setMotionBlur(cursorMotionBlur); + overlay.setClickBounce(cursorClickBounce); + overlay.setSway(cursorSway); + overlay.reset(); + }, [ + cursorSize, + cursorSmoothing, + cursorMotionBlur, + cursorClickBounce, + cursorSway, + ]); + + const handleLoadedMetadata = ( + e: React.SyntheticEvent, + ) => { + const video = e.currentTarget; + onDurationChange(video.duration); + video.currentTime = 0; + video.pause(); + allowPlaybackRef.current = false; + currentTimeRef.current = 0; + + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + + const waitForRenderableFrame = () => { + const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0; + const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; + if (hasDimensions && hasData) { + videoReadyRafRef.current = null; + setVideoReady(true); + return; + } + videoReadyRafRef.current = requestAnimationFrame( + waitForRenderableFrame, + ); + }; + + videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); + }; + + const [resolvedWallpaper, setResolvedWallpaper] = useState( + null, + ); + + useEffect(() => { + let mounted = true; + (async () => { + try { + if (!wallpaper) { + const def = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH); + if (mounted) setResolvedWallpaper(def); + return; + } + + if ( + wallpaper.startsWith("#") || + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + if (mounted) setResolvedWallpaper(wallpaper); + return; + } + + // If it's a data URL (custom uploaded image), use as-is + if (wallpaper.startsWith("data:")) { + if (mounted) setResolvedWallpaper(wallpaper); + return; + } + + if ( + wallpaper.startsWith("http") || + wallpaper.startsWith("file://") || + wallpaper.startsWith("/") + ) { + const renderable = await getRenderableAssetUrl(wallpaper); + if (mounted) setResolvedWallpaper(renderable); + return; + } + const p = await getRenderableAssetUrl( + await getAssetPath(wallpaper.replace(/^\//, "")), + ); + if (mounted) setResolvedWallpaper(p); + } catch (err) { + if (mounted) + setResolvedWallpaper(wallpaper || DEFAULT_WALLPAPER_PATH); + } + })(); + return () => { + mounted = false; + }; + }, [wallpaper]); + + useEffect(() => { + return () => { + if (videoReadyRafRef.current) { + cancelAnimationFrame(videoReadyRafRef.current); + videoReadyRafRef.current = null; + } + }; + }, []); + + const isImageUrl = Boolean( + resolvedWallpaper && + (resolvedWallpaper.startsWith("file://") || + resolvedWallpaper.startsWith("http") || + resolvedWallpaper.startsWith("/") || + resolvedWallpaper.startsWith("data:")), + ); + const backgroundStyle = isImageUrl + ? { backgroundImage: `url(${resolvedWallpaper || ""})` } + : { background: resolvedWallpaper || "" }; + + const nativeAspectRatio = (() => { + const locked = lockedVideoDimensionsRef.current; + if (locked && locked.height > 0) { + return locked.width / locked.height; + } + const video = videoRef.current; + if (video && video.videoHeight > 0) { + return video.videoWidth / video.videoHeight; + } + return 16 / 9; + })(); + + return ( +
+ {/* Background layer */} +
0 ? `blur(${backgroundBlur}px)` : "none", + }} + /> +
0 + ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` + : "none", + }} + /> + {/* Only render overlay after PIXI and video are fully initialized */} + {pixiReady && videoReady && ( +
+
+ {(() => { + const filtered = (annotationRegions || []).filter( + (annotation) => { + if ( + typeof annotation.startMs !== "number" || + typeof annotation.endMs !== "number" + ) + return false; + + if (annotation.id === selectedAnnotationId) return true; + + const timeMs = Math.round(currentTime * 1000); + return ( + timeMs >= annotation.startMs && timeMs <= annotation.endMs + ); + }, + ); + + // Sort by z-index (lowest to highest) so higher z-index renders on top + const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex); + + // Handle click-through cycling: when clicking same annotation, cycle to next + const handleAnnotationClick = (clickedId: string) => { + if (!onSelectAnnotation) return; + + // If clicking on already selected annotation and there are multiple overlapping + if (clickedId === selectedAnnotationId && sorted.length > 1) { + // Find current index and cycle to next + const currentIndex = sorted.findIndex( + (a) => a.id === clickedId, + ); + const nextIndex = (currentIndex + 1) % sorted.length; + onSelectAnnotation(sorted[nextIndex].id); + } else { + // First click or clicking different annotation + onSelectAnnotation(clickedId); + } + }; + + return sorted.map((annotation) => ( + + onAnnotationPositionChange?.(id, position) + } + onSizeChange={(id, size) => + onAnnotationSizeChange?.(id, size) + } + onClick={handleAnnotationClick} + zIndex={annotation.zIndex} + isSelectedBoost={annotation.id === selectedAnnotationId} + /> + )); + })()} +
+ )} +
+ ); + }, +); + +VideoPlayback.displayName = "VideoPlayback"; export default VideoPlayback; - diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index be25b8fd..6445b46c 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,11 +1,21 @@ -import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils"; -import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { + ASPECT_RATIOS, + type AspectRatio, + isCustomAspectRatio, +} from "@/utils/aspectRatioUtils"; +import type { + ExportFormat, + ExportQuality, + GifFrameRate, + GifSizePreset, +} from "@/lib/exporter"; import { WALLPAPER_PATHS } from "@/lib/wallpapers"; import { DEFAULT_CURSOR_CLICK_BOUNCE, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_SWAY, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, @@ -35,6 +45,7 @@ export interface ProjectEditorState { cursorSmoothing: number; cursorMotionBlur: number; cursorClickBounce: number; + cursorSway: number; borderRadius: number; padding: number; cropRegion: CropRegion; @@ -68,7 +79,10 @@ function isFileUrl(value: string): boolean { return /^file:\/\//i.test(value); } -function encodePathSegments(pathname: string, keepWindowsDrive = false): string { +function encodePathSegments( + pathname: string, + keepWindowsDrive = false, +): string { return pathname .split("/") .map((segment, index) => { @@ -92,11 +106,15 @@ export function toFileUrl(filePath: string): string { // UNC path: //server/share/... if (normalized.startsWith("//")) { const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/"); - const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/"); + const encodedPath = pathParts + .map((part) => encodeURIComponent(part)) + .join("/"); return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`; } - const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`; + const absolutePath = normalized.startsWith("/") + ? normalized + : `/${normalized}`; return `file://${encodePathSegments(absolutePath)}`; } @@ -142,7 +160,9 @@ export function deriveNextId(prefix: string, ids: string[]): number { return max + 1; } -export function validateProjectData(candidate: unknown): candidate is EditorProjectData { +export function validateProjectData( + candidate: unknown, +): candidate is EditorProjectData { if (!candidate || typeof candidate !== "object") return false; const project = candidate as Partial; if (typeof project.version !== "number") return false; @@ -151,27 +171,49 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj return true; } -export function normalizeProjectEditor(editor: Partial): ProjectEditorState { +export function normalizeProjectEditor( + editor: Partial, +): ProjectEditorState { const validAspectRatios = new Set(ASPECT_RATIOS); - const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>).motionBlurEnabled; + const legacyMotionBlurEnabled = ( + editor as Partial<{ motionBlurEnabled: boolean }> + ).motionBlurEnabled; const legacyShowBlur = (editor as Partial<{ showBlur: boolean }>).showBlur; - const normalizedZoomMotionBlur = isFiniteNumber((editor as Partial).zoomMotionBlur) - ? clamp((editor as Partial).zoomMotionBlur as number, 0, 2) + const normalizedZoomMotionBlur = isFiniteNumber( + (editor as Partial).zoomMotionBlur, + ) + ? clamp( + (editor as Partial).zoomMotionBlur as number, + 0, + 2, + ) : legacyMotionBlurEnabled ? 0.35 : DEFAULT_ZOOM_MOTION_BLUR; - const normalizedBackgroundBlur = isFiniteNumber((editor as Partial).backgroundBlur) - ? clamp((editor as Partial).backgroundBlur as number, 0, 8) + const normalizedBackgroundBlur = isFiniteNumber( + (editor as Partial).backgroundBlur, + ) + ? clamp( + (editor as Partial).backgroundBlur as number, + 0, + 8, + ) : legacyShowBlur ? 2 : 0; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions - .filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string")) + .filter((region): region is ZoomRegion => + Boolean(region && typeof region.id === "string"), + ) .map((region) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); @@ -179,10 +221,20 @@ export function normalizeProjectEditor(editor: Partial): Pro id: region.id, startMs, endMs, - depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH, + depth: [1, 2, 3, 4, 5, 6].includes(region.depth) + ? region.depth + : DEFAULT_ZOOM_DEPTH, focus: { - cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1), - cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1), + cx: clamp( + isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, + 0, + 1, + ), + cy: clamp( + isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, + 0, + 1, + ), }, }; }) @@ -190,10 +242,16 @@ export function normalizeProjectEditor(editor: Partial): Pro const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions) ? editor.trimRegions - .filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string")) + .filter((region): region is TrimRegion => + Boolean(region && typeof region.id === "string"), + ) .map((region) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); return { @@ -204,12 +262,20 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; - const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions) + const normalizedSpeedRegions: SpeedRegion[] = Array.isArray( + editor.speedRegions, + ) ? editor.speedRegions - .filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string")) + .filter((region): region is SpeedRegion => + Boolean(region && typeof region.id === "string"), + ) .map((region) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); @@ -233,12 +299,20 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; - const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions) + const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray( + editor.annotationRegions, + ) ? editor.annotationRegions - .filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === "string")) + .filter((region): region is AnnotationRegion => + Boolean(region && typeof region.id === "string"), + ) .map((region, index) => { - const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; - const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const rawStart = isFiniteNumber(region.startMs) + ? Math.round(region.startMs) + : 0; + const rawEnd = isFiniteNumber(region.endMs) + ? Math.round(region.endMs) + : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); @@ -246,37 +320,56 @@ export function normalizeProjectEditor(editor: Partial): Pro id: region.id, startMs, endMs, - type: region.type === "image" || region.type === "figure" ? region.type : "text", + type: + region.type === "image" || region.type === "figure" + ? region.type + : "text", content: typeof region.content === "string" ? region.content : "", - textContent: typeof region.textContent === "string" ? region.textContent : undefined, - imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined, + textContent: + typeof region.textContent === "string" + ? region.textContent + : undefined, + imageContent: + typeof region.imageContent === "string" + ? region.imageContent + : undefined, position: { x: clamp( - isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x, + isFiniteNumber(region.position?.x) + ? region.position.x + : DEFAULT_ANNOTATION_POSITION.x, 0, 100, ), y: clamp( - isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y, + isFiniteNumber(region.position?.y) + ? region.position.y + : DEFAULT_ANNOTATION_POSITION.y, 0, 100, ), }, size: { width: clamp( - isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width, + isFiniteNumber(region.size?.width) + ? region.size.width + : DEFAULT_ANNOTATION_SIZE.width, 1, 200, ), height: clamp( - isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height, + isFiniteNumber(region.size?.height) + ? region.size.height + : DEFAULT_ANNOTATION_SIZE.height, 1, 200, ), }, style: { ...DEFAULT_ANNOTATION_STYLE, - ...(region.style && typeof region.style === "object" ? region.style : {}), + ...(region.style && typeof region.style === "object" + ? region.style + : {}), }, zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1, figureData: region.figureData @@ -289,9 +382,15 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; - const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x; - const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y; - const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width; + const rawCropX = isFiniteNumber(editor.cropRegion?.x) + ? editor.cropRegion.x + : DEFAULT_CROP_REGION.x; + const rawCropY = isFiniteNumber(editor.cropRegion?.y) + ? editor.cropRegion.y + : DEFAULT_CROP_REGION.y; + const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) + ? editor.cropRegion.width + : DEFAULT_CROP_REGION.width; const rawCropHeight = isFiniteNumber(editor.cropRegion?.height) ? editor.cropRegion.height : DEFAULT_CROP_REGION.height; @@ -302,25 +401,60 @@ export function normalizeProjectEditor(editor: Partial): Pro const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY); return { - wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0], - shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67, + wallpaper: + typeof editor.wallpaper === "string" + ? editor.wallpaper + : WALLPAPER_PATHS[0], + shadowIntensity: + typeof editor.shadowIntensity === "number" + ? editor.shadowIntensity + : 0.67, backgroundBlur: normalizedBackgroundBlur, zoomMotionBlur: normalizedZoomMotionBlur, - connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true, - showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true, - loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false, - cursorSize: isFiniteNumber(editor.cursorSize) ? clamp(editor.cursorSize, 0.5, 10) : DEFAULT_CURSOR_SIZE, + connectZooms: + typeof editor.connectZooms === "boolean" ? editor.connectZooms : true, + showCursor: + typeof editor.showCursor === "boolean" ? editor.showCursor : true, + loopCursor: + typeof editor.loopCursor === "boolean" ? editor.loopCursor : false, + cursorSize: isFiniteNumber(editor.cursorSize) + ? clamp(editor.cursorSize, 0.5, 10) + : DEFAULT_CURSOR_SIZE, cursorSmoothing: isFiniteNumber(editor.cursorSmoothing) ? clamp(editor.cursorSmoothing, 0, 2) : DEFAULT_CURSOR_SMOOTHING, - cursorMotionBlur: isFiniteNumber((editor as Partial).cursorMotionBlur) - ? clamp((editor as Partial).cursorMotionBlur as number, 0, 2) + cursorMotionBlur: isFiniteNumber( + (editor as Partial).cursorMotionBlur, + ) + ? clamp( + (editor as Partial).cursorMotionBlur as number, + 0, + 2, + ) : DEFAULT_CURSOR_MOTION_BLUR, - cursorClickBounce: isFiniteNumber((editor as Partial).cursorClickBounce) - ? clamp((editor as Partial).cursorClickBounce as number, 0, 5) + cursorClickBounce: isFiniteNumber( + (editor as Partial).cursorClickBounce, + ) + ? clamp( + (editor as Partial).cursorClickBounce as number, + 0, + 5, + ) : DEFAULT_CURSOR_CLICK_BOUNCE, - borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5, - padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50, + cursorSway: isFiniteNumber( + (editor as Partial).cursorSway, + ) + ? clamp( + (editor as Partial).cursorSway as number, + 0, + 2, + ) + : DEFAULT_CURSOR_SWAY, + borderRadius: + typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5, + padding: isFiniteNumber(editor.padding) + ? clamp(editor.padding, 0, 100) + : 50, cropRegion: { x: cropX, y: cropY, @@ -333,10 +467,14 @@ export function normalizeProjectEditor(editor: Partial): Pro annotationRegions: normalizedAnnotationRegions, aspectRatio: typeof editor.aspectRatio === "string" && - (validAspectRatios.has(editor.aspectRatio as AspectRatio) || isCustomAspectRatio(editor.aspectRatio)) + (validAspectRatios.has(editor.aspectRatio as AspectRatio) || + isCustomAspectRatio(editor.aspectRatio)) ? (editor.aspectRatio as AspectRatio) : "16:9", - exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good", + exportQuality: + editor.exportQuality === "medium" || editor.exportQuality === "source" + ? editor.exportQuality + : "good", exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4", gifFrameRate: editor.gifFrameRate === 15 || @@ -347,17 +485,21 @@ export function normalizeProjectEditor(editor: Partial): Pro : 15, gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true, gifSizePreset: - editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original" + editor.gifSizePreset === "medium" || + editor.gifSizePreset === "large" || + editor.gifSizePreset === "original" ? editor.gifSizePreset : "medium", }; } -export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData { +export function createProjectData( + videoPath: string, + editor: ProjectEditorState, +): EditorProjectData { return { version: PROJECT_VERSION, videoPath, editor, }; } - diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index fa782ce0..a3fc846b 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -17,8 +17,23 @@ export interface CursorTelemetryPoint { timeMs: number; cx: number; cy: number; - interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup'; - cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed'; + interactionType?: + | "move" + | "click" + | "double-click" + | "right-click" + | "middle-click" + | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; } export interface CursorVisualSettings { @@ -26,12 +41,14 @@ export interface CursorVisualSettings { smoothing: number; motionBlur: number; clickBounce: number; + sway: number; } export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.35; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; +export const DEFAULT_CURSOR_SWAY = 0; export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; export interface TrimRegion { @@ -40,9 +57,17 @@ export interface TrimRegion { endMs: number; } -export type AnnotationType = 'text' | 'image' | 'figure'; +export type AnnotationType = "text" | "image" | "figure"; -export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left'; +export type ArrowDirection = + | "up" + | "down" + | "left" + | "right" + | "up-right" + | "up-left" + | "down-right" + | "down-left"; export interface FigureData { arrowDirection: ArrowDirection; @@ -65,18 +90,18 @@ export interface AnnotationTextStyle { backgroundColor: string; fontSize: number; // pixels fontFamily: string; - fontWeight: 'normal' | 'bold'; - fontStyle: 'normal' | 'italic'; - textDecoration: 'none' | 'underline'; - textAlign: 'left' | 'center' | 'right'; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline"; + textAlign: "left" | "center" | "right"; } function getDefaultAnnotationFontFamily() { - if (typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)) { + if (typeof navigator !== "undefined" && /mac/i.test(navigator.platform)) { return '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif'; } - return 'Inter, system-ui, sans-serif'; + return "Inter, system-ui, sans-serif"; } export interface AnnotationRegion { @@ -105,29 +130,27 @@ export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = { }; export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = { - color: '#ffffff', - backgroundColor: 'transparent', + color: "#ffffff", + backgroundColor: "transparent", fontSize: 32, fontFamily: getDefaultAnnotationFontFamily(), - fontWeight: 'bold', - fontStyle: 'normal', - textDecoration: 'none', - textAlign: 'center', + fontWeight: "bold", + fontStyle: "normal", + textDecoration: "none", + textAlign: "center", }; export const DEFAULT_FIGURE_DATA: FigureData = { - arrowDirection: 'right', - color: '#2563EB', + arrowDirection: "right", + color: "#2563EB", strokeWidth: 4, }; - - export interface CropRegion { - x: number; - y: number; - width: number; - height: number; + x: number; + y: number; + width: number; + height: number; } export const DEFAULT_CROP_REGION: CropRegion = { @@ -169,7 +192,10 @@ export const ZOOM_DEPTH_SCALES: Record = { export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3; -export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus { +export function clampFocusToDepth( + focus: ZoomFocus, + _depth: ZoomDepth, +): ZoomFocus { return { cx: clamp(focus.cx, 0, 1), cy: clamp(focus.cy, 0, 1), @@ -180,4 +206,3 @@ function clamp(value: number, min: number, max: number) { if (Number.isNaN(value)) return (min + max) / 2; return Math.min(max, Math.max(min, value)); } - diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 974a0844..17e1393e 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -1,10 +1,26 @@ -import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from 'pixi.js'; -import { MotionBlurFilter } from 'pixi-filters/motion-blur'; -import type { CursorTelemetryPoint } from '../types'; -import { createSpringState, getCursorSpringConfig, resetSpringState, stepSpringValue } from './motionSmoothing'; -import { uploadedCursorAssets, UPLOADED_CURSOR_SAMPLE_SIZE } from './uploadedCursorAssets'; +import { + Assets, + BlurFilter, + Container, + Graphics, + Sprite, + Texture, +} from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import type { CursorTelemetryPoint } from "../types"; +import { + createSpringState, + getCursorSpringConfig, + resetSpringState, + stepSpringValue, +} from "./motionSmoothing"; +import { computeCursorSwayRotation } from "./cursorSway"; +import { + uploadedCursorAssets, + UPLOADED_CURSOR_SAMPLE_SIZE, +} from "./uploadedCursorAssets"; -type CursorAssetKey = NonNullable; +type CursorAssetKey = NonNullable; type LoadedCursorAsset = { texture: Texture; @@ -39,6 +55,8 @@ export interface CursorRenderConfig { motionBlur: number; /** Click bounce multiplier. */ clickBounce: number; + /** Cursor sway multiplier. */ + sway: number; } export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { @@ -49,6 +67,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = { smoothingFactor: 0.18, motionBlur: 0, clickBounce: 1, + sway: 0, }; const REFERENCE_WIDTH = 1920; @@ -57,7 +76,10 @@ const CLICK_ANIMATION_MS = 140; const CLICK_RING_FADE_MS = 240; const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08; const CURSOR_TIME_DISCONTINUITY_MS = 100; -const CURSOR_SVG_DROP_SHADOW_FILTER = 'drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))'; +const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7; +const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18; +const CURSOR_SVG_DROP_SHADOW_FILTER = + "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))"; const CURSOR_SHADOW_COLOR = 0x000000; const CURSOR_SHADOW_ALPHA = 0.35; const CURSOR_SHADOW_OFFSET_X = 0; @@ -68,22 +90,25 @@ const CURSOR_SHADOW_PADDING = 12; let cursorAssetsPromise: Promise | null = null; let loadedCursorAssets: Partial> = {}; const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [ - 'arrow', - 'text', - 'pointer', - 'crosshair', - 'open-hand', - 'closed-hand', - 'resize-ew', - 'resize-ns', - 'not-allowed', + "arrow", + "text", + "pointer", + "crosshair", + "open-hand", + "closed-hand", + "resize-ew", + "resize-ns", + "not-allowed", ]; function loadImage(dataUrl: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); - image.onerror = () => reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`)); + image.onerror = () => + reject( + new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`), + ); image.src = dataUrl; }); } @@ -92,7 +117,10 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } -function getNormalizedAnchor(systemAsset: SystemCursorAsset | undefined, fallbackAnchor: { x: number; y: number }) { +function getNormalizedAnchor( + systemAsset: SystemCursorAsset | undefined, + fallbackAnchor: { x: number; y: number }, +) { if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) { return fallbackAnchor; } @@ -120,17 +148,17 @@ async function rasterizeAndCropSvg( const img = await loadImage(url); // Draw at full sample size - const srcCanvas = document.createElement('canvas'); + const srcCanvas = document.createElement("canvas"); srcCanvas.width = sampleSize; srcCanvas.height = sampleSize; - const srcCtx = srcCanvas.getContext('2d')!; + const srcCtx = srcCanvas.getContext("2d")!; srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize); // Crop to trim bounds - const dstCanvas = document.createElement('canvas'); + const dstCanvas = document.createElement("canvas"); dstCanvas.width = trimWidth; dstCanvas.height = trimHeight; - const dstCtx = dstCanvas.getContext('2d')!; + const dstCtx = dstCanvas.getContext("2d")!; dstCtx.drawImage( srcCanvas, trimX, @@ -144,7 +172,7 @@ async function rasterizeAndCropSvg( ); return { - dataUrl: dstCanvas.toDataURL('image/png'), + dataUrl: dstCanvas.toDataURL("image/png"), width: dstCanvas.width, height: dstCanvas.height, }; @@ -161,7 +189,7 @@ function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset { function getAvailableCursorKeys(): CursorAssetKey[] { const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[]; - return loadedKeys.length > 0 ? loadedKeys : ['arrow']; + return loadedKeys.length > 0 ? loadedKeys : ["arrow"]; } export async function preloadCursorAssets() { @@ -175,7 +203,10 @@ export async function preloadCursorAssets() { systemCursors = result.cursors; } } catch (error) { - console.warn('[CursorRenderer] Failed to fetch system cursor assets:', error); + console.warn( + "[CursorRenderer] Failed to fetch system cursor assets:", + error, + ); } const entries = await Promise.all( @@ -217,31 +248,42 @@ export async function preloadCursorAssets() { const img = await loadImage(finalUrl); width = img.naturalWidth; height = img.naturalHeight; - normalizedAnchor = getNormalizedAnchor(systemAsset, { x: 0, y: 0 }); + normalizedAnchor = getNormalizedAnchor(systemAsset, { + x: 0, + y: 0, + }); } await Assets.load(finalUrl); const image = await loadImage(finalUrl); const texture = Texture.from(finalUrl); - return [key, { - texture, - image, - aspectRatio: height > 0 ? width / height : 1, - anchorX: normalizedAnchor.x, - anchorY: normalizedAnchor.y, - } satisfies LoadedCursorAsset] as const; + return [ + key, + { + texture, + image, + aspectRatio: height > 0 ? width / height : 1, + anchorX: normalizedAnchor.x, + anchorY: normalizedAnchor.y, + } satisfies LoadedCursorAsset, + ] as const; } catch (error) { - console.warn(`[CursorRenderer] Failed to load cursor image for: ${key}`, error); + console.warn( + `[CursorRenderer] Failed to load cursor image for: ${key}`, + error, + ); return null; } - }) + }), ); - loadedCursorAssets = Object.fromEntries(entries.filter(Boolean).map((entry) => entry!)) as Partial>; + loadedCursorAssets = Object.fromEntries( + entries.filter(Boolean).map((entry) => entry!), + ) as Partial>; if (!loadedCursorAssets.arrow) { - throw new Error('Failed to initialize the fallback arrow cursor asset'); + throw new Error("Failed to initialize the fallback arrow cursor asset"); } })(); } @@ -264,7 +306,10 @@ export function interpolateCursorPosition( } if (timeMs >= samples[samples.length - 1].timeMs) { - return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy }; + return { + cx: samples[samples.length - 1].cx, + cy: samples[samples.length - 1].cy, + }; } let lo = 0; @@ -307,17 +352,22 @@ function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) { return samples[lo]?.timeMs <= timeMs ? samples[lo] : null; } -function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: number) { +function findLatestInteractionSample( + samples: CursorTelemetryPoint[], + timeMs: number, +) { for (let index = samples.length - 1; index >= 0; index -= 1) { const sample = samples[index]; if (sample.timeMs > timeMs) { continue; } - if (sample.interactionType === 'click' - || sample.interactionType === 'double-click' - || sample.interactionType === 'right-click' - || sample.interactionType === 'middle-click') { + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { return sample; } } @@ -325,7 +375,10 @@ function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: nu return null; } -function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) { +function findLatestStableCursorType( + samples: CursorTelemetryPoint[], + timeMs: number, +) { // Binary search to find position at timeMs, then scan backwards let lo = 0; let hi = samples.length - 1; @@ -350,41 +403,69 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num continue; } - if (sample.interactionType === 'click' - || sample.interactionType === 'double-click' - || sample.interactionType === 'right-click' - || sample.interactionType === 'middle-click') { + if ( + sample.interactionType === "click" || + sample.interactionType === "double-click" || + sample.interactionType === "right-click" || + sample.interactionType === "middle-click" + ) { continue; } return sample.cursorType; } - return findLatestSample(samples, timeMs)?.cursorType ?? 'arrow'; + return findLatestSample(samples, timeMs)?.cursorType ?? "arrow"; } function getCursorViewportScale(viewport: CursorViewportRect) { return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } +function getCursorSwaySpringConfig(smoothingFactor: number) { + const baseConfig = getCursorSpringConfig( + Math.min( + 2, + Math.max( + 0.15, + smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER + + CURSOR_SWAY_SMOOTHING_OFFSET, + ), + ), + ); + + return { + ...baseConfig, + damping: baseConfig.damping * 0.9, + mass: Math.max(0.55, baseConfig.mass * 0.8), + restDelta: 0.0005, + restSpeed: 0.02, + }; +} + function getCursorVisualState(samples: CursorTelemetryPoint[], timeMs: number) { const latestClick = findLatestInteractionSample(samples, timeMs); const interactionType = latestClick?.interactionType; - const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY; - const isClickEvent = interactionType === 'click' - || interactionType === 'double-click' - || interactionType === 'right-click' - || interactionType === 'middle-click'; - const clickBounceProgress = latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS - ? 1 - ageMs / CLICK_ANIMATION_MS - : 0; + const ageMs = latestClick + ? Math.max(0, timeMs - latestClick.timeMs) + : Number.POSITIVE_INFINITY; + const isClickEvent = + interactionType === "click" || + interactionType === "double-click" || + interactionType === "right-click" || + interactionType === "middle-click"; + const clickBounceProgress = + latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS + ? 1 - ageMs / CLICK_ANIMATION_MS + : 0; return { cursorType: findLatestStableCursorType(samples, timeMs), clickBounceProgress, - clickProgress: latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS - ? 1 - ageMs / CLICK_RING_FADE_MS - : 0, + clickProgress: + latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS + ? 1 - ageMs / CLICK_RING_FADE_MS + : 0, }; } @@ -402,7 +483,9 @@ export class SmoothedCursorState { private xSpring = createSpringState(0.5); private ySpring = createSpringState(0.5); - constructor(config: Pick) { + constructor( + config: Pick, + ) { this.smoothingFactor = config.smoothingFactor; this.trailLength = config.trailLength; } @@ -423,7 +506,10 @@ export class SmoothedCursorState { return; } - if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) { + if ( + this.smoothingFactor <= 0 || + (this.lastTimeMs !== null && timeMs < this.lastTimeMs) + ) { this.snapTo(targetX, targetY, timeMs); return; } @@ -433,7 +519,10 @@ export class SmoothedCursorState { this.trail.length = this.trailLength; } - const deltaMs = this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs); + const deltaMs = + this.lastTimeMs === null + ? 1000 / 60 + : Math.max(1, timeMs - this.lastTimeMs); this.lastTimeMs = timeMs; const springConfig = getCursorSpringConfig(this.smoothingFactor); @@ -468,7 +557,13 @@ export class SmoothedCursorState { } } -function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) { +function drawClickRing( + graphics: Graphics, + px: number, + py: number, + h: number, + progress: number, +) { void graphics; void px; void py; @@ -487,13 +582,15 @@ export class PixiCursorOverlay { private config: CursorRenderConfig; private lastRenderedPoint: { px: number; py: number } | null = null; private lastRenderedTimeMs: number | null = null; + private swayRotation = 0; + private swaySpring = createSpringState(0); constructor(config: Partial = {}) { this.config = { ...DEFAULT_CURSOR_CONFIG, ...config }; this.state = new SmoothedCursorState(this.config); this.container = new Container(); - this.container.label = 'cursor-overlay'; + this.container.label = "cursor-overlay"; this.clickRingGraphics = new Graphics(); this.cursorShadowSprites = {}; @@ -542,7 +639,8 @@ export class PixiCursorOverlay { setMotionBlur(motionBlur: number) { this.config.motionBlur = Math.max(0, motionBlur); - this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null; + this.container.filters = + this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null; if (this.config.motionBlur <= 0) { this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; this.cursorMotionBlurFilter.kernelSize = 5; @@ -554,6 +652,10 @@ export class PixiCursorOverlay { this.config.clickBounce = Math.max(0, clickBounce); } + setSway(sway: number) { + this.config.sway = clamp(sway, 0, 2); + } + update( samples: CursorTelemetryPoint[], timeMs: number, @@ -561,10 +663,17 @@ export class PixiCursorOverlay { visible: boolean, freeze = false, ): void { - if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) { + if ( + !visible || + samples.length === 0 || + viewport.width <= 0 || + viewport.height <= 0 + ) { this.container.visible = false; this.lastRenderedPoint = null; this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; return; } @@ -575,11 +684,15 @@ export class PixiCursorOverlay { return; } - const sameFrameTime = this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; - const hasTimeDiscontinuity = this.lastRenderedTimeMs !== null - && Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; + const sameFrameTime = + this.lastRenderedTimeMs !== null && + Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001; + const hasTimeDiscontinuity = + this.lastRenderedTimeMs !== null && + Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS; + const shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity; - if (freeze || hasTimeDiscontinuity) { + if (shouldFreezeCursorMotion) { if (!sameFrameTime || !this.lastRenderedPoint) { this.state.snapTo(target.cx, target.cy, timeMs); } @@ -591,29 +704,52 @@ export class PixiCursorOverlay { const px = viewport.x + this.state.x * viewport.width; const py = viewport.y + this.state.y * viewport.height; const h = this.config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState(samples, timeMs); - const spriteKey = (cursorType in this.cursorSprites ? cursorType : 'arrow') as CursorAssetKey; + const { cursorType, clickBounceProgress, clickProgress } = + getCursorVisualState(samples, timeMs); + const spriteKey = ( + cursorType in this.cursorSprites ? cursorType : "arrow" + ) as CursorAssetKey; const asset = getCursorAsset(spriteKey); - const shadowSprite = this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!; + const shadowSprite = + this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!; const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!; - const bounceScale = Math.max(0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce)); + const bounceScale = Math.max( + 0.72, + 1 - + Math.sin(clickBounceProgress * Math.PI) * + (0.08 * this.config.clickBounce), + ); const scaledH = h; + const swayRotation = this.updateCursorSway( + px, + py, + timeMs, + shouldFreezeCursorMotion, + ); this.clickRingGraphics.clear(); drawClickRing(this.clickRingGraphics, px, py, h, clickProgress); - for (const [key, currentShadowSprite] of Object.entries(this.cursorShadowSprites) as Array<[CursorAssetKey, Sprite]>) { + for (const [key, currentShadowSprite] of Object.entries( + this.cursorShadowSprites, + ) as Array<[CursorAssetKey, Sprite]>) { currentShadowSprite.visible = key === spriteKey; } - for (const [key, currentSprite] of Object.entries(this.cursorSprites) as Array<[CursorAssetKey, Sprite]>) { + for (const [key, currentSprite] of Object.entries( + this.cursorSprites, + ) as Array<[CursorAssetKey, Sprite]>) { currentSprite.visible = key === spriteKey; } if (shadowSprite) { shadowSprite.height = scaledH * bounceScale; shadowSprite.width = scaledH * bounceScale * asset.aspectRatio; - shadowSprite.position.set(px + CURSOR_SHADOW_OFFSET_X, py + CURSOR_SHADOW_OFFSET_Y); + shadowSprite.position.set( + px + CURSOR_SHADOW_OFFSET_X, + py + CURSOR_SHADOW_OFFSET_Y, + ); + shadowSprite.rotation = swayRotation; } if (sprite) { @@ -621,15 +757,60 @@ export class PixiCursorOverlay { sprite.height = scaledH * bounceScale; sprite.width = scaledH * bounceScale * asset.aspectRatio; sprite.position.set(px, py); + sprite.rotation = swayRotation; } - this.applyCursorMotionBlur(px, py, timeMs, freeze); + this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion); this.lastRenderedPoint = { px, py }; this.lastRenderedTimeMs = timeMs; } - private applyCursorMotionBlur(px: number, py: number, timeMs: number, freeze: boolean) { - if (freeze || this.config.motionBlur <= 0 || !this.lastRenderedPoint || this.lastRenderedTimeMs === null) { + private updateCursorSway( + px: number, + py: number, + timeMs: number, + freeze: boolean, + ) { + const deltaMs = + this.lastRenderedTimeMs === null || freeze + ? 1000 / 60 + : Math.max(1, timeMs - this.lastRenderedTimeMs); + const targetRotation = + !freeze && this.lastRenderedPoint && this.lastRenderedTimeMs !== null + ? computeCursorSwayRotation( + px - this.lastRenderedPoint.px, + py - this.lastRenderedPoint.py, + timeMs - this.lastRenderedTimeMs, + this.config.sway, + ) + : 0; + + this.swayRotation = stepSpringValue( + this.swaySpring, + targetRotation, + deltaMs, + getCursorSwaySpringConfig(this.config.smoothingFactor), + ); + + if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) { + this.swayRotation = 0; + } + + return this.swayRotation; + } + + private applyCursorMotionBlur( + px: number, + py: number, + timeMs: number, + freeze: boolean, + ) { + if ( + freeze || + this.config.motionBlur <= 0 || + !this.lastRenderedPoint || + this.lastRenderedTimeMs === null + ) { this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; this.cursorMotionBlurFilter.kernelSize = 5; this.cursorMotionBlurFilter.offset = 0; @@ -639,15 +820,20 @@ export class PixiCursorOverlay { const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs); const dx = px - this.lastRenderedPoint.px; const dy = py - this.lastRenderedPoint.py; - const velocityScale = (1000 / deltaMs) * this.config.motionBlur * CURSOR_MOTION_BLUR_BASE_MULTIPLIER; + const velocityScale = + (1000 / deltaMs) * + this.config.motionBlur * + CURSOR_MOTION_BLUR_BASE_MULTIPLIER; const velocity = { x: dx * velocityScale, y: dy * velocityScale, }; const magnitude = Math.hypot(velocity.x, velocity.y); - this.cursorMotionBlurFilter.velocity = magnitude > 0.05 ? velocity : { x: 0, y: 0 }; - this.cursorMotionBlurFilter.kernelSize = magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5; + this.cursorMotionBlurFilter.velocity = + magnitude > 0.05 ? velocity : { x: 0, y: 0 }; + this.cursorMotionBlurFilter.kernelSize = + magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5; this.cursorMotionBlurFilter.offset = magnitude > 0.5 ? -0.25 : 0; } @@ -665,6 +851,8 @@ export class PixiCursorOverlay { this.container.visible = false; this.lastRenderedPoint = null; this.lastRenderedTimeMs = null; + this.swayRotation = 0; + resetSpringState(this.swaySpring, 0); this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 }; this.cursorMotionBlurFilter.kernelSize = 5; this.cursorMotionBlurFilter.offset = 0; @@ -688,7 +876,8 @@ export function drawCursorOnCanvas( smoothedState: SmoothedCursorState, config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG, ): void { - if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return; + if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) + return; const target = interpolateCursorPosition(samples, timeMs); if (!target) return; @@ -698,10 +887,18 @@ export function drawCursorOnCanvas( const px = viewport.x + smoothedState.x * viewport.width; const py = viewport.y + smoothedState.y * viewport.height; const h = config.dotRadius * getCursorViewportScale(viewport); - const { cursorType, clickBounceProgress } = getCursorVisualState(samples, timeMs); - const spriteKey = (cursorType && loadedCursorAssets[cursorType] ? cursorType : 'arrow') as CursorAssetKey; + const { cursorType, clickBounceProgress } = getCursorVisualState( + samples, + timeMs, + ); + const spriteKey = ( + cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow" + ) as CursorAssetKey; const asset = getCursorAsset(spriteKey); - const bounceScale = Math.max(0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce)); + const bounceScale = Math.max( + 0.72, + 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce), + ); ctx.save(); ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER; @@ -711,8 +908,13 @@ export function drawCursorOnCanvas( const hotspotX = asset.anchorX * drawWidth; const hotspotY = asset.anchorY * drawHeight; ctx.globalAlpha = config.dotAlpha; - ctx.drawImage(asset.image, px - hotspotX, py - hotspotY, drawWidth, drawHeight); + ctx.drawImage( + asset.image, + px - hotspotX, + py - hotspotY, + drawWidth, + drawHeight, + ); ctx.restore(); } - diff --git a/src/components/video-editor/videoPlayback/cursorSway.test.ts b/src/components/video-editor/videoPlayback/cursorSway.test.ts new file mode 100644 index 00000000..842eb020 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorSway.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { computeCursorSwayRotation } from "./cursorSway"; + +describe("computeCursorSwayRotation", () => { + it("returns zero when sway is disabled or there is no movement", () => { + expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0); + expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0); + }); + + it("leans opposite the motion direction", () => { + expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeLessThan(0); + expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeLessThan(0); + expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeGreaterThan(0); + }); + + it("increases with faster movement for the same direction", () => { + const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1)); + const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1)); + + expect(fast).toBeGreaterThan(slow); + }); + + it("maps a 2x slider value to a 6x sway intensity", () => { + expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo( + Math.PI / 3, + 6, + ); + }); +}); diff --git a/src/components/video-editor/videoPlayback/cursorSway.ts b/src/components/video-editor/videoPlayback/cursorSway.ts new file mode 100644 index 00000000..08c9c55e --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorSway.ts @@ -0,0 +1,49 @@ +import { clampDeltaMs } from "./motionSmoothing"; + +const CURSOR_SWAY_MAX_ROTATION = Math.PI / 18; +const CURSOR_SWAY_SPEED_REFERENCE = 1400; +const CURSOR_SWAY_VERTICAL_WEIGHT = 0.65; +const CURSOR_SWAY_INTENSITY_SCALE = 3; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function computeCursorSwayRotation( + dx: number, + dy: number, + deltaMs: number, + sway: number, +) { + if (sway <= 0) { + return 0; + } + + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance < 0.01) { + return 0; + } + + const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000); + const speedFactor = clamp( + speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, + 0, + 1, + ); + if (speedFactor <= 0) { + return 0; + } + + const directionalBias = clamp( + (-dx - dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, + -1, + 1, + ); + return ( + directionalBias * + speedFactor * + CURSOR_SWAY_MAX_ROTATION * + sway * + CURSOR_SWAY_INTENSITY_SCALE + ); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 2405ce5f..826d72a6 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -1,60 +1,61 @@ { - "zoom": { - "level": "Zoom Level", - "selectRegion": "Select a zoom region to adjust", - "deleteZoom": "Delete Zoom" - }, - "trim": { - "deleteRegion": "Delete Trim Region" - }, - "speed": { - "playbackSpeed": "Playback Speed", - "selectRegion": "Select a speed region to adjust", - "deleteRegion": "Delete Speed Region" - }, - "effects": { - "title": "Video Effects", - "showCursor": "Show Cursor", - "loopCursor": "Loop cursor", - "backgroundBlur": "Background Blur", - "zoomMotionBlur": "Zoom Motion Blur", - "connectZooms": "Connect Zooms", - "cursorSize": "Cursor Size", - "cursorSmoothing": "Cursor Smoothing", - "off": "Off", - "cursorMotionBlur": "Cursor Motion Blur", - "cursorClickBounce": "Cursor Click Bounce", - "shadow": "Shadow", - "roundness": "Roundness", - "padding": "Padding" - }, - "crop": { - "title": "Crop Video", - "instruction": "Drag on each side to adjust the crop area" - }, - "background": { - "title": "Background", - "image": "Image", - "color": "Color", - "gradient": "Gradient", - "uploadCustom": "Upload Custom", - "uploadSuccess": "Custom image uploaded successfully!", - "uploadError": "Please upload a JPG or JPEG image file." - }, - "export": { - "mp4": "MP4", - "gif": "GIF", - "quality": { - "low": "Low", - "medium": "Medium", - "high": "High" - }, - "loop": "Loop", - "outputDimensions": "Output: {{dimensions}}px", - "loadProject": "Load Project", - "saveProject": "Save Project", - "exportVideo": "Export {{format}}", - "reportBug": "Report Bug", - "starOnGithub": "Star on GitHub" - } + "zoom": { + "level": "Zoom Level", + "selectRegion": "Select a zoom region to adjust", + "deleteZoom": "Delete Zoom" + }, + "trim": { + "deleteRegion": "Delete Trim Region" + }, + "speed": { + "playbackSpeed": "Playback Speed", + "selectRegion": "Select a speed region to adjust", + "deleteRegion": "Delete Speed Region" + }, + "effects": { + "title": "Video Effects", + "showCursor": "Show Cursor", + "loopCursor": "Loop cursor", + "backgroundBlur": "Background Blur", + "zoomMotionBlur": "Zoom Motion Blur", + "connectZooms": "Connect Zooms", + "cursorSize": "Cursor Size", + "cursorSmoothing": "Cursor Smoothing", + "off": "Off", + "cursorMotionBlur": "Cursor Motion Blur", + "cursorClickBounce": "Cursor Click Bounce", + "cursorSway": "Cursor Sway", + "shadow": "Shadow", + "roundness": "Roundness", + "padding": "Padding" + }, + "crop": { + "title": "Crop Video", + "instruction": "Drag on each side to adjust the crop area" + }, + "background": { + "title": "Background", + "image": "Image", + "color": "Color", + "gradient": "Gradient", + "uploadCustom": "Upload Custom", + "uploadSuccess": "Custom image uploaded successfully!", + "uploadError": "Please upload a JPG or JPEG image file." + }, + "export": { + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Low", + "medium": "Medium", + "high": "High" + }, + "loop": "Loop", + "outputDimensions": "Output: {{dimensions}}px", + "loadProject": "Load Project", + "saveProject": "Save Project", + "exportVideo": "Export {{format}}", + "reportBug": "Report Bug", + "starOnGithub": "Star on GitHub" + } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 7b3a1379..9132587c 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -1,60 +1,61 @@ { - "zoom": { - "level": "Nivel de zoom", - "selectRegion": "Selecciona una región de zoom para ajustar", - "deleteZoom": "Eliminar zoom" - }, - "trim": { - "deleteRegion": "Eliminar región de recorte" - }, - "speed": { - "playbackSpeed": "Velocidad de reproducción", - "selectRegion": "Selecciona una región de velocidad para ajustar", - "deleteRegion": "Eliminar región de velocidad" - }, - "effects": { - "title": "Efectos de video", - "showCursor": "Mostrar cursor", - "loopCursor": "Cursor en bucle", - "backgroundBlur": "Desenfoque de fondo", - "zoomMotionBlur": "Desenfoque de movimiento del zoom", - "connectZooms": "Conectar zooms", - "cursorSize": "Tamaño del cursor", - "cursorSmoothing": "Suavizado del cursor", - "off": "Desactivado", - "cursorMotionBlur": "Desenfoque de movimiento del cursor", - "cursorClickBounce": "Rebote de clic del cursor", - "shadow": "Sombra", - "roundness": "Redondez", - "padding": "Relleno" - }, - "crop": { - "title": "Recortar video", - "instruction": "Arrastra cada lado para ajustar el área de recorte" - }, - "background": { - "title": "Fondo", - "image": "Imagen", - "color": "Color", - "gradient": "Degradado", - "uploadCustom": "Subir personalizado", - "uploadSuccess": "¡Imagen personalizada subida exitosamente!", - "uploadError": "Por favor sube un archivo de imagen JPG o JPEG." - }, - "export": { - "mp4": "MP4", - "gif": "GIF", - "quality": { - "low": "Baja", - "medium": "Media", - "high": "Alta" - }, - "loop": "Bucle", - "outputDimensions": "Salida: {{dimensions}}px", - "loadProject": "Cargar proyecto", - "saveProject": "Guardar proyecto", - "exportVideo": "Exportar {{format}}", - "reportBug": "Reportar error", - "starOnGithub": "Estrella en GitHub" - } + "zoom": { + "level": "Nivel de zoom", + "selectRegion": "Selecciona una región de zoom para ajustar", + "deleteZoom": "Eliminar zoom" + }, + "trim": { + "deleteRegion": "Eliminar región de recorte" + }, + "speed": { + "playbackSpeed": "Velocidad de reproducción", + "selectRegion": "Selecciona una región de velocidad para ajustar", + "deleteRegion": "Eliminar región de velocidad" + }, + "effects": { + "title": "Efectos de video", + "showCursor": "Mostrar cursor", + "loopCursor": "Cursor en bucle", + "backgroundBlur": "Desenfoque de fondo", + "zoomMotionBlur": "Desenfoque de movimiento del zoom", + "connectZooms": "Conectar zooms", + "cursorSize": "Tamaño del cursor", + "cursorSmoothing": "Suavizado del cursor", + "off": "Desactivado", + "cursorMotionBlur": "Desenfoque de movimiento del cursor", + "cursorClickBounce": "Rebote de clic del cursor", + "cursorSway": "Balanceo del cursor", + "shadow": "Sombra", + "roundness": "Redondez", + "padding": "Relleno" + }, + "crop": { + "title": "Recortar video", + "instruction": "Arrastra cada lado para ajustar el área de recorte" + }, + "background": { + "title": "Fondo", + "image": "Imagen", + "color": "Color", + "gradient": "Degradado", + "uploadCustom": "Subir personalizado", + "uploadSuccess": "¡Imagen personalizada subida exitosamente!", + "uploadError": "Por favor sube un archivo de imagen JPG o JPEG." + }, + "export": { + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Baja", + "medium": "Media", + "high": "Alta" + }, + "loop": "Bucle", + "outputDimensions": "Salida: {{dimensions}}px", + "loadProject": "Cargar proyecto", + "saveProject": "Guardar proyecto", + "exportVideo": "Exportar {{format}}", + "reportBug": "Reportar error", + "starOnGithub": "Estrella en GitHub" + } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 46106e60..e492d8bd 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -1,60 +1,61 @@ { - "zoom": { - "level": "缩放级别", - "selectRegion": "选择缩放区域以调整", - "deleteZoom": "删除缩放" - }, - "trim": { - "deleteRegion": "删除修剪区域" - }, - "speed": { - "playbackSpeed": "播放速度", - "selectRegion": "选择变速区域以调整", - "deleteRegion": "删除变速区域" - }, - "effects": { - "title": "视频效果", - "showCursor": "显示光标", - "loopCursor": "循环光标", - "backgroundBlur": "背景模糊", - "zoomMotionBlur": "缩放运动模糊", - "connectZooms": "连接缩放", - "cursorSize": "光标大小", - "cursorSmoothing": "光标平滑", - "off": "关", - "cursorMotionBlur": "光标运动模糊", - "cursorClickBounce": "光标点击弹跳", - "shadow": "阴影", - "roundness": "圆角", - "padding": "内边距" - }, - "crop": { - "title": "裁剪视频", - "instruction": "拖动各边以调整裁剪区域" - }, - "background": { - "title": "背景", - "image": "图片", - "color": "颜色", - "gradient": "渐变", - "uploadCustom": "上传自定义", - "uploadSuccess": "自定义图片上传成功!", - "uploadError": "请上传 JPG 或 JPEG 图片文件。" - }, - "export": { - "mp4": "MP4", - "gif": "GIF", - "quality": { - "low": "低", - "medium": "中", - "high": "高" - }, - "loop": "循环", - "outputDimensions": "输出:{{dimensions}}px", - "loadProject": "加载项目", - "saveProject": "保存项目", - "exportVideo": "导出{{format}}", - "reportBug": "报告问题", - "starOnGithub": "在 GitHub 上加星" - } + "zoom": { + "level": "缩放级别", + "selectRegion": "选择缩放区域以调整", + "deleteZoom": "删除缩放" + }, + "trim": { + "deleteRegion": "删除修剪区域" + }, + "speed": { + "playbackSpeed": "播放速度", + "selectRegion": "选择变速区域以调整", + "deleteRegion": "删除变速区域" + }, + "effects": { + "title": "视频效果", + "showCursor": "显示光标", + "loopCursor": "循环光标", + "backgroundBlur": "背景模糊", + "zoomMotionBlur": "缩放运动模糊", + "connectZooms": "连接缩放", + "cursorSize": "光标大小", + "cursorSmoothing": "光标平滑", + "off": "关", + "cursorMotionBlur": "光标运动模糊", + "cursorClickBounce": "光标点击弹跳", + "cursorSway": "光标摆动", + "shadow": "阴影", + "roundness": "圆角", + "padding": "内边距" + }, + "crop": { + "title": "裁剪视频", + "instruction": "拖动各边以调整裁剪区域" + }, + "background": { + "title": "背景", + "image": "图片", + "color": "颜色", + "gradient": "渐变", + "uploadCustom": "上传自定义", + "uploadSuccess": "自定义图片上传成功!", + "uploadError": "请上传 JPG 或 JPEG 图片文件。" + }, + "export": { + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "低", + "medium": "中", + "high": "高" + }, + "loop": "循环", + "outputDimensions": "输出:{{dimensions}}px", + "loadProject": "加载项目", + "saveProject": "保存项目", + "exportVideo": "导出{{format}}", + "reportBug": "报告问题", + "starOnGithub": "在 GitHub 上加星" + } } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 65dc925d..c88ffee3 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1,13 +1,40 @@ -import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js'; -import { MotionBlurFilter } from 'pixi-filters/motion-blur'; -import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types'; -import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types'; -import { getAssetPath, getRenderableAssetUrl } from '@/lib/assetPath'; -import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; -import { applyZoomTransform, computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState } from '@/components/video-editor/videoPlayback/zoomTransform'; -import { DEFAULT_FOCUS, ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX } from '@/components/video-editor/videoPlayback/constants'; -import { renderAnnotations } from './annotationRenderer'; -import { PixiCursorOverlay, DEFAULT_CURSOR_CONFIG, preloadCursorAssets } from '@/components/video-editor/videoPlayback/cursorRenderer'; +import { + Application, + Container, + Sprite, + Graphics, + BlurFilter, + Texture, +} from "pixi.js"; +import { MotionBlurFilter } from "pixi-filters/motion-blur"; +import type { + ZoomRegion, + CropRegion, + AnnotationRegion, + SpeedRegion, + CursorTelemetryPoint, +} from "@/components/video-editor/types"; +import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; +import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; +import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; +import { + applyZoomTransform, + computeFocusFromTransform, + computeZoomTransform, + createMotionBlurState, + type MotionBlurState, +} from "@/components/video-editor/videoPlayback/zoomTransform"; +import { + DEFAULT_FOCUS, + ZOOM_SCALE_DEADZONE, + ZOOM_TRANSLATION_DEADZONE_PX, +} from "@/components/video-editor/videoPlayback/constants"; +import { renderAnnotations } from "./annotationRenderer"; +import { + PixiCursorOverlay, + DEFAULT_CURSOR_CONFIG, + preloadCursorAssets, +} from "@/components/video-editor/videoPlayback/cursorRenderer"; interface FrameRenderConfig { width: number; @@ -34,6 +61,7 @@ interface FrameRenderConfig { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorSway?: number; } interface AnimationState { @@ -94,23 +122,29 @@ export class FrameRenderer { await preloadCursorAssets(); } catch (error) { cursorOverlayEnabled = false; - console.warn('[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.', error); + console.warn( + "[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.", + error, + ); } // Create canvas for rendering - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = this.config.width; canvas.height = this.config.height; - + // Try to set colorSpace if supported (may not be available on all platforms) try { - if (canvas && 'colorSpace' in canvas) { + if (canvas && "colorSpace" in canvas) { // @ts-ignore - canvas.colorSpace = 'srgb'; + canvas.colorSpace = "srgb"; } } catch (error) { // Silently ignore colorSpace errors on platforms that don't support it - console.warn('[FrameRenderer] colorSpace not supported on this platform:', error); + console.warn( + "[FrameRenderer] colorSpace not supported on this platform:", + error, + ); } // Initialize PixiJS with optimized settings for export performance @@ -135,10 +169,14 @@ export class FrameRenderer { if (cursorOverlayEnabled) { this.cursorOverlay = new PixiCursorOverlay({ - dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4), - smoothingFactor: this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor, + dotRadius: + DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4), + smoothingFactor: + this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor, motionBlur: this.config.cursorMotionBlur ?? 0, - clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce, + clickBounce: + this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce, + sway: this.config.cursorSway ?? DEFAULT_CURSOR_CONFIG.sway, }); } @@ -154,24 +192,28 @@ export class FrameRenderer { this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter]; // Setup composite canvas for final output with shadows - this.compositeCanvas = document.createElement('canvas'); + this.compositeCanvas = document.createElement("canvas"); this.compositeCanvas.width = this.config.width; this.compositeCanvas.height = this.config.height; - this.compositeCtx = this.compositeCanvas.getContext('2d', { willReadFrequently: false }); - + this.compositeCtx = this.compositeCanvas.getContext("2d", { + willReadFrequently: false, + }); + if (!this.compositeCtx) { - throw new Error('Failed to get 2D context for composite canvas'); + throw new Error("Failed to get 2D context for composite canvas"); } // Setup shadow canvas if needed if (this.config.showShadow) { - this.shadowCanvas = document.createElement('canvas'); + this.shadowCanvas = document.createElement("canvas"); this.shadowCanvas.width = this.config.width; this.shadowCanvas.height = this.config.height; - this.shadowCtx = this.shadowCanvas.getContext('2d', { willReadFrequently: false }); - + this.shadowCtx = this.shadowCanvas.getContext("2d", { + willReadFrequently: false, + }); + if (!this.shadowCtx) { - throw new Error('Failed to get 2D context for shadow canvas'); + throw new Error("Failed to get 2D context for shadow canvas"); } } @@ -185,44 +227,55 @@ export class FrameRenderer { } private async setupBackground(): Promise { - const wallpaper = await this.resolveWallpaperForExport(this.config.wallpaper); + const wallpaper = await this.resolveWallpaperForExport( + this.config.wallpaper, + ); // Create background canvas for separate rendering (not affected by zoom) - const bgCanvas = document.createElement('canvas'); + const bgCanvas = document.createElement("canvas"); bgCanvas.width = this.config.width; bgCanvas.height = this.config.height; - const bgCtx = bgCanvas.getContext('2d')!; + const bgCtx = bgCanvas.getContext("2d")!; try { // Render background based on type - if (wallpaper.startsWith('file://') || wallpaper.startsWith('data:') || wallpaper.startsWith('/') || wallpaper.startsWith('http')) { + if ( + wallpaper.startsWith("file://") || + wallpaper.startsWith("data:") || + wallpaper.startsWith("/") || + wallpaper.startsWith("http") + ) { // 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) + imageUrl.startsWith("http") && + window.location.origin && + !imageUrl.startsWith(window.location.origin) ) { - img.crossOrigin = 'anonymous'; + img.crossOrigin = "anonymous"; } - + await new Promise((resolve, reject) => { img.onload = () => resolve(); img.onerror = (err) => { - console.error('[FrameRenderer] Failed to load background image:', imageUrl, err); + console.error( + "[FrameRenderer] Failed to load background image:", + imageUrl, + err, + ); reject(new Error(`Failed to load background image: ${imageUrl}`)); }; img.src = imageUrl; }); - + // Draw the image using cover and center positioning const imgAspect = img.width / img.height; const canvasAspect = this.config.width / this.config.height; - + let drawWidth, drawHeight, drawX, drawY; - + if (imgAspect > canvasAspect) { drawHeight = this.config.height; drawWidth = drawHeight * imgAspect; @@ -234,26 +287,32 @@ export class FrameRenderer { drawX = 0; drawY = (this.config.height - drawHeight) / 2; } - + bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight); - } else if (wallpaper.startsWith('#')) { + } else if (wallpaper.startsWith("#")) { bgCtx.fillStyle = wallpaper; bgCtx.fillRect(0, 0, this.config.width, this.config.height); - } else if (wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { - - const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); + } else if ( + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { + const gradientMatch = wallpaper.match( + /(linear|radial)-gradient\((.+)\)/, + ); if (gradientMatch) { const [, type, params] = gradientMatch; - const parts = params.split(',').map(s => s.trim()); - + const parts = params.split(",").map((s) => s.trim()); + let gradient: CanvasGradient; - - if (type === 'linear') { + + if (type === "linear") { gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height); parts.forEach((part, index) => { - if (part.startsWith('to ') || part.includes('deg')) return; - - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); + if (part.startsWith("to ") || part.includes("deg")) return; + + const colorMatch = part.match( + /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/, + ); if (colorMatch) { const color = colorMatch[1]; const position = index / (parts.length - 1); @@ -265,9 +324,11 @@ export class FrameRenderer { const cy = this.config.height / 2; const radius = Math.max(this.config.width, this.config.height) / 2; gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); - + parts.forEach((part, index) => { - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); + const colorMatch = part.match( + /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/, + ); if (colorMatch) { const color = colorMatch[1]; const position = index / (parts.length - 1); @@ -275,12 +336,14 @@ export class FrameRenderer { } }); } - + bgCtx.fillStyle = gradient; bgCtx.fillRect(0, 0, this.config.width, this.config.height); } else { - console.warn('[FrameRenderer] Could not parse gradient, using black fallback'); - bgCtx.fillStyle = '#000000'; + console.warn( + "[FrameRenderer] Could not parse gradient, using black fallback", + ); + bgCtx.fillStyle = "#000000"; bgCtx.fillRect(0, 0, this.config.width, this.config.height); } } else { @@ -288,8 +351,11 @@ export class FrameRenderer { bgCtx.fillRect(0, 0, this.config.width, this.config.height); } } catch (error) { - console.error('[FrameRenderer] Error setting up background, using fallback:', error); - bgCtx.fillStyle = '#000000'; + console.error( + "[FrameRenderer] Error setting up background, using fallback:", + error, + ); + bgCtx.fillStyle = "#000000"; bgCtx.fillRect(0, 0, this.config.width, this.config.height); } @@ -299,15 +365,18 @@ export class FrameRenderer { private async resolveWallpaperImageUrl(wallpaper: string): Promise { if ( - wallpaper.startsWith('file://') - || wallpaper.startsWith('data:') - || wallpaper.startsWith('http') + 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')) { + const resolved = await getAssetPath(wallpaper.replace(/^\/+/, "")); + if ( + resolved.startsWith("/") && + window.location.protocol.startsWith("http") + ) { return `${window.location.origin}${resolved}`; } @@ -319,14 +388,19 @@ export class FrameRenderer { return wallpaper; } - if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { + if ( + wallpaper.startsWith("#") || + wallpaper.startsWith("linear-gradient") || + wallpaper.startsWith("radial-gradient") + ) { return wallpaper; } - const looksLikeAbsoluteFilePath = wallpaper.startsWith('/') - && !wallpaper.startsWith('//') - && !wallpaper.startsWith('/wallpapers/') - && !wallpaper.startsWith('/app-icons/'); + const looksLikeAbsoluteFilePath = + wallpaper.startsWith("/") && + !wallpaper.startsWith("//") && + !wallpaper.startsWith("/wallpapers/") && + !wallpaper.startsWith("/app-icons/"); const wallpaperAsset = looksLikeAbsoluteFilePath ? `file://${encodeURI(wallpaper)}` @@ -337,7 +411,7 @@ export class FrameRenderer { async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise { if (!this.app || !this.videoContainer || !this.cameraContainer) { - throw new Error('Renderer not initialized'); + throw new Error("Renderer not initialized"); } this.currentVideoTime = timestamp / 1000000; @@ -377,13 +451,13 @@ export class FrameRenderer { } const TICKS_PER_FRAME = 1; - + let maxMotionIntensity = 0; for (let i = 0; i < TICKS_PER_FRAME; i++) { const motionIntensity = this.updateAnimationState(timeMs); maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity); } - + // Apply transform once with maximum motion intensity from all ticks applyZoomTransform({ cameraContainer: this.cameraContainer, @@ -415,7 +489,11 @@ export class FrameRenderer { this.compositeWithShadows(); // Render annotations on top if present - if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) { + if ( + this.config.annotationRegions && + this.config.annotationRegions.length > 0 && + this.compositeCtx + ) { // Calculate scale factor based on export vs preview dimensions const previewWidth = this.config.previewWidth || 1920; const previewHeight = this.config.previewHeight || 1080; @@ -429,14 +507,19 @@ export class FrameRenderer { this.config.width, this.config.height, timeMs, - scaleFactor + scaleFactor, ); } - } private updateLayout(): void { - if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return; + if ( + !this.app || + !this.videoSprite || + !this.maskGraphics || + !this.videoContainer + ) + return; const { width, height } = this.config; const { cropRegion, borderRadius = 0, padding = 0 } = this.config; @@ -451,13 +534,16 @@ export class FrameRenderer { const croppedVideoWidth = videoWidth * (cropEndX - cropStartX); const croppedVideoHeight = videoHeight * (cropEndY - cropStartY); - + // Calculate scale to fit in viewport // Padding is a percentage (0-100), where 50% ~ 0.8 scale const paddingScale = 1.0 - (padding / 100) * 0.4; const viewportWidth = width * paddingScale; const viewportHeight = height * paddingScale; - const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight); + const scale = Math.min( + viewportWidth / croppedVideoWidth, + viewportHeight / croppedVideoHeight, + ); this.videoSprite.scale.set(scale); @@ -468,8 +554,8 @@ export class FrameRenderer { const centerOffsetX = (width - croppedDisplayWidth) / 2; const centerOffsetY = (height - croppedDisplayHeight) / 2; - const spriteX = centerOffsetX - (cropRegion.x * fullVideoDisplayWidth); - const spriteY = centerOffsetY - (cropRegion.y * fullVideoDisplayHeight); + const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth; + const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight; this.videoSprite.position.set(spriteX, spriteY); this.videoContainer.position.set(0, 0); @@ -477,11 +563,20 @@ export class FrameRenderer { // scale border radius by export/preview canvas ratio const previewWidth = this.config.previewWidth || 1920; const previewHeight = this.config.previewHeight || 1080; - const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight); + const canvasScaleFactor = Math.min( + width / previewWidth, + height / previewHeight, + ); const scaledBorderRadius = borderRadius * canvasScaleFactor; - + this.maskGraphics.clear(); - this.maskGraphics.roundRect(centerOffsetX, centerOffsetY, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius); + this.maskGraphics.roundRect( + centerOffsetX, + centerOffsetY, + croppedDisplayWidth, + croppedDisplayHeight, + scaledBorderRadius, + ); this.maskGraphics.fill({ color: 0xffffff }); // Cache layout info @@ -490,17 +585,26 @@ export class FrameRenderer { videoSize: { width: croppedVideoWidth, height: croppedVideoHeight }, baseScale: scale, baseOffset: { x: spriteX, y: spriteY }, - maskRect: { x: centerOffsetX, y: centerOffsetY, width: croppedDisplayWidth, height: croppedDisplayHeight }, + maskRect: { + x: centerOffsetX, + y: centerOffsetY, + width: croppedDisplayWidth, + height: croppedDisplayHeight, + }, }; } private updateAnimationState(timeMs: number): number { if (!this.cameraContainer || !this.layoutCache) return 0; - const { region, strength, blendedScale, transition } = findDominantRegion(this.config.zoomRegions, timeMs, { - connectZooms: this.config.connectZooms, - }); - + const { region, strength, blendedScale, transition } = findDominantRegion( + this.config.zoomRegions, + timeMs, + { + connectZooms: this.config.connectZooms, + }, + ); + const defaultFocus = DEFAULT_FOCUS; let targetScaleFactor = 1; let targetFocus = { ...defaultFocus }; @@ -509,7 +613,7 @@ export class FrameRenderer { if (region && strength > 0) { const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; const regionFocus = region.focus; - + targetScaleFactor = zoomScale; targetFocus = regionFocus; targetProgress = strength; @@ -533,9 +637,15 @@ export class FrameRenderer { }); 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, + 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; @@ -570,15 +680,18 @@ export class FrameRenderer { focusY: state.focusY, }); - state.appliedScale = Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - state.x = Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - state.y = Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + state.appliedScale = + Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE + ? projectedTransform.scale + : projectedTransform.scale; + state.x = + Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX + ? projectedTransform.x + : projectedTransform.x; + state.y = + Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX + ? projectedTransform.y + : projectedTransform.y; this.lastMotionVector = { x: state.x - prevX, @@ -588,7 +701,8 @@ export class FrameRenderer { return Math.max( Math.abs(state.appliedScale - prevScale), Math.abs(state.x - prevX) / Math.max(1, this.layoutCache.stageSize.width), - Math.abs(state.y - prevY) / Math.max(1, this.layoutCache.stageSize.height) + Math.abs(state.y - prevY) / + Math.max(1, this.layoutCache.stageSize.height), ); } @@ -606,7 +720,7 @@ export class FrameRenderer { // Step 1: Draw background layer (with optional blur, not affected by zoom) if (this.backgroundSprite) { const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement; - + if (this.config.backgroundBlur > 0) { ctx.save(); ctx.filter = `blur(${this.config.backgroundBlur * 3}px)`; @@ -616,15 +730,22 @@ export class FrameRenderer { ctx.drawImage(bgCanvas, 0, 0, w, h); } } else { - console.warn('[FrameRenderer] No background sprite found during compositing!'); + console.warn( + "[FrameRenderer] No background sprite found during compositing!", + ); } // Draw video layer with shadows on top of background - if (this.config.showShadow && this.config.shadowIntensity > 0 && this.shadowCanvas && this.shadowCtx) { + if ( + this.config.showShadow && + this.config.shadowIntensity > 0 && + this.shadowCanvas && + this.shadowCtx + ) { const shadowCtx = this.shadowCtx; shadowCtx.clearRect(0, 0, w, h); shadowCtx.save(); - + // Calculate shadow parameters based on intensity (0-1) const intensity = this.config.shadowIntensity; const baseBlur1 = 48 * intensity; @@ -634,8 +755,8 @@ export class FrameRenderer { const baseAlpha2 = 0.5 * intensity; const baseAlpha3 = 0.3 * intensity; const baseOffset = 12 * intensity; - - shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset/3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset/6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`; + + shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`; shadowCtx.drawImage(videoCanvas, 0, 0, w, h); shadowCtx.restore(); ctx.drawImage(this.shadowCanvas, 0, 0, w, h); @@ -646,12 +767,11 @@ export class FrameRenderer { getCanvas(): HTMLCanvasElement { if (!this.compositeCanvas) { - throw new Error('Renderer not initialized'); + throw new Error("Renderer not initialized"); } return this.compositeCanvas; } - destroy(): void { if (this.videoSprite) { const videoTexture = this.videoSprite.texture; @@ -661,7 +781,11 @@ export class FrameRenderer { } this.backgroundSprite = null; if (this.app) { - this.app.destroy(true, { children: true, texture: false, textureSource: false }); + this.app.destroy(true, { + children: true, + texture: false, + textureSource: false, + }); this.app = null; } this.cameraContainer = null; @@ -679,4 +803,3 @@ export class FrameRenderer { this.compositeCtx = null; } } - diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 50dca858..9961f4ca 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -1,10 +1,26 @@ -import GIF from 'gif.js'; -import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types'; -import { StreamingVideoDecoder } from './streamingDecoder'; -import { FrameRenderer } from './frameRenderer'; -import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types'; +import GIF from "gif.js"; +import type { + ExportProgress, + ExportResult, + GifFrameRate, + GifSizePreset, + GIF_SIZE_PRESETS, +} from "./types"; +import { StreamingVideoDecoder } from "./streamingDecoder"; +import { FrameRenderer } from "./frameRenderer"; +import type { + ZoomRegion, + CropRegion, + TrimRegion, + AnnotationRegion, + SpeedRegion, + CursorTelemetryPoint, +} from "@/components/video-editor/types"; -const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString(); +const GIF_WORKER_URL = new URL( + "gif.js/dist/gif.worker.js", + import.meta.url, +).toString(); interface GifExporterConfig { videoUrl: string; @@ -33,6 +49,7 @@ interface GifExporterConfig { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorSway?: number; previewWidth?: number; previewHeight?: number; onProgress?: (progress: ExportProgress) => void; @@ -50,13 +67,13 @@ export function calculateOutputDimensions( sourceWidth: number, sourceHeight: number, sizePreset: GifSizePreset, - sizePresets: typeof GIF_SIZE_PRESETS + sizePresets: typeof GIF_SIZE_PRESETS, ): { width: number; height: number } { const preset = sizePresets[sizePreset]; const maxHeight = preset.maxHeight; // If original is smaller than max height or preset is 'original', use source dimensions - if (sourceHeight <= maxHeight || sizePreset === 'original') { + if (sourceHeight <= maxHeight || sizePreset === "original") { return { width: sourceWidth, height: sourceHeight }; } @@ -90,7 +107,9 @@ export class GifExporter { // Initialize streaming decoder and load video metadata this.streamingDecoder = new StreamingVideoDecoder(); - const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl); + const videoInfo = await this.streamingDecoder.loadMetadata( + this.config.videoUrl, + ); // Initialize frame renderer this.renderer = new FrameRenderer({ @@ -118,6 +137,7 @@ export class GifExporter { cursorSmoothing: this.config.cursorSmoothing, cursorMotionBlur: this.config.cursorMotionBlur, cursorClickBounce: this.config.cursorClickBounce, + cursorSway: this.config.cursorSway, }); await this.renderer.initialize(); @@ -134,25 +154,33 @@ export class GifExporter { height: this.config.height, workerScript: GIF_WORKER_URL, repeat, - background: '#000000', + background: "#000000", transparent: null, - dither: 'FloydSteinberg', + dither: "FloydSteinberg", }); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + this.config.trimRegions, + this.config.speedRegions, + ); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); // Calculate frame delay in milliseconds (gif.js uses ms) const frameDelay = Math.round(1000 / this.config.frameRate); - console.log('[GifExporter] Original duration:', videoInfo.duration, 's'); - console.log('[GifExporter] Effective duration:', effectiveDuration, 's'); - console.log('[GifExporter] Total frames to export:', totalFrames); - console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS'); - console.log('[GifExporter] Frame delay:', frameDelay, 'ms'); - console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once'); - console.log('[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)'); + console.log("[GifExporter] Original duration:", videoInfo.duration, "s"); + console.log("[GifExporter] Effective duration:", effectiveDuration, "s"); + console.log("[GifExporter] Total frames to export:", totalFrames); + console.log("[GifExporter] Frame rate:", this.config.frameRate, "FPS"); + console.log("[GifExporter] Frame delay:", frameDelay, "ms"); + console.log( + "[GifExporter] Loop:", + this.config.loop ? "infinite" : "once", + ); + console.log( + "[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)", + ); let frameIndex = 0; @@ -174,11 +202,11 @@ export class GifExporter { this.addRenderedGifFrame(frameDelay); frameIndex++; this.reportProgress(frameIndex, totalFrames); - } + }, ); if (this.cancelled) { - return { success: false, error: 'Export cancelled' }; + return { success: false, error: "Export cancelled" }; } // Update progress to show we're now in the finalizing phase @@ -188,25 +216,25 @@ export class GifExporter { totalFrames, percentage: 100, estimatedTimeRemaining: 0, - phase: 'finalizing', + phase: "finalizing", }); } // Render the GIF const blob = await new Promise((resolve, _reject) => { - this.gif!.on('finished', (blob: Blob) => { + this.gif!.on("finished", (blob: Blob) => { resolve(blob); }); // Track rendering progress - this.gif!.on('progress', (progress: number) => { + this.gif!.on("progress", (progress: number) => { if (this.config.onProgress) { this.config.onProgress({ currentFrame: totalFrames, totalFrames, percentage: 100, estimatedTimeRemaining: 0, - phase: 'finalizing', + phase: "finalizing", renderProgress: Math.round(progress * 100), }); } @@ -218,7 +246,7 @@ export class GifExporter { return { success: true, blob }; } catch (error) { - console.error('GIF Export error:', error); + console.error("GIF Export error:", error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -260,7 +288,7 @@ export class GifExporter { try { this.streamingDecoder.destroy(); } catch (e) { - console.warn('Error destroying streaming decoder:', e); + console.warn("Error destroying streaming decoder:", e); } this.streamingDecoder = null; } @@ -269,7 +297,7 @@ export class GifExporter { try { this.renderer.destroy(); } catch (e) { - console.warn('Error destroying renderer:', e); + console.warn("Error destroying renderer:", e); } this.renderer = null; } @@ -277,4 +305,3 @@ export class GifExporter { this.gif = null; } } - diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 9eee91ea..5e44062d 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -1,9 +1,16 @@ -import type { ExportConfig, ExportProgress, ExportResult } from './types'; -import { AudioProcessor } from './audioEncoder'; -import { StreamingVideoDecoder } from './streamingDecoder'; -import { FrameRenderer } from './frameRenderer'; -import { VideoMuxer } from './muxer'; -import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types'; +import type { ExportConfig, ExportProgress, ExportResult } from "./types"; +import { AudioProcessor } from "./audioEncoder"; +import { StreamingVideoDecoder } from "./streamingDecoder"; +import { FrameRenderer } from "./frameRenderer"; +import { VideoMuxer } from "./muxer"; +import type { + ZoomRegion, + CropRegion, + TrimRegion, + AnnotationRegion, + SpeedRegion, + CursorTelemetryPoint, +} from "@/components/video-editor/types"; interface VideoExporterConfig extends ExportConfig { videoUrl: string; @@ -27,6 +34,7 @@ interface VideoExporterConfig extends ExportConfig { cursorSmoothing?: number; cursorMotionBlur?: number; cursorClickBounce?: number; + cursorSway?: number; previewWidth?: number; previewHeight?: number; onProgress?: (progress: ExportProgress) => void; @@ -60,7 +68,9 @@ export class VideoExporter { // Initialize streaming decoder and load video metadata this.streamingDecoder = new StreamingVideoDecoder(); - const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl); + const videoInfo = await this.streamingDecoder.loadMetadata( + this.config.videoUrl, + ); // Initialize frame renderer this.renderer = new FrameRenderer({ @@ -88,6 +98,7 @@ export class VideoExporter { cursorSmoothing: this.config.cursorSmoothing, cursorMotionBlur: this.config.cursorMotionBlur, cursorClickBounce: this.config.cursorClickBounce, + cursorSway: this.config.cursorSway, }); await this.renderer.initialize(); @@ -101,13 +112,26 @@ export class VideoExporter { await this.muxer.initialize(); // Calculate effective duration and frame count (excluding trim regions) - const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions); + const effectiveDuration = this.streamingDecoder.getEffectiveDuration( + this.config.trimRegions, + this.config.speedRegions, + ); const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate); - console.log('[VideoExporter] Original duration:', videoInfo.duration, 's'); - console.log('[VideoExporter] Effective duration:', effectiveDuration, 's'); - console.log('[VideoExporter] Total frames to export:', totalFrames); - console.log('[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)'); + console.log( + "[VideoExporter] Original duration:", + videoInfo.duration, + "s", + ); + console.log( + "[VideoExporter] Effective duration:", + effectiveDuration, + "s", + ); + console.log("[VideoExporter] Total frames to export:", totalFrames); + console.log( + "[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)", + ); const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds let frameIndex = 0; @@ -131,20 +155,26 @@ export class VideoExporter { await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex); frameIndex++; this.reportProgress(frameIndex, totalFrames); - } + }, ); if (this.cancelled) { - return { success: false, error: 'Export cancelled' }; + return { success: false, error: "Export cancelled" }; } // Finalize encoding - if (this.encoder && this.encoder.state === 'configured') { - await this.awaitWithWindowsTimeout(this.encoder.flush(), 'encoder flush'); + if (this.encoder && this.encoder.state === "configured") { + await this.awaitWithWindowsTimeout( + this.encoder.flush(), + "encoder flush", + ); } // Wait for queued muxing operations to complete - await this.awaitWithWindowsTimeout(this.pendingMuxing, 'muxing queued video chunks'); + await this.awaitWithWindowsTimeout( + this.pendingMuxing, + "muxing queued video chunks", + ); if (hasAudio && !this.cancelled) { const demuxer = this.streamingDecoder.getDemuxer(); @@ -158,17 +188,20 @@ export class VideoExporter { this.config.trimRegions, this.config.speedRegions, ), - 'audio processing', + "audio processing", ); } } // Finalize muxer and get output blob - const blob = await this.awaitWithWindowsTimeout(this.muxer!.finalize(), 'muxer finalization'); + const blob = await this.awaitWithWindowsTimeout( + this.muxer!.finalize(), + "muxer finalization", + ); return { success: true, blob }; } catch (error) { - console.error('Export error:', error); + console.error("Export error:", error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -179,13 +212,16 @@ export class VideoExporter { } private isWindowsPlatform(): boolean { - if (typeof navigator === 'undefined') { + if (typeof navigator === "undefined") { return false; } return /Win/i.test(navigator.platform); } - private async awaitWithWindowsTimeout(promise: Promise, stage: string): Promise { + private async awaitWithWindowsTimeout( + promise: Promise, + stage: string, + ): Promise { if (!this.isWindowsPlatform()) { return promise; } @@ -208,7 +244,11 @@ export class VideoExporter { } } - private async encodeRenderedFrame(timestamp: number, frameDuration: number, frameIndex: number) { + private async encodeRenderedFrame( + timestamp: number, + frameDuration: number, + frameIndex: number, + ) { const canvas = this.renderer!.getCanvas(); // @ts-ignore - colorSpace not in TypeScript definitions but works at runtime @@ -216,22 +256,28 @@ export class VideoExporter { timestamp, duration: frameDuration, colorSpace: { - primaries: 'bt709', - transfer: 'iec61966-2-1', - matrix: 'rgb', + primaries: "bt709", + transfer: "iec61966-2-1", + matrix: "rgb", fullRange: true, }, }); - while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) { - await new Promise(resolve => setTimeout(resolve, 5)); + while ( + this.encoder && + this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && + !this.cancelled + ) { + await new Promise((resolve) => setTimeout(resolve, 5)); } - if (this.encoder && this.encoder.state === 'configured') { + if (this.encoder && this.encoder.state === "configured") { this.encodeQueue++; this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 }); } else { - console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`); + console.warn( + `[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`, + ); } exportFrame.close(); @@ -259,7 +305,9 @@ export class VideoExporter { // Capture decoder config metadata from encoder output if (meta?.decoderConfig?.description && !videoDescription) { const desc = meta.decoderConfig.description; - videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any)); + videoDescription = new Uint8Array( + desc instanceof ArrayBuffer ? desc : (desc as any), + ); this.videoDescription = videoDescription; } // Capture colorSpace from encoder metadata if provided @@ -276,15 +324,15 @@ export class VideoExporter { if (isFirstChunk && this.videoDescription) { // Add decoder config for the first chunk const colorSpace = this.videoColorSpace || { - primaries: 'bt709', - transfer: 'iec61966-2-1', - matrix: 'rgb', + primaries: "bt709", + transfer: "iec61966-2-1", + matrix: "rgb", fullRange: true, }; const metadata: EncodedVideoChunkMetadata = { decoderConfig: { - codec: this.config.codec || 'avc1.640033', + codec: this.config.codec || "avc1.640033", codedWidth: this.config.width, codedHeight: this.config.height, description: this.videoDescription, @@ -297,19 +345,19 @@ export class VideoExporter { await this.muxer!.addVideoChunk(chunk, meta); } } catch (error) { - console.error('Muxing error:', error); + console.error("Muxing error:", error); } }); this.encodeQueue--; }, error: (error) => { - console.error('[VideoExporter] Encoder error:', error); + console.error("[VideoExporter] Encoder error:", error); // Stop export encoding failed this.cancelled = true; }, }); - const codec = this.config.codec || 'avc1.640033'; + const codec = this.config.codec || "avc1.640033"; const encoderConfig: VideoEncoderConfig = { codec, @@ -317,9 +365,9 @@ export class VideoExporter { height: this.config.height, bitrate: this.config.bitrate, framerate: this.config.frameRate, - latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput - bitrateMode: 'variable', - hardwareAcceleration: 'prefer-hardware', + latencyMode: "quality", // Changed from 'realtime' to 'quality' for better throughput + bitrateMode: "variable", + hardwareAcceleration: "prefer-hardware", }; // Check hardware support first @@ -327,16 +375,19 @@ export class VideoExporter { if (hardwareSupport.supported) { // Use hardware encoding - console.log('[VideoExporter] Using hardware acceleration'); + console.log("[VideoExporter] Using hardware acceleration"); this.encoder.configure(encoderConfig); } else { // Fall back to software encoding - console.log('[VideoExporter] Hardware not supported, using software encoding'); - encoderConfig.hardwareAcceleration = 'prefer-software'; + console.log( + "[VideoExporter] Hardware not supported, using software encoding", + ); + encoderConfig.hardwareAcceleration = "prefer-software"; - const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig); + const softwareSupport = + await VideoEncoder.isConfigSupported(encoderConfig); if (!softwareSupport.supported) { - throw new Error('Video encoding not supported on this system'); + throw new Error("Video encoding not supported on this system"); } this.encoder.configure(encoderConfig); @@ -357,11 +408,11 @@ export class VideoExporter { private cleanup(): void { if (this.encoder) { try { - if (this.encoder.state === 'configured') { + if (this.encoder.state === "configured") { this.encoder.close(); } } catch (e) { - console.warn('Error closing encoder:', e); + console.warn("Error closing encoder:", e); } this.encoder = null; } @@ -370,7 +421,7 @@ export class VideoExporter { try { this.streamingDecoder.destroy(); } catch (e) { - console.warn('Error destroying streaming decoder:', e); + console.warn("Error destroying streaming decoder:", e); } this.streamingDecoder = null; } @@ -379,13 +430,13 @@ export class VideoExporter { try { this.renderer.destroy(); } catch (e) { - console.warn('Error destroying renderer:', e); + console.warn("Error destroying renderer:", e); } this.renderer = null; } this.muxer = null; - this.audioProcessor = null; + this.audioProcessor = null; this.encodeQueue = 0; this.pendingMuxing = Promise.resolve(); this.chunkCount = 0; @@ -393,4 +444,3 @@ export class VideoExporter { this.videoColorSpace = undefined; } } -