Merge pull request #241 from marcusschiesser/codex/add-multiple-layout-presets-for-video
Add selectable webcam layout presets (Picture in Picture, Vertical Stack)
This commit is contained in:
Generated
+4
@@ -73,6 +73,10 @@
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.22.1",
|
||||
"npm": "10.9.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"engines": {
|
||||
"node": "22.22.1",
|
||||
"npm": "10.9.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||
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 { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
@@ -110,6 +111,16 @@ export function LaunchWindow() {
|
||||
};
|
||||
}, [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 [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
@@ -251,8 +262,8 @@ export function LaunchWindow() {
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => {
|
||||
void setWebcamEnabled(!webcamEnabled);
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
||||
>
|
||||
|
||||
@@ -25,10 +25,18 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -43,6 +51,7 @@ import type {
|
||||
CropRegion,
|
||||
FigureData,
|
||||
PlaybackSpeed,
|
||||
WebcamLayoutPreset,
|
||||
ZoomDepth,
|
||||
} from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
@@ -132,6 +141,9 @@ interface SettingsPanelProps {
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
hasWebcam?: boolean;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -197,6 +209,9 @@ export function SettingsPanel({
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
hasWebcam = false,
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
onWebcamLayoutPresetChange,
|
||||
}: SettingsPanelProps) {
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
@@ -567,7 +582,47 @@ export function SettingsPanel({
|
||||
)}
|
||||
</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">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Span } from "dnd-timeline";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
import {
|
||||
@@ -76,6 +75,7 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
@@ -173,6 +173,7 @@ export default function VideoEditor() {
|
||||
speedRegions: normalizedEditor.speedRegions,
|
||||
annotationRegions: normalizedEditor.annotationRegions,
|
||||
aspectRatio: normalizedEditor.aspectRatio,
|
||||
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||
});
|
||||
setExportQuality(normalizedEditor.exportQuality);
|
||||
setExportFormat(normalizedEditor.exportFormat);
|
||||
@@ -240,6 +241,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -261,6 +263,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -352,6 +355,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -404,6 +408,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -1021,6 +1026,7 @@ export default function VideoEditor() {
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
@@ -1148,6 +1154,7 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
@@ -1212,6 +1219,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
isPlaying,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
],
|
||||
@@ -1351,6 +1359,7 @@ export default function VideoEditor() {
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
@@ -1474,6 +1483,9 @@ export default function VideoEditor() {
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
@@ -1521,8 +1533,6 @@ export default function VideoEditor() {
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
<Toaster theme="dark" className="pointer-events-auto" />
|
||||
|
||||
<ExportDialog
|
||||
isOpen={showExportDialog}
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
|
||||
import {
|
||||
getWebcamLayoutCssBoxShadow,
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import {
|
||||
type AspectRatio,
|
||||
formatAspectRatioForCSS,
|
||||
@@ -57,6 +62,7 @@ import {
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
currentTime: number;
|
||||
@@ -101,6 +107,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{
|
||||
videoPath,
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
onDurationChange,
|
||||
onTimeUpdate,
|
||||
currentTime,
|
||||
@@ -134,7 +141,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||
const videoContainerRef = useRef<Container | null>(null);
|
||||
@@ -144,11 +150,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
@@ -258,6 +261,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
||||
borderRadius,
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -267,6 +272,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
baseOffsetRef.current = result.baseOffset;
|
||||
baseMaskRef.current = result.maskRect;
|
||||
cropBoundsRef.current = result.cropBounds;
|
||||
setWebcamLayout(result.webcamRect);
|
||||
|
||||
// Reset camera container to identity
|
||||
cameraContainer.scale.set(1);
|
||||
@@ -279,7 +285,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
updateOverlayForRegion(activeRegion);
|
||||
}
|
||||
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
|
||||
}, [
|
||||
updateOverlayForRegion,
|
||||
cropRegion,
|
||||
borderRadius,
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
layoutVideoContentRef.current = layoutVideoContent;
|
||||
@@ -910,6 +923,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||
const webcamCssBoxShadow = useMemo(
|
||||
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
|
||||
[webcamLayoutPreset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
@@ -934,34 +951,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
}, [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(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
if (!webcamVideo || !webcamVideoPath) {
|
||||
@@ -1075,7 +1064,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative rounded-sm overflow-hidden"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -1120,7 +1108,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 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,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
backgroundColor: "#000",
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
@@ -39,6 +41,7 @@ export interface ProjectEditorState {
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
gifFrameRate: GifFrameRate;
|
||||
@@ -341,6 +344,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
aspectRatio:
|
||||
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:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||
|
||||
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 {
|
||||
cx: number; // normalized horizontal center (0-1)
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
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";
|
||||
|
||||
interface LayoutParams {
|
||||
@@ -11,6 +18,8 @@ interface LayoutParams {
|
||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -18,7 +27,8 @@ interface LayoutResult {
|
||||
videoSize: { width: number; height: number };
|
||||
baseScale: 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 };
|
||||
}
|
||||
|
||||
@@ -33,6 +43,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
lockedVideoDimensions,
|
||||
borderRadius = 0,
|
||||
padding = 0,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
} = params;
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
@@ -71,11 +83,19 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const maxDisplayWidth = width * paddingScale;
|
||||
const maxDisplayHeight = height * paddingScale;
|
||||
|
||||
const scale = Math.min(
|
||||
maxDisplayWidth / croppedVideoWidth,
|
||||
maxDisplayHeight / croppedVideoHeight,
|
||||
1,
|
||||
);
|
||||
const compositeLayout = computeCompositeLayout({
|
||||
canvasSize: { width, height },
|
||||
maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
|
||||
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
webcamSize: webcamDimensions,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
});
|
||||
|
||||
if (!compositeLayout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
|
||||
|
||||
videoSprite.scale.set(scale);
|
||||
|
||||
@@ -84,30 +104,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
const fullVideoDisplayHeight = videoHeight * scale;
|
||||
|
||||
// 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,
|
||||
// the cropped region appears centered
|
||||
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
||||
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
||||
// We want that point to be at centerOffsetX, centerOffsetY
|
||||
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
|
||||
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
|
||||
// We want that point to be at screenRect.x, screenRect.y
|
||||
const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth;
|
||||
const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight;
|
||||
|
||||
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
|
||||
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 });
|
||||
|
||||
return {
|
||||
@@ -115,7 +130,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
ZoomRegion,
|
||||
} 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";
|
||||
|
||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||
@@ -24,6 +25,7 @@ export interface EditorState {
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
@@ -39,6 +41,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const MIN_FRAME_RATE = 30;
|
||||
@@ -157,7 +158,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return true;
|
||||
}
|
||||
|
||||
const accessResult = await window.electronAPI.requestCameraAccess();
|
||||
const accessResult = await requestCameraAccess();
|
||||
if (!accessResult.success) {
|
||||
toast.error("Failed to request camera access.");
|
||||
return false;
|
||||
@@ -168,19 +169,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const probeStream = await navigator.mediaDevices.getUserMedia({
|
||||
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;
|
||||
}
|
||||
setWebcamEnabledState(true);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
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,
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
WebcamLayoutPreset,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
@@ -30,7 +31,12 @@ import {
|
||||
createMotionBlurState,
|
||||
type MotionBlurState,
|
||||
} 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";
|
||||
|
||||
interface FrameRenderConfig {
|
||||
@@ -47,8 +53,8 @@ interface FrameRenderConfig {
|
||||
cropRegion: CropRegion;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
webcamWidth?: number;
|
||||
webcamHeight?: number;
|
||||
webcamSize?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
previewWidth?: number;
|
||||
@@ -71,6 +77,7 @@ interface LayoutCache {
|
||||
baseScale: number;
|
||||
baseOffset: { x: number; y: 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.
|
||||
@@ -329,7 +336,7 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
// Apply layout
|
||||
this.updateLayout();
|
||||
this.updateLayout(webcamFrame);
|
||||
|
||||
const timeMs = this.currentVideoTime * 1000;
|
||||
const TICKS_PER_FRAME = 1;
|
||||
@@ -393,7 +400,7 @@ export class FrameRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private updateLayout(): void {
|
||||
private updateLayout(webcamFrame?: VideoFrame | null): void {
|
||||
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
||||
|
||||
const { width, height } = this.config;
|
||||
@@ -415,7 +422,16 @@ export class FrameRenderer {
|
||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||
const viewportWidth = width * paddingScale;
|
||||
const viewportHeight = height * paddingScale;
|
||||
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
|
||||
const 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
|
||||
this.videoSprite.width = videoWidth * scale;
|
||||
@@ -427,12 +443,10 @@ export class FrameRenderer {
|
||||
this.videoSprite.y = -cropPixelY;
|
||||
|
||||
// Position video container
|
||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
||||
this.videoContainer.x = centerOffsetX;
|
||||
this.videoContainer.y = centerOffsetY;
|
||||
const croppedDisplayWidth = compositeLayout.screenRect.width;
|
||||
const croppedDisplayHeight = compositeLayout.screenRect.height;
|
||||
this.videoContainer.x = compositeLayout.screenRect.x;
|
||||
this.videoContainer.y = compositeLayout.screenRect.y;
|
||||
|
||||
// scale border radius by export/preview canvas ratio
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
@@ -455,8 +469,9 @@ export class FrameRenderer {
|
||||
stageSize: { width, height },
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
baseOffset: { x: centerOffsetX, y: centerOffsetY },
|
||||
maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
||||
baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
|
||||
maskRect: compositeLayout.screenRect,
|
||||
webcamRect: compositeLayout.webcamRect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -628,34 +643,36 @@ export class FrameRenderer {
|
||||
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
}
|
||||
|
||||
if (webcamFrame && this.config.webcamWidth && this.config.webcamHeight) {
|
||||
const layout = computeWebcamOverlayLayout({
|
||||
stageWidth: w,
|
||||
stageHeight: h,
|
||||
videoWidth: this.config.webcamWidth,
|
||||
videoHeight: this.config.webcamHeight,
|
||||
});
|
||||
|
||||
if (layout) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(layout.x, layout.y, layout.width, layout.height, layout.borderRadius);
|
||||
ctx.closePath();
|
||||
ctx.shadowColor = "rgba(0,0,0,0.35)";
|
||||
ctx.shadowBlur = 24;
|
||||
ctx.shadowOffsetY = 10;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fill();
|
||||
ctx.clip();
|
||||
ctx.drawImage(
|
||||
webcamFrame as unknown as CanvasImageSource,
|
||||
layout.x,
|
||||
layout.y,
|
||||
layout.width,
|
||||
layout.height,
|
||||
);
|
||||
ctx.restore();
|
||||
const webcamRect = this.layoutCache?.webcamRect ?? null;
|
||||
if (webcamFrame && webcamRect) {
|
||||
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
webcamRect.x,
|
||||
webcamRect.y,
|
||||
webcamRect.width,
|
||||
webcamRect.height,
|
||||
webcamRect.borderRadius,
|
||||
);
|
||||
ctx.closePath();
|
||||
if (preset.shadow) {
|
||||
ctx.shadowColor = preset.shadow.color;
|
||||
ctx.shadowBlur = preset.shadow.blur;
|
||||
ctx.shadowOffsetX = preset.shadow.offsetX;
|
||||
ctx.shadowOffsetY = preset.shadow.offsetY;
|
||||
}
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fill();
|
||||
ctx.clip();
|
||||
ctx.drawImage(
|
||||
webcamFrame as unknown as CanvasImageSource,
|
||||
webcamRect.x,
|
||||
webcamRect.y,
|
||||
webcamRect.width,
|
||||
webcamRect.height,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
@@ -39,6 +40,7 @@ interface GifExporterConfig {
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
@@ -136,8 +138,8 @@ export class GifExporter {
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
webcamWidth: webcamInfo?.width,
|
||||
webcamHeight: webcamInfo?.height,
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
@@ -27,6 +28,7 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
@@ -85,8 +87,8 @@ export class VideoExporter {
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
webcamWidth: webcamInfo?.width,
|
||||
webcamHeight: webcamInfo?.height,
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user