From c04867685f465c077902f890e3a1e0fd68cc5e94 Mon Sep 17 00:00:00 2001 From: joao Date: Fri, 3 Apr 2026 17:12:21 -0300 Subject: [PATCH 01/14] feat: webcam focus timeline regions + camera shape selector - Add webcam focus as a timeline region type (like zoom/speed boxes): draws attention to the webcam for a time range, blurring/dimming the screen recording and expanding the webcam to portrait center - Export parity: blur+dim and enlarged webcam rect baked into rendered frames via FrameRenderer (canvas cover-crop for correct aspect ratio) - Restore webcam mask shape selector (rectangle/circle/square/rounded) with shape-aware dimensions in compositeLayout and export pipeline - In-app WebcamFocusHelp dialog and README docs Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 ++ src/components/video-editor/SettingsPanel.tsx | 86 +++++++++++ src/components/video-editor/VideoEditor.tsx | 87 +++++++++++ src/components/video-editor/VideoPlayback.tsx | 118 +++++++++++---- .../video-editor/WebcamFocusHelp.tsx | 123 +++++++++++++++ .../video-editor/projectPersistence.ts | 27 ++++ src/components/video-editor/timeline/Item.tsx | 26 +++- .../timeline/ItemGlass.module.css | 32 +++- .../video-editor/timeline/TimelineEditor.tsx | 140 +++++++++++++++++- src/components/video-editor/types.ts | 10 ++ .../video-editor/videoPlayback/layoutUtils.ts | 5 +- src/hooks/useEditorHistory.ts | 7 + src/i18n/locales/en/dialogs.json | 15 ++ src/i18n/locales/en/settings.json | 3 +- src/i18n/locales/en/timeline.json | 13 +- src/i18n/locales/es/dialogs.json | 15 ++ src/i18n/locales/es/settings.json | 3 +- src/i18n/locales/es/timeline.json | 13 +- src/i18n/locales/zh-CN/dialogs.json | 15 ++ src/i18n/locales/zh-CN/settings.json | 3 +- src/i18n/locales/zh-CN/timeline.json | 13 +- src/lib/compositeLayout.ts | 37 +++-- src/lib/exporter/frameRenderer.ts | 130 ++++++++++++++-- src/lib/exporter/gifExporter.ts | 6 + src/lib/exporter/videoExporter.ts | 6 + src/lib/webcamMaskShapes.ts | 48 ++++++ 26 files changed, 914 insertions(+), 81 deletions(-) create mode 100644 src/components/video-editor/WebcamFocusHelp.tsx create mode 100644 src/lib/webcamMaskShapes.ts diff --git a/README.md b/README.md index 0e9ed4df7..dc6f7e9c1 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,22 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist - Add annotations (text, arrows, images). - Trim sections of the clip. - Customize speed at different segments. +- **Webcam Focus** — draw attention to your face at key moments. - Export in different aspect ratios and resolutions. +### Webcam Focus + +When you record with a webcam, you can mark specific time ranges on the timeline where the webcam should take center stage. During those segments the screen recording blurs and dims while the webcam expands to fill most of the frame. Outside the region everything returns to the normal layout. Both transitions animate smoothly. + +**How to use it:** +1. Make sure your recording includes a webcam feed. +2. In the editor, click the **camera icon** (🎥) in the timeline toolbar to place a Webcam Focus region at the current playhead position. +3. Drag the edges of the indigo region to set the start and end times. +4. Press **Play** to preview — the webcam enlarges to portrait near-full-screen and the screen recording fades behind it. +5. Delete a region by selecting it and pressing `Delete` / `Backspace`. + +The effect is saved with your project and included in the exported video. + ## Installation Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index bc7ebb07f..b80125367 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -51,6 +51,7 @@ import type { FigureData, PlaybackSpeed, WebcamLayoutPreset, + WebcamMaskShape, ZoomDepth, ZoomFocusMode, } from "./types"; @@ -147,6 +148,8 @@ interface SettingsPanelProps { hasWebcam?: boolean; webcamLayoutPreset?: WebcamLayoutPreset; onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; + webcamMaskShape?: WebcamMaskShape; + onWebcamMaskShapeChange?: (shape: WebcamMaskShape) => void; } export default SettingsPanel; @@ -218,6 +221,8 @@ export function SettingsPanel({ hasWebcam = false, webcamLayoutPreset = "picture-in-picture", onWebcamLayoutPresetChange, + webcamMaskShape = "rectangle", + onWebcamMaskShapeChange, }: SettingsPanelProps) { const t = useScopedT("settings"); const [wallpaperPaths, setWallpaperPaths] = useState([]); @@ -665,6 +670,87 @@ export function SettingsPanel({ + {webcamLayoutPreset === "picture-in-picture" && ( +
+
+ {t("layout.webcamShape")} +
+
+ {( + [ + { value: "rectangle", label: "Rect" }, + { value: "circle", label: "Circle" }, + { value: "square", label: "Square" }, + { value: "rounded", label: "Rounded" }, + ] as Array<{ value: WebcamMaskShape; label: string }> + ).map((shape) => ( + + ))} +
+
+ )} )} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 91b51e507..ca1540cdf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -75,6 +75,7 @@ export default function VideoEditor() { zoomRegions, trimRegions, speedRegions, + webcamFocusRegions, annotationRegions, cropRegion, wallpaper, @@ -85,6 +86,7 @@ export default function VideoEditor() { padding, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, } = editorState; @@ -104,6 +106,7 @@ export default function VideoEditor() { const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [selectedWebcamFocusId, setSelectedWebcamFocusId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -128,6 +131,7 @@ export default function VideoEditor() { const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); + const nextWebcamFocusIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); @@ -193,9 +197,11 @@ export default function VideoEditor() { zoomRegions: normalizedEditor.zoomRegions, trimRegions: normalizedEditor.trimRegions, speedRegions: normalizedEditor.speedRegions, + webcamFocusRegions: normalizedEditor.webcamFocusRegions, annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, + webcamMaskShape: normalizedEditor.webcamMaskShape, webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); @@ -208,6 +214,7 @@ export default function VideoEditor() { setSelectedTrimId(null); setSelectedSpeedId(null); setSelectedAnnotationId(null); + setSelectedWebcamFocusId(null); nextZoomIdRef.current = deriveNextId( "zoom", @@ -262,9 +269,11 @@ export default function VideoEditor() { zoomRegions, trimRegions, speedRegions, + webcamFocusRegions, annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -285,9 +294,11 @@ export default function VideoEditor() { zoomRegions, trimRegions, speedRegions, + webcamFocusRegions, annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -378,9 +389,11 @@ export default function VideoEditor() { zoomRegions, trimRegions, speedRegions, + webcamFocusRegions, annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -432,9 +445,11 @@ export default function VideoEditor() { zoomRegions, trimRegions, speedRegions, + webcamFocusRegions, annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -789,6 +804,55 @@ export default function VideoEditor() { [selectedSpeedId, pushState], ); + const handleWebcamFocusAdded = useCallback( + (span: Span) => { + const id = `webcam-focus-${nextWebcamFocusIdRef.current++}`; + pushState((prev) => ({ + webcamFocusRegions: [ + ...prev.webcamFocusRegions, + { id, startMs: Math.round(span.start), endMs: Math.round(span.end) }, + ], + })); + setSelectedWebcamFocusId(id); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + setSelectedSpeedId(null); + }, + [pushState], + ); + + const handleWebcamFocusSpanChange = useCallback( + (id: string, span: Span) => { + pushState((prev) => ({ + webcamFocusRegions: prev.webcamFocusRegions.map((r) => + r.id === id ? { ...r, startMs: Math.round(span.start), endMs: Math.round(span.end) } : r, + ), + })); + }, + [pushState], + ); + + const handleWebcamFocusDelete = useCallback( + (id: string) => { + pushState((prev) => ({ + webcamFocusRegions: prev.webcamFocusRegions.filter((r) => r.id !== id), + })); + if (selectedWebcamFocusId === id) setSelectedWebcamFocusId(null); + }, + [selectedWebcamFocusId, pushState], + ); + + const handleSelectWebcamFocus = useCallback((id: string | null) => { + setSelectedWebcamFocusId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + setSelectedSpeedId(null); + } + }, []); + const handleAnnotationAdded = useCallback( (span: Span) => { const id = `annotation-${nextAnnotationIdRef.current++}`; @@ -990,6 +1054,12 @@ export default function VideoEditor() { } }, [selectedSpeedId, speedRegions]); + useEffect(() => { + if (selectedWebcamFocusId && !webcamFocusRegions.some((r) => r.id === selectedWebcamFocusId)) { + setSelectedWebcamFocusId(null); + } + }, [selectedWebcamFocusId, webcamFocusRegions]); + const handleShowExportedFile = useCallback(async (filePath: string) => { try { const result = await window.electronAPI.revealInFolder(filePath); @@ -1103,7 +1173,9 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, + webcamFocusRegions: webcamVideoPath ? webcamFocusRegions : undefined, previewWidth, previewHeight, cursorTelemetry, @@ -1235,7 +1307,9 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, + webcamFocusRegions: webcamVideoPath ? webcamFocusRegions : undefined, previewWidth, previewHeight, cursorTelemetry, @@ -1304,7 +1378,9 @@ export default function VideoEditor() { isPlaying, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, + webcamFocusRegions, exportQuality, handleExportSaved, cursorTelemetry, @@ -1489,7 +1565,9 @@ export default function VideoEditor() { videoPath={videoPath || ""} webcamVideoPath={webcamVideoPath || undefined} webcamLayoutPreset={webcamLayoutPreset} + webcamMaskShape={webcamMaskShape} webcamPosition={webcamPosition} + webcamFocusRegions={webcamVideoPath ? webcamFocusRegions : undefined} onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })} onWebcamPositionDragEnd={commitState} onDurationChange={setDuration} @@ -1570,6 +1648,13 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} selectedSpeedId={selectedSpeedId} onSelectSpeed={handleSelectSpeed} + webcamFocusRegions={webcamFocusRegions} + onWebcamFocusAdded={handleWebcamFocusAdded} + onWebcamFocusSpanChange={handleWebcamFocusSpanChange} + onWebcamFocusDelete={handleWebcamFocusDelete} + selectedWebcamFocusId={selectedWebcamFocusId} + onSelectWebcamFocus={handleSelectWebcamFocus} + hasWebcam={Boolean(webcamVideoPath)} annotationRegions={annotationRegions} onAnnotationAdded={handleAnnotationAdded} onAnnotationSpanChange={handleAnnotationSpanChange} @@ -1637,6 +1722,8 @@ export default function VideoEditor() { webcamPosition: preset === "vertical-stack" ? null : webcamPosition, }) } + webcamMaskShape={webcamMaskShape} + onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8c2d120c3..2a7dfd6b7 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -25,6 +25,7 @@ import { type StyledRenderRect, type WebcamLayoutPreset, } from "@/lib/compositeLayout"; +import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { type AspectRatio, formatAspectRatioForCSS, @@ -66,7 +67,9 @@ interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; + webcamMaskShape?: import("./types").WebcamMaskShape; webcamPosition?: { cx: number; cy: number } | null; + webcamFocusRegions?: import("./types").WebcamFocusRegion[]; onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; onWebcamPositionDragEnd?: () => void; onDurationChange: (duration: number) => void; @@ -115,7 +118,9 @@ const VideoPlayback = forwardRef( videoPath, webcamVideoPath, webcamLayoutPreset, + webcamMaskShape, webcamPosition, + webcamFocusRegions = [], onWebcamPositionChange, onWebcamPositionDragEnd, onDurationChange, @@ -279,6 +284,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, }); @@ -309,6 +315,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, ]); @@ -1032,6 +1039,33 @@ const VideoPlayback = forwardRef( [webcamLayoutPreset], ); + const isInFocusRegion = useMemo(() => { + if (!webcamFocusRegions.length) return false; + const currentMs = currentTime * 1000; + return webcamFocusRegions.some((r) => currentMs >= r.startMs && currentMs < r.endMs); + }, [webcamFocusRegions, currentTime]); + + const focusedWebcamRect = useMemo(() => { + if (!webcamDimensions || !webcamLayout) return null; + const { width: stageW, height: stageH } = stageSizeRef.current; + if (!stageW || !stageH) return null; + const scale = Math.min( + (stageH * 0.9) / webcamDimensions.height, + (stageW * 0.8) / webcamDimensions.width, + ); + const w = Math.round(webcamDimensions.width * scale); + const h = Math.round(webcamDimensions.height * scale); + return { + x: Math.round((stageW - w) / 2), + y: Math.round((stageH - h) / 2), + width: w, + height: h, + borderRadius: webcamLayout.borderRadius, + maskShape: webcamLayout.maskShape, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [webcamDimensions, webcamLayout, isInFocusRegion]); + useEffect(() => { const webcamVideo = webcamVideoRef.current; if (!webcamVideo || !webcamVideoPath) { @@ -1195,37 +1229,63 @@ const VideoPlayback = forwardRef( ref={containerRef} className="absolute inset-0" style={{ - filter: - showShadow && shadowIntensity > 0 - ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` - : "none", + filter: (() => { + const shadow = + showShadow && shadowIntensity > 0 + ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` + : ""; + const blur = isInFocusRegion ? "blur(14px) brightness(0.5)" : ""; + return [blur, shadow].filter(Boolean).join(" ") || "none"; + })(), + transition: "filter 0.35s ease-in-out", }} /> - {webcamVideoPath && ( -