Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions PR_DESCRIPTION.md
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!*
125 changes: 125 additions & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Button } from "../ui/button";
import Colorful from "@uiw/react-color-colorful";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip } from "../ui/tooltip";
import styles from "./LaunchWindow.module.css";

Expand Down Expand Up @@ -106,8 +108,14 @@ export function LaunchWindow() {
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
captureBackgroundColor,
setCaptureBackgroundColor,
} = useScreenRecorder();

const [isBgPickerOpen, setIsBgPickerOpen] = useState(false);
const [showCustomWheel, setShowCustomWheel] = useState(false);
const BG_PRESETS = ["#000000", "#ffffff", "#1e90ff", "#00b140"] as const;

const showMicControls = microphoneEnabled && !recording;
const showWebcamControls = webcamEnabled && !recording;

Expand Down Expand Up @@ -541,6 +549,123 @@ export function LaunchWindow() {
</button>
</div>

{/* Background color for compositing */}
{!recording && (
<Popover open={isBgPickerOpen} onOpenChange={setIsBgPickerOpen}>
<PopoverTrigger asChild>
<div className={`${hudGroupClasses} px-2 ${styles.electronNoDrag}`}>
<button
className={hudIconBtnClasses}
title="Recording background color"
>
<span
className="w-5 h-5 rounded-full border-2 border-white/50 ring-1 ring-white/15 flex-shrink-0 overflow-hidden"
style={
captureBackgroundColor === null
? {
backgroundImage:
"linear-gradient(45deg,#888 25%,transparent 25%),linear-gradient(-45deg,#888 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#888 75%),linear-gradient(-45deg,transparent 75%,#888 75%)",
backgroundSize: "6px 6px",
backgroundPosition: "0 0,0 3px,3px -3px,-3px 0",
backgroundColor: "#ddd",
}
: { background: captureBackgroundColor }
}
/>
</button>
</div>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className={`w-auto p-3 bg-[rgba(22,22,30,0.98)] border-white/10 text-white shadow-2xl ${styles.electronNoDrag}`}
>
<div className="text-[11px] text-white/50 mb-2.5 font-medium">Recording Background</div>
<div className="flex items-center gap-3">
{/* Transparent swatch */}
<button
className={`w-8 h-8 rounded-full flex-shrink-0 transition-all outline-none overflow-hidden border border-white/10 ${
captureBackgroundColor === null
? "ring-2 ring-white ring-offset-2 ring-offset-[#16161e]"
: "opacity-90 hover:opacity-100"
}`}
style={{
backgroundImage:
"linear-gradient(45deg,#888 25%,transparent 25%),linear-gradient(-45deg,#888 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#888 75%),linear-gradient(-45deg,transparent 75%,#888 75%)",
backgroundSize: "8px 8px",
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
backgroundColor: "#ddd",
}}
onClick={() => {
setCaptureBackgroundColor(null);
setShowCustomWheel(false);
setIsBgPickerOpen(false);
}}
title="Transparent"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{/* Preset swatches */}
{BG_PRESETS.map((color) => (
<button
key={color}
className={`w-8 h-8 rounded-full flex-shrink-0 transition-all outline-none border border-white/10 ${
captureBackgroundColor === color
? "ring-2 ring-white ring-offset-2 ring-offset-[#16161e]"
: "opacity-90 hover:opacity-100"
}`}
style={{ background: color }}
onClick={() => {
setCaptureBackgroundColor(color);
setShowCustomWheel(false);
setIsBgPickerOpen(false);
}}
title={color}
/>
))}

{/* Custom color swatch */}
{(() => {
const isCustom =
captureBackgroundColor !== null &&
!(BG_PRESETS as readonly string[]).includes(captureBackgroundColor);
return (
<button
className={`w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center transition-all overflow-hidden outline-none border border-dashed ${
isCustom || showCustomWheel
? "ring-2 ring-white ring-offset-2 ring-offset-[#16161e] border-white/40 opacity-100"
: "border-white/30 opacity-90 hover:opacity-100"
}`}
style={isCustom ? { background: captureBackgroundColor! } : {}}
onClick={() => setShowCustomWheel((v) => !v)}
title="Custom color"
>
{!isCustom && (
<span className="text-white/60 text-base leading-none">+</span>
)}
</button>
);
})()}
</div>

{/* Inline color wheel */}
{showCustomWheel && (
<div className="mt-3">
<Colorful
color={
captureBackgroundColor !== null &&
!(BG_PRESETS as readonly string[]).includes(captureBackgroundColor)
? captureBackgroundColor
: "#ff6600"
}
onChange={(c) => setCaptureBackgroundColor(c.hex)}
disableAlpha={true}
style={{ borderRadius: "8px", width: "220px" }}
/>
</div>
)}
</PopoverContent>
</Popover>
)}

{/* Record/Stop group */}
<button
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
Expand Down
65 changes: 51 additions & 14 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,6 +67,7 @@ import {
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
type FigureData,
getZoomScale,
type PlaybackSpeed,
type Rotation3DPreset,
type SpeedRegion,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -1958,6 +1972,7 @@ export default function VideoEditor() {
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={cursorTelemetry}
cursorClickTimestamps={cursorClickTimestamps}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -2113,7 +2151,6 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
onSaveDiagnostic={handleSaveDiagnostic}
/>
Comment on lines 2179 to 2184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore diagnostic export callback in settings panel

The SettingsPanel no longer receives onSaveDiagnostic, which makes its diagnostics action disappear (SettingsPanel only renders that button when the callback is present). After this change, users lose the in-app way to export diagnostic bundles during export failures, which is a regression in troubleshooting/support workflow.

Useful? React with 👍 / 👎.

</div>
</div>
Expand Down
Loading
Loading