fix: center stacked screen and webcam layout

This commit is contained in:
Marcus Schiesser
2026-03-19 17:51:51 +08:00
parent 579887e2f8
commit 83a60926d8
11 changed files with 482 additions and 406 deletions
+42 -24
View File
@@ -36,10 +36,10 @@ 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";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/webcamOverlay";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
@@ -582,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">
@@ -601,28 +641,6 @@ 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">
+17 -50
View File
@@ -20,11 +20,11 @@ import {
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import {
computeWebcamOverlayLayout,
getWebcamLayoutCssBoxShadow,
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamOverlayLayout,
} from "@/lib/webcamOverlay";
} from "@/lib/compositeLayout";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -141,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);
@@ -151,15 +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 [screenVideoDimensions, setScreenVideoDimensions] = 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);
@@ -269,6 +261,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lockedVideoDimensions: lockedVideoDimensionsRef.current,
borderRadius,
padding,
webcamDimensions,
webcamLayoutPreset,
});
if (result) {
@@ -278,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);
@@ -290,7 +285,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
updateOverlayForRegion(activeRegion);
}
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
}, [
updateOverlayForRegion,
cropRegion,
borderRadius,
padding,
webcamDimensions,
webcamLayoutPreset,
]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
@@ -620,9 +622,6 @@ 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(() => {
@@ -952,37 +951,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
}, [webcamVideoPath]);
useEffect(() => {
const stage = stageRef.current;
if (!stage || !webcamDimensions || !screenVideoDimensions) {
setWebcamLayout(null);
return;
}
const updateLayout = () => {
const layout = computeWebcamOverlayLayout({
stageWidth: stage.clientWidth,
stageHeight: stage.clientHeight,
videoWidth: webcamDimensions.width,
videoHeight: webcamDimensions.height,
layoutPreset: webcamLayoutPreset,
screenVideoWidth: screenVideoDimensions?.width,
screenVideoHeight: screenVideoDimensions?.height,
});
setWebcamLayout(layout);
};
updateLayout();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(updateLayout);
observer.observe(stage);
return () => observer.disconnect();
}, [screenVideoDimensions, webcamDimensions, webcamLayoutPreset]);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
@@ -1096,7 +1064,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={stageRef}
className="relative rounded-sm overflow-hidden"
style={{
width: "100%",
@@ -1130,7 +1097,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && screenVideoDimensions && (
{webcamVideoPath && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { WebcamLayoutPreset } from "@/lib/webcamOverlay";
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type { WebcamLayoutPreset };
@@ -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 },
};
}
+78
View File
@@ -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();
});
});
+250
View File
@@ -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,
};
}
+54 -46
View File
@@ -31,7 +31,12 @@ import {
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import { computeWebcamOverlayLayout, getWebcamLayoutPresetDefinition } from "@/lib/webcamOverlay";
import {
computeCompositeLayout,
getWebcamLayoutPresetDefinition,
type Size,
type StyledRenderRect,
} from "@/lib/compositeLayout";
import { renderAnnotations } from "./annotationRenderer";
interface FrameRenderConfig {
@@ -48,8 +53,7 @@ interface FrameRenderConfig {
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
webcamWidth?: number;
webcamHeight?: number;
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
@@ -73,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.
@@ -417,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: this.config.webcamSize,
layoutPreset: this.config.webcamLayoutPreset,
});
if (!compositeLayout) return;
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
// Position video sprite
this.videoSprite.width = videoWidth * scale;
@@ -429,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;
@@ -457,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,
};
}
@@ -630,41 +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,
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();
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,
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();
}
}
+1 -2
View File
@@ -138,8 +138,7 @@ 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,
+1 -2
View File
@@ -87,8 +87,7 @@ 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,
-63
View File
@@ -1,63 +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);
});
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();
});
});
-196
View File
@@ -1,196 +0,0 @@
export interface WebcamOverlayLayout {
x: number;
y: number;
width: number;
height: number;
margin: number;
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,
layoutPreset = "picture-in-picture",
screenVideoWidth,
screenVideoHeight,
} = params;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
if (stageWidth <= 0 || stageHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
return null;
}
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);
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(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
),
};
}