fix: stabilize lint/typecheck and shortcut typing
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,50 +1,55 @@
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS, FIXED_SHORTCUTS } from "@/lib/shortcuts";
|
||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
title="Customize shortcuts"
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
title="Customize shortcuts"
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+1417
-1238
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,114 +1,124 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CropRegion } from "@/components/video-editor/types";
|
||||
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;
|
||||
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",
|
||||
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[];
|
||||
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 };
|
||||
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: [],
|
||||
};
|
||||
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: [] });
|
||||
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);
|
||||
// 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 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 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 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 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;
|
||||
}, []);
|
||||
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,
|
||||
};
|
||||
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
|
||||
|
||||
+103
-82
@@ -1,127 +1,148 @@
|
||||
export const SHORTCUT_ACTIONS = [
|
||||
'addZoom',
|
||||
'addTrim',
|
||||
'addSpeed',
|
||||
'addAnnotation',
|
||||
'addKeyframe',
|
||||
'deleteSelected',
|
||||
'playPause',
|
||||
"addZoom",
|
||||
"addTrim",
|
||||
"addSpeed",
|
||||
"addAnnotation",
|
||||
"addKeyframe",
|
||||
"deleteSelected",
|
||||
"playPause",
|
||||
] as const;
|
||||
|
||||
export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number];
|
||||
|
||||
export interface ShortcutBinding {
|
||||
key: string;
|
||||
/** Maps to Cmd on macOS, Ctrl on Windows/Linux */
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
key: string;
|
||||
/** Maps to Cmd on macOS, Ctrl on Windows/Linux */
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}
|
||||
|
||||
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
|
||||
|
||||
export interface FixedShortcut {
|
||||
label: string;
|
||||
display: string;
|
||||
bindings: ShortcutBinding[];
|
||||
label: string;
|
||||
display: string;
|
||||
bindings: ShortcutBinding[];
|
||||
}
|
||||
|
||||
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', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] },
|
||||
{ label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] },
|
||||
{ label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] },
|
||||
{ label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] },
|
||||
{ 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",
|
||||
display: "Shift + Tab",
|
||||
bindings: [{ key: "tab", shift: true }],
|
||||
},
|
||||
{
|
||||
label: "Delete Selected (alt)",
|
||||
display: "Del / ⌫",
|
||||
bindings: [{ key: "delete" }, { key: "backspace" }],
|
||||
},
|
||||
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
|
||||
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
|
||||
];
|
||||
|
||||
export type ShortcutConflict =
|
||||
| { type: 'configurable'; action: ShortcutAction }
|
||||
| { type: 'fixed'; label: string };
|
||||
| { type: "configurable"; action: ShortcutAction }
|
||||
| { type: "fixed"; label: string };
|
||||
|
||||
export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
|
||||
return (
|
||||
a.key.toLowerCase() === b.key.toLowerCase() &&
|
||||
!!a.ctrl === !!b.ctrl &&
|
||||
!!a.shift === !!b.shift &&
|
||||
!!a.alt === !!b.alt
|
||||
);
|
||||
return (
|
||||
a.key.toLowerCase() === b.key.toLowerCase() &&
|
||||
!!a.ctrl === !!b.ctrl &&
|
||||
!!a.shift === !!b.shift &&
|
||||
!!a.alt === !!b.alt
|
||||
);
|
||||
}
|
||||
|
||||
export function findConflict(
|
||||
binding: ShortcutBinding,
|
||||
forAction: ShortcutAction,
|
||||
config: ShortcutsConfig,
|
||||
binding: ShortcutBinding,
|
||||
forAction: ShortcutAction,
|
||||
config: ShortcutsConfig,
|
||||
): ShortcutConflict | null {
|
||||
for (const fixed of FIXED_SHORTCUTS) {
|
||||
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
|
||||
return { type: 'fixed', label: fixed.label };
|
||||
}
|
||||
}
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
if (action !== forAction && bindingsEqual(config[action], binding)) {
|
||||
return { type: 'configurable', action };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
for (const fixed of FIXED_SHORTCUTS) {
|
||||
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
|
||||
return { type: "fixed", label: fixed.label };
|
||||
}
|
||||
}
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
if (action !== forAction && bindingsEqual(config[action], binding)) {
|
||||
return { type: "configurable", action };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
|
||||
addZoom: { key: 'z' },
|
||||
addTrim: { key: 't' },
|
||||
addSpeed: { key: 's' },
|
||||
addAnnotation: { key: 'a' },
|
||||
addKeyframe: { key: 'f' },
|
||||
deleteSelected: { key: 'd', ctrl: true },
|
||||
playPause: { key: ' ' },
|
||||
addZoom: { key: "z" },
|
||||
addTrim: { key: "t" },
|
||||
addSpeed: { key: "s" },
|
||||
addAnnotation: { key: "a" },
|
||||
addKeyframe: { key: "f" },
|
||||
deleteSelected: { key: "d", ctrl: true },
|
||||
playPause: { key: " " },
|
||||
};
|
||||
|
||||
export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
addZoom: 'Add Zoom',
|
||||
addTrim: 'Add Trim',
|
||||
addSpeed: 'Add Speed',
|
||||
addAnnotation: 'Add Annotation',
|
||||
addKeyframe: 'Add Keyframe',
|
||||
deleteSelected: 'Delete Selected',
|
||||
playPause: 'Play / Pause',
|
||||
addZoom: "Add Zoom",
|
||||
addTrim: "Add Trim",
|
||||
addSpeed: "Add Speed",
|
||||
addAnnotation: "Add Annotation",
|
||||
addKeyframe: "Add Keyframe",
|
||||
deleteSelected: "Delete Selected",
|
||||
playPause: "Play / Pause",
|
||||
};
|
||||
|
||||
export function matchesShortcut(
|
||||
e: KeyboardEvent,
|
||||
binding: ShortcutBinding,
|
||||
isMacPlatform: boolean,
|
||||
e: KeyboardEvent,
|
||||
binding: ShortcutBinding,
|
||||
isMacPlatform: boolean,
|
||||
): boolean {
|
||||
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
|
||||
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
|
||||
|
||||
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
|
||||
if (primaryMod !== !!binding.ctrl) return false;
|
||||
if (e.shiftKey !== !!binding.shift) return false;
|
||||
if (e.altKey !== !!binding.alt) return false;
|
||||
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
|
||||
if (primaryMod !== !!binding.ctrl) return false;
|
||||
if (e.shiftKey !== !!binding.shift) return false;
|
||||
if (e.altKey !== !!binding.alt) return false;
|
||||
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc',
|
||||
'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→',
|
||||
" ": "Space",
|
||||
delete: "Del",
|
||||
backspace: "⌫",
|
||||
escape: "Esc",
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
};
|
||||
|
||||
export function formatBinding(binding: ShortcutBinding, isMac: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
|
||||
if (binding.shift) parts.push(isMac ? '⇧' : 'Shift');
|
||||
if (binding.alt) parts.push(isMac ? '⌥' : 'Alt');
|
||||
parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
|
||||
return parts.join(' + ');
|
||||
const parts: string[] = [];
|
||||
if (binding.ctrl) parts.push(isMac ? "⌘" : "Ctrl");
|
||||
if (binding.shift) parts.push(isMac ? "⇧" : "Shift");
|
||||
if (binding.alt) parts.push(isMac ? "⌥" : "Alt");
|
||||
parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
|
||||
return parts.join(" + ");
|
||||
}
|
||||
|
||||
export function mergeWithDefaults(partial: Partial<ShortcutsConfig>): ShortcutsConfig {
|
||||
const merged = { ...DEFAULT_SHORTCUTS };
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
if (partial[action]) {
|
||||
merged[action] = partial[action] as ShortcutBinding;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
const merged = { ...DEFAULT_SHORTCUTS };
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
if (partial[action]) {
|
||||
merged[action] = partial[action] as ShortcutBinding;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
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