Merge pull request #174 from FabLrc/feature/undo-redo

feat: implement undo/redo functionality in video editor
This commit is contained in:
Sid
2026-03-13 18:49:27 -07:00
committed by GitHub
19 changed files with 654 additions and 481 deletions
+3 -2
View File
@@ -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
View File
@@ -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: () => {
+3 -1
View File
@@ -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>
+15 -3
View File
@@ -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
+13 -6
View File
@@ -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(
+6 -2
View File
@@ -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(
+18 -18
View File
@@ -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 };
}
+124
View File
@@ -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,
};
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
};
}, [enabled]);
}, [enabled, selectedDeviceId]);
return {
devices,
+15 -9
View File
@@ -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;
}
}
+2 -5
View File
@@ -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}`;
+33 -11
View File
@@ -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();
+5 -1
View File
@@ -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
+9
View File
@@ -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",
+2 -2
View File
@@ -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,