mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 14:40:47 +08:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c322825969 | ||
|
|
ece93683b8 | ||
|
|
203282be43 | ||
|
|
d8871d9228 | ||
|
|
dd0b7d6586 | ||
|
|
459b71f792 | ||
|
|
adc0cf795c | ||
|
|
796506819d | ||
|
|
2a2d7e7aba | ||
|
|
3eeecc46cf | ||
|
|
6236d2a13d | ||
|
|
c84c244761 | ||
|
|
83a60926d8 | ||
|
|
579887e2f8 | ||
|
|
a0682e6716 | ||
|
|
038d6c40ab | ||
|
|
45636410fe | ||
|
|
d968689975 | ||
|
|
7e65d52847 | ||
|
|
1680ef9b77 | ||
|
|
b7070f3ac8 | ||
|
|
de18a2f46f |
Generated
+4
@@ -73,6 +73,10 @@
|
|||||||
"vite-plugin-electron": "^0.28.6",
|
"vite-plugin-electron": "^0.28.6",
|
||||||
"vite-plugin-electron-renderer": "^0.14.5",
|
"vite-plugin-electron-renderer": "^0.14.5",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.22.1",
|
||||||
|
"npm": "10.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "npm@10.9.4",
|
||||||
|
"engines": {
|
||||||
|
"node": "22.22.1",
|
||||||
|
"npm": "10.9.4"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||||
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||||
@@ -48,5 +49,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return <TooltipProvider>{content}</TooltipProvider>;
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
{content}
|
||||||
|
<Toaster theme="dark" className="pointer-events-auto" />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { RxDragHandleDots2 } from "react-icons/rx";
|
|||||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||||
|
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||||
import { formatTimePadded } from "../../utils/timeUtils";
|
import { formatTimePadded } from "../../utils/timeUtils";
|
||||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||||
import { Tooltip } from "../ui/tooltip";
|
import { Tooltip } from "../ui/tooltip";
|
||||||
@@ -110,6 +111,16 @@ export function LaunchWindow() {
|
|||||||
};
|
};
|
||||||
}, [recording, recordingStart]);
|
}, [recording, recordingStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void requestCameraAccess().catch((error) => {
|
||||||
|
console.warn("Failed to trigger camera access request during development:", error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||||
|
|
||||||
@@ -251,8 +262,8 @@ export function LaunchWindow() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
void setWebcamEnabled(!webcamEnabled);
|
await setWebcamEnabled(!webcamEnabled);
|
||||||
}}
|
}}
|
||||||
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { getAssetPath } from "@/lib/assetPath";
|
import { getAssetPath } from "@/lib/assetPath";
|
||||||
|
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -43,6 +51,7 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
FigureData,
|
FigureData,
|
||||||
PlaybackSpeed,
|
PlaybackSpeed,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomDepth,
|
ZoomDepth,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { SPEED_OPTIONS } from "./types";
|
import { SPEED_OPTIONS } from "./types";
|
||||||
@@ -121,6 +130,8 @@ interface SettingsPanelProps {
|
|||||||
onSaveProject?: () => void;
|
onSaveProject?: () => void;
|
||||||
onLoadProject?: () => void;
|
onLoadProject?: () => void;
|
||||||
onExport?: () => void;
|
onExport?: () => void;
|
||||||
|
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
|
||||||
|
onSaveUnsavedExport?: () => void;
|
||||||
selectedAnnotationId?: string | null;
|
selectedAnnotationId?: string | null;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
onAnnotationContentChange?: (id: string, content: string) => void;
|
onAnnotationContentChange?: (id: string, content: string) => void;
|
||||||
@@ -132,6 +143,9 @@ interface SettingsPanelProps {
|
|||||||
selectedSpeedValue?: PlaybackSpeed | null;
|
selectedSpeedValue?: PlaybackSpeed | null;
|
||||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||||
onSpeedDelete?: (id: string) => void;
|
onSpeedDelete?: (id: string) => void;
|
||||||
|
hasWebcam?: boolean;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsPanel;
|
export default SettingsPanel;
|
||||||
@@ -186,6 +200,8 @@ export function SettingsPanel({
|
|||||||
onSaveProject,
|
onSaveProject,
|
||||||
onLoadProject,
|
onLoadProject,
|
||||||
onExport,
|
onExport,
|
||||||
|
unsavedExport,
|
||||||
|
onSaveUnsavedExport,
|
||||||
selectedAnnotationId,
|
selectedAnnotationId,
|
||||||
annotationRegions = [],
|
annotationRegions = [],
|
||||||
onAnnotationContentChange,
|
onAnnotationContentChange,
|
||||||
@@ -197,6 +213,9 @@ export function SettingsPanel({
|
|||||||
selectedSpeedValue,
|
selectedSpeedValue,
|
||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onSpeedDelete,
|
onSpeedDelete,
|
||||||
|
hasWebcam = false,
|
||||||
|
webcamLayoutPreset = "picture-in-picture",
|
||||||
|
onWebcamLayoutPresetChange,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||||
@@ -567,7 +586,47 @@ export function SettingsPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
|
<Accordion
|
||||||
|
type="multiple"
|
||||||
|
defaultValue={hasWebcam ? ["layout", "effects", "background"] : ["effects", "background"]}
|
||||||
|
className="space-y-1"
|
||||||
|
>
|
||||||
|
{hasWebcam && (
|
||||||
|
<AccordionItem
|
||||||
|
value="layout"
|
||||||
|
className="border-white/5 rounded-xl bg-white/[0.02] px-3"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
||||||
|
<span className="text-xs font-medium">Layout</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-3">
|
||||||
|
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||||
|
<div className="text-[10px] font-medium text-slate-300 mb-1.5">Preset</div>
|
||||||
|
<Select
|
||||||
|
value={webcamLayoutPreset}
|
||||||
|
onValueChange={(value: WebcamLayoutPreset) =>
|
||||||
|
onWebcamLayoutPresetChange?.(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
|
||||||
|
<SelectValue placeholder="Select preset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
|
||||||
|
<SelectItem key={preset.value} value={preset.value} className="text-xs">
|
||||||
|
{preset.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1095,6 +1154,17 @@ export function SettingsPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{unsavedExport && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
onClick={onSaveUnsavedExport}
|
||||||
|
className="w-full mb-2 py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-500/20 hover:bg-indigo-500/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Choose Save Location
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
data-testid={getTestId("export-button")}
|
data-testid={getTestId("export-button")}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Span } from "dnd-timeline";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +75,7 @@ export default function VideoEditor() {
|
|||||||
borderRadius,
|
borderRadius,
|
||||||
padding,
|
padding,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
} = editorState;
|
} = editorState;
|
||||||
|
|
||||||
// ── Non-undoable state
|
// ── Non-undoable state
|
||||||
@@ -105,6 +105,11 @@ export default function VideoEditor() {
|
|||||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
|
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
|
||||||
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
|
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
|
||||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
|
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
|
||||||
|
const [unsavedExport, setUnsavedExport] = useState<{
|
||||||
|
arrayBuffer: ArrayBuffer;
|
||||||
|
fileName: string;
|
||||||
|
format: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||||
const nextZoomIdRef = useRef(1);
|
const nextZoomIdRef = useRef(1);
|
||||||
@@ -173,6 +178,7 @@ export default function VideoEditor() {
|
|||||||
speedRegions: normalizedEditor.speedRegions,
|
speedRegions: normalizedEditor.speedRegions,
|
||||||
annotationRegions: normalizedEditor.annotationRegions,
|
annotationRegions: normalizedEditor.annotationRegions,
|
||||||
aspectRatio: normalizedEditor.aspectRatio,
|
aspectRatio: normalizedEditor.aspectRatio,
|
||||||
|
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||||
});
|
});
|
||||||
setExportQuality(normalizedEditor.exportQuality);
|
setExportQuality(normalizedEditor.exportQuality);
|
||||||
setExportFormat(normalizedEditor.exportFormat);
|
setExportFormat(normalizedEditor.exportFormat);
|
||||||
@@ -240,6 +246,7 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
@@ -261,6 +268,7 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
@@ -352,6 +360,7 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
@@ -404,6 +413,7 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
@@ -961,6 +971,27 @@ export default function VideoEditor() {
|
|||||||
[handleShowExportedFile],
|
[handleShowExportedFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSaveUnsavedExport = useCallback(async () => {
|
||||||
|
if (!unsavedExport) return;
|
||||||
|
try {
|
||||||
|
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||||
|
unsavedExport.arrayBuffer,
|
||||||
|
unsavedExport.fileName,
|
||||||
|
);
|
||||||
|
if (saveResult.canceled) {
|
||||||
|
toast.info("Export canceled");
|
||||||
|
} else if (saveResult.success && saveResult.path) {
|
||||||
|
setUnsavedExport(null);
|
||||||
|
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
|
||||||
|
} else {
|
||||||
|
toast.error(saveResult.message || "Failed to save export");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving unsaved export:", error);
|
||||||
|
toast.error("Failed to save exported video");
|
||||||
|
}
|
||||||
|
}, [unsavedExport, handleExportSaved]);
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
async (settings: ExportSettings) => {
|
async (settings: ExportSettings) => {
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
@@ -1021,6 +1052,7 @@ export default function VideoEditor() {
|
|||||||
videoPadding: padding,
|
videoPadding: padding,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
|
webcamLayoutPreset,
|
||||||
previewWidth,
|
previewWidth,
|
||||||
previewHeight,
|
previewHeight,
|
||||||
onProgress: (progress: ExportProgress) => {
|
onProgress: (progress: ExportProgress) => {
|
||||||
@@ -1039,8 +1071,10 @@ export default function VideoEditor() {
|
|||||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||||
|
|
||||||
if (saveResult.canceled) {
|
if (saveResult.canceled) {
|
||||||
|
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
|
||||||
toast.info("Export canceled");
|
toast.info("Export canceled");
|
||||||
} else if (saveResult.success && saveResult.path) {
|
} else if (saveResult.success && saveResult.path) {
|
||||||
|
setUnsavedExport(null);
|
||||||
handleExportSaved("GIF", saveResult.path);
|
handleExportSaved("GIF", saveResult.path);
|
||||||
} else {
|
} else {
|
||||||
setExportError(saveResult.message || "Failed to save GIF");
|
setExportError(saveResult.message || "Failed to save GIF");
|
||||||
@@ -1148,6 +1182,7 @@ export default function VideoEditor() {
|
|||||||
padding,
|
padding,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
|
webcamLayoutPreset,
|
||||||
previewWidth,
|
previewWidth,
|
||||||
previewHeight,
|
previewHeight,
|
||||||
onProgress: (progress: ExportProgress) => {
|
onProgress: (progress: ExportProgress) => {
|
||||||
@@ -1166,8 +1201,10 @@ export default function VideoEditor() {
|
|||||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||||
|
|
||||||
if (saveResult.canceled) {
|
if (saveResult.canceled) {
|
||||||
|
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
|
||||||
toast.info("Export canceled");
|
toast.info("Export canceled");
|
||||||
} else if (saveResult.success && saveResult.path) {
|
} else if (saveResult.success && saveResult.path) {
|
||||||
|
setUnsavedExport(null);
|
||||||
handleExportSaved("Video", saveResult.path);
|
handleExportSaved("Video", saveResult.path);
|
||||||
} else {
|
} else {
|
||||||
setExportError(saveResult.message || "Failed to save video");
|
setExportError(saveResult.message || "Failed to save video");
|
||||||
@@ -1212,6 +1249,7 @@ export default function VideoEditor() {
|
|||||||
annotationRegions,
|
annotationRegions,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
handleExportSaved,
|
handleExportSaved,
|
||||||
],
|
],
|
||||||
@@ -1351,6 +1389,7 @@ export default function VideoEditor() {
|
|||||||
ref={videoPlaybackRef}
|
ref={videoPlaybackRef}
|
||||||
videoPath={videoPath || ""}
|
videoPath={videoPath || ""}
|
||||||
webcamVideoPath={webcamVideoPath || undefined}
|
webcamVideoPath={webcamVideoPath || undefined}
|
||||||
|
webcamLayoutPreset={webcamLayoutPreset}
|
||||||
onDurationChange={setDuration}
|
onDurationChange={setDuration}
|
||||||
onTimeUpdate={setCurrentTime}
|
onTimeUpdate={setCurrentTime}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@@ -1474,6 +1513,9 @@ export default function VideoEditor() {
|
|||||||
cropRegion={cropRegion}
|
cropRegion={cropRegion}
|
||||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
|
hasWebcam={Boolean(webcamVideoPath)}
|
||||||
|
webcamLayoutPreset={webcamLayoutPreset}
|
||||||
|
onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })}
|
||||||
videoElement={videoPlaybackRef.current?.video || null}
|
videoElement={videoPlaybackRef.current?.video || null}
|
||||||
exportQuality={exportQuality}
|
exportQuality={exportQuality}
|
||||||
onExportQualityChange={setExportQuality}
|
onExportQualityChange={setExportQuality}
|
||||||
@@ -1516,13 +1558,13 @@ export default function VideoEditor() {
|
|||||||
}
|
}
|
||||||
onSpeedChange={handleSpeedChange}
|
onSpeedChange={handleSpeedChange}
|
||||||
onSpeedDelete={handleSpeedDelete}
|
onSpeedDelete={handleSpeedDelete}
|
||||||
|
unsavedExport={unsavedExport}
|
||||||
|
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toaster theme="dark" className="pointer-events-auto" />
|
|
||||||
|
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
isOpen={showExportDialog}
|
isOpen={showExportDialog}
|
||||||
onClose={() => setShowExportDialog(false)}
|
onClose={() => setShowExportDialog(false)}
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAssetPath } from "@/lib/assetPath";
|
import { getAssetPath } from "@/lib/assetPath";
|
||||||
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
|
import {
|
||||||
|
getWebcamLayoutCssBoxShadow,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
import {
|
import {
|
||||||
type AspectRatio,
|
type AspectRatio,
|
||||||
formatAspectRatioForCSS,
|
formatAspectRatioForCSS,
|
||||||
@@ -57,6 +62,7 @@ import {
|
|||||||
interface VideoPlaybackProps {
|
interface VideoPlaybackProps {
|
||||||
videoPath: string;
|
videoPath: string;
|
||||||
webcamVideoPath?: string;
|
webcamVideoPath?: string;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
onDurationChange: (duration: number) => void;
|
onDurationChange: (duration: number) => void;
|
||||||
onTimeUpdate: (time: number) => void;
|
onTimeUpdate: (time: number) => void;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
@@ -101,6 +107,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
{
|
{
|
||||||
videoPath,
|
videoPath,
|
||||||
webcamVideoPath,
|
webcamVideoPath,
|
||||||
|
webcamLayoutPreset,
|
||||||
onDurationChange,
|
onDurationChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -134,7 +141,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const appRef = useRef<Application | null>(null);
|
const appRef = useRef<Application | null>(null);
|
||||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||||
const videoContainerRef = useRef<Container | null>(null);
|
const videoContainerRef = useRef<Container | null>(null);
|
||||||
@@ -144,11 +150,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const [videoReady, setVideoReady] = useState(false);
|
const [videoReady, setVideoReady] = useState(false);
|
||||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
|
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||||
const [webcamDimensions, setWebcamDimensions] = useState<{
|
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
const currentTimeRef = useRef(0);
|
const currentTimeRef = useRef(0);
|
||||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||||
const selectedZoomIdRef = useRef<string | null>(null);
|
const selectedZoomIdRef = useRef<string | null>(null);
|
||||||
@@ -258,6 +261,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
||||||
borderRadius,
|
borderRadius,
|
||||||
padding,
|
padding,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -267,6 +272,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
baseOffsetRef.current = result.baseOffset;
|
baseOffsetRef.current = result.baseOffset;
|
||||||
baseMaskRef.current = result.maskRect;
|
baseMaskRef.current = result.maskRect;
|
||||||
cropBoundsRef.current = result.cropBounds;
|
cropBoundsRef.current = result.cropBounds;
|
||||||
|
setWebcamLayout(result.webcamRect);
|
||||||
|
|
||||||
// Reset camera container to identity
|
// Reset camera container to identity
|
||||||
cameraContainer.scale.set(1);
|
cameraContainer.scale.set(1);
|
||||||
@@ -279,7 +285,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
|
|
||||||
updateOverlayForRegion(activeRegion);
|
updateOverlayForRegion(activeRegion);
|
||||||
}
|
}
|
||||||
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
|
}, [
|
||||||
|
updateOverlayForRegion,
|
||||||
|
cropRegion,
|
||||||
|
borderRadius,
|
||||||
|
padding,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
layoutVideoContentRef.current = layoutVideoContent;
|
layoutVideoContentRef.current = layoutVideoContent;
|
||||||
@@ -910,6 +923,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||||
|
const webcamCssBoxShadow = useMemo(
|
||||||
|
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
|
||||||
|
[webcamLayoutPreset],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webcamVideo = webcamVideoRef.current;
|
const webcamVideo = webcamVideoRef.current;
|
||||||
@@ -934,34 +951,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
};
|
};
|
||||||
}, [webcamVideoPath]);
|
}, [webcamVideoPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stage = stageRef.current;
|
|
||||||
if (!stage || !webcamDimensions) {
|
|
||||||
setWebcamLayout(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateLayout = () => {
|
|
||||||
const layout = computeWebcamOverlayLayout({
|
|
||||||
stageWidth: stage.clientWidth,
|
|
||||||
stageHeight: stage.clientHeight,
|
|
||||||
videoWidth: webcamDimensions.width,
|
|
||||||
videoHeight: webcamDimensions.height,
|
|
||||||
});
|
|
||||||
setWebcamLayout(layout);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateLayout();
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateLayout);
|
|
||||||
observer.observe(stage);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [webcamDimensions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webcamVideo = webcamVideoRef.current;
|
const webcamVideo = webcamVideoRef.current;
|
||||||
if (!webcamVideo || !webcamVideoPath) {
|
if (!webcamVideo || !webcamVideoPath) {
|
||||||
@@ -1075,7 +1064,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={stageRef}
|
|
||||||
className="relative rounded-sm overflow-hidden"
|
className="relative rounded-sm overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -1120,7 +1108,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
width: webcamLayout?.width ?? 0,
|
width: webcamLayout?.width ?? 0,
|
||||||
height: webcamLayout?.height ?? 0,
|
height: webcamLayout?.height ?? 0,
|
||||||
borderRadius: webcamLayout?.borderRadius ?? 0,
|
borderRadius: webcamLayout?.borderRadius ?? 0,
|
||||||
boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
|
boxShadow: webcamCssBoxShadow,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
opacity: webcamLayout ? 1 : 0,
|
opacity: webcamLayout ? 1 : 0,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ describe("projectPersistence media compatibility", () => {
|
|||||||
speedRegions: [],
|
speedRegions: [],
|
||||||
annotationRegions: [],
|
annotationRegions: [],
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: "picture-in-picture",
|
||||||
exportQuality: "good",
|
exportQuality: "good",
|
||||||
exportFormat: "mp4",
|
exportFormat: "mp4",
|
||||||
gifFrameRate: 15,
|
gifFrameRate: 15,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
DEFAULT_CROP_REGION,
|
DEFAULT_CROP_REGION,
|
||||||
DEFAULT_FIGURE_DATA,
|
DEFAULT_FIGURE_DATA,
|
||||||
DEFAULT_PLAYBACK_SPEED,
|
DEFAULT_PLAYBACK_SPEED,
|
||||||
|
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
DEFAULT_ZOOM_DEPTH,
|
DEFAULT_ZOOM_DEPTH,
|
||||||
type SpeedRegion,
|
type SpeedRegion,
|
||||||
type TrimRegion,
|
type TrimRegion,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ export interface ProjectEditorState {
|
|||||||
speedRegions: SpeedRegion[];
|
speedRegions: SpeedRegion[];
|
||||||
annotationRegions: AnnotationRegion[];
|
annotationRegions: AnnotationRegion[];
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
exportQuality: ExportQuality;
|
exportQuality: ExportQuality;
|
||||||
exportFormat: ExportFormat;
|
exportFormat: ExportFormat;
|
||||||
gifFrameRate: GifFrameRate;
|
gifFrameRate: GifFrameRate;
|
||||||
@@ -341,6 +344,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
annotationRegions: normalizedAnnotationRegions,
|
annotationRegions: normalizedAnnotationRegions,
|
||||||
aspectRatio:
|
aspectRatio:
|
||||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
||||||
|
webcamLayoutPreset:
|
||||||
|
editor.webcamLayoutPreset === "vertical-stack" ||
|
||||||
|
editor.webcamLayoutPreset === "picture-in-picture"
|
||||||
|
? editor.webcamLayoutPreset
|
||||||
|
: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
exportQuality:
|
exportQuality:
|
||||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||||
? editor.exportQuality
|
? editor.exportQuality
|
||||||
|
|||||||
@@ -169,6 +169,33 @@ function createInitialRange(totalMs: number): Range {
|
|||||||
return { start: 0, end: FALLBACK_RANGE_MS };
|
return { start: 0, end: FALLBACK_RANGE_MS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampVisibleRange(candidate: Range, totalMs: number): Range {
|
||||||
|
if (totalMs <= 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = Math.max(candidate.end - candidate.start, 1);
|
||||||
|
|
||||||
|
if (span >= totalMs) {
|
||||||
|
return { start: 0, end: totalMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
|
||||||
|
return { start, end: start + span };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
|
||||||
|
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||||
|
return delta * 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||||
|
return delta * pageSizePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||||
const totalSeconds = milliseconds / 1000;
|
const totalSeconds = milliseconds / 1000;
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
@@ -204,18 +231,21 @@ function PlaybackCursor({
|
|||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
keyframes = [],
|
keyframes = [],
|
||||||
}: {
|
}: {
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||||
timelineRef: React.RefObject<HTMLDivElement>;
|
timelineRef: React.RefObject<HTMLDivElement>;
|
||||||
keyframes?: { id: string; time: number }[];
|
keyframes?: { id: string; time: number }[];
|
||||||
}) {
|
}) {
|
||||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@@ -225,6 +255,7 @@ function PlaybackCursor({
|
|||||||
|
|
||||||
const rect = timelineRef.current.getBoundingClientRect();
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||||
|
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
|
||||||
|
|
||||||
// Allow dragging outside to 0 or max, but clamp the value
|
// Allow dragging outside to 0 or max, but clamp the value
|
||||||
const relativeMs = pixelsToValue(clickX);
|
const relativeMs = pixelsToValue(clickX);
|
||||||
@@ -243,11 +274,51 @@ function PlaybackCursor({
|
|||||||
absoluteMs = nearbyKeyframe.time;
|
absoluteMs = nearbyKeyframe.time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDragPreviewTimeMs(absoluteMs);
|
||||||
|
|
||||||
|
const visibleMs = range.end - range.start;
|
||||||
|
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
|
||||||
|
const msPerPixel = visibleMs / contentWidth;
|
||||||
|
const overflowLeftPx = Math.max(0, -clickX);
|
||||||
|
const overflowRightPx = Math.max(0, clickX - contentWidth);
|
||||||
|
|
||||||
|
if (overflowLeftPx > 0 && range.start > 0) {
|
||||||
|
const shiftMs = overflowLeftPx * msPerPixel;
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start - shiftMs,
|
||||||
|
end: previous.end - shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
|
||||||
|
const shiftMs = overflowRightPx * msPerPixel;
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start + shiftMs,
|
||||||
|
end: previous.end + shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSeek(absoluteMs / 1000);
|
onSeek(absoluteMs / 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setDragPreviewTimeMs(null);
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,6 +334,7 @@ function PlaybackCursor({
|
|||||||
}, [
|
}, [
|
||||||
isDragging,
|
isDragging,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
range.start,
|
range.start,
|
||||||
@@ -272,11 +344,14 @@ function PlaybackCursor({
|
|||||||
keyframes,
|
keyframes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (videoDurationMs <= 0 || currentTimeMs < 0) {
|
const displayTimeMs =
|
||||||
|
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
|
||||||
|
|
||||||
|
if (videoDurationMs <= 0 || displayTimeMs < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
|
||||||
|
|
||||||
if (clampedTime < range.start || clampedTime > range.end) {
|
if (clampedTime < range.start || clampedTime > range.end) {
|
||||||
return null;
|
return null;
|
||||||
@@ -299,6 +374,7 @@ function PlaybackCursor({
|
|||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation(); // Prevent timeline click
|
e.stopPropagation(); // Prevent timeline click
|
||||||
|
setDragPreviewTimeMs(currentTimeMs);
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -444,6 +520,7 @@ function Timeline({
|
|||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
onSelectTrim,
|
onSelectTrim,
|
||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
@@ -458,6 +535,7 @@ function Timeline({
|
|||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||||
onSelectZoom?: (id: string | null) => void;
|
onSelectZoom?: (id: string | null) => void;
|
||||||
onSelectTrim?: (id: string | null) => void;
|
onSelectTrim?: (id: string | null) => void;
|
||||||
onSelectAnnotation?: (id: string | null) => void;
|
onSelectAnnotation?: (id: string | null) => void;
|
||||||
@@ -514,6 +592,46 @@ function Timeline({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTimelineWheel = useCallback(
|
||||||
|
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleMs = range.end - range.start;
|
||||||
|
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dominantDelta =
|
||||||
|
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||||
|
if (dominantDelta === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
|
||||||
|
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
|
||||||
|
const shiftMs = pixelsToValue(normalizedDeltaPx);
|
||||||
|
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start + shiftMs,
|
||||||
|
end: previous.end + shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
|
||||||
|
);
|
||||||
|
|
||||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||||
@@ -525,6 +643,7 @@ function Timeline({
|
|||||||
style={style}
|
style={style}
|
||||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||||
onClick={handleTimelineClick}
|
onClick={handleTimelineClick}
|
||||||
|
onWheel={handleTimelineWheel}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||||
@@ -532,6 +651,7 @@ function Timeline({
|
|||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
videoDurationMs={videoDurationMs}
|
videoDurationMs={videoDurationMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
timelineRef={localTimelineRef}
|
timelineRef={localTimelineRef}
|
||||||
keyframes={keyframes}
|
keyframes={keyframes}
|
||||||
/>
|
/>
|
||||||
@@ -657,17 +777,15 @@ export default function TimelineEditor({
|
|||||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||||
const [scrollLabels, setScrollLabels] = useState({
|
const [scrollLabels, setScrollLabels] = useState({
|
||||||
pan: "Shift + Ctrl + Scroll",
|
pan: "Scroll",
|
||||||
zoom: "Ctrl + Scroll",
|
zoom: "Ctrl + Scroll",
|
||||||
});
|
});
|
||||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
|
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
||||||
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
setScrollLabels({ pan: "Scroll", zoom });
|
||||||
setScrollLabels({ pan, zoom });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1351,6 +1469,7 @@ export default function TimelineEditor({
|
|||||||
videoDurationMs={totalMs}
|
videoDurationMs={totalMs}
|
||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
|
onRangeChange={setRange}
|
||||||
onSelectZoom={onSelectZoom}
|
onSelectZoom={onSelectZoom}
|
||||||
onSelectTrim={onSelectTrim}
|
onSelectTrim={onSelectTrim}
|
||||||
onSelectAnnotation={onSelectAnnotation}
|
onSelectAnnotation={onSelectAnnotation}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||||
|
|
||||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
export type { WebcamLayoutPreset };
|
||||||
|
|
||||||
|
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||||
|
|
||||||
export interface ZoomFocus {
|
export interface ZoomFocus {
|
||||||
cx: number; // normalized horizontal center (0-1)
|
cx: number; // normalized horizontal center (0-1)
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Application, Graphics, Sprite } from "pixi.js";
|
import { Application, Graphics, Sprite } from "pixi.js";
|
||||||
|
import {
|
||||||
|
computeCompositeLayout,
|
||||||
|
type RenderRect,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
import type { CropRegion } from "../types";
|
import type { CropRegion } from "../types";
|
||||||
|
|
||||||
interface LayoutParams {
|
interface LayoutParams {
|
||||||
@@ -11,6 +18,8 @@ interface LayoutParams {
|
|||||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
|
webcamDimensions?: Size | null;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutResult {
|
interface LayoutResult {
|
||||||
@@ -18,7 +27,8 @@ interface LayoutResult {
|
|||||||
videoSize: { width: number; height: number };
|
videoSize: { width: number; height: number };
|
||||||
baseScale: number;
|
baseScale: number;
|
||||||
baseOffset: { x: number; y: number };
|
baseOffset: { x: number; y: number };
|
||||||
maskRect: { x: number; y: number; width: number; height: number };
|
maskRect: RenderRect;
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +43,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
lockedVideoDimensions,
|
lockedVideoDimensions,
|
||||||
borderRadius = 0,
|
borderRadius = 0,
|
||||||
padding = 0,
|
padding = 0,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||||
@@ -71,11 +83,19 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
const maxDisplayWidth = width * paddingScale;
|
const maxDisplayWidth = width * paddingScale;
|
||||||
const maxDisplayHeight = height * paddingScale;
|
const maxDisplayHeight = height * paddingScale;
|
||||||
|
|
||||||
const scale = Math.min(
|
const compositeLayout = computeCompositeLayout({
|
||||||
maxDisplayWidth / croppedVideoWidth,
|
canvasSize: { width, height },
|
||||||
maxDisplayHeight / croppedVideoHeight,
|
maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
|
||||||
1,
|
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
);
|
webcamSize: webcamDimensions,
|
||||||
|
layoutPreset: webcamLayoutPreset,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!compositeLayout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
|
||||||
|
|
||||||
videoSprite.scale.set(scale);
|
videoSprite.scale.set(scale);
|
||||||
|
|
||||||
@@ -84,30 +104,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
const fullVideoDisplayHeight = videoHeight * scale;
|
const fullVideoDisplayHeight = videoHeight * scale;
|
||||||
|
|
||||||
// Calculate display size of just the cropped region
|
// Calculate display size of just the cropped region
|
||||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
|
||||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
|
||||||
|
|
||||||
// Center the cropped region in the container
|
|
||||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
|
||||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
|
||||||
|
|
||||||
// Position the full video sprite so that when we apply the mask,
|
// Position the full video sprite so that when we apply the mask,
|
||||||
// the cropped region appears centered
|
// the cropped region appears centered
|
||||||
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
||||||
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
||||||
// We want that point to be at centerOffsetX, centerOffsetY
|
// We want that point to be at screenRect.x, screenRect.y
|
||||||
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
|
const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth;
|
||||||
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
|
const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight;
|
||||||
|
|
||||||
videoSprite.position.set(spriteX, spriteY);
|
videoSprite.position.set(spriteX, spriteY);
|
||||||
|
|
||||||
// Create a mask that only shows the cropped region (centered in container)
|
|
||||||
const maskX = centerOffsetX;
|
|
||||||
const maskY = centerOffsetY;
|
|
||||||
|
|
||||||
// Apply border radius
|
// Apply border radius
|
||||||
maskGraphics.clear();
|
maskGraphics.clear();
|
||||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
|
maskGraphics.roundRect(
|
||||||
|
compositeLayout.screenRect.x,
|
||||||
|
compositeLayout.screenRect.y,
|
||||||
|
compositeLayout.screenRect.width,
|
||||||
|
compositeLayout.screenRect.height,
|
||||||
|
borderRadius,
|
||||||
|
);
|
||||||
maskGraphics.fill({ color: 0xffffff });
|
maskGraphics.fill({ color: 0xffffff });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +130,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
baseScale: scale,
|
baseScale: scale,
|
||||||
baseOffset: { x: spriteX, y: spriteY },
|
baseOffset: { x: spriteX, y: spriteY },
|
||||||
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
maskRect: compositeLayout.screenRect,
|
||||||
|
webcamRect: compositeLayout.webcamRect,
|
||||||
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_LAYOUT_PRESET } from "@/components/video-editor/types";
|
||||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
|
|
||||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||||
@@ -24,6 +25,7 @@ export interface EditorState {
|
|||||||
borderRadius: number;
|
borderRadius: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||||
@@ -39,6 +41,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
|||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
padding: 50,
|
padding: 50,
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||||
|
|
||||||
const TARGET_FRAME_RATE = 60;
|
const TARGET_FRAME_RATE = 60;
|
||||||
const MIN_FRAME_RATE = 30;
|
const MIN_FRAME_RATE = 30;
|
||||||
@@ -157,7 +158,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessResult = await window.electronAPI.requestCameraAccess();
|
const accessResult = await requestCameraAccess();
|
||||||
if (!accessResult.success) {
|
if (!accessResult.success) {
|
||||||
toast.error("Failed to request camera access.");
|
toast.error("Failed to request camera access.");
|
||||||
return false;
|
return false;
|
||||||
@@ -168,19 +169,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
setWebcamEnabledState(true);
|
||||||
const probeStream = await navigator.mediaDevices.getUserMedia({
|
return true;
|
||||||
audio: false,
|
|
||||||
video: true,
|
|
||||||
});
|
|
||||||
probeStream.getTracks().forEach((track) => track.stop());
|
|
||||||
setWebcamEnabledState(true);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to preflight webcam access:", error);
|
|
||||||
toast.error("Camera access denied. Webcam overlay will stay disabled.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const finalizeRecording = useCallback(
|
const finalizeRecording = useCallback(
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeCompositeLayout } from "./compositeLayout";
|
||||||
|
|
||||||
|
describe("computeCompositeLayout", () => {
|
||||||
|
it("anchors the overlay in the lower-right corner", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1920, height: 1080 },
|
||||||
|
screenSize: { width: 1920, height: 1080 },
|
||||||
|
webcamSize: { width: 1280, height: 720 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout!.webcamRect).not.toBeNull();
|
||||||
|
expect(layout!.webcamRect!.x + layout!.webcamRect!.width).toBeLessThanOrEqual(1920);
|
||||||
|
expect(layout!.webcamRect!.y + layout!.webcamRect!.height).toBeLessThanOrEqual(1080);
|
||||||
|
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
|
||||||
|
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1280, height: 720 },
|
||||||
|
screenSize: { width: 1280, height: 720 },
|
||||||
|
webcamSize: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout!.webcamRect).not.toBeNull();
|
||||||
|
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
|
||||||
|
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
|
||||||
|
expect(
|
||||||
|
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
|
||||||
|
).toBeLessThanOrEqual(1920);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers the combined screen and webcam stack in vertical stack mode", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1920, height: 1080 },
|
||||||
|
maxContentSize: { width: 1536, height: 864 },
|
||||||
|
screenSize: { width: 1920, height: 1080 },
|
||||||
|
webcamSize: { width: 1280, height: 720 },
|
||||||
|
layoutPreset: "vertical-stack",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout?.screenRect).toEqual({
|
||||||
|
x: 576,
|
||||||
|
y: 108,
|
||||||
|
width: 768,
|
||||||
|
height: 432,
|
||||||
|
});
|
||||||
|
expect(layout?.webcamRect).toEqual({
|
||||||
|
x: 576,
|
||||||
|
y: 540,
|
||||||
|
width: 768,
|
||||||
|
height: 432,
|
||||||
|
borderRadius: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1920, height: 1080 },
|
||||||
|
maxContentSize: { width: 1536, height: 864 },
|
||||||
|
screenSize: { width: 1920, height: 1080 },
|
||||||
|
layoutPreset: "vertical-stack",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout?.screenRect).toEqual({
|
||||||
|
x: 192,
|
||||||
|
y: 108,
|
||||||
|
width: 1536,
|
||||||
|
height: 864,
|
||||||
|
});
|
||||||
|
expect(layout?.webcamRect).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
export interface RenderRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyledRenderRect extends RenderRect {
|
||||||
|
borderRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
|
||||||
|
|
||||||
|
export interface WebcamLayoutShadow {
|
||||||
|
color: string;
|
||||||
|
blur: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BorderRadiusRule {
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
fraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverlayTransform {
|
||||||
|
type: "overlay";
|
||||||
|
maxStageFraction: number;
|
||||||
|
marginFraction: number;
|
||||||
|
minMargin: number;
|
||||||
|
minSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackTransform {
|
||||||
|
type: "stack";
|
||||||
|
gap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebcamLayoutPresetDefinition {
|
||||||
|
label: string;
|
||||||
|
transform: OverlayTransform | StackTransform;
|
||||||
|
borderRadius: BorderRadiusRule;
|
||||||
|
shadow: WebcamLayoutShadow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebcamCompositeLayout {
|
||||||
|
screenRect: RenderRect;
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STAGE_FRACTION = 0.18;
|
||||||
|
const MARGIN_FRACTION = 0.02;
|
||||||
|
const MIN_SIZE = 96;
|
||||||
|
const MAX_BORDER_RADIUS = 24;
|
||||||
|
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
|
||||||
|
"picture-in-picture": {
|
||||||
|
label: "Picture in Picture",
|
||||||
|
transform: {
|
||||||
|
type: "overlay",
|
||||||
|
maxStageFraction: MAX_STAGE_FRACTION,
|
||||||
|
marginFraction: MARGIN_FRACTION,
|
||||||
|
minMargin: 12,
|
||||||
|
minSize: MIN_SIZE,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
max: MAX_BORDER_RADIUS,
|
||||||
|
min: 12,
|
||||||
|
fraction: 0.12,
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
color: "rgba(0,0,0,0.35)",
|
||||||
|
blur: 24,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"vertical-stack": {
|
||||||
|
label: "Vertical Stack",
|
||||||
|
transform: {
|
||||||
|
type: "stack",
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
max: 0,
|
||||||
|
min: 0,
|
||||||
|
fraction: 0,
|
||||||
|
},
|
||||||
|
shadow: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
|
||||||
|
([value, preset]) => ({
|
||||||
|
value: value as WebcamLayoutPreset,
|
||||||
|
label: preset.label,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getWebcamLayoutPresetDefinition(
|
||||||
|
preset: WebcamLayoutPreset = "picture-in-picture",
|
||||||
|
): WebcamLayoutPresetDefinition {
|
||||||
|
return WEBCAM_LAYOUT_PRESET_MAP[preset];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebcamLayoutCssBoxShadow(
|
||||||
|
preset: WebcamLayoutPreset = "picture-in-picture",
|
||||||
|
): string {
|
||||||
|
const shadow = getWebcamLayoutPresetDefinition(preset).shadow;
|
||||||
|
return shadow
|
||||||
|
? `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`
|
||||||
|
: "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCompositeLayout(params: {
|
||||||
|
canvasSize: Size;
|
||||||
|
maxContentSize?: Size;
|
||||||
|
screenSize: Size;
|
||||||
|
webcamSize?: Size | null;
|
||||||
|
layoutPreset?: WebcamLayoutPreset;
|
||||||
|
}): WebcamCompositeLayout | null {
|
||||||
|
const {
|
||||||
|
canvasSize,
|
||||||
|
maxContentSize = canvasSize,
|
||||||
|
screenSize,
|
||||||
|
webcamSize,
|
||||||
|
layoutPreset = "picture-in-picture",
|
||||||
|
} = params;
|
||||||
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||||
|
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
|
||||||
|
const { width: screenWidth, height: screenHeight } = screenSize;
|
||||||
|
const webcamWidth = webcamSize?.width;
|
||||||
|
const webcamHeight = webcamSize?.height;
|
||||||
|
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
||||||
|
|
||||||
|
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.transform.type === "stack") {
|
||||||
|
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
||||||
|
return {
|
||||||
|
screenRect: centerRect({
|
||||||
|
canvasSize,
|
||||||
|
size: screenSize,
|
||||||
|
maxSize: maxContentSize,
|
||||||
|
}),
|
||||||
|
webcamRect: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = preset.transform.gap;
|
||||||
|
const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
|
||||||
|
const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
|
||||||
|
const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
|
||||||
|
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||||
|
const resolvedScreenHeight = Math.round(screenHeight * clampedScale);
|
||||||
|
const resolvedScreenWidth = Math.round(screenWidth * clampedScale);
|
||||||
|
const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale);
|
||||||
|
const resolvedGap = Math.round(gap * clampedScale);
|
||||||
|
const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight;
|
||||||
|
const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2));
|
||||||
|
const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2));
|
||||||
|
const screenRect = {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
width: resolvedScreenWidth,
|
||||||
|
height: resolvedScreenHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenRect,
|
||||||
|
webcamRect: {
|
||||||
|
x: left,
|
||||||
|
y: top + resolvedScreenHeight + resolvedGap,
|
||||||
|
width: resolvedScreenWidth,
|
||||||
|
height: resolvedWebcamHeight,
|
||||||
|
borderRadius: Math.min(
|
||||||
|
preset.borderRadius.max,
|
||||||
|
Math.max(
|
||||||
|
preset.borderRadius.min,
|
||||||
|
Math.round(
|
||||||
|
Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = preset.transform;
|
||||||
|
const screenRect = centerRect({
|
||||||
|
canvasSize,
|
||||||
|
size: screenSize,
|
||||||
|
maxSize: maxContentSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
||||||
|
return { screenRect, webcamRect: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = Math.max(
|
||||||
|
transform.minMargin,
|
||||||
|
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
|
||||||
|
);
|
||||||
|
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
|
||||||
|
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
|
||||||
|
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
|
||||||
|
const width = Math.round(webcamWidth * scale);
|
||||||
|
const height = Math.round(webcamHeight * scale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenRect,
|
||||||
|
webcamRect: {
|
||||||
|
x: Math.max(0, Math.round(canvasWidth - margin - width)),
|
||||||
|
y: Math.max(0, Math.round(canvasHeight - margin - height)),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius: Math.min(
|
||||||
|
preset.borderRadius.max,
|
||||||
|
Math.max(
|
||||||
|
preset.borderRadius.min,
|
||||||
|
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
|
||||||
|
const { canvasSize, size, maxSize } = params;
|
||||||
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||||
|
const { width, height } = size;
|
||||||
|
const { width: maxWidth, height: maxHeight } = maxSize;
|
||||||
|
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
||||||
|
const resolvedWidth = Math.round(width * scale);
|
||||||
|
const resolvedHeight = Math.round(height * scale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
|
||||||
|
y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
|
||||||
|
width: resolvedWidth,
|
||||||
|
height: resolvedHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
AnnotationRegion,
|
AnnotationRegion,
|
||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomDepth,
|
ZoomDepth,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
@@ -30,8 +31,19 @@ import {
|
|||||||
createMotionBlurState,
|
createMotionBlurState,
|
||||||
type MotionBlurState,
|
type MotionBlurState,
|
||||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||||
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
|
import {
|
||||||
|
computeCompositeLayout,
|
||||||
|
getWebcamLayoutPresetDefinition,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
import { renderAnnotations } from "./annotationRenderer";
|
import { renderAnnotations } from "./annotationRenderer";
|
||||||
|
import {
|
||||||
|
getLinearGradientPoints,
|
||||||
|
getRadialGradientShape,
|
||||||
|
parseCssGradient,
|
||||||
|
resolveLinearGradientAngle,
|
||||||
|
} from "./gradientParser";
|
||||||
|
|
||||||
interface FrameRenderConfig {
|
interface FrameRenderConfig {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -47,8 +59,8 @@ interface FrameRenderConfig {
|
|||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
videoWidth: number;
|
videoWidth: number;
|
||||||
videoHeight: number;
|
videoHeight: number;
|
||||||
webcamWidth?: number;
|
webcamSize?: Size | null;
|
||||||
webcamHeight?: number;
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
speedRegions?: SpeedRegion[];
|
speedRegions?: SpeedRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
@@ -71,6 +83,7 @@ interface LayoutCache {
|
|||||||
baseScale: number;
|
baseScale: number;
|
||||||
baseOffset: { x: number; y: number };
|
baseOffset: { x: number; y: number };
|
||||||
maskRect: { x: number; y: number; width: number; height: number };
|
maskRect: { x: number; y: number; width: number; height: number };
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
|
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
|
||||||
@@ -156,7 +169,9 @@ export class FrameRenderer {
|
|||||||
this.compositeCanvas = document.createElement("canvas");
|
this.compositeCanvas = document.createElement("canvas");
|
||||||
this.compositeCanvas.width = this.config.width;
|
this.compositeCanvas.width = this.config.width;
|
||||||
this.compositeCanvas.height = this.config.height;
|
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) {
|
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");
|
||||||
@@ -167,7 +182,9 @@ export class FrameRenderer {
|
|||||||
this.shadowCanvas = document.createElement("canvas");
|
this.shadowCanvas = document.createElement("canvas");
|
||||||
this.shadowCanvas.width = this.config.width;
|
this.shadowCanvas.width = this.config.width;
|
||||||
this.shadowCanvas.height = this.config.height;
|
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) {
|
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");
|
||||||
@@ -248,40 +265,39 @@ export class FrameRenderer {
|
|||||||
wallpaper.startsWith("linear-gradient") ||
|
wallpaper.startsWith("linear-gradient") ||
|
||||||
wallpaper.startsWith("radial-gradient")
|
wallpaper.startsWith("radial-gradient")
|
||||||
) {
|
) {
|
||||||
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
|
const parsedGradient = parseCssGradient(wallpaper);
|
||||||
if (gradientMatch) {
|
if (parsedGradient) {
|
||||||
const [, type, params] = gradientMatch;
|
const gradient =
|
||||||
const parts = params.split(",").map((s) => s.trim());
|
parsedGradient.type === "linear"
|
||||||
|
? (() => {
|
||||||
|
const points = getLinearGradientPoints(
|
||||||
|
resolveLinearGradientAngle(parsedGradient.descriptor),
|
||||||
|
this.config.width,
|
||||||
|
this.config.height,
|
||||||
|
);
|
||||||
|
|
||||||
let gradient: CanvasGradient;
|
return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1);
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
const shape = getRadialGradientShape(
|
||||||
|
parsedGradient.descriptor,
|
||||||
|
this.config.width,
|
||||||
|
this.config.height,
|
||||||
|
);
|
||||||
|
|
||||||
if (type === "linear") {
|
return bgCtx.createRadialGradient(
|
||||||
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
|
shape.cx,
|
||||||
parts.forEach((part, index) => {
|
shape.cy,
|
||||||
if (part.startsWith("to ") || part.includes("deg")) return;
|
0,
|
||||||
|
shape.cx,
|
||||||
|
shape.cy,
|
||||||
|
shape.radius,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
parsedGradient.stops.forEach((stop) => {
|
||||||
if (colorMatch) {
|
gradient.addColorStop(stop.offset, stop.color);
|
||||||
const color = colorMatch[1];
|
});
|
||||||
const position = index / (parts.length - 1);
|
|
||||||
gradient.addColorStop(position, color);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const cx = this.config.width / 2;
|
|
||||||
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]+)/);
|
|
||||||
if (colorMatch) {
|
|
||||||
const color = colorMatch[1];
|
|
||||||
const position = index / (parts.length - 1);
|
|
||||||
gradient.addColorStop(position, color);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bgCtx.fillStyle = gradient;
|
bgCtx.fillStyle = gradient;
|
||||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||||
@@ -329,7 +345,7 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
this.updateLayout();
|
this.updateLayout(webcamFrame);
|
||||||
|
|
||||||
const timeMs = this.currentVideoTime * 1000;
|
const timeMs = this.currentVideoTime * 1000;
|
||||||
const TICKS_PER_FRAME = 1;
|
const TICKS_PER_FRAME = 1;
|
||||||
@@ -393,7 +409,7 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLayout(): void {
|
private updateLayout(webcamFrame?: VideoFrame | null): 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 { width, height } = this.config;
|
||||||
@@ -415,7 +431,16 @@ export class FrameRenderer {
|
|||||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||||
const viewportWidth = width * paddingScale;
|
const viewportWidth = width * paddingScale;
|
||||||
const viewportHeight = height * paddingScale;
|
const viewportHeight = height * paddingScale;
|
||||||
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
|
const compositeLayout = computeCompositeLayout({
|
||||||
|
canvasSize: { width, height },
|
||||||
|
maxContentSize: { width: viewportWidth, height: viewportHeight },
|
||||||
|
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
|
webcamSize: webcamFrame ? this.config.webcamSize : null,
|
||||||
|
layoutPreset: this.config.webcamLayoutPreset,
|
||||||
|
});
|
||||||
|
if (!compositeLayout) return;
|
||||||
|
|
||||||
|
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
|
||||||
|
|
||||||
// Position video sprite
|
// Position video sprite
|
||||||
this.videoSprite.width = videoWidth * scale;
|
this.videoSprite.width = videoWidth * scale;
|
||||||
@@ -427,12 +452,10 @@ export class FrameRenderer {
|
|||||||
this.videoSprite.y = -cropPixelY;
|
this.videoSprite.y = -cropPixelY;
|
||||||
|
|
||||||
// Position video container
|
// Position video container
|
||||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
const croppedDisplayWidth = compositeLayout.screenRect.width;
|
||||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
const croppedDisplayHeight = compositeLayout.screenRect.height;
|
||||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
this.videoContainer.x = compositeLayout.screenRect.x;
|
||||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
this.videoContainer.y = compositeLayout.screenRect.y;
|
||||||
this.videoContainer.x = centerOffsetX;
|
|
||||||
this.videoContainer.y = centerOffsetY;
|
|
||||||
|
|
||||||
// scale border radius by export/preview canvas ratio
|
// scale border radius by export/preview canvas ratio
|
||||||
const previewWidth = this.config.previewWidth || 1920;
|
const previewWidth = this.config.previewWidth || 1920;
|
||||||
@@ -455,8 +478,9 @@ export class FrameRenderer {
|
|||||||
stageSize: { width, height },
|
stageSize: { width, height },
|
||||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
baseScale: scale,
|
baseScale: scale,
|
||||||
baseOffset: { x: centerOffsetX, y: centerOffsetY },
|
baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
|
||||||
maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
maskRect: compositeLayout.screenRect,
|
||||||
|
webcamRect: compositeLayout.webcamRect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,34 +652,36 @@ export class FrameRenderer {
|
|||||||
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (webcamFrame && this.config.webcamWidth && this.config.webcamHeight) {
|
const webcamRect = this.layoutCache?.webcamRect ?? null;
|
||||||
const layout = computeWebcamOverlayLayout({
|
if (webcamFrame && webcamRect) {
|
||||||
stageWidth: w,
|
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||||
stageHeight: h,
|
ctx.save();
|
||||||
videoWidth: this.config.webcamWidth,
|
ctx.beginPath();
|
||||||
videoHeight: this.config.webcamHeight,
|
ctx.roundRect(
|
||||||
});
|
webcamRect.x,
|
||||||
|
webcamRect.y,
|
||||||
if (layout) {
|
webcamRect.width,
|
||||||
ctx.save();
|
webcamRect.height,
|
||||||
ctx.beginPath();
|
webcamRect.borderRadius,
|
||||||
ctx.roundRect(layout.x, layout.y, layout.width, layout.height, layout.borderRadius);
|
);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.shadowColor = "rgba(0,0,0,0.35)";
|
if (preset.shadow) {
|
||||||
ctx.shadowBlur = 24;
|
ctx.shadowColor = preset.shadow.color;
|
||||||
ctx.shadowOffsetY = 10;
|
ctx.shadowBlur = preset.shadow.blur;
|
||||||
ctx.fillStyle = "#000000";
|
ctx.shadowOffsetX = preset.shadow.offsetX;
|
||||||
ctx.fill();
|
ctx.shadowOffsetY = preset.shadow.offsetY;
|
||||||
ctx.clip();
|
|
||||||
ctx.drawImage(
|
|
||||||
webcamFrame as unknown as CanvasImageSource,
|
|
||||||
layout.x,
|
|
||||||
layout.y,
|
|
||||||
layout.width,
|
|
||||||
layout.height,
|
|
||||||
);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(
|
||||||
|
webcamFrame as unknown as CanvasImageSource,
|
||||||
|
webcamRect.x,
|
||||||
|
webcamRect.y,
|
||||||
|
webcamRect.width,
|
||||||
|
webcamRect.height,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +699,11 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
this.backgroundSprite = null;
|
this.backgroundSprite = null;
|
||||||
if (this.app) {
|
if (this.app) {
|
||||||
this.app.destroy(true, { children: true, texture: true, textureSource: true });
|
this.app.destroy(true, {
|
||||||
|
children: true,
|
||||||
|
texture: true,
|
||||||
|
textureSource: true,
|
||||||
|
});
|
||||||
this.app = null;
|
this.app = null;
|
||||||
}
|
}
|
||||||
this.cameraContainer = null;
|
this.cameraContainer = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||||
@@ -39,6 +40,7 @@ interface GifExporterConfig {
|
|||||||
padding?: number;
|
padding?: number;
|
||||||
videoPadding?: number;
|
videoPadding?: number;
|
||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
@@ -136,8 +138,8 @@ export class GifExporter {
|
|||||||
cropRegion: this.config.cropRegion,
|
cropRegion: this.config.cropRegion,
|
||||||
videoWidth: videoInfo.width,
|
videoWidth: videoInfo.width,
|
||||||
videoHeight: videoInfo.height,
|
videoHeight: videoInfo.height,
|
||||||
webcamWidth: webcamInfo?.width,
|
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||||
webcamHeight: webcamInfo?.height,
|
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||||
annotationRegions: this.config.annotationRegions,
|
annotationRegions: this.config.annotationRegions,
|
||||||
speedRegions: this.config.speedRegions,
|
speedRegions: this.config.speedRegions,
|
||||||
previewWidth: this.config.previewWidth,
|
previewWidth: this.config.previewWidth,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getLinearGradientPoints,
|
||||||
|
getRadialGradientShape,
|
||||||
|
parseCssGradient,
|
||||||
|
resolveLinearGradientAngle,
|
||||||
|
} from "./gradientParser";
|
||||||
|
|
||||||
|
describe("parseCssGradient", () => {
|
||||||
|
it("parses rgba-based gradient presets without splitting inside color functions", () => {
|
||||||
|
const parsed = parseCssGradient(
|
||||||
|
"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% )",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed?.type).toBe("linear");
|
||||||
|
expect(parsed?.descriptor).toBe("111.6deg");
|
||||||
|
expect(parsed?.stops).toHaveLength(4);
|
||||||
|
expect(parsed?.stops.map((stop) => stop.color)).toEqual([
|
||||||
|
"rgba(114,167,232,1)",
|
||||||
|
"rgba(253,129,82,1)",
|
||||||
|
"rgba(253,129,82,1)",
|
||||||
|
"rgba(249,202,86,1)",
|
||||||
|
]);
|
||||||
|
expect(parsed?.stops[0]?.offset).toBeCloseTo(0.094);
|
||||||
|
expect(parsed?.stops[1]?.offset).toBeCloseTo(0.439);
|
||||||
|
expect(parsed?.stops[2]?.offset).toBeCloseTo(0.548);
|
||||||
|
expect(parsed?.stops[3]?.offset).toBeCloseTo(0.863);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills missing stop positions for simple hex gradients", () => {
|
||||||
|
const parsed = parseCssGradient("linear-gradient(135deg, #FBC8B4, #2447B1)");
|
||||||
|
|
||||||
|
expect(parsed?.stops).toEqual([
|
||||||
|
{ color: "#FBC8B4", offset: 0 },
|
||||||
|
{ color: "#2447B1", offset: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gradient geometry", () => {
|
||||||
|
it("maps linear directions to canvas endpoints", () => {
|
||||||
|
const angle = resolveLinearGradientAngle("to right");
|
||||||
|
const points = getLinearGradientPoints(angle, 1920, 1080);
|
||||||
|
|
||||||
|
expect(points.x0).toBeCloseTo(0);
|
||||||
|
expect(points.y0).toBeCloseTo(540);
|
||||||
|
expect(points.x1).toBeCloseTo(1920);
|
||||||
|
expect(points.y1).toBeCloseTo(540);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses radial positions from the descriptor", () => {
|
||||||
|
const shape = getRadialGradientShape("circle farthest-corner at 10% 20%", 1000, 500);
|
||||||
|
|
||||||
|
expect(shape.cx).toBe(100);
|
||||||
|
expect(shape.cy).toBe(100);
|
||||||
|
expect(shape.radius).toBeCloseTo(Math.hypot(900, 400));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
export interface ParsedGradientStop {
|
||||||
|
color: string;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedGradient {
|
||||||
|
type: "linear" | "radial";
|
||||||
|
descriptor: string | null;
|
||||||
|
stops: ParsedGradientStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_TOKEN_RE = /^(#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\([^)]*\)|[a-zA-Z-]+)/;
|
||||||
|
|
||||||
|
export function parseCssGradient(input: string): ParsedGradient | null {
|
||||||
|
const gradientMatch = input.match(/^(linear|radial)-gradient\((.*)\)$/i);
|
||||||
|
if (!gradientMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = gradientMatch[1].toLowerCase() as ParsedGradient["type"];
|
||||||
|
const rawArgs = splitGradientArgs(gradientMatch[2]);
|
||||||
|
if (rawArgs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let descriptor: string | null = null;
|
||||||
|
let stopArgs = rawArgs;
|
||||||
|
|
||||||
|
if (isGradientDescriptor(type, rawArgs[0])) {
|
||||||
|
descriptor = rawArgs[0];
|
||||||
|
stopArgs = rawArgs.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedStops = stopArgs
|
||||||
|
.map((part) => parseColorStop(part))
|
||||||
|
.filter((stop): stop is { color: string; offset: number | null } => stop !== null);
|
||||||
|
|
||||||
|
if (parsedStops.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
descriptor,
|
||||||
|
stops: normalizeStopOffsets(parsedStops),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLinearGradientPoints(angleDeg: number, width: number, height: number) {
|
||||||
|
const radians = (angleDeg * Math.PI) / 180;
|
||||||
|
const vx = Math.sin(radians);
|
||||||
|
const vy = -Math.cos(radians);
|
||||||
|
const halfSpan = (Math.abs(vx) * width + Math.abs(vy) * height) / 2;
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x0: cx - vx * halfSpan,
|
||||||
|
y0: cy - vy * halfSpan,
|
||||||
|
x1: cx + vx * halfSpan,
|
||||||
|
y1: cy + vy * halfSpan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLinearGradientAngle(descriptor: string | null): number {
|
||||||
|
if (!descriptor) {
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
const angleMatch = descriptor.match(/(-?\d*\.?\d+)deg/i);
|
||||||
|
if (angleMatch) {
|
||||||
|
return Number.parseFloat(angleMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = descriptor.trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
const directionMap: Record<string, number> = {
|
||||||
|
"to top": 0,
|
||||||
|
"to top right": 45,
|
||||||
|
"to right": 90,
|
||||||
|
"to bottom right": 135,
|
||||||
|
"to bottom": 180,
|
||||||
|
"to bottom left": 225,
|
||||||
|
"to left": 270,
|
||||||
|
"to top left": 315,
|
||||||
|
};
|
||||||
|
|
||||||
|
return directionMap[normalized] ?? 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRadialGradientShape(descriptor: string | null, width: number, height: number) {
|
||||||
|
const atMatch = descriptor?.match(/at\s+(-?\d*\.?\d+)%\s+(-?\d*\.?\d+)%/i);
|
||||||
|
const cx = atMatch ? (Number.parseFloat(atMatch[1]) / 100) * width : width / 2;
|
||||||
|
const cy = atMatch ? (Number.parseFloat(atMatch[2]) / 100) * height : height / 2;
|
||||||
|
|
||||||
|
const distances = [
|
||||||
|
Math.hypot(cx, cy),
|
||||||
|
Math.hypot(width - cx, cy),
|
||||||
|
Math.hypot(cx, height - cy),
|
||||||
|
Math.hypot(width - cx, height - cy),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
radius: Math.max(...distances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitGradientArgs(input: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
for (const char of input) {
|
||||||
|
if (char === "(") {
|
||||||
|
depth += 1;
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ")") {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "," && depth === 0) {
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
parts.push(trimmed);
|
||||||
|
}
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
parts.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGradientDescriptor(type: ParsedGradient["type"], part: string) {
|
||||||
|
if (type === "linear") {
|
||||||
|
return /^\s*to\s+/i.test(part) || /-?\d*\.?\d+deg/i.test(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\b(circle|ellipse|closest|farthest)\b/i.test(part) || /\bat\b/i.test(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorStop(part: string): { color: string; offset: number | null } | null {
|
||||||
|
const match = part.trim().match(COLOR_TOKEN_RE);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = match[1];
|
||||||
|
const rest = part.slice(match[0].length);
|
||||||
|
const percentMatch = rest.match(/(-?\d*\.?\d+)%/);
|
||||||
|
const offset = percentMatch ? clamp(Number.parseFloat(percentMatch[1]) / 100, 0, 1) : null;
|
||||||
|
|
||||||
|
return { color, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStopOffsets(
|
||||||
|
stops: Array<{ color: string; offset: number | null }>,
|
||||||
|
): ParsedGradientStop[] {
|
||||||
|
const explicitCount = stops.filter((stop) => stop.offset !== null).length;
|
||||||
|
if (explicitCount === 0) {
|
||||||
|
if (stops.length === 1) {
|
||||||
|
return [{ color: stops[0].color, offset: 0 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.map((stop, index) => ({
|
||||||
|
color: stop.color,
|
||||||
|
offset: index / (stops.length - 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = stops.map((stop) => stop.offset);
|
||||||
|
const firstExplicit = resolved.findIndex((offset) => offset !== null);
|
||||||
|
const lastExplicit = findLastDefinedIndex(resolved);
|
||||||
|
|
||||||
|
for (let index = 0; index < firstExplicit; index += 1) {
|
||||||
|
const end = resolved[firstExplicit] ?? 0;
|
||||||
|
resolved[index] = firstExplicit === 0 ? end : (end * index) / firstExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = lastExplicit + 1; index < resolved.length; index += 1) {
|
||||||
|
const start = resolved[lastExplicit] ?? 1;
|
||||||
|
const denominator = resolved.length - 1 - lastExplicit;
|
||||||
|
resolved[index] =
|
||||||
|
denominator <= 0 ? start : start + ((1 - start) * (index - lastExplicit)) / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runStart = firstExplicit;
|
||||||
|
while (runStart < lastExplicit) {
|
||||||
|
const nextExplicit = resolved.findIndex((offset, index) => index > runStart && offset !== null);
|
||||||
|
if (nextExplicit === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = resolved[runStart] ?? 0;
|
||||||
|
const end = resolved[nextExplicit] ?? start;
|
||||||
|
const gap = nextExplicit - runStart;
|
||||||
|
|
||||||
|
for (let index = runStart + 1; index < nextExplicit; index += 1) {
|
||||||
|
resolved[index] = start + ((end - start) * (index - runStart)) / gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStart = nextExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.map((stop, index) => ({
|
||||||
|
color: stop.color,
|
||||||
|
offset: clamp(resolved[index] ?? 0, 0, 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastDefinedIndex(values: Array<number | null>) {
|
||||||
|
for (let index = values.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (values[index] !== null) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldFailDecodeEndedEarly } from "./streamingDecoder";
|
||||||
|
|
||||||
|
describe("shouldFailDecodeEndedEarly", () => {
|
||||||
|
it("does not fail once every segment has been satisfied", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: 5.33,
|
||||||
|
requiredEndSec: 6.498,
|
||||||
|
streamDurationSec: 5.33,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when decode stops far before the required end", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: 5.33,
|
||||||
|
requiredEndSec: 10,
|
||||||
|
streamDurationSec: 5.33,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when no frame could be decoded for a non-empty timeline", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: null,
|
||||||
|
requiredEndSec: 1,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the decoder has not reached the reported stream end", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: 4.9,
|
||||||
|
requiredEndSec: 6.498,
|
||||||
|
streamDurationSec: 5.33,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { WebDemuxer } from "web-demuxer";
|
import { WebDemuxer } from "web-demuxer";
|
||||||
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||||
|
|
||||||
|
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export interface DecodedVideoInfo {
|
export interface DecodedVideoInfo {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -12,6 +14,55 @@ export interface DecodedVideoInfo {
|
|||||||
audioCodec?: string;
|
audioCodec?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EarlyDecodeEndCheck = {
|
||||||
|
cancelled: boolean;
|
||||||
|
lastDecodedFrameSec: number | null;
|
||||||
|
requiredEndSec: number;
|
||||||
|
streamDurationSec?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EARLY_DECODE_END_THRESHOLD_SEC = 1;
|
||||||
|
const METADATA_TAIL_TOLERANCE_SEC = 1.5;
|
||||||
|
const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25;
|
||||||
|
|
||||||
|
export function shouldFailDecodeEndedEarly({
|
||||||
|
cancelled,
|
||||||
|
lastDecodedFrameSec,
|
||||||
|
requiredEndSec,
|
||||||
|
streamDurationSec,
|
||||||
|
}: EarlyDecodeEndCheck): boolean {
|
||||||
|
if (cancelled || requiredEndSec <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastDecodedFrameSec === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeGapSec = requiredEndSec - lastDecodedFrameSec;
|
||||||
|
if (decodeGapSec <= EARLY_DECODE_END_THRESHOLD_SEC) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataTailSec = requiredEndSec - streamDurationSec;
|
||||||
|
const decodedNearStreamEnd =
|
||||||
|
Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC;
|
||||||
|
|
||||||
|
if (
|
||||||
|
decodedNearStreamEnd &&
|
||||||
|
metadataTailSec > 0 &&
|
||||||
|
metadataTailSec <= METADATA_TAIL_TOLERANCE_SEC
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Caller must close the VideoFrame after use. */
|
/** Caller must close the VideoFrame after use. */
|
||||||
type OnFrameCallback = (
|
type OnFrameCallback = (
|
||||||
frame: VideoFrame,
|
frame: VideoFrame,
|
||||||
@@ -36,7 +87,11 @@ export class StreamingVideoDecoder {
|
|||||||
const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl);
|
const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl);
|
||||||
|
|
||||||
if (!isRemoteUrl && window.electronAPI?.readBinaryFile) {
|
if (!isRemoteUrl && window.electronAPI?.readBinaryFile) {
|
||||||
const result = await window.electronAPI.readBinaryFile(videoUrl);
|
const result = await this.withTimeout(
|
||||||
|
window.electronAPI.readBinaryFile(videoUrl),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while loading the source video.",
|
||||||
|
);
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || result.error || "Failed to read source video");
|
throw new Error(result.message || result.error || "Failed to read source video");
|
||||||
}
|
}
|
||||||
@@ -49,11 +104,19 @@ export class StreamingVideoDecoder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(videoUrl);
|
const response = await this.withTimeout(
|
||||||
|
fetch(videoUrl),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while loading the source video.",
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await this.withTimeout(
|
||||||
|
response.blob(),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while reading the source video.",
|
||||||
|
);
|
||||||
const filename = videoUrl.split("/").pop() || "video";
|
const filename = videoUrl.split("/").pop() || "video";
|
||||||
return {
|
return {
|
||||||
blob,
|
blob,
|
||||||
@@ -67,9 +130,17 @@ export class StreamingVideoDecoder {
|
|||||||
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
|
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
|
||||||
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
|
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
|
||||||
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
|
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
|
||||||
await this.demuxer.load(file);
|
await this.withTimeout(
|
||||||
|
this.demuxer.load(file),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while parsing the source video.",
|
||||||
|
);
|
||||||
|
|
||||||
const mediaInfo = await this.demuxer.getMediaInfo();
|
const mediaInfo = await this.withTimeout(
|
||||||
|
this.demuxer.getMediaInfo(),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while reading video metadata.",
|
||||||
|
);
|
||||||
const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video");
|
const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video");
|
||||||
|
|
||||||
let frameRate = 60;
|
let frameRate = 60;
|
||||||
@@ -366,12 +437,17 @@ export class StreamingVideoDecoder {
|
|||||||
|
|
||||||
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
||||||
if (
|
if (
|
||||||
!this.cancelled &&
|
shouldFailDecodeEndedEarly({
|
||||||
lastDecodedFrameSec !== null &&
|
cancelled: this.cancelled,
|
||||||
requiredEndSec - lastDecodedFrameSec > 1
|
lastDecodedFrameSec,
|
||||||
|
requiredEndSec,
|
||||||
|
streamDurationSec: this.metadata.streamDuration,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
|
const decodedAtLabel =
|
||||||
|
lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
|
`Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,4 +548,20 @@ export class StreamingVideoDecoder {
|
|||||||
this.demuxer = null;
|
this.demuxer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+186
-106
@@ -3,6 +3,7 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||||
@@ -12,6 +13,9 @@ import { VideoMuxer } from "./muxer";
|
|||||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||||
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
||||||
|
|
||||||
|
const ENCODER_STALL_TIMEOUT_MS = 15_000;
|
||||||
|
const ENCODER_FLUSH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
interface VideoExporterConfig extends ExportConfig {
|
interface VideoExporterConfig extends ExportConfig {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
webcamVideoUrl?: string;
|
webcamVideoUrl?: string;
|
||||||
@@ -27,6 +31,7 @@ interface VideoExporterConfig extends ExportConfig {
|
|||||||
padding?: number;
|
padding?: number;
|
||||||
videoPadding?: number;
|
videoPadding?: number;
|
||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
@@ -43,35 +48,76 @@ export class VideoExporter {
|
|||||||
private webcamDecoder: StreamingVideoDecoder | null = null;
|
private webcamDecoder: StreamingVideoDecoder | null = null;
|
||||||
private cancelled = false;
|
private cancelled = false;
|
||||||
private encodeQueue = 0;
|
private encodeQueue = 0;
|
||||||
// Increased queue size for better throughput with hardware encoding
|
// Keep a smaller queue for software encoding so Windows does not balloon memory.
|
||||||
private readonly MAX_ENCODE_QUEUE = 120;
|
private readonly MAX_ENCODE_QUEUE = 120;
|
||||||
private videoDescription: Uint8Array | undefined;
|
private videoDescription: Uint8Array | undefined;
|
||||||
private videoColorSpace: VideoColorSpaceInit | undefined;
|
private videoColorSpace: VideoColorSpaceInit | undefined;
|
||||||
// Track muxing promises for parallel processing
|
|
||||||
private muxingPromises: Promise<void>[] = [];
|
private muxingPromises: Promise<void>[] = [];
|
||||||
private chunkCount = 0;
|
private chunkCount = 0;
|
||||||
|
private lastEncoderOutputAt = 0;
|
||||||
|
private fatalEncoderError: Error | null = null;
|
||||||
|
|
||||||
constructor(config: VideoExporterConfig) {
|
constructor(config: VideoExporterConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ExportResult> {
|
async export(): Promise<ExportResult> {
|
||||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
const encoderPreferences = this.getEncoderPreferences();
|
||||||
try {
|
let lastError: Error | null = null;
|
||||||
this.cleanup();
|
|
||||||
this.cancelled = false;
|
|
||||||
|
|
||||||
// Initialize streaming decoder and load video metadata
|
for (const encoderPreference of encoderPreferences) {
|
||||||
this.streamingDecoder = new StreamingVideoDecoder();
|
try {
|
||||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
return await this.exportWithEncoderPreference(encoderPreference);
|
||||||
|
} catch (error) {
|
||||||
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
lastError = normalizedError;
|
||||||
|
|
||||||
|
if (this.cancelled) {
|
||||||
|
return { success: false, error: "Export cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoderPreferences.length > 1) {
|
||||||
|
console.warn(
|
||||||
|
`[VideoExporter] ${encoderPreference} export attempt failed:`,
|
||||||
|
normalizedError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: lastError?.message || "Export failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportWithEncoderPreference(
|
||||||
|
encoderPreference: HardwareAcceleration,
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||||
|
let stopWebcamDecode = false;
|
||||||
|
let webcamDecodeError: Error | null = null;
|
||||||
|
let webcamDecodePromise: Promise<void> | null = null;
|
||||||
|
let webcamDecoder: StreamingVideoDecoder | null = null;
|
||||||
|
|
||||||
|
this.cleanup();
|
||||||
|
this.cancelled = false;
|
||||||
|
this.fatalEncoderError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamingDecoder = new StreamingVideoDecoder();
|
||||||
|
this.streamingDecoder = streamingDecoder;
|
||||||
|
const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||||
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
|
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
|
||||||
if (this.config.webcamVideoUrl) {
|
if (this.config.webcamVideoUrl) {
|
||||||
this.webcamDecoder = new StreamingVideoDecoder();
|
webcamDecoder = new StreamingVideoDecoder();
|
||||||
webcamInfo = await this.webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
|
this.webcamDecoder = webcamDecoder;
|
||||||
|
webcamInfo = await webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize frame renderer
|
const renderer = new FrameRenderer({
|
||||||
this.renderer = new FrameRenderer({
|
|
||||||
width: this.config.width,
|
width: this.config.width,
|
||||||
height: this.config.height,
|
height: this.config.height,
|
||||||
wallpaper: this.config.wallpaper,
|
wallpaper: this.config.wallpaper,
|
||||||
@@ -85,25 +131,24 @@ export class VideoExporter {
|
|||||||
cropRegion: this.config.cropRegion,
|
cropRegion: this.config.cropRegion,
|
||||||
videoWidth: videoInfo.width,
|
videoWidth: videoInfo.width,
|
||||||
videoHeight: videoInfo.height,
|
videoHeight: videoInfo.height,
|
||||||
webcamWidth: webcamInfo?.width,
|
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||||
webcamHeight: webcamInfo?.height,
|
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||||
annotationRegions: this.config.annotationRegions,
|
annotationRegions: this.config.annotationRegions,
|
||||||
speedRegions: this.config.speedRegions,
|
speedRegions: this.config.speedRegions,
|
||||||
previewWidth: this.config.previewWidth,
|
previewWidth: this.config.previewWidth,
|
||||||
previewHeight: this.config.previewHeight,
|
previewHeight: this.config.previewHeight,
|
||||||
});
|
});
|
||||||
await this.renderer.initialize();
|
this.renderer = renderer;
|
||||||
|
await renderer.initialize();
|
||||||
|
|
||||||
// Initialize video encoder
|
await this.initializeEncoder(encoderPreference);
|
||||||
await this.initializeEncoder();
|
|
||||||
|
|
||||||
// Initialize muxer (with audio if source has an audio track)
|
|
||||||
const hasAudio = videoInfo.hasAudio;
|
const hasAudio = videoInfo.hasAudio;
|
||||||
this.muxer = new VideoMuxer(this.config, hasAudio);
|
const muxer = new VideoMuxer(this.config, hasAudio);
|
||||||
await this.muxer.initialize();
|
this.muxer = muxer;
|
||||||
|
await muxer.initialize();
|
||||||
|
|
||||||
// Calculate effective duration and frame count (excluding trim regions)
|
const effectiveDuration = streamingDecoder.getEffectiveDuration(
|
||||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
|
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
);
|
);
|
||||||
@@ -115,16 +160,19 @@ export class VideoExporter {
|
|||||||
console.log("[VideoExporter] Total frames to export:", totalFrames);
|
console.log("[VideoExporter] Total frames to export:", totalFrames);
|
||||||
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||||
|
|
||||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
const frameDuration = 1_000_000 / this.config.frameRate;
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
|
const maxEncodeQueue =
|
||||||
|
encoderPreference === "prefer-software"
|
||||||
|
? Math.min(this.MAX_ENCODE_QUEUE, 32)
|
||||||
|
: this.MAX_ENCODE_QUEUE;
|
||||||
|
|
||||||
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
||||||
let stopWebcamDecode = false;
|
webcamDecodePromise =
|
||||||
let webcamDecodeError: Error | null = null;
|
webcamDecoder && webcamFrameQueue
|
||||||
const webcamDecodePromise =
|
|
||||||
this.webcamDecoder && webcamFrameQueue
|
|
||||||
? (() => {
|
? (() => {
|
||||||
const queue = webcamFrameQueue;
|
const queue = webcamFrameQueue;
|
||||||
return this.webcamDecoder
|
return webcamDecoder
|
||||||
.decodeAll(
|
.decodeAll(
|
||||||
this.config.frameRate,
|
this.config.frameRate,
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
@@ -142,7 +190,7 @@ export class VideoExporter {
|
|||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
|
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
|
||||||
throw error;
|
throw webcamDecodeError;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (webcamDecodeError) {
|
if (webcamDecodeError) {
|
||||||
@@ -154,8 +202,7 @@ export class VideoExporter {
|
|||||||
})()
|
})()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Stream decode and process frames — no seeking!
|
await streamingDecoder.decodeAll(
|
||||||
await this.streamingDecoder.decodeAll(
|
|
||||||
this.config.frameRate,
|
this.config.frameRate,
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
@@ -166,21 +213,22 @@ export class VideoExporter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.fatalEncoderError) {
|
||||||
|
throw this.fatalEncoderError;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = frameIndex * frameDuration;
|
const timestamp = frameIndex * frameDuration;
|
||||||
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
||||||
const renderer = this.renderer;
|
if (this.cancelled) {
|
||||||
if (this.cancelled || !renderer) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the frame with all effects using source timestamp
|
const sourceTimestampUs = sourceTimestampMs * 1000;
|
||||||
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
|
|
||||||
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
|
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
|
||||||
|
|
||||||
const canvas = renderer.getCanvas();
|
const canvas = renderer.getCanvas();
|
||||||
|
|
||||||
// Create VideoFrame from canvas on GPU without reading pixels
|
// @ts-expect-error - colorSpace is available at runtime even if TS does not know it.
|
||||||
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
|
|
||||||
const exportFrame = new VideoFrame(canvas, {
|
const exportFrame = new VideoFrame(canvas, {
|
||||||
timestamp,
|
timestamp,
|
||||||
duration: frameDuration,
|
duration: frameDuration,
|
||||||
@@ -192,12 +240,19 @@ export class VideoExporter {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check encoder queue before encoding to keep it full
|
|
||||||
while (
|
while (
|
||||||
this.encoder &&
|
this.encoder &&
|
||||||
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
|
this.encoder.encodeQueueSize >= maxEncodeQueue &&
|
||||||
!this.cancelled
|
!this.cancelled
|
||||||
) {
|
) {
|
||||||
|
if (Date.now() - this.lastEncoderOutputAt > ENCODER_STALL_TIMEOUT_MS) {
|
||||||
|
exportFrame.close();
|
||||||
|
throw new Error(
|
||||||
|
encoderPreference === "prefer-hardware"
|
||||||
|
? "The hardware video encoder stopped responding. Retrying with a safer encoder."
|
||||||
|
: "The video encoder stopped responding during export.",
|
||||||
|
);
|
||||||
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,18 +266,14 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exportFrame.close();
|
exportFrame.close();
|
||||||
|
|
||||||
frameIndex++;
|
frameIndex++;
|
||||||
|
|
||||||
// Update progress
|
this.reportProgress({
|
||||||
if (this.config.onProgress) {
|
currentFrame: frameIndex,
|
||||||
this.config.onProgress({
|
totalFrames,
|
||||||
currentFrame: frameIndex,
|
percentage: (frameIndex / totalFrames) * 100,
|
||||||
totalFrames,
|
estimatedTimeRemaining: 0,
|
||||||
percentage: (frameIndex / totalFrames) * 100,
|
});
|
||||||
estimatedTimeRemaining: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
videoFrame.close();
|
videoFrame.close();
|
||||||
webcamFrame?.close();
|
webcamFrame?.close();
|
||||||
@@ -234,38 +285,47 @@ export class VideoExporter {
|
|||||||
return { success: false, error: "Export cancelled" };
|
return { success: false, error: "Export cancelled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.fatalEncoderError) {
|
||||||
|
throw this.fatalEncoderError;
|
||||||
|
}
|
||||||
|
|
||||||
stopWebcamDecode = true;
|
stopWebcamDecode = true;
|
||||||
webcamFrameQueue?.destroy();
|
webcamFrameQueue?.destroy();
|
||||||
this.webcamDecoder?.cancel();
|
webcamDecoder?.cancel();
|
||||||
await webcamDecodePromise;
|
await webcamDecodePromise;
|
||||||
|
|
||||||
// Finalize encoding
|
|
||||||
if (this.encoder && this.encoder.state === "configured") {
|
if (this.encoder && this.encoder.state === "configured") {
|
||||||
await this.encoder.flush();
|
await this.withTimeout(
|
||||||
|
this.encoder.flush(),
|
||||||
|
ENCODER_FLUSH_TIMEOUT_MS,
|
||||||
|
encoderPreference === "prefer-hardware"
|
||||||
|
? "The hardware video encoder stopped responding while finalizing the export."
|
||||||
|
: "The video encoder stopped responding while finalizing the export.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fatalEncoderError) {
|
||||||
|
throw this.fatalEncoderError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all video muxing operations to complete
|
|
||||||
await Promise.all(this.muxingPromises);
|
await Promise.all(this.muxingPromises);
|
||||||
|
|
||||||
if (this.config.onProgress) {
|
this.reportProgress({
|
||||||
this.config.onProgress({
|
currentFrame: totalFrames,
|
||||||
currentFrame: totalFrames,
|
totalFrames,
|
||||||
totalFrames,
|
percentage: 100,
|
||||||
percentage: 100,
|
estimatedTimeRemaining: 0,
|
||||||
estimatedTimeRemaining: 0,
|
phase: "finalizing",
|
||||||
phase: "finalizing",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process audio track if present
|
|
||||||
if (hasAudio && !this.cancelled) {
|
if (hasAudio && !this.cancelled) {
|
||||||
const demuxer = this.streamingDecoder!.getDemuxer();
|
const demuxer = streamingDecoder.getDemuxer();
|
||||||
if (demuxer) {
|
if (demuxer) {
|
||||||
console.log("[VideoExporter] Processing audio track...");
|
console.log("[VideoExporter] Processing audio track...");
|
||||||
this.audioProcessor = new AudioProcessor();
|
this.audioProcessor = new AudioProcessor();
|
||||||
await this.audioProcessor.process(
|
await this.audioProcessor.process(
|
||||||
demuxer,
|
demuxer,
|
||||||
this.muxer!,
|
muxer,
|
||||||
this.config.videoUrl,
|
this.config.videoUrl,
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
@@ -274,31 +334,30 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize muxer and get output blob
|
const blob = await muxer.finalize();
|
||||||
const blob = await this.muxer!.finalize();
|
|
||||||
|
|
||||||
return { success: true, blob };
|
return { success: true, blob };
|
||||||
} catch (error) {
|
|
||||||
console.error("Export error:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
|
stopWebcamDecode = true;
|
||||||
webcamFrameQueue?.destroy();
|
webcamFrameQueue?.destroy();
|
||||||
this.cleanup();
|
webcamDecoder?.cancel();
|
||||||
|
if (webcamDecodePromise) {
|
||||||
|
await webcamDecodePromise.catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initializeEncoder(): Promise<void> {
|
private async initializeEncoder(hardwareAcceleration: HardwareAcceleration): Promise<void> {
|
||||||
this.encodeQueue = 0;
|
this.encodeQueue = 0;
|
||||||
this.muxingPromises = [];
|
this.muxingPromises = [];
|
||||||
this.chunkCount = 0;
|
this.chunkCount = 0;
|
||||||
|
this.lastEncoderOutputAt = Date.now();
|
||||||
|
this.fatalEncoderError = null;
|
||||||
let videoDescription: Uint8Array | undefined;
|
let videoDescription: Uint8Array | undefined;
|
||||||
|
|
||||||
this.encoder = new VideoEncoder({
|
this.encoder = new VideoEncoder({
|
||||||
output: (chunk, meta) => {
|
output: (chunk, meta) => {
|
||||||
// Capture decoder config metadata from encoder output
|
this.lastEncoderOutputAt = Date.now();
|
||||||
|
|
||||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||||
const desc = meta.decoderConfig.description;
|
const desc = meta.decoderConfig.description;
|
||||||
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
|
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
|
||||||
@@ -308,19 +367,17 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
this.videoDescription = videoDescription;
|
this.videoDescription = videoDescription;
|
||||||
}
|
}
|
||||||
// Capture colorSpace from encoder metadata if provided
|
|
||||||
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
||||||
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream chunk to muxer immediately (parallel processing)
|
|
||||||
const isFirstChunk = this.chunkCount === 0;
|
const isFirstChunk = this.chunkCount === 0;
|
||||||
this.chunkCount++;
|
this.chunkCount++;
|
||||||
|
|
||||||
const muxingPromise = (async () => {
|
const muxingPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (isFirstChunk && this.videoDescription) {
|
if (isFirstChunk && this.videoDescription) {
|
||||||
// Add decoder config for the first chunk
|
|
||||||
const colorSpace = this.videoColorSpace || {
|
const colorSpace = this.videoColorSpace || {
|
||||||
primaries: "bt709",
|
primaries: "bt709",
|
||||||
transfer: "iec61966-2-1",
|
transfer: "iec61966-2-1",
|
||||||
@@ -352,43 +409,37 @@ export class VideoExporter {
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error("[VideoExporter] Encoder error:", error);
|
console.error("[VideoExporter] Encoder error:", error);
|
||||||
// Stop export encoding failed
|
this.fatalEncoderError =
|
||||||
this.cancelled = true;
|
error instanceof Error ? error : new Error(`Video encoder error: ${String(error)}`);
|
||||||
|
this.streamingDecoder?.cancel();
|
||||||
|
this.webcamDecoder?.cancel();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const codec = this.config.codec || "avc1.640033";
|
|
||||||
|
|
||||||
const encoderConfig: VideoEncoderConfig = {
|
const encoderConfig: VideoEncoderConfig = {
|
||||||
codec,
|
codec: this.config.codec || "avc1.640033",
|
||||||
width: this.config.width,
|
width: this.config.width,
|
||||||
height: this.config.height,
|
height: this.config.height,
|
||||||
bitrate: this.config.bitrate,
|
bitrate: this.config.bitrate,
|
||||||
framerate: this.config.frameRate,
|
framerate: this.config.frameRate,
|
||||||
latencyMode: "quality", // Changed from 'realtime' to 'quality' for better throughput
|
latencyMode: "quality",
|
||||||
bitrateMode: "variable",
|
bitrateMode: "variable",
|
||||||
hardwareAcceleration: "prefer-hardware",
|
hardwareAcceleration,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check hardware support first
|
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||||
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
|
if (!support.supported) {
|
||||||
|
throw new Error(
|
||||||
if (hardwareSupport.supported) {
|
hardwareAcceleration === "prefer-hardware"
|
||||||
// Use hardware encoding
|
? "Hardware video encoding is not supported on this system."
|
||||||
console.log("[VideoExporter] Using hardware acceleration");
|
: "Software video encoding is not supported on this system.",
|
||||||
this.encoder.configure(encoderConfig);
|
);
|
||||||
} else {
|
|
||||||
// Fall back to software encoding
|
|
||||||
console.log("[VideoExporter] Hardware not supported, using software encoding");
|
|
||||||
encoderConfig.hardwareAcceleration = "prefer-software";
|
|
||||||
|
|
||||||
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
|
|
||||||
if (!softwareSupport.supported) {
|
|
||||||
throw new Error("Video encoding not supported on this system");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.encoder.configure(encoderConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[VideoExporter] Using ${hardwareAcceleration === "prefer-hardware" ? "hardware" : "software"} acceleration`,
|
||||||
|
);
|
||||||
|
this.encoder.configure(encoderConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -451,5 +502,34 @@ export class VideoExporter {
|
|||||||
this.chunkCount = 0;
|
this.chunkCount = 0;
|
||||||
this.videoDescription = undefined;
|
this.videoDescription = undefined;
|
||||||
this.videoColorSpace = undefined;
|
this.videoColorSpace = undefined;
|
||||||
|
this.lastEncoderOutputAt = 0;
|
||||||
|
this.fatalEncoderError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEncoderPreferences(): HardwareAcceleration[] {
|
||||||
|
if (typeof navigator !== "undefined" && /\bWindows\b/i.test(navigator.userAgent)) {
|
||||||
|
return ["prefer-software", "prefer-hardware"];
|
||||||
|
}
|
||||||
|
return ["prefer-hardware", "prefer-software"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportProgress(progress: ExportProgress): void {
|
||||||
|
this.config.onProgress?.(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type CameraAccessResult = {
|
||||||
|
success: boolean;
|
||||||
|
granted: boolean;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDeniedStatus(error: unknown) {
|
||||||
|
if (error instanceof DOMException) {
|
||||||
|
return error.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestCameraAccess(): Promise<CameraAccessResult> {
|
||||||
|
if (window.electronAPI?.requestCameraAccess) {
|
||||||
|
try {
|
||||||
|
const electronResult = await window.electronAPI.requestCameraAccess();
|
||||||
|
if (!electronResult.success || !electronResult.granted) {
|
||||||
|
return electronResult;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
granted: false,
|
||||||
|
status: "error",
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
granted: false,
|
||||||
|
status: "unsupported",
|
||||||
|
error: "Camera access is not supported in this runtime.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
return { success: true, granted: true, status: "granted" };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
granted: false,
|
||||||
|
status: getDeniedStatus(error),
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { computeWebcamOverlayLayout } from "./webcamOverlay";
|
|
||||||
|
|
||||||
describe("computeWebcamOverlayLayout", () => {
|
|
||||||
it("anchors the overlay in the lower-right corner", () => {
|
|
||||||
const layout = computeWebcamOverlayLayout({
|
|
||||||
stageWidth: 1920,
|
|
||||||
stageHeight: 1080,
|
|
||||||
videoWidth: 1280,
|
|
||||||
videoHeight: 720,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(layout).not.toBeNull();
|
|
||||||
expect(layout!.x + layout!.width).toBeLessThanOrEqual(1920);
|
|
||||||
expect(layout!.y + layout!.height).toBeLessThanOrEqual(1080);
|
|
||||||
expect(layout!.x).toBeGreaterThan(1920 / 2);
|
|
||||||
expect(layout!.y).toBeGreaterThan(1080 / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
|
|
||||||
const layout = computeWebcamOverlayLayout({
|
|
||||||
stageWidth: 1280,
|
|
||||||
stageHeight: 720,
|
|
||||||
videoWidth: 1920,
|
|
||||||
videoHeight: 1080,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(layout).not.toBeNull();
|
|
||||||
expect(layout!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
|
|
||||||
expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
|
|
||||||
expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
export interface WebcamOverlayLayout {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
margin: number;
|
|
||||||
borderRadius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_STAGE_FRACTION = 0.18;
|
|
||||||
const MARGIN_FRACTION = 0.02;
|
|
||||||
const MIN_SIZE = 96;
|
|
||||||
const MAX_BORDER_RADIUS = 24;
|
|
||||||
|
|
||||||
export function computeWebcamOverlayLayout(params: {
|
|
||||||
stageWidth: number;
|
|
||||||
stageHeight: number;
|
|
||||||
videoWidth: number;
|
|
||||||
videoHeight: number;
|
|
||||||
}): WebcamOverlayLayout | null {
|
|
||||||
const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
|
|
||||||
|
|
||||||
if (stageWidth <= 0 || stageHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const margin = Math.max(12, Math.round(Math.min(stageWidth, stageHeight) * MARGIN_FRACTION));
|
|
||||||
const maxWidth = Math.max(MIN_SIZE, stageWidth * MAX_STAGE_FRACTION);
|
|
||||||
const maxHeight = Math.max(MIN_SIZE, stageHeight * MAX_STAGE_FRACTION);
|
|
||||||
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
|
|
||||||
const width = Math.round(videoWidth * scale);
|
|
||||||
const height = Math.round(videoHeight * scale);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: Math.max(0, Math.round(stageWidth - margin - width)),
|
|
||||||
y: Math.max(0, Math.round(stageHeight - margin - height)),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
margin,
|
|
||||||
borderRadius: Math.min(
|
|
||||||
MAX_BORDER_RADIUS,
|
|
||||||
Math.max(12, Math.round(Math.min(width, height) * 0.12)),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -87,8 +87,8 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
await editorWindow.getByTestId("testId-gif-format-button").click();
|
await editorWindow.getByTestId("testId-gif-format-button").click();
|
||||||
await editorWindow.getByTestId("testId-export-button").click();
|
await editorWindow.getByTestId("testId-export-button").click();
|
||||||
|
|
||||||
// ── 6. Wait for the toast to say exported successfully
|
// ── 6. Wait for the success toast.
|
||||||
await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({
|
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user