Skip to content

Editor multi-track, gradient noise, permissions, and performance fixes#1669

Merged
richiemcilroy merged 38 commits intomainfrom
misc-fixes-3
Mar 19, 2026
Merged

Editor multi-track, gradient noise, permissions, and performance fixes#1669
richiemcilroy merged 38 commits intomainfrom
misc-fixes-3

Conversation

@richiemcilroy
Copy link
Member

@richiemcilroy richiemcilroy commented Mar 19, 2026

  • Gradient noise: Add noise texture support to gradient backgrounds via WGSL shader with configurable intensity/scale
    • Multi-track timeline: Mask and text segments now support multiple tracks with add/remove track UI
    • Permission flow: Camera/microphone permissions are now optional during setup; request flow uses activation policy toggle on macOS
    • Performance: Reuse ImageData buffers in camera previews, skip full camera channels to reduce frame drops, improve WebSocket cleanup and disposal
    • Editor UX: Extract GradientEditor component, simplify presets dropdown with save-to-preset, add platform-specific zoom hints, improve mask overlay interaction and track segment handles
    • Recording: Remove auto-restart on system display unavailable, return clear error message instead
    • Rendering: Disable fade duration for sensitive mask type, create mask uniform buffer per-render call
    • Testing: Add desktop memory soak test scripts

Greptile Summary

This PR delivers a broad set of improvements across the editor, recording pipeline, and renderer. The four headline areas are: (1) gradient noise textures via a new WGSL fbm shader and matching GradientEditor component, (2) multi-lane support for mask and text timeline tracks with add/delete UI, (3) making camera and microphone permissions optional during setup with a proper macOS activation-policy toggle for the permission dialog, and (4) a collection of performance and cleanup fixes (ImageData buffer reuse, channel pre-check to skip clones, improved WebSocket disposal, per-render mask uniform buffer).

Key changes to note:

  • sRGB gamma conversion removed from background.rs — colours in BackgroundSource::Color and BackgroundSource::Gradient are now passed through as raw value / 255.0 rather than being converted to linear space. This is a deliberate visual change covered by a new test (test_color_conversion_uses_normalized_byte_values), but will alter the appearance of existing projects that relied on the previous gamma-correct rendering.
  • Screen capture no longer auto-restarts on display sleep/lid close — both the video and audio monitors now propagate an error immediately, which will terminate an active recording session rather than transparently recovering.
  • apps/src/utils/tauri.ts is a newly added file that appears to be a duplicate of the generated bindings at apps/desktop/src/utils/tauri.ts; the intent and ownership of this copy should be clarified to prevent the two from diverging.
  • animated / animation_speed fields are added to BackgroundSource::Gradient in the config and exposed in the TypeScript bindings but are silently discarded with .. in every match arm — this is forward-looking scaffolding but should be documented to avoid confusion.

Confidence Score: 3/5

  • Largely safe but carries intentional breaking visual changes and a behaviour change that terminates recordings on display sleep — worth explicit sign-off before merging to main.
  • The multi-track UI and socket/camera cleanup changes are well-implemented and low-risk. However, three changes warrant attention before merging: (1) the intentional removal of sRGB→linear conversion changes how all colour and gradient backgrounds render for every existing project; (2) replacing auto-restart with immediate error propagation on screen-capture system-stop will cause recording failures on lid-close/display-sleep rather than transparent recovery; and (3) the unexplained duplicate apps/src/utils/tauri.ts file could lead to stale generated types.
  • crates/recording/src/sources/screen_capture/macos.rs (system-stop behaviour change), crates/rendering/src/layers/background.rs (sRGB removal), apps/src/utils/tauri.ts (duplicate generated bindings), crates/rendering/src/layers/mask.rs (per-frame GPU allocation)

Important Files Changed

