From ca962ff16bb40e803a62ecd9ea5685347087a3a1 Mon Sep 17 00:00:00 2001 From: Garry Laly Date: Sun, 5 Apr 2026 19:45:50 +0700 Subject: [PATCH 1/3] feat: Add webcam size presets (small/medium/large) --- src/components/video-editor/SettingsPanel.tsx | 247 ++++++++++++++---- src/components/video-editor/types.ts | 3 + src/hooks/useEditorHistory.ts | 4 + src/lib/compositeLayout.ts | 14 +- 4 files changed, 210 insertions(+), 58 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7e556b8..3862c26 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -36,10 +36,18 @@ 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"; @@ -52,10 +60,11 @@ import type { PlaybackSpeed, WebcamLayoutPreset, WebcamMaskShape, + WebcamSizePreset, ZoomDepth, ZoomFocusMode, } from "./types"; -import { SPEED_OPTIONS } from "./types"; +import { SPEED_OPTIONS, DEFAULT_WEBCAM_SIZE_PRESET } from "./types"; const WALLPAPER_COUNT = 18; const WALLPAPER_RELATIVE = Array.from( @@ -132,13 +141,20 @@ interface SettingsPanelProps { onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; onExport?: () => void; - unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null; + unsavedExport?: { + arrayBuffer: ArrayBuffer; + fileName: string; + format: string; + } | null; onSaveUnsavedExport?: () => void; selectedAnnotationId?: string | null; annotationRegions?: AnnotationRegion[]; onAnnotationContentChange?: (id: string, content: string) => void; onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; - onAnnotationStyleChange?: (id: string, style: Partial) => void; + onAnnotationStyleChange?: ( + id: string, + style: Partial, + ) => void; onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void; onAnnotationDelete?: (id: string) => void; selectedSpeedId?: string | null; @@ -150,6 +166,8 @@ interface SettingsPanelProps { onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; webcamMaskShape?: import("./types").WebcamMaskShape; onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; + webcamSizePreset?: WebcamSizePreset; + onWebcamSizePresetChange?: (preset: WebcamSizePreset) => void; } export default SettingsPanel; @@ -223,6 +241,8 @@ export function SettingsPanel({ onWebcamLayoutPresetChange, webcamMaskShape = "rectangle", onWebcamMaskShapeChange, + webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, + onWebcamSizePresetChange, }: SettingsPanelProps) { const t = useScopedT("settings"); const [wallpaperPaths, setWallpaperPaths] = useState([]); @@ -233,7 +253,9 @@ 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}`)); @@ -279,13 +301,22 @@ 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; @@ -299,7 +330,10 @@ 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; @@ -333,11 +367,13 @@ 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; } @@ -419,7 +455,10 @@ 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 @@ -458,12 +497,19 @@ export function SettingsPanel({ return ( 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)} @@ -476,11 +522,17 @@ export function SettingsPanel({
- {t("zoom.level")} + + {t("zoom.level")} +
{zoomEnabled && selectedZoomDepth && ( - {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + { + ZOOM_DEPTH_OPTIONS.find( + (o) => o.depth === selectedZoomDepth, + )?.label + } )} @@ -498,7 +550,9 @@ 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", @@ -510,7 +564,9 @@ export function SettingsPanel({ })}
{!zoomEnabled && ( -

{t("zoom.selectRegion")}

+

+ {t("zoom.selectRegion")} +

)} {zoomEnabled && hasCursorTelemetry && (
@@ -576,11 +632,13 @@ export function SettingsPanel({
- {t("speed.playbackSpeed")} + + {t("speed.playbackSpeed")} + {selectedSpeedId && selectedSpeedValue && ( - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? - `${selectedSpeedValue}×`} + {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue) + ?.label ?? `${selectedSpeedValue}×`} )}
@@ -610,11 +668,15 @@ export function SettingsPanel({ })}
{!selectedSpeedId && ( -

{t("speed.selectRegion")}

+

+ {t("speed.selectRegion")} +

)} {selectedSpeedId && ( ))}
@@ -755,11 +829,16 @@ export function SettingsPanel({ )} - +
- {t("effects.title")} + + {t("effects.title")} +
@@ -783,7 +862,9 @@ export function SettingsPanel({ {t("effects.motionBlur")}
- {motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)} + {motionBlurAmount === 0 + ? t("effects.off") + : motionBlurAmount.toFixed(2)}
{t("effects.roundness")}
- {borderRadius}px + + {borderRadius}px + onBorderRadiusChange?.(values[0])} + onValueChange={(values) => + onBorderRadiusChange?.(values[0]) + } onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} @@ -840,11 +925,15 @@ export function SettingsPanel({ {t("effects.padding")} - {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} + {webcamLayoutPreset === "vertical-stack" + ? "—" + : `${padding}%`} onPaddingChange?.(values[0])} onValueCommit={() => onPaddingCommit?.()} min={0} @@ -874,7 +963,9 @@ export function SettingsPanel({
- {t("background.title")} + + {t("background.title")} +
@@ -939,7 +1030,9 @@ export function SettingsPanel({ role="button" > ))} @@ -1265,7 +1392,9 @@ export function SettingsPanel({ {gifOutputDimensions.width} × {gifOutputDimensions.height}px
- {t("gifSettings.loop")} + + {t("gifSettings.loop")} + - {exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")} + {exportFormat === "gif" + ? t("export.gifButton") + : t("export.videoButton")}
@@ -1314,7 +1445,9 @@ export function SettingsPanel({ ))}
)} + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+
+ {t("layout.webcamSize")} +
+
+ {webcamSizePreset}% +
+
+ onWebcamSizePresetChange?.(values[0])} + onValueCommit={() => onWebcamSizePresetCommit?.()} + min={10} + max={50} + step={1} + className="w-full" + /> +
+ )}
)} - +
- - {t("effects.title")} - + {t("effects.title")}
@@ -862,9 +815,7 @@ export function SettingsPanel({ {t("effects.motionBlur")} - {motionBlurAmount === 0 - ? t("effects.off") - : motionBlurAmount.toFixed(2)} + {motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)} {t("effects.roundness")} - - {borderRadius}px - + {borderRadius}px - onBorderRadiusChange?.(values[0]) - } + onValueChange={(values) => onBorderRadiusChange?.(values[0])} onValueCommit={() => onBorderRadiusCommit?.()} min={0} max={16} @@ -925,15 +872,11 @@ export function SettingsPanel({ {t("effects.padding")} - {webcamLayoutPreset === "vertical-stack" - ? "—" - : `${padding}%`} + {webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`} onPaddingChange?.(values[0])} onValueCommit={() => onPaddingCommit?.()} min={0} @@ -963,9 +906,7 @@ export function SettingsPanel({
- - {t("background.title")} - + {t("background.title")}
@@ -1030,9 +971,7 @@ export function SettingsPanel({ role="button" > ))} @@ -1392,9 +1299,7 @@ export function SettingsPanel({ {gifOutputDimensions.width} × {gifOutputDimensions.height}px
- - {t("gifSettings.loop")} - + {t("gifSettings.loop")} - {exportFormat === "gif" - ? t("export.gifButton") - : t("export.videoButton")} + {exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
@@ -1445,9 +1348,7 @@ export function SettingsPanel({