Merge pull request #174 from FabLrc/feature/undo-redo
feat: implement undo/redo functionality in video editor
This commit is contained in:
Vendored
+3
-2
@@ -27,8 +27,9 @@ interface Window {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: any) => Promise<any>;
|
||||
getSelectedSource: () => Promise<any>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
getAssetBasePath: () => Promise<string | null>;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
selectSource: (source: ProcessedDesktopSource) => {
|
||||
return ipcRenderer.invoke("select-source", source);
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
|
||||
@@ -108,7 +108,9 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
||||
if (isDragging) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Pointer may already be released; ignore.
|
||||
}
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
|
||||
const [scrollLabels, setScrollLabels] = useState({
|
||||
pan: "Shift + Ctrl + Scroll",
|
||||
zoom: "Ctrl + Scroll",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(["shift", "mod", "Scroll"]),
|
||||
formatShortcut(["mod", "Scroll"]),
|
||||
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
@@ -47,25 +33,20 @@ export function KeyboardShortcutsHelp() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{scrollLabels.pan}
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{scrollLabels.zoom}
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Cycle Annotations</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
Tab
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||
{FIXED_SHORTCUTS.map((fixed) => (
|
||||
<div key={fixed.label} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{fixed.label}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{isMac
|
||||
? fixed.display
|
||||
.replace(/Ctrl/g, "⌘")
|
||||
.replace(/Shift/g, "⇧")
|
||||
.replace(/Alt/g, "⌥")
|
||||
: fixed.display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
AnnotationRegion,
|
||||
AnnotationType,
|
||||
CropRegion,
|
||||
FigureData,
|
||||
PlaybackSpeed,
|
||||
ZoomDepth,
|
||||
} from "./types";
|
||||
@@ -86,14 +87,17 @@ interface SettingsPanelProps {
|
||||
onTrimDelete?: (id: string) => void;
|
||||
shadowIntensity?: number;
|
||||
onShadowChange?: (intensity: number) => void;
|
||||
onShadowCommit?: () => void;
|
||||
showBlur?: boolean;
|
||||
onBlurChange?: (showBlur: boolean) => void;
|
||||
motionBlurEnabled?: boolean;
|
||||
onMotionBlurChange?: (enabled: boolean) => void;
|
||||
borderRadius?: number;
|
||||
onBorderRadiusChange?: (radius: number) => void;
|
||||
onBorderRadiusCommit?: () => void;
|
||||
padding?: number;
|
||||
onPaddingChange?: (padding: number) => void;
|
||||
onPaddingCommit?: () => void;
|
||||
cropRegion?: CropRegion;
|
||||
onCropChange?: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
@@ -118,7 +122,7 @@ interface SettingsPanelProps {
|
||||
onAnnotationContentChange?: (id: string, content: string) => void;
|
||||
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
@@ -148,14 +152,17 @@ export function SettingsPanel({
|
||||
onTrimDelete,
|
||||
shadowIntensity = 0,
|
||||
onShadowChange,
|
||||
onShadowCommit,
|
||||
showBlur,
|
||||
onBlurChange,
|
||||
motionBlurEnabled = false,
|
||||
onMotionBlurChange,
|
||||
borderRadius = 0,
|
||||
onBorderRadiusChange,
|
||||
onBorderRadiusCommit,
|
||||
padding = 50,
|
||||
onPaddingChange,
|
||||
onPaddingCommit,
|
||||
cropRegion,
|
||||
onCropChange,
|
||||
aspectRatio,
|
||||
@@ -196,7 +203,7 @@ export function SettingsPanel({
|
||||
try {
|
||||
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
|
||||
if (mounted) setWallpaperPaths(resolved);
|
||||
} catch {
|
||||
} catch (_err) {
|
||||
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
|
||||
}
|
||||
})();
|
||||
@@ -480,6 +487,7 @@ export function SettingsPanel({
|
||||
<Slider
|
||||
value={[shadowIntensity]}
|
||||
onValueChange={(values) => onShadowChange?.(values[0])}
|
||||
onValueCommit={() => onShadowCommit?.()}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
@@ -494,6 +502,7 @@ export function SettingsPanel({
|
||||
<Slider
|
||||
value={[borderRadius]}
|
||||
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
||||
onValueCommit={() => onBorderRadiusCommit?.()}
|
||||
min={0}
|
||||
max={16}
|
||||
step={0.5}
|
||||
@@ -508,6 +517,7 @@ export function SettingsPanel({
|
||||
<Slider
|
||||
value={[padding]}
|
||||
onValueChange={(values) => onPaddingChange?.(values[0])}
|
||||
onValueCommit={() => onPaddingCommit?.()}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -620,7 +630,9 @@ export function SettingsPanel({
|
||||
s.replace(/^file:\/\//, "").replace(/^\//, "");
|
||||
if (clean(selected).endsWith(clean(path))) return true;
|
||||
if (clean(path).endsWith(clean(selected))) return true;
|
||||
} catch {}
|
||||
} catch {
|
||||
// Best-effort comparison; fallback to strict match.
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
return (
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ShortcutsConfigDialog() {
|
||||
|
||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||
}, [captureFor]);
|
||||
}, [captureFor, draft]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ interface VideoPlaybackProps {
|
||||
selectedZoomId: string | null;
|
||||
onSelectZoom: (id: string | null) => void;
|
||||
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
|
||||
onZoomFocusDragEnd?: () => void;
|
||||
isPlaying: boolean;
|
||||
showShadow?: boolean;
|
||||
shadowIntensity?: number;
|
||||
@@ -92,6 +93,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
selectedZoomId,
|
||||
onSelectZoom,
|
||||
onZoomFocusChange,
|
||||
onZoomFocusDragEnd,
|
||||
isPlaying,
|
||||
showShadow,
|
||||
shadowIntensity = 0,
|
||||
@@ -339,7 +341,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
isDraggingFocusRef.current = false;
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {}
|
||||
} catch {
|
||||
// Pointer may already be released.
|
||||
}
|
||||
onZoomFocusDragEnd?.();
|
||||
};
|
||||
|
||||
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -437,14 +442,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
requestAnimationFrame(() => {
|
||||
const finalApp = appRef.current;
|
||||
if (wasPlaying && video) {
|
||||
video.play().catch(() => {});
|
||||
video.play().catch(() => {
|
||||
// Ignore autoplay restoration failures.
|
||||
});
|
||||
}
|
||||
if (tickerWasStarted && finalApp?.ticker) {
|
||||
finalApp.ticker.start();
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
@@ -549,7 +556,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
}, [videoPath]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
@@ -644,7 +651,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
videoSpriteRef.current = null;
|
||||
};
|
||||
}, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]);
|
||||
}, [pixiReady, videoReady, onTimeUpdate, onPlayStateChange, layoutVideoContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
@@ -827,7 +834,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
|
||||
if (mounted) setResolvedWallpaper(p);
|
||||
} catch {
|
||||
} catch (_err) {
|
||||
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -187,12 +187,12 @@ export default function TimelineWrapper({
|
||||
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatTooltipMs = (ms: number) => {
|
||||
const formatTooltipMs = useCallback((ms: number) => {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showTooltip = useCallback(
|
||||
(span: { start: number; end: number } | null, screenX?: number) => {
|
||||
@@ -213,7 +213,7 @@ export default function TimelineWrapper({
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
[formatTooltipMs],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback(
|
||||
|
||||
@@ -36,7 +36,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
getIsMac()
|
||||
.then(setIsMac)
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep default non-mac fallback if detection fails.
|
||||
});
|
||||
|
||||
window.electronAPI
|
||||
.getShortcuts?.()
|
||||
@@ -45,7 +47,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep default shortcuts if persisted settings can't be loaded.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistShortcuts = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface AudioLevelMeterOptions {
|
||||
enabled: boolean;
|
||||
@@ -13,6 +13,22 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
cleanup();
|
||||
@@ -85,23 +101,7 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
||||
mounted = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [options.enabled, options.deviceId, options.smoothingFactor]);
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
};
|
||||
}, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]);
|
||||
|
||||
return { level };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
// selection change would feel surprising to the user).
|
||||
export interface EditorState {
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
cropRegion: CropRegion;
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
motionBlurEnabled: boolean;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
zoomRegions: [],
|
||||
trimRegions: [],
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
cropRegion: DEFAULT_CROP_REGION,
|
||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurEnabled: false,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
interface History {
|
||||
past: EditorState[];
|
||||
present: EditorState;
|
||||
future: EditorState[];
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 80;
|
||||
|
||||
function resolve(present: EditorState, update: StateUpdate): EditorState {
|
||||
const partial = typeof update === "function" ? update(present) : update;
|
||||
return { ...present, ...partial };
|
||||
}
|
||||
|
||||
function withCheckpoint(history: History, newPresent: EditorState): History {
|
||||
return {
|
||||
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
|
||||
present: newPresent,
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
|
||||
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
|
||||
|
||||
// Tracks whether a live-update series (e.g. slider drag) is in progress.
|
||||
// The first updateState call saves the pre-interaction state as a checkpoint.
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const pushState = useCallback((update: StateUpdate) => {
|
||||
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const updateState = useCallback((update: StateUpdate) => {
|
||||
const isFirst = !dirtyRef.current;
|
||||
dirtyRef.current = true;
|
||||
setHistory((prev) => {
|
||||
const next = resolve(prev.present, update);
|
||||
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitState = useCallback(() => {
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.past.length) return prev;
|
||||
const previous = prev.past[prev.past.length - 1];
|
||||
return {
|
||||
past: prev.past.slice(0, -1),
|
||||
present: previous,
|
||||
future: [prev.present, ...prev.future],
|
||||
};
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (!prev.future.length) return prev;
|
||||
const [next, ...remainingFuture] = prev.future;
|
||||
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
|
||||
});
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
pushState,
|
||||
updateState,
|
||||
commitState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) {
|
||||
mounted = false;
|
||||
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, selectedDeviceId]);
|
||||
|
||||
return {
|
||||
devices,
|
||||
|
||||
@@ -104,7 +104,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
microphoneStream.current = null;
|
||||
}
|
||||
if (mixingContext.current) {
|
||||
mixingContext.current.close().catch(() => {});
|
||||
mixingContext.current.close().catch(() => {
|
||||
// Ignore close errors during recorder teardown.
|
||||
});
|
||||
mixingContext.current = null;
|
||||
}
|
||||
mediaRecorder.current.stop();
|
||||
@@ -142,7 +144,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
microphoneStream.current = null;
|
||||
}
|
||||
if (mixingContext.current) {
|
||||
mixingContext.current.close().catch(() => {});
|
||||
mixingContext.current.close().catch(() => {
|
||||
// Ignore close errors during cleanup.
|
||||
});
|
||||
mixingContext.current = null;
|
||||
}
|
||||
};
|
||||
@@ -171,7 +175,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
@@ -179,20 +183,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
},
|
||||
},
|
||||
video: videoConstraints,
|
||||
});
|
||||
} as unknown as MediaStreamConstraints);
|
||||
} catch (audioErr) {
|
||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||
toast.error("System audio not available. Recording without system audio.");
|
||||
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
});
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
} else {
|
||||
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: videoConstraints,
|
||||
});
|
||||
} as unknown as MediaStreamConstraints);
|
||||
}
|
||||
screenStream.current = screenMediaStream;
|
||||
|
||||
@@ -354,7 +358,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
microphoneStream.current = null;
|
||||
}
|
||||
if (mixingContext.current) {
|
||||
mixingContext.current.close().catch(() => {});
|
||||
mixingContext.current.close().catch(() => {
|
||||
// Ignore close errors during error recovery.
|
||||
});
|
||||
mixingContext.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,8 @@ export async function getAssetPath(relativePath: string): Promise<string> {
|
||||
return `/${relativePath.replace(/^\//, "")}`;
|
||||
}
|
||||
|
||||
if (
|
||||
(window as any).electronAPI &&
|
||||
typeof (window as any).electronAPI.getAssetBasePath === "function"
|
||||
) {
|
||||
const base = await (window as any).electronAPI.getAssetBasePath();
|
||||
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
|
||||
const base = await window.electronAPI.getAssetBasePath();
|
||||
if (base) {
|
||||
const normalized = base.replace(/\\/g, "/");
|
||||
return `file://${normalized}/${relativePath}`;
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Application, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js";
|
||||
import {
|
||||
Application,
|
||||
BlurFilter,
|
||||
Container,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Texture,
|
||||
type TextureSourceLike,
|
||||
} from "pixi.js";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
@@ -42,6 +51,14 @@ interface AnimationState {
|
||||
focusY: number;
|
||||
}
|
||||
|
||||
interface LayoutCache {
|
||||
stageSize: { width: number; height: number };
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: number;
|
||||
baseOffset: { x: number; y: number };
|
||||
maskRect: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
|
||||
|
||||
export class FrameRenderer {
|
||||
@@ -49,7 +66,7 @@ export class FrameRenderer {
|
||||
private cameraContainer: Container | null = null;
|
||||
private videoContainer: Container | null = null;
|
||||
private videoSprite: Sprite | null = null;
|
||||
private backgroundSprite: Sprite | null = null;
|
||||
private backgroundSprite: HTMLCanvasElement | null = null;
|
||||
private maskGraphics: Graphics | null = null;
|
||||
private blurFilter: BlurFilter | null = null;
|
||||
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||
@@ -58,7 +75,7 @@ export class FrameRenderer {
|
||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||
private config: FrameRenderConfig;
|
||||
private animationState: AnimationState;
|
||||
private layoutCache: any = null;
|
||||
private layoutCache: LayoutCache | null = null;
|
||||
private currentVideoTime = 0;
|
||||
|
||||
constructor(config: FrameRenderConfig) {
|
||||
@@ -263,7 +280,7 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
// Store the background canvas for compositing
|
||||
this.backgroundSprite = bgCanvas as any;
|
||||
this.backgroundSprite = bgCanvas;
|
||||
}
|
||||
|
||||
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
|
||||
@@ -275,13 +292,13 @@ export class FrameRenderer {
|
||||
|
||||
// Create or update video sprite from VideoFrame
|
||||
if (!this.videoSprite) {
|
||||
const texture = Texture.from(videoFrame as any);
|
||||
const texture = Texture.from(videoFrame as unknown as TextureSourceLike);
|
||||
this.videoSprite = new Sprite(texture);
|
||||
this.videoContainer.addChild(this.videoSprite);
|
||||
} else {
|
||||
// Destroy old texture to avoid memory leaks, then create new one
|
||||
const oldTexture = this.videoSprite.texture;
|
||||
const newTexture = Texture.from(videoFrame as any);
|
||||
const newTexture = Texture.from(videoFrame as unknown as TextureSourceLike);
|
||||
this.videoSprite.texture = newTexture;
|
||||
oldTexture.destroy(true);
|
||||
}
|
||||
@@ -298,12 +315,17 @@ export class FrameRenderer {
|
||||
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
|
||||
}
|
||||
|
||||
const layoutCache = this.layoutCache;
|
||||
if (!layoutCache) {
|
||||
throw new Error("Layout cache not initialized");
|
||||
}
|
||||
|
||||
// Apply transform once with maximum motion intensity from all ticks
|
||||
applyZoomTransform({
|
||||
cameraContainer: this.cameraContainer,
|
||||
blurFilter: this.blurFilter,
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
baseMask: this.layoutCache.maskRect,
|
||||
stageSize: layoutCache.stageSize,
|
||||
baseMask: layoutCache.maskRect,
|
||||
zoomScale: this.animationState.scale,
|
||||
focusX: this.animationState.focusX,
|
||||
focusY: this.animationState.focusY,
|
||||
@@ -411,10 +433,10 @@ export class FrameRenderer {
|
||||
|
||||
private clampFocusToStage(
|
||||
focus: { cx: number; cy: number },
|
||||
depth: number,
|
||||
depth: ZoomDepth,
|
||||
): { cx: number; cy: number } {
|
||||
if (!this.layoutCache) return focus;
|
||||
return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize);
|
||||
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
|
||||
}
|
||||
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
@@ -493,7 +515,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;
|
||||
const bgCanvas = this.backgroundSprite;
|
||||
|
||||
if (this.config.showBlur) {
|
||||
ctx.save();
|
||||
|
||||
@@ -219,7 +219,11 @@ 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));
|
||||
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
|
||||
videoDescription = new Uint8Array(desc);
|
||||
} else if (ArrayBuffer.isView(desc)) {
|
||||
videoDescription = new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength);
|
||||
}
|
||||
this.videoDescription = videoDescription;
|
||||
}
|
||||
// Capture colorSpace from encoder metadata if provided
|
||||
|
||||
@@ -27,6 +27,15 @@ export interface FixedShortcut {
|
||||
}
|
||||
|
||||
export const FIXED_SHORTCUTS: FixedShortcut[] = [
|
||||
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
|
||||
{
|
||||
label: "Redo",
|
||||
display: "Ctrl + Shift + Z / Ctrl + Y",
|
||||
bindings: [
|
||||
{ key: "z", ctrl: true, shift: true },
|
||||
{ key: "y", ctrl: true },
|
||||
],
|
||||
},
|
||||
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
|
||||
{
|
||||
label: "Cycle Annotations Backward",
|
||||
|
||||
Vendored
+2
-2
@@ -20,8 +20,8 @@ interface Window {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: any) => Promise<any>;
|
||||
getSelectedSource: () => Promise<any>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
|
||||
Reference in New Issue
Block a user