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)
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
WebcamLayoutPreset,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
createMotionBlurState,
|
||||
type MotionBlurState,
|
||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
|
||||
import { computeWebcamOverlayLayout, getWebcamLayoutPresetDefinition } from "@/lib/webcamOverlay";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
|
||||
interface FrameRenderConfig {
|
||||
@@ -49,6 +50,7 @@ interface FrameRenderConfig {
|
||||
videoHeight: number;
|
||||
webcamWidth?: number;
|
||||
webcamHeight?: number;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
previewWidth?: number;
|
||||
@@ -634,16 +636,23 @@ export class FrameRenderer {
|
||||
stageHeight: h,
|
||||
videoWidth: this.config.webcamWidth,
|
||||
videoHeight: this.config.webcamHeight,
|
||||
layoutPreset: this.config.webcamLayoutPreset,
|
||||
screenVideoWidth: this.config.videoWidth,
|
||||
screenVideoHeight: this.config.videoHeight,
|
||||
});
|
||||
|
||||
if (layout) {
|
||||
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||
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;
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
@@ -138,6 +140,7 @@ export class GifExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
webcamWidth: webcamInfo?.width,
|
||||
webcamHeight: webcamInfo?.height,
|
||||
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;
|
||||
@@ -87,6 +89,7 @@ export class VideoExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
webcamWidth: webcamInfo?.width,
|
||||
webcamHeight: webcamInfo?.height,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
|
||||
@@ -30,4 +30,34 @@ describe("computeWebcamOverlayLayout", () => {
|
||||
expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
|
||||
expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
|
||||
});
|
||||
|
||||
it("places the webcam directly below the screencast in vertical stack mode", () => {
|
||||
const layout = computeWebcamOverlayLayout({
|
||||
stageWidth: 1920,
|
||||
stageHeight: 1080,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
screenVideoWidth: 1920,
|
||||
screenVideoHeight: 1080,
|
||||
layoutPreset: "vertical-stack",
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.y).toBe(648);
|
||||
expect(layout?.height).toBe(432);
|
||||
expect(layout?.width).toBe(768);
|
||||
expect(layout?.borderRadius).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for vertical stack until screen dimensions are available", () => {
|
||||
const layout = computeWebcamOverlayLayout({
|
||||
stageWidth: 1920,
|
||||
stageHeight: 1080,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
layoutPreset: "vertical-stack",
|
||||
});
|
||||
|
||||
expect(layout).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+157
-6
@@ -7,26 +7,174 @@ export interface WebcamOverlayLayout {
|
||||
borderRadius: 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;
|
||||
}
|
||||
|
||||
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 computeWebcamOverlayLayout(params: {
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
layoutPreset?: WebcamLayoutPreset;
|
||||
screenVideoWidth?: number;
|
||||
screenVideoHeight?: number;
|
||||
}): WebcamOverlayLayout | null {
|
||||
const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
|
||||
const {
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
layoutPreset = "picture-in-picture",
|
||||
screenVideoWidth,
|
||||
screenVideoHeight,
|
||||
} = params;
|
||||
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
||||
|
||||
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);
|
||||
if (preset.transform.type === "stack") {
|
||||
if (
|
||||
!screenVideoWidth ||
|
||||
!screenVideoHeight ||
|
||||
screenVideoWidth <= 0 ||
|
||||
screenVideoHeight <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gap = preset.transform.gap;
|
||||
const scale = Math.min(
|
||||
stageWidth / Math.max(screenVideoWidth, videoWidth),
|
||||
stageHeight / (screenVideoHeight + gap + videoHeight),
|
||||
);
|
||||
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||||
const screenHeight = Math.round(screenVideoHeight * clampedScale);
|
||||
const webcamHeight = Math.round(videoHeight * clampedScale);
|
||||
const webcamWidth = Math.round(videoWidth * clampedScale);
|
||||
const scaledGap = Math.round(gap * clampedScale);
|
||||
const contentHeight = screenHeight + scaledGap + webcamHeight;
|
||||
const topOffset = Math.max(0, Math.floor((stageHeight - contentHeight) / 2));
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.floor((stageWidth - webcamWidth) / 2)),
|
||||
y: Math.max(0, topOffset + screenHeight + scaledGap),
|
||||
width: webcamWidth,
|
||||
height: webcamHeight,
|
||||
margin: 0,
|
||||
borderRadius: Math.min(
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(webcamWidth, webcamHeight) * preset.borderRadius.fraction),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const transform = preset.transform;
|
||||
const margin = Math.max(
|
||||
transform.minMargin,
|
||||
Math.round(Math.min(stageWidth, stageHeight) * transform.marginFraction),
|
||||
);
|
||||
const maxWidth = Math.max(transform.minSize, stageWidth * transform.maxStageFraction);
|
||||
const maxHeight = Math.max(transform.minSize, stageHeight * transform.maxStageFraction);
|
||||
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
|
||||
const width = Math.round(videoWidth * scale);
|
||||
const height = Math.round(videoHeight * scale);
|
||||
@@ -38,8 +186,11 @@ export function computeWebcamOverlayLayout(params: {
|
||||
height,
|
||||
margin,
|
||||
borderRadius: Math.min(
|
||||
MAX_BORDER_RADIUS,
|
||||
Math.max(12, Math.round(Math.min(width, height) * 0.12)),
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user