Filename Overview
crates/rendering/src/layers/background.rs Removes sRGB-to-linear gamma conversion in favour of direct byte normalisation (÷255); adds noise_intensity/noise_scale fields. Visual change is intentional and covered by a new test.
crates/rendering/src/layers/mask.rs Moves uniform buffer allocation from init time to per-render-call; correct for correctness but introduces per-frame GPU allocations that may affect performance at high frame rates.
crates/recording/src/feeds/camera.rs Adds is_full() pre-check to skip clone when channel is saturated; TOCTOU race is benign but the 30-frame warning period is frame-rate-dependent.
crates/recording/src/sources/screen_capture/macos.rs Removes auto-restart on system-stop; both video and audio streams now propagate an error instead of recovering, which terminates recordings on display sleep/lid close.
crates/project/src/configuration.rs Adds noise_intensity/noise_scale/animated/animation_speed to Gradient variant and track field to MaskSegment/TextSegment; animated and animation_speed are accepted but silently discarded everywhere.
apps/desktop/src/routes/editor/GradientEditor.tsx New component for gradient editing with noise controls; SVG feTurbulence preview approximates (but does not exactly match) the WGSL fbm rendering, which is acceptable for a live preview.
apps/desktop/src/routes/editor/Timeline/index.tsx Adds deleteTrackLane helper, handleDeleteTrackLane, handleAddTrack; shifts from Show to For loops for multi-row rendering. Track state changes from boolean to count.
apps/desktop/src/routes/camera.tsx Reuses ImageData buffer to reduce GC pressure; putImageData is called directly in the message handler so the canvas still updates every frame regardless of the setLatestFrame guard.
apps/desktop/src/utils/socket.ts Adds dispose() method; cleanup() now nulls ws.onmessage, terminates strideWorker, clears WebGPU init flag, and removes __capFpsStats from globalThis; strideWorker is reused instead of recreated on each initDirectCanvas call.
apps/src/utils/tauri.ts New file that is an apparent duplicate of apps/desktop/src/utils/tauri.ts; likely unintentional or mis-generated — two copies of the bindings risk diverging.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Editor["Editor UI"]
        GE[GradientEditor.tsx\nnoise_intensity / noise_scale sliders]
        TM[TrackManager\nAdd / toggle multi-track]
        TI[Timeline/index.tsx\nFor each textTrackRows / maskTrackRows]
        MT[MaskTrack / TextTrack\nlaneIndex prop]
    end

    subgraph Config["Project Config (Rust)"]
        BS[BackgroundSource::Gradient\nnoise_intensity, noise_scale\nanimated ⚠️ unused]
        MS[MaskSegment / TextSegment\ntrack: u32]
    end

    subgraph Rendering["Rendering Pipeline (wgpu)"]
        BG[background.rs\nGradientOrColorUniforms\nnoise_intensity, noise_scale]
        WGSL[gradient-or-color.wgsl\nfbm noise → overlay blend]
        MASK[mask.rs\nper-render uniform buffer\nfade_duration=0 for Sensitive]
    end

    subgraph Recording["Recording (macOS)"]
        CAM[camera.rs\nis_full pre-check → skip clone]
        SC[screen_capture/macos.rs\nsystem-stop → Err instead of restart]
        PERM[permissions.rs\nactivation policy toggle]
    end

    GE -->|setProject background.source| BS
    TM -->|handleAddTrack / handleToggleTrack| TI
    TI -->|laneIndex| MT
    MT -->|setProject maskSegments| MS
    BS -->|From BackgroundSource| BG
    BG -->|GradientOrColorUniforms| WGSL
    MS -->|interpolate_masks| MASK
    CAM -->|frame drop optimisation| SC
    PERM -->|optional camera/mic| SC
Loading

