feat: add selectable webcam layout presets
This commit is contained in:
@@ -25,6 +25,13 @@ 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";
|
||||
@@ -32,6 +39,7 @@ import { getAssetPath } from "@/lib/assetPath";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/webcamOverlay";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { getTestId } from "@/utils/getTestId";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
@@ -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[]>([]);
|
||||
@@ -586,6 +601,28 @@ export function SettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasWebcam && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">Webcam Layout</div>
|
||||
<Select
|
||||
value={webcamLayoutPreset}
|
||||
onValueChange={(value: WebcamLayoutPreset) =>
|
||||
onWebcamLayoutPresetChange?.(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
|
||||
<SelectValue placeholder="Select layout" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value} className="text-xs">
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function VideoEditor() {
|
||||
borderRadius,
|
||||
padding,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
@@ -173,6 +174,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 +242,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -261,6 +264,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -352,6 +356,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -404,6 +409,7 @@ export default function VideoEditor() {
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
@@ -1021,6 +1027,7 @@ export default function VideoEditor() {
|
||||
videoPadding: padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
@@ -1148,6 +1155,7 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
@@ -1212,6 +1220,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
isPlaying,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
],
|
||||
@@ -1351,6 +1360,7 @@ export default function VideoEditor() {
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
@@ -1474,6 +1484,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}
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
|
||||
import {
|
||||
computeWebcamOverlayLayout,
|
||||
getWebcamLayoutCssBoxShadow,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamOverlayLayout,
|
||||
} from "@/lib/webcamOverlay";
|
||||
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,
|
||||
@@ -149,6 +156,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [screenVideoDimensions, setScreenVideoDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
@@ -609,6 +620,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
setScreenVideoDimensions({ width: video.videoWidth, height: video.videoHeight });
|
||||
}
|
||||
}, [videoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -910,6 +924,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||
const webcamCssBoxShadow = useMemo(
|
||||
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
|
||||
[webcamLayoutPreset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
@@ -936,7 +954,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage || !webcamDimensions) {
|
||||
if (!stage || !webcamDimensions || !screenVideoDimensions) {
|
||||
setWebcamLayout(null);
|
||||
return;
|
||||
}
|
||||
@@ -947,6 +965,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
stageHeight: stage.clientHeight,
|
||||
videoWidth: webcamDimensions.width,
|
||||
videoHeight: webcamDimensions.height,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
screenVideoWidth: screenVideoDimensions?.width,
|
||||
screenVideoHeight: screenVideoDimensions?.height,
|
||||
});
|
||||
setWebcamLayout(layout);
|
||||
};
|
||||
@@ -960,7 +981,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const observer = new ResizeObserver(updateLayout);
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, [webcamDimensions]);
|
||||
}, [screenVideoDimensions, webcamDimensions, webcamLayoutPreset]);
|
||||
|
||||
useEffect(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
@@ -1109,7 +1130,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath && (
|
||||
{webcamVideoPath && screenVideoDimensions && (
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
@@ -1120,7 +1141,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/webcamOverlay";
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user