-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat: background color compositing for transparent windows + improved AI zoom suggestions for mobile #556
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: background color compositing for transparent windows + improved AI zoom suggestions for mobile #556
Changes from 1 commit
3e7729f
159639a
3be8238
b057a4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| # Pull Request Template | ||
|
|
||
| ## Description | ||
|
|
||
| This PR adds two major features: | ||
|
|
||
| 1. **Recording background color compositing** — when recording a transparent window (e.g. iPhone Mirroring, iOS Simulator), the transparent areas are now filled with a user-chosen solid color via an off-screen canvas pipeline, instead of encoding as black. | ||
|
|
||
| 2. **Improved AI zoom suggestions for mobile recordings** — the "Suggest Zooms" wand now generates a zoom region for every tap/click during recording, not just long cursor dwells. Click timestamps and short cursor pauses (120ms+) are both used as candidates, and suggestions no longer block each other so all interactions appear on the timeline. | ||
|
|
||
| ## Motivation | ||
|
|
||
| **Background compositing**: When recording a transparent Electron window (e.g. a mirrored iPhone in a frameless window), H.264/VP8 video codecs have no alpha channel — transparent areas encode as solid black. Users had no way to control or replace that background color. | ||
|
|
||
| **AI zoom suggestions**: The previous dwell-detection algorithm required 450ms+ of cursor stillness, missing rapid navigation taps on mobile recordings. Suggestions were also silently dropped when they overlapped each other, causing most mobile interactions to be skipped entirely. | ||
|
|
||
| ## Type of Change | ||
| - [x] New Feature | ||
| - [x] Bug Fix | ||
| - [ ] Refactor / Code Cleanup | ||
| - [ ] Documentation Update | ||
| - [ ] Other (please specify) | ||
|
|
||
| ## Related Issue(s) | ||
| <!-- Link related issues here --> | ||
|
|
||
| ## Screenshots / Video | ||
|
|
||
| <!-- Add a screen recording showing the color picker in the HUD and the zoom suggestions appearing for each tap --> | ||
|
|
||
| ## Testing | ||
|
|
||
| **Background color picker:** | ||
| 1. Launch a transparent window recording (iPhone Mirroring or iOS Simulator) | ||
| 2. Before starting recording, click the colored circle in the HUD bar | ||
| 3. Select a color from the presets or the custom color wheel | ||
| 4. Record and export — the background should be the chosen color instead of black | ||
| 5. Select the transparent (checkerboard) option — **known bug**: transparent areas still export as black because video codecs do not support alpha. This option is preserved in the UI for future codec support but does not currently work. | ||
|
|
||
| **AI zoom suggestions (mobile):** | ||
| 1. Record an iPhone Mirroring session with several quick tap navigations (~1s apart) | ||
| 2. Open the recording in the editor | ||
| 3. Click the magic wand "Suggest Zooms" button on the zoom timeline row | ||
| 4. A zoom region should appear for each tap, even if they are adjacent or overlapping | ||
| 5. Pre-existing manually-created zoom regions are respected and will not be overwritten | ||
|
|
||
| ## Known Bug | ||
|
|
||
| **Transparent background does not work** — selecting the checkerboard/transparent swatch sets `captureBackgroundColor` to `null`, which bypasses canvas compositing and passes the raw video track to `MediaRecorder`. Because H.264 (and VP8/VP9) have no alpha channel, any transparent pixels in the source window are encoded as black. The transparent option is present in the UI but produces the same black result as having no compositing. A fix would require a codec that supports alpha (e.g. HEVC with alpha, or ProRes 4444), which is not currently supported by `MediaRecorder` in Chromium/Electron. | ||
|
|
||
| ## Checklist | ||
| - [x] I have performed a self-review of my code. | ||
| - [ ] I have added any necessary screenshots or videos. | ||
| - [ ] I have linked related issue(s) and updated the changelog if applicable. | ||
|
|
||
| --- | ||
|
|
||
| ## Files Changed | ||
|
|
||
| | File | Change | | ||
| |------|--------| | ||
| | `src/hooks/useScreenRecorder.ts` | Canvas compositing pipeline; `captureBackgroundColor` state | | ||
| | `src/components/launch/LaunchWindow.tsx` | HUD color picker (presets, transparent swatch, custom wheel) | | ||
| | `src/components/video-editor/VideoPlayback.tsx` | Write clamped auto-focus position back to `smoothedAutoFocusRef` | | ||
| | `src/components/video-editor/timeline/zoomSuggestionUtils.ts` | `detectClickCandidates()`; lowered `MIN_DWELL_DURATION_MS` 450→120ms; per-candidate `durationMs`/`startOffsetMs` | | ||
| | `src/components/video-editor/timeline/TimelineEditor.tsx` | Merge click+dwell candidates; removed inter-suggestion blocking; pass `cursorClickTimestamps` prop | | ||
| | `src/components/video-editor/VideoEditor.tsx` | Pass `cursorClickTimestamps` to `TimelineEditor` | | ||
|
|
||
| --- | ||
| *Thank you for contributing!* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { matchesShortcut } from "@/lib/shortcuts"; | |
| import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; | ||
| import { BackgroundLoadError } from "@/lib/wallpaper"; | ||
| import { | ||
| type AspectRatio, | ||
| getAspectRatioValue, | ||
| getNativeAspectRatioValue, | ||
| isPortraitAspectRatio, | ||
|
|
@@ -66,6 +67,7 @@ import { | |
| DEFAULT_PLAYBACK_SPEED, | ||
| DEFAULT_ZOOM_DEPTH, | ||
| type FigureData, | ||
| getZoomScale, | ||
| type PlaybackSpeed, | ||
| type Rotation3DPreset, | ||
| type SpeedRegion, | ||
|
|
@@ -814,6 +816,17 @@ export default function VideoEditor() { | |
| [selectedZoomId, pushState], | ||
| ); | ||
|
|
||
| const handleZoomScaleChange = useCallback( | ||
| (id: string, scale: number) => { | ||
| updateState((prev) => ({ | ||
| zoomRegions: prev.zoomRegions.map((region) => | ||
| region.id === id ? { ...region, customScale: scale } : region, | ||
| ), | ||
| })); | ||
| }, | ||
| [updateState], | ||
| ); | ||
|
|
||
| const handleZoomFocusModeChange = useCallback( | ||
| (focusMode: ZoomFocusMode) => { | ||
| if (!selectedZoomId) return; | ||
|
|
@@ -855,6 +868,20 @@ export default function VideoEditor() { | |
| [selectedZoomId, pushState], | ||
| ); | ||
|
|
||
| const handleZoomAspectRatioChange = useCallback( | ||
| (ratio: AspectRatio) => { | ||
| if (!selectedZoomId) return; | ||
| pushState((prev) => ({ | ||
| zoomRegions: prev.zoomRegions.map((z) => | ||
| z.id === selectedZoomId | ||
| ? { ...z, zoomAspectRatio: ratio === "native" ? undefined : ratio } | ||
| : z, | ||
| ), | ||
| })); | ||
| }, | ||
| [selectedZoomId, pushState], | ||
| ); | ||
|
|
||
| const handleTrimDelete = useCallback( | ||
| (id: string) => { | ||
| pushState((prev) => ({ | ||
|
|
@@ -1730,19 +1757,6 @@ export default function VideoEditor() { | |
| } | ||
| }, []); | ||
|
|
||
| const handleSaveDiagnostic = useCallback(async () => { | ||
| const result = await window.electronAPI.saveDiagnostic({ | ||
| error: exportError ?? "Manual diagnostic export", | ||
| projectState: editorState, | ||
| logs: [], | ||
| }); | ||
| if (result.success) { | ||
| toast.success("Diagnostic file saved"); | ||
| } else if (!result.canceled) { | ||
| toast.error("Failed to save diagnostic file"); | ||
| } | ||
| }, [exportError, editorState]); | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className="flex items-center justify-center h-screen bg-background"> | ||
|
|
@@ -1958,6 +1972,7 @@ export default function VideoEditor() { | |
| currentTime={currentTime} | ||
| onSeek={handleSeek} | ||
| cursorTelemetry={cursorTelemetry} | ||
| cursorClickTimestamps={cursorClickTimestamps} | ||
| zoomRegions={zoomRegions} | ||
| onZoomAdded={handleZoomAdded} | ||
| onZoomSuggested={handleZoomSuggested} | ||
|
|
@@ -2018,6 +2033,19 @@ export default function VideoEditor() { | |
| selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null | ||
| } | ||
| onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} | ||
| selectedZoomScale={ | ||
| selectedZoomId | ||
| ? getZoomScale(zoomRegions.find((z) => z.id === selectedZoomId) ?? { depth: DEFAULT_ZOOM_DEPTH }) | ||
| : null | ||
| } | ||
| onZoomScaleChange={(scale) => selectedZoomId && handleZoomScaleChange(selectedZoomId, scale)} | ||
| onZoomScaleCommit={commitState} | ||
| selectedZoomAspectRatio={ | ||
| selectedZoomId | ||
| ? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomAspectRatio ?? "native") | ||
| : null | ||
| } | ||
| onZoomAspectRatioChange={handleZoomAspectRatioChange} | ||
| selectedZoomFocusMode={ | ||
| selectedZoomId | ||
| ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") | ||
|
|
@@ -2052,6 +2080,16 @@ export default function VideoEditor() { | |
| cropRegion={cropRegion} | ||
| onCropChange={(r) => pushState({ cropRegion: r })} | ||
| aspectRatio={aspectRatio} | ||
| onAspectRatioChange={(ar) => | ||
| pushState({ | ||
| aspectRatio: ar, | ||
| webcamLayoutPreset: | ||
| (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || | ||
| (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") | ||
| ? "picture-in-picture" | ||
| : webcamLayoutPreset, | ||
| }) | ||
| } | ||
| hasWebcam={Boolean(webcamVideoPath)} | ||
| webcamLayoutPreset={webcamLayoutPreset} | ||
| onWebcamLayoutPresetChange={(preset) => | ||
|
|
@@ -2113,7 +2151,6 @@ export default function VideoEditor() { | |
| onSpeedDelete={handleSpeedDelete} | ||
| unsavedExport={unsavedExport} | ||
| onSaveUnsavedExport={handleSaveUnsavedExport} | ||
| onSaveDiagnostic={handleSaveDiagnostic} | ||
| /> | ||
|
Comment on lines
2179
to
2184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
| </div> | ||
| </div> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.