Comments Outside Diff (2)

  1. apps/desktop/src/routes/camera.tsx, line 1175-1183 (link)

    P1 Camera preview will freeze after first frame

    The guard condition currentFrame.data !== imageData.data compares the inner ImageData reference — which is always the same reusable buffer returned by getReusableFrameData. After the very first frame is rendered:

    • currentFrame.data → the reusable ImageData object
    • imageData.data → the same reusable ImageData object
    • currentFrame.data !== imageData.data is always false
    • setLatestFrame is never called again

    If canvas redraws are driven by the latestFrame signal (which this component relies on), the preview freezes on the first frame even though the pixel buffer is still being updated in-place.

    Compare this with the correctly-implemented CameraPreviewInline in target-select-overlay.tsx, which calls drawFrame(imageData) unconditionally on every frame so the canvas is always painted, and only uses setFrame for auxiliary state:

    If unconditional signal updates are too expensive, add a direct putImageData call (as done in target-select-overlay.tsx) and then keep the guard only for the dimensions-tracking branch.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/routes/camera.tsx
    Line: 1175-1183
    
    Comment:
    **Camera preview will freeze after first frame**
    
    The guard condition `currentFrame.data !== imageData.data` compares the *inner* `ImageData` reference — which is always the same reusable buffer returned by `getReusableFrameData`. After the very first frame is rendered:
    
    - `currentFrame.data` → the reusable `ImageData` object
    - `imageData.data` → the same reusable `ImageData` object
    -`currentFrame.data !== imageData.data` is **always `false`**
    -`setLatestFrame` is **never called again**
    
    If canvas redraws are driven by the `latestFrame` signal (which this component relies on), the preview freezes on the first frame even though the pixel buffer is still being updated in-place.
    
    Compare this with the correctly-implemented `CameraPreviewInline` in `target-select-overlay.tsx`, which calls `drawFrame(imageData)` **unconditionally** on every frame so the canvas is always painted, and only uses `setFrame` for auxiliary state:
    
    
    
    If unconditional signal updates are too expensive, add a direct `putImageData` call (as done in `target-select-overlay.tsx`) and then keep the guard only for the dimensions-tracking branch.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. crates/rendering/src/layers/background.rs, line 33-68 (link)

    P1 sRGB-to-linear conversion silently removed — rendered colors will change

    The previous code called srgb_to_linear(from[0]) (which applies the ~2.2 gamma curve) before uploading values to the GPU. This PR replaces it with a straight from[0] as f32 / 255.0, which keeps values in sRGB space.

    The difference is non-trivial for mid-range values. For example, R=128:

    • old: srgb_to_linear(128)0.216 (linear)
    • new: 128 / 255.00.502 (sRGB, ~2.3× brighter in linear terms)

    If the wgpu surface/swap-chain is configured for sRGB output (i.e. the hardware applies the inverse gamma on write), passing pre-gamma values was correct and this change will produce overly saturated / bright backgrounds in the exported video. If the surface is linear, the old code was wrong and this fixes it.

    This looks intentional (the corresponding gradient-editor CSS preview now matches the WGSL shader since both operate on the same 0-255 sRGB space), but it is a visible rendering behaviour change for every existing project that uses a gradient or solid-color background. It should be explicitly documented and the render-test baselines updated so it doesn't silently regress.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/rendering/src/layers/background.rs
    Line: 33-68
    
    Comment:
    **sRGB-to-linear conversion silently removed — rendered colors will change**
    
    The previous code called `srgb_to_linear(from[0])` (which applies the ~2.2 gamma curve) before uploading values to the GPU. This PR replaces it with a straight `from[0] as f32 / 255.0`, which keeps values in sRGB space.
    
    The difference is non-trivial for mid-range values. For example, R=128:
    - old: `srgb_to_linear(128)`**0.216** (linear)
    - new: `128 / 255.0`**0.502** (sRGB, ~2.3× brighter in linear terms)
    
    If the wgpu surface/swap-chain is configured for sRGB output (i.e. the hardware applies the inverse gamma on write), passing pre-gamma values was correct and this change will produce overly saturated / bright backgrounds in the exported video. If the surface is linear, the old code was wrong and this fixes it.
    
    This looks intentional (the corresponding gradient-editor CSS preview now matches the WGSL shader since both operate on the same 0-255 sRGB space), but it is a visible rendering behaviour change for *every existing project* that uses a gradient or solid-color background. It should be explicitly documented and the render-test baselines updated so it doesn't silently regress.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/src/utils/tauri.ts
Line: 1-3

Comment:
**Unexpected duplicate of generated bindings**

This file appears to be an exact copy of `apps/desktop/src/utils/tauri.ts`, generated by `tauri-specta`. Both files contain the complete command/type bindings for the desktop Tauri app.

