feat: Add webcam size presets with slider

This commit is contained in:
Garry Laly
2026-04-05 20:00:44 +07:00
parent ca962ff16b
commit 79201569c5
14 changed files with 308 additions and 229 deletions
+79 -178
View File
@@ -36,18 +36,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type {
ExportFormat,
ExportQuality,
GifFrameRate,
GifSizePreset,
} from "@/lib/exporter";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import {
type AspectRatio,
isPortraitAspectRatio,
} from "@/utils/aspectRatioUtils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CropControl } from "./CropControl";
@@ -64,7 +56,7 @@ import type {
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS, DEFAULT_WEBCAM_SIZE_PRESET } from "./types";
import { DEFAULT_WEBCAM_SIZE_PRESET, SPEED_OPTIONS } from "./types";
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
@@ -151,10 +143,7 @@ interface SettingsPanelProps {
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (
id: string,
style: Partial<AnnotationRegion["style"]>,
) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDelete?: (id: string) => void;
selectedSpeedId?: string | null;
@@ -167,7 +156,8 @@ interface SettingsPanelProps {
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (preset: WebcamSizePreset) => void;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
}
export default SettingsPanel;
@@ -243,6 +233,7 @@ export function SettingsPanel({
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
@@ -253,9 +244,7 @@ export function SettingsPanel({
let mounted = true;
(async () => {
try {
const resolved = await Promise.all(
WALLPAPER_RELATIVE.map((p) => getAssetPath(p)),
);
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
if (mounted) setWallpaperPaths(resolved);
} catch (_err) {
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
@@ -301,22 +290,13 @@ export function SettingsPanel({
const next = { ...cropRegion };
switch (field) {
case "x":
next.x = Math.max(
0,
Math.min(pixelValue / videoWidth, 1 - next.width),
);
next.x = Math.max(0, Math.min(pixelValue / videoWidth, 1 - next.width));
break;
case "y":
next.y = Math.max(
0,
Math.min(pixelValue / videoHeight, 1 - next.height),
);
next.y = Math.max(0, Math.min(pixelValue / videoHeight, 1 - next.height));
break;
case "width": {
const newWidth = Math.max(
0.05,
Math.min(pixelValue / videoWidth, 1 - next.x),
);
const newWidth = Math.max(0.05, Math.min(pixelValue / videoWidth, 1 - next.x));
if (cropAspectLocked && next.width > 0 && next.height > 0) {
const ratio = next.width / next.height;
const newHeight = newWidth / ratio;
@@ -330,10 +310,7 @@ export function SettingsPanel({
break;
}
case "height": {
const newHeight = Math.max(
0.05,
Math.min(pixelValue / videoHeight, 1 - next.y),
);
const newHeight = Math.max(0.05, Math.min(pixelValue / videoHeight, 1 - next.y));
if (cropAspectLocked && next.width > 0 && next.height > 0) {
const ratio = next.width / next.height;
const newWidth = newHeight * ratio;
@@ -367,13 +344,11 @@ export function SettingsPanel({
const targetRatio = Number(wStr) / Number(hStr);
const next = { ...cropRegion };
const nextHeight =
(next.width * videoWidth) / (targetRatio * videoHeight);
const nextHeight = (next.width * videoWidth) / (targetRatio * videoHeight);
if (next.y + nextHeight <= 1 && nextHeight >= 0.05) {
next.height = nextHeight;
} else {
const nextWidth =
(next.height * videoHeight * targetRatio) / videoWidth;
const nextWidth = (next.height * videoHeight * targetRatio) / videoWidth;
if (next.x + nextWidth <= 1 && nextWidth >= 0.05) {
next.width = nextWidth;
}
@@ -455,10 +430,7 @@ export function SettingsPanel({
event.target.value = "";
};
const handleRemoveCustomImage = (
imageUrl: string,
event: React.MouseEvent,
) => {
const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => {
event.stopPropagation();
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
@@ -497,19 +469,12 @@ export function SettingsPanel({
return (
<AnnotationSettingsPanel
annotation={selectedAnnotation}
onContentChange={(content) =>
onAnnotationContentChange(selectedAnnotation.id, content)
}
onTypeChange={(type) =>
onAnnotationTypeChange(selectedAnnotation.id, type)
}
onStyleChange={(style) =>
onAnnotationStyleChange(selectedAnnotation.id, style)
}
onContentChange={(content) => onAnnotationContentChange(selectedAnnotation.id, content)}
onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)}
onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)}
onFigureDataChange={
onAnnotationFigureDataChange
? (figureData) =>
onAnnotationFigureDataChange(selectedAnnotation.id, figureData)
? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData)
: undefined
}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
@@ -522,17 +487,11 @@ export function SettingsPanel({
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">
{t("zoom.level")}
</span>
<span className="text-sm font-medium text-slate-200">{t("zoom.level")}</span>
<div className="flex items-center gap-2">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
{
ZOOM_DEPTH_OPTIONS.find(
(o) => o.depth === selectedZoomDepth,
)?.label
}
{ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
</span>
)}
<KeyboardShortcutsHelp />
@@ -550,9 +509,7 @@ export function SettingsPanel({
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out",
zoomEnabled
? "opacity-100 cursor-pointer"
: "opacity-40 cursor-not-allowed",
zoomEnabled ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
@@ -564,9 +521,7 @@ export function SettingsPanel({
})}
</div>
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">
{t("zoom.selectRegion")}
</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
{zoomEnabled && hasCursorTelemetry && (
<div className="mt-3">
@@ -632,13 +587,11 @@ export function SettingsPanel({
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">
{t("speed.playbackSpeed")}
</span>
<span className="text-sm font-medium text-slate-200">{t("speed.playbackSpeed")}</span>
{selectedSpeedId && selectedSpeedValue && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)
?.label ?? `${selectedSpeedValue}×`}
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
`${selectedSpeedValue}×`}
</span>
)}
</div>
@@ -668,15 +621,11 @@ export function SettingsPanel({
})}
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">
{t("speed.selectRegion")}
</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
{selectedSpeedId && (
<Button
onClick={() =>
selectedSpeedId && onSpeedDelete?.(selectedSpeedId)
}
onClick={() => selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
variant="destructive"
size="sm"
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
@@ -689,11 +638,7 @@ export function SettingsPanel({
<Accordion
type="multiple"
defaultValue={
hasWebcam
? ["layout", "effects", "background"]
: ["effects", "background"]
}
defaultValue={hasWebcam ? ["layout", "effects", "background"] : ["effects", "background"]}
className="space-y-1"
>
{hasWebcam && (
@@ -704,9 +649,7 @@ export function SettingsPanel({
<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">
{t("layout.title")}
</span>
<span className="text-xs font-medium">{t("layout.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -729,11 +672,7 @@ export function SettingsPanel({
preset.value === "picture-in-picture" ||
isPortraitAspectRatio(aspectRatio),
).map((preset) => (
<SelectItem
key={preset.value}
value={preset.value}
className="text-xs"
>
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
: t("layout.verticalStack")}
@@ -817,28 +756,42 @@ export function SettingsPanel({
/>
)}
</svg>
<span className="text-[8px] leading-none">
{shape.label}
</span>
<span className="text-[8px] leading-none">{shape.label}</span>
</button>
))}
</div>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">
{t("layout.webcamSize")}
</div>
<div className="text-[10px] font-medium text-slate-400">
{webcamSizePreset}%
</div>
</div>
<Slider
value={[webcamSizePreset]}
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
onValueCommit={() => onWebcamSizePresetCommit?.()}
min={10}
max={50}
step={1}
className="w-full"
/>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
<AccordionItem
value="effects"
className="border-white/5 rounded-xl bg-white/[0.02] px-3"
>
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">
{t("effects.title")}
</span>
<span className="text-xs font-medium">{t("effects.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -862,9 +815,7 @@ export function SettingsPanel({
{t("effects.motionBlur")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{motionBlurAmount === 0
? t("effects.off")
: motionBlurAmount.toFixed(2)}
{motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)}
</span>
</div>
<Slider
@@ -901,15 +852,11 @@ export function SettingsPanel({
<div className="text-[10px] font-medium text-slate-300">
{t("effects.roundness")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{borderRadius}px
</span>
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
</div>
<Slider
value={[borderRadius]}
onValueChange={(values) =>
onBorderRadiusChange?.(values[0])
}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
onValueCommit={() => onBorderRadiusCommit?.()}
min={0}
max={16}
@@ -925,15 +872,11 @@ export function SettingsPanel({
{t("effects.padding")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{webcamLayoutPreset === "vertical-stack"
? "—"
: `${padding}%`}
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
</span>
</div>
<Slider
value={[
webcamLayoutPreset === "vertical-stack" ? 0 : padding,
]}
value={[webcamLayoutPreset === "vertical-stack" ? 0 : padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
onValueCommit={() => onPaddingCommit?.()}
min={0}
@@ -963,9 +906,7 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">
{t("background.title")}
</span>
<span className="text-xs font-medium">{t("background.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -1030,9 +971,7 @@ export function SettingsPanel({
role="button"
>
<button
onClick={(e) =>
handleRemoveCustomImage(imageUrl, e)
}
onClick={(e) => handleRemoveCustomImage(imageUrl, e)}
className="absolute top-0.5 right-0.5 w-3 h-3 bg-red-500/90 hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<X className="w-2 h-2 text-white" />
@@ -1051,10 +990,8 @@ export function SettingsPanel({
try {
const clean = (s: string) =>
s.replace(/^file:\/\//, "").replace(/^\//, "");
if (clean(selected).endsWith(clean(path)))
return true;
if (clean(path).endsWith(clean(selected)))
return true;
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {
// Best-effort comparison; fallback to strict match.
}
@@ -1139,12 +1076,8 @@ export function SettingsPanel({
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-slate-200">
{t("crop.cropVideo")}
</span>
<p className="text-sm text-slate-400 mt-2">
{t("crop.dragInstruction")}
</p>
<span className="text-xl font-bold text-slate-200">{t("crop.cropVideo")}</span>
<p className="text-sm text-slate-400 mt-2">{t("crop.dragInstruction")}</p>
</div>
<Button
variant="ghost"
@@ -1178,9 +1111,7 @@ export function SettingsPanel({
min={0}
max={max}
value={getCropPixelValue(field)}
onChange={(e) =>
handleCropNumericChange(field, Number(e.target.value))
}
onChange={(e) => handleCropNumericChange(field, Number(e.target.value))}
className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
@@ -1199,40 +1130,22 @@ export function SettingsPanel({
<option value="" className="bg-[#1a1a1f] text-slate-200">
{t("crop.free")}
</option>
<option
value="16:9"
className="bg-[#1a1a1f] text-slate-200"
>
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
16:9
</option>
<option
value="9:16"
className="bg-[#1a1a1f] text-slate-200"
>
<option value="9:16" className="bg-[#1a1a1f] text-slate-200">
9:16
</option>
<option
value="4:3"
className="bg-[#1a1a1f] text-slate-200"
>
<option value="4:3" className="bg-[#1a1a1f] text-slate-200">
4:3
</option>
<option
value="3:4"
className="bg-[#1a1a1f] text-slate-200"
>
<option value="3:4" className="bg-[#1a1a1f] text-slate-200">
3:4
</option>
<option
value="1:1"
className="bg-[#1a1a1f] text-slate-200"
>
<option value="1:1" className="bg-[#1a1a1f] text-slate-200">
1:1
</option>
<option
value="21:9"
className="bg-[#1a1a1f] text-slate-200"
>
<option value="21:9" className="bg-[#1a1a1f] text-slate-200">
21:9
</option>
</select>
@@ -1246,9 +1159,7 @@ export function SettingsPanel({
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
)}
title={
cropAspectLocked
? t("crop.unlockAspectRatio")
: t("crop.lockAspectRatio")
cropAspectLocked ? t("crop.unlockAspectRatio") : t("crop.lockAspectRatio")
}
>
{cropAspectLocked ? (
@@ -1370,9 +1281,7 @@ export function SettingsPanel({
<button
key={key}
data-testid={getTestId(`gif-size-button-${key}`)}
onClick={() =>
onGifSizePresetChange?.(key as GifSizePreset)
}
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
gifSizePreset === key
@@ -1380,9 +1289,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
{key === "original"
? "Orig"
: key.charAt(0).toUpperCase() + key.slice(1, 3)}
{key === "original" ? "Orig" : key.charAt(0).toUpperCase() + key.slice(1, 3)}
</button>
))}
</div>
@@ -1392,9 +1299,7 @@ export function SettingsPanel({
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400">
{t("gifSettings.loop")}
</span>
<span className="text-[10px] text-slate-400">{t("gifSettings.loop")}</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
@@ -1424,9 +1329,7 @@ export function SettingsPanel({
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-4 h-4" />
{exportFormat === "gif"
? t("export.gifButton")
: t("export.videoButton")}
{exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
</Button>
<div className="flex gap-2 mt-3">
@@ -1445,9 +1348,7 @@ export function SettingsPanel({
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl(
"https://github.com/siddharthvaddem/openscreen",
);
window.electronAPI?.openExternalUrl("https://github.com/siddharthvaddem/openscreen");
}}
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
+49 -9
View File
@@ -86,6 +86,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
} = editorState;
@@ -198,6 +199,7 @@ export default function VideoEditor() {
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamSizePreset: normalizedEditor.webcamSizePreset,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
@@ -237,7 +239,10 @@ export default function VideoEditor() {
JSON.stringify(
createProjectData(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
? {
screenVideoPath: sourcePath,
webcamVideoPath: webcamSourcePath,
}
: { screenVideoPath: sourcePath },
normalizedEditor,
),
@@ -268,6 +273,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -292,6 +298,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -386,6 +393,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -441,6 +449,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -648,7 +657,11 @@ export default function VideoEditor() {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
@@ -661,7 +674,11 @@ export default function VideoEditor() {
pushState((prev) => ({
trimRegions: prev.trimRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
@@ -687,7 +704,11 @@ export default function VideoEditor() {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
? {
...region,
depth,
focus: clampFocusToDepth(region.focus, depth),
}
: region,
),
}));
@@ -709,7 +730,9 @@ export default function VideoEditor() {
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
pushState((prev) => ({
zoomRegions: prev.zoomRegions.filter((r) => r.id !== id),
}));
if (selectedZoomId === id) {
setSelectedZoomId(null);
}
@@ -719,7 +742,9 @@ export default function VideoEditor() {
const handleTrimDelete = useCallback(
(id: string) => {
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
pushState((prev) => ({
trimRegions: prev.trimRegions.filter((r) => r.id !== id),
}));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
@@ -745,7 +770,9 @@ export default function VideoEditor() {
endMs: Math.round(span.end),
speed: DEFAULT_PLAYBACK_SPEED,
};
pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
pushState((prev) => ({
speedRegions: [...prev.speedRegions, newRegion],
}));
setSelectedSpeedId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
@@ -810,7 +837,9 @@ export default function VideoEditor() {
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
@@ -823,7 +852,11 @@ export default function VideoEditor() {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
@@ -1110,6 +1143,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
@@ -1243,6 +1277,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
@@ -1313,6 +1348,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
handleExportSaved,
@@ -1499,6 +1535,7 @@ export default function VideoEditor() {
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
@@ -1649,6 +1686,9 @@ export default function VideoEditor() {
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
webcamSizePreset={webcamSizePreset}
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
onWebcamSizePresetCommit={commitState}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
+28 -5
View File
@@ -24,6 +24,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
@@ -69,6 +70,7 @@ interface VideoPlaybackProps {
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
@@ -119,6 +121,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
@@ -195,7 +198,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const lockedVideoDimensionsRef = useRef<{
width: number;
height: number;
} | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
@@ -283,6 +289,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -314,6 +321,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
]);
@@ -648,7 +656,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
return;
}
@@ -672,7 +684,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
mounted = false;
setPixiReady(false);
if (app && app.renderer) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
}
appRef.current = null;
cameraContainerRef.current = null;
@@ -853,12 +869,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const ss = stageSizeRef.current;
const viewportRatio =
bm.width > 0 && bm.height > 0
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
? {
widthRatio: ss.width / bm.width,
heightRatio: ss.height / bm.height,
}
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
{
connectZooms: true,
cursorTelemetry: cursorTelemetryRef.current,
viewportRatio,
},
);
const defaultFocus = DEFAULT_FOCUS;
@@ -14,12 +14,14 @@ import {
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type WebcamSizePreset,
type ZoomRegion,
} from "./types";
@@ -47,6 +49,7 @@ export interface ProjectEditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
@@ -363,6 +366,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
+3 -2
View File
@@ -3,9 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
export type WebcamSizePreset = "small" | "medium" | "large";
/** Webcam size as a percentage of the canvas reference dimension (1050). */
export type WebcamSizePreset = number;
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = "medium";
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
@@ -5,6 +5,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import type { CropRegion, WebcamMaskShape } from "../types";
@@ -20,6 +21,7 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
@@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
} = params;
@@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
+2 -1
View File
@@ -24,7 +24,8 @@
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"webcamShape": "Camera Shape"
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
"effects": {
"title": "Video Effects",
+2 -1
View File
@@ -24,7 +24,8 @@
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
"webcamShape": "Forma de cámara"
"webcamShape": "Forma de cámara",
"webcamSize": "Tamaño de cámara"
},
"effects": {
"title": "Efectos de video",
+2 -1
View File
@@ -24,7 +24,8 @@
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
"webcamShape": "摄像头形状"
"webcamShape": "摄像头形状",
"webcamSize": "摄像头大小"
},
"effects": {
"title": "视频效果",
+109 -18
View File
@@ -24,16 +24,111 @@ describe("computeCompositeLayout", () => {
webcamSize: { width: 1920, height: 1080 },
});
const refDim = Math.sqrt(1280 * 720);
const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25
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(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1);
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(
Math.round(refDim * defaultFraction) + 1,
);
expect(
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
).toBeLessThanOrEqual(1920);
});
it("uses cover-style full-width stacking in vertical stack mode", () => {
it("produces consistent webcam size across landscape and portrait aspect ratios", () => {
const webcamSize = { width: 1280, height: 720 };
const landscape = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize,
webcamSizePreset: 50,
});
const portrait = computeCompositeLayout({
canvasSize: { width: 1080, height: 1920 },
screenSize: { width: 1080, height: 1920 },
webcamSize,
webcamSizePreset: 50,
});
expect(landscape).not.toBeNull();
expect(portrait).not.toBeNull();
// Same total pixel count — webcam area should be comparable
const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height;
const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height;
expect(landscapeArea).toBe(portraitArea);
});
it("scales the webcam proportionally as webcamSizePreset increases", () => {
const canvasSize = { width: 1920, height: 1080 };
const screenSize = { width: 1920, height: 1080 };
const webcamSize = { width: 1280, height: 720 };
const small = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 10,
});
const medium = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 25,
});
const large = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 50,
});
expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width);
expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width);
expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height);
expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height);
});
it("clamps webcamSizePreset to the valid range (1050)", () => {
const canvasSize = { width: 1920, height: 1080 };
const screenSize = { width: 1920, height: 1080 };
const webcamSize = { width: 1280, height: 720 };
const atMin = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 10,
});
const belowMin = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 1,
});
const atMax = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 50,
});
const aboveMax = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 100,
});
// Values below 10 should clamp to 10
expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width);
expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height);
// Values above 50 should clamp to 50
expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width);
expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height);
});
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 },
@@ -43,23 +138,19 @@ describe("computeCompositeLayout", () => {
});
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 0,
});
expect(layout?.webcamRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 1080,
borderRadius: 0,
});
expect(layout?.screenCover).toBe(true);
// Webcam is full-width at the bottom
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.x).toBe(0);
expect(layout!.webcamRect!.width).toBe(1920);
expect(layout!.webcamRect!.borderRadius).toBe(0);
// Screen fills remaining space at the top (cover mode)
expect(layout!.screenRect.x).toBe(0);
expect(layout!.screenRect.y).toBe(0);
expect(layout!.screenRect.width).toBe(1920);
expect(layout!.screenCover).toBe(true);
});
it("fills the canvas with the screen when vertical stack has no webcam", () => {
it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
+14 -14
View File
@@ -16,7 +16,8 @@ export interface Size {
}
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
export type WebcamSizePreset = "small" | "medium" | "large";
/** Webcam size as a percentage of the canvas reference dimension (1050). */
export type WebcamSizePreset = number;
export interface WebcamLayoutShadow {
color: string;
@@ -33,7 +34,6 @@ interface BorderRadiusRule {
interface OverlayTransform {
type: "overlay";
maxStageFraction: number;
marginFraction: number;
minMargin: number;
minSize: number;
@@ -58,12 +58,11 @@ export interface WebcamCompositeLayout {
screenCover?: boolean;
}
// Webcam size fractions for different presets
const WEBCAM_SIZE_FRACTIONS = {
small: 0.10,
medium: 0.18,
large: 0.30,
} as const;
/** Convert a webcam size percentage (1050) to a fraction of the reference dimension. */
function webcamSizeToFraction(percent: number): number {
const clamped = Math.max(10, Math.min(50, percent));
return clamped / 100;
}
const MARGIN_FRACTION = 0.02;
const MAX_BORDER_RADIUS = 24;
@@ -72,7 +71,6 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
label: "Picture in Picture",
transform: {
type: "overlay",
maxStageFraction: MAX_STAGE_FRACTION,
marginFraction: MARGIN_FRACTION,
minMargin: 0,
minSize: 0,
@@ -142,7 +140,7 @@ export function computeCompositeLayout(params: {
screenSize,
webcamSize,
layoutPreset = "picture-in-picture",
webcamSizePreset = "medium",
webcamSizePreset = 25,
webcamPosition,
webcamMaskShape = "rectangle",
} = params;
@@ -152,8 +150,7 @@ export function computeCompositeLayout(params: {
const webcamHeight = webcamSize?.height;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
// Get the max stage fraction based on size preset
const MAX_STAGE_FRACTION = WEBCAM_SIZE_FRACTIONS[webcamSizePreset];
const MAX_STAGE_FRACTION = webcamSizeToFraction(webcamSizePreset);
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
return null;
@@ -210,8 +207,11 @@ export function computeCompositeLayout(params: {
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);
// Use geometric mean so the webcam occupies a consistent visual proportion
// regardless of whether the canvas is portrait or landscape.
const referenceDim = Math.sqrt(canvasWidth * canvasHeight);
const maxWidth = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
const maxHeight = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
let width = Math.round(webcamWidth * scale);
let height = Math.round(webcamHeight * scale);
+3
View File
@@ -13,6 +13,7 @@ import type {
CropRegion,
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
@@ -70,6 +71,7 @@ interface FrameRenderConfig {
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
@@ -453,6 +455,7 @@ export class FrameRenderer {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
webcamMaskShape: this.config.webcamMaskShape,
});
+3
View File
@@ -5,6 +5,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -42,6 +43,7 @@ interface GifExporterConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
@@ -144,6 +146,7 @@ export class GifExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
+3
View File
@@ -4,6 +4,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -33,6 +34,7 @@ interface VideoExporterConfig extends ExportConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
@@ -137,6 +139,7 @@ export class VideoExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,