feat: Add webcam size presets with slider
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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 (10–50). */
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠",
|
||||
"webcamShape": "摄像头形状"
|
||||
"webcamShape": "摄像头形状",
|
||||
"webcamSize": "摄像头大小"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
|
||||
+109
-18
@@ -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 (10–50)", () => {
|
||||
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
@@ -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 (10–50). */
|
||||
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 (10–50) 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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user