Since `apps/desktop/src/utils/tauri.ts` is already the canonical location for these bindings, adding a second copy at `apps/src/utils/tauri.ts` risks the two diverging silently — if the code generator only writes to one location, the other will become stale. If this is intentional (e.g., to share types across multiple apps in the monorepo), the import path should be updated in dependent files to point to the shared location and the desktop copy could be removed or aliased.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/rendering/src/layers/mask.rs
Line: 30-38

Comment:
**GPU buffer allocated per render call**

The `uniforms_buffer` is now created fresh on every call to `render()` via `device.create_buffer_init(...)`. GPU buffer creation involves allocating device memory, and with many masks at a high frame rate this can create significant GC pressure on the GPU allocator.

The previous approach — allocating the buffer once in `MaskLayer::new()` and updating it via `queue.write_buffer` — is the standard pattern for uniform buffers that change every frame. If the reason for this change is to avoid needing `queue` in the `init` path or to support multiple concurrent renders, a pool/recycling strategy or a `COPY_DST` write path would be more efficient than per-call allocation.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/recording/src/feeds/camera.rs
Line: 785-795

Comment:
**TOCTOU race between `is_full()` and `try_send()`**

The `is_full()` pre-check and the subsequent `try_send` are not atomic. If the consumer drains the channel between `is_full()` returning `true` and `continue` executing, the frame is dropped unnecessarily. Conversely, if the channel fills between `is_full()` returning `false` and the `try_send`, the existing `TrySendError::Full` handler fires redundantly.

The pre-check is an optimisation to avoid the `.clone()` when the channel is clearly saturated, so the race is benign in practice — worst case is an extra dropped frame or a redundant log warning. However, the warning threshold of one log per 30 frames assumes a 30-fps source; at higher frame rates the log will fire more frequently than intended, and at lower rates it may fire less frequently, potentially masking sustained congestion.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/project/src/configuration.rs
Line: 44-50

Comment:
**`animated` / `animation_speed` fields added but never consumed**

The two new `Gradient` variant fields — `animated: Option<bool>` and `animation_speed: Option<f32>` — are accepted in the serialised config but are silently discarded with `..` in every `BackgroundSource::Gradient { .. }` match arm across both rendering crates. They are also exposed in the generated TypeScript bindings (`animated?: boolean | null; animation_speed?: number | null`).

If these are placeholders for future work, adding a `#[doc = "TODO: not yet implemented"]` comment or `#[allow(dead_code)]` lint suppression would make the intent clearer and prevent confusion about why saving `animated: true` has no effect.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/recording/src/sources/screen_capture/macos.rs
Line: 790-797

Comment:
**System-audio stream now errors instead of awaiting restart**

Before this change, when the audio stream received a system-stop error (e.g. lid close / display sleep) it continued the monitor loop, effectively waiting for a `SourceRestarted` signal from the video monitor that would re-attach the audio. After this change, the audio monitor immediately returns `Err(system_stop_message())`, which will terminate the recording.

However, the video monitor also now returns an error on system-stop instead of restarting. So both paths now propagate an error up, which is consistent. The concern is that a momentary display sleep (e.g. the lid is briefly closed then reopened) that previously would have recovered transparently will now terminate the entire recording session with a user-visible error. Make sure this is the intended UX; the old "try-restart" behaviour may be worth preserving as a best-effort path before giving up.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "fmt"

@paragon-review
Copy link

Paragon Summary

This pull request review analyzed 45 files and found no issues. The review examined code changes, potential bugs, security vulnerabilities, performance issues, and code quality concerns using automated analysis tools.

Paragon did not detect any problems in the current diff. Proceed with merge after your normal checks.

This PR adds multi-track timeline support for mask and text segments, introduces configurable gradient noise textures via WGSL shaders, improves macOS camera/microphone permission handling, and optimizes camera preview performance, WebSocket cleanup, and rendering efficiency.

Key changes:

  • Multi-track timeline support for mask and text segments with add/remove track UI
  • Gradient noise texture support via WGSL shader with configurable intensity/scale
  • Camera/microphone permissions made optional during setup with macOS activation policy toggle
  • Performance optimizations: ImageData buffer reuse, reduced camera frame drops, improved WebSocket cleanup
  • Desktop memory soak test scripts added for testing

Confidence score: 5/5

  • This PR has low risk with no critical or high-priority issues identified
  • Score reflects clean code review with only minor suggestions or no issues found
  • Code quality checks passed - safe to proceed with merge

45 files reviewed, 0 comments

Dashboard

style={{ "background-color": text() }}
class="size-[2rem] rounded-[0.5rem] cursor-pointer transition-[box-shadow]"
style={{
"background-color": text(),
Copy link

Choose a reason for hiding this comment

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

Using text() directly here means the swatch border can go invalid while a user is mid-edit (e.g. #12), since color-mix(...) will fail to parse. Since you already have normalizeHexInput, could use that for the preview so it stays stable.

Suggested change
"background-color": text(),
style={{
"background-color": normalizeHexInput(text(), prevColor),
"box-shadow": `inset 0 0 0 1px ${getColorPreviewBorderColor(normalizeHexInput(text(), prevColor))}`,
}}

class="size-[3rem] rounded-[0.5rem]"
class="size-[2rem] rounded-[0.5rem]"
style={{
"background-color": text(),
Copy link

Choose a reason for hiding this comment

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

Same idea here: while typing, text() can be an invalid color which makes color-mix(...) fail. Consider using a simple hex guard for the preview.

Suggested change
"background-color": text(),
style={{
"background-color": /^#[0-9A-F]{6}$/i.test(text()) ? text() : prevColor,
"box-shadow": `inset 0 0 0 1px ${getColorPreviewBorderColor(/^#[0-9A-F]{6}$/i.test(text()) ? text() : prevColor)}`,
}}

std::env::var("CAP_BUNDLE_ID").unwrap_or_else(|_| "com.apple.Terminal".to_string());
#[cfg(not(debug_assertions))]
let bundle_id = "so.cap.desktop";
let bundle_id = app.config().identifier.clone();
Copy link

Choose a reason for hiding this comment

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

Switching to app.config().identifier makes sense for release, but if local dev permissions end up attributed to a different bundle id (e.g. when running unbundled), an env override can keep this command useful.

Suggested change
let bundle_id = app.config().identifier.clone();
let bundle_id = std::env::var("CAP_BUNDLE_ID").unwrap_or_else(|_| app.config().identifier.clone());

let bundle_id = "so.cap.desktop";
#[instrument(skip(app))]
async fn reset_microphone_permissions(app: AppHandle) -> Result<(), ()> {
let bundle_id = app.config().identifier.clone();
Copy link

Choose a reason for hiding this comment

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

Same override pattern here (if you want to support resetting a different id during dev/testing).

Suggested change
let bundle_id = app.config().identifier.clone();
let bundle_id = std::env::var("CAP_BUNDLE_ID").unwrap_or_else(|_| app.config().identifier.clone());

} else if (type === "microphone") {
await commands.resetMicrophonePermissions();
const window = getCurrentWindow();
await window.setAlwaysOnTop(false);
Copy link

Choose a reason for hiding this comment

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

This always-on-top toggle seems intended to keep OS dialogs/settings visible; the currentStatus === "denied" early-return path above currently bypasses it. Might be worth wrapping that path in the same try/finally so Settings can’t open behind the always-on-top window.

Suggested change
await window.setAlwaysOnTop(false);
const window = getCurrentWindow();
await window.setAlwaysOnTop(false);
try {
if (currentStatus === "denied") {
await commands.openPermissionSettings(type);
return;
}
await commands.requestPermission(type);
const check = await commands.doPermissionsCheck(false);
const status = type === "camera" ? check.camera : check.microphone;
if (status !== "granted") {
await commands.openPermissionSettings(type);
}
} finally {
await window.setAlwaysOnTop(true);
}

@richiemcilroy
Copy link
Member Author

@greptileai please rereview

warn!("Screen capture audio stream stopped by the system; awaiting restart");
continue;
warn!("Screen capture audio stream stopped by the system");
return Err(anyhow!(system_stop_message()));
Copy link

Choose a reason for hiding this comment

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

Worth considering a cancellation guard here: SystemAudioSource::stop() cancels cancel_token, but this task currently has no way to see that and can still return the system-stopped message during an intentional stop.

@richiemcilroy richiemcilroy merged commit 3766eac into main Mar 19, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant