Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
89 changes: 87 additions & 2 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,88 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) {
formatTooltip="%"
/>
</Field>
<Field
name="Border"
icon={<IconCapSettings class="size-4" />}
value={
<Toggle
checked={project.background.border?.enabled ?? false}
onChange={(enabled) => {
const prev = project.background.border ?? {
enabled: false,
width: 5.0,
color: [255, 255, 255],
opacity: 80.0,
};

setProject("background", "border", {
...prev,
enabled,
});
}}
/>
}
/>
{project.background.border?.enabled && (
<>
<Field name="Border Width" icon={<IconCapEnlarge class="size-4" />}>
<Slider
value={[project.background.border?.width ?? 5.0]}
onChange={(v) =>
setProject("background", "border", {
...(project.background.border ?? {
enabled: true,
width: 5.0,
color: [255, 255, 255],
opacity: 80.0,
}),
width: v[0],
})
}
minValue={1}
maxValue={20}
step={0.1}
formatTooltip="px"
/>
</Field>
<Field name="Border Color" icon={<IconCapImage class="size-4" />}>
<RgbInput
value={project.background.border?.color ?? [255, 255, 255]}
onChange={(color) =>
setProject("background", "border", {
...(project.background.border ?? {
enabled: true,
width: 5.0,
color: [255, 255, 255],
opacity: 80.0,
}),
color,
})
}
/>
</Field>
<Field name="Border Opacity" icon={<IconCapShadow class="size-4" />}>
<Slider
value={[project.background.border?.opacity ?? 80.0]}
onChange={(v) =>
setProject("background", "border", {
...(project.background.border ?? {
enabled: true,
width: 5.0,
color: [255, 255, 255],
opacity: 80.0,
}),
opacity: v[0],
})
}
minValue={0}
maxValue={100}
step={0.1}
formatTooltip="%"
/>
</Field>
</>
)}
<Field name="Shadow" icon={<IconCapShadow class="size-4" />}>
<Slider
value={[project.background.shadow!]}
Expand Down Expand Up @@ -1839,7 +1921,9 @@ function ZoomSegmentPreview(props: {
const video = document.createElement("video");
createEffect(() => {
const path = convertFileSrc(
`${editorInstance.path}/content/segments/segment-${segmentIndex()}/display.mp4`,
`${
editorInstance.path
}/content/segments/segment-${segmentIndex()}/display.mp4`,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical: newline inserted into file path breaks video preview

The multiline template literal inserts a newline between editorInstance.path and the rest of the path, producing an invalid URL. Use a single-line template string.

Apply:

- const path = convertFileSrc(
-   `${
-     editorInstance.path
-   }/content/segments/segment-${segmentIndex()}/display.mp4`,
- );
+ const path = convertFileSrc(
+   `${editorInstance.path}/content/segments/segment-${segmentIndex()}/display.mp4`,
+ );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`${
editorInstance.path
}/content/segments/segment-${segmentIndex()}/display.mp4`,
);
const path = convertFileSrc(
`${editorInstance.path}/content/segments/segment-${segmentIndex()}/display.mp4`,
);
🤖 Prompt for AI Agents
In apps/desktop/src/routes/editor/ConfigSidebar.tsx around lines 1924 to 1927,
the multiline template literal introduces an unintended newline into the
generated file path (breaking the video preview); replace the multiline template
string with a single-line template literal so the resulting path is contiguous
(e.g.
`${editorInstance.path}/content/segments/segment-${segmentIndex()}/display.mp4`)
ensuring no embedded whitespace or line breaks.

video.src = path;
video.preload = "auto";
Expand Down Expand Up @@ -1992,7 +2076,7 @@ function ZoomSegmentConfig(props: {
<KTabs.Trigger
value="auto"
class="z-10 flex-1 py-2.5 text-gray-11 transition-colors duration-100 outline-none ui-selected:text-gray-12 peer"
disabled={!generalSettings.data?.customCursorCapture2}
disabled={!generalSettings.data?.custom_cursor_capture2}
>
Auto
</KTabs.Trigger>
Expand Down Expand Up @@ -2411,6 +2495,7 @@ function RgbInput(props: {
ref={colorInput}
type="color"
class="absolute left-0 bottom-0 w-[3rem] opacity-0"
value={rgbToHex(props.value)}
onChange={(e) => {
const value = hexToRgb(e.target.value);
if (value) props.onChange(value);
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/utils/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export type BackgroundConfiguration = {
crop: Crop | null;
shadow?: number;
advancedShadow?: ShadowConfiguration | null;
border?: BorderConfiguration | null;
};
export type BackgroundSource =
| { type: "wallpaper"; path: string | null }
Expand All @@ -407,6 +408,12 @@ export type BackgroundSource =
to: [number, number, number];
angle?: number;
};
export type BorderConfiguration = {
enabled: boolean;
width: number;
color: [number, number, number];
opacity: number;
};
export type Camera = {
hide: boolean;
mirror: boolean;
Expand Down Expand Up @@ -548,7 +555,7 @@ export type GeneralSettingsStore = {
windowTransparency?: boolean;
postStudioRecordingBehaviour?: PostStudioRecordingBehaviour;
mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour;
customCursorCapture2?: boolean;
custom_cursor_capture2?: boolean;
serverUrl?: string;
recordingCountdown?: number | null;
enableNativeCameraPreview: boolean;
Expand Down
23 changes: 23 additions & 0 deletions crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ pub struct ShadowConfiguration {
pub blur: f32, // Shadow blur amount (0-100)
}

#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BorderConfiguration {
pub enabled: bool,
pub width: f32, // Border width in pixels
pub color: Color, // Border color (RGB)
pub opacity: f32, // Border opacity (0-100)
}

#[derive(Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundConfiguration {
Expand All @@ -206,6 +215,19 @@ pub struct BackgroundConfiguration {
pub shadow: f32,
#[serde(default)]
pub advanced_shadow: Option<ShadowConfiguration>,
#[serde(default)]
pub border: Option<BorderConfiguration>,
}

impl Default for BorderConfiguration {
fn default() -> Self {
Self {
enabled: false,
width: 5.0,
color: [255, 255, 255], // White
opacity: 80.0, // 80% opacity
}
}
}

impl Default for BackgroundConfiguration {
Expand All @@ -219,6 +241,7 @@ impl Default for BackgroundConfiguration {
crop: None,
shadow: 73.6,
advanced_shadow: Some(ShadowConfiguration::default()),
border: None, // Border is disabled by default for backwards compatibility
}
}
}
Expand Down
51 changes: 50 additions & 1 deletion crates/rendering-skia/src/layers/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ impl From<BackgroundSource> for Background {
pub struct BackgroundLayer {
// Current background configuration
current_background: Option<Background>,
current_border: Option<cap_project::BorderConfiguration>,

// Track what we rendered last to detect changes
last_rendered_background: Option<Background>,
last_rendered_border: Option<cap_project::BorderConfiguration>,
last_rendered_size: (u32, u32),

// For image backgrounds
Expand All @@ -72,7 +74,9 @@ impl BackgroundLayer {
pub fn new() -> Self {
Self {
current_background: None,
current_border: None,
last_rendered_background: None,
last_rendered_border: None,
last_rendered_size: (0, 0),
image_path: None,
loaded_image: None,
Expand Down Expand Up @@ -102,6 +106,41 @@ impl BackgroundLayer {
}
}

fn render_border(
&self,
canvas: &Canvas,
bounds: Rect,
border: &cap_project::BorderConfiguration,
) {
if !border.enabled || border.width <= 0.0 {
return;
}

let mut paint = Paint::default();
paint.set_style(skia_safe::PaintStyle::Stroke);
paint.set_stroke_width(border.width);
paint.set_anti_alias(true);

let alpha = ((border.opacity / 100.0).clamp(0.0, 1.0) * 255.0) as u8;
let border_color = Color::from_argb(
alpha,
(border.color[0] >> 8) as u8,
(border.color[1] >> 8) as u8,
(border.color[2] >> 8) as u8,
);
paint.set_color(border_color);

let inset = border.width / 2.0;
let border_rect = Rect::from_xywh(
bounds.left() + inset,
bounds.top() + inset,
bounds.width() - border.width,
bounds.height() - border.width,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Guard against negative or degenerate rect sizes for thick borders.

When border.widthbounds.width() or bounds.height(), Rect::from_xywh receives negative width/height. Clamp to zero to avoid drawing issues.

-        let border_rect = Rect::from_xywh(
-            bounds.left() + inset,
-            bounds.top() + inset,
-            bounds.width() - border.width,
-            bounds.height() - border.width,
-        );
+        let border_rect = Rect::from_xywh(
+            bounds.left() + inset,
+            bounds.top() + inset,
+            (bounds.width() - border.width).max(0.0),
+            (bounds.height() - border.width).max(0.0),
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let inset = border.width / 2.0;
let border_rect = Rect::from_xywh(
bounds.left() + inset,
bounds.top() + inset,
bounds.width() - border.width,
bounds.height() - border.width,
);
let inset = border.width / 2.0;
let border_rect = Rect::from_xywh(
bounds.left() + inset,
bounds.top() + inset,
(bounds.width() - border.width).max(0.0),
(bounds.height() - border.width).max(0.0),
);
🤖 Prompt for AI Agents
In crates/rendering-skia/src/layers/background.rs around lines 133 to 139, the
computed border_rect can get negative width/height when border.width >=
bounds.width() or bounds.height(); change the math to clamp the computed width
and height to a minimum of 0.0 (e.g., compute let w = (bounds.width() -
border.width).max(0.0) and let h = (bounds.height() - border.width).max(0.0) and
pass those into Rect::from_xywh while keeping the inset-based x/y), so
Rect::from_xywh never receives negative dimensions.


canvas.draw_rect(border_rect, &paint);
}

fn render_color(&self, canvas: &Canvas, color: &[u16; 3], _bounds: Rect) {
// Convert from u16 (0-65535) to u8 (0-255)
let skia_color = Color::from_argb(
Expand All @@ -121,7 +160,6 @@ impl BackgroundLayer {
angle: u16,
bounds: Rect,
) {
// Convert colors from u16 (0-65535) to u8 (0-255)
let start_color = Color::from_argb(
255, // Full opacity
(from[0] >> 8) as u8,
Expand Down Expand Up @@ -208,19 +246,29 @@ impl RecordableLayer for BackgroundLayer {
let canvas = recorder.begin_recording(bounds, None);
self.render_background(canvas, bounds);

// Render border if enabled
if let Some(border) = &uniforms.border {
if border.enabled {
self.render_border(canvas, bounds, border);
}
}

// Update what was last rendered
self.last_rendered_background = self.current_background.clone();
self.last_rendered_border = self.current_border.clone();
self.last_rendered_size = uniforms.output_size;

recorder.finish_recording_as_picture(None)
}

fn needs_update(&self, uniforms: &SkiaProjectUniforms) -> bool {
let new_background = Background::from(uniforms.background.clone());
let new_border = uniforms.border.clone();
let new_size = uniforms.output_size;

// Check against what was last rendered, not what's currently prepared
self.last_rendered_background.as_ref() != Some(&new_background)
|| self.last_rendered_border != new_border
|| self.last_rendered_size != new_size
}

Expand Down Expand Up @@ -261,6 +309,7 @@ impl RecordableLayer for BackgroundLayer {

// Update current state (but not last_rendered, that happens in record())
self.current_background = Some(new_background);
self.current_border = frame_data.uniforms.border.clone();

Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions crates/rendering-skia/src/layers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub use background::BackgroundLayer;
pub struct SkiaProjectUniforms {
pub output_size: (u32, u32),
pub background: cap_project::BackgroundSource,
pub border: Option<cap_project::BorderConfiguration>,
// Add more fields as needed
}

Expand Down
16 changes: 14 additions & 2 deletions crates/rendering/src/composite_frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ pub struct CompositeVideoFrameUniforms {
pub shadow_opacity: f32,
pub shadow_blur: f32,
pub opacity: f32,
pub _padding: [f32; 3],
pub border_enabled: f32,
pub border_width: f32,
pub _padding0: f32,
pub _padding1: [f32; 2],
pub _padding1b: [f32; 2],
pub border_color: [f32; 4],
pub _padding2: [f32; 4],
}

impl Default for CompositeVideoFrameUniforms {
Expand All @@ -47,7 +53,13 @@ impl Default for CompositeVideoFrameUniforms {
shadow_opacity: Default::default(),
shadow_blur: Default::default(),
opacity: 1.0,
_padding: Default::default(),
border_enabled: 0.0,
border_width: 5.0,
_padding0: 0.0,
_padding1: [0.0; 2],
_padding1b: [0.0; 2],
border_color: [1.0, 1.0, 1.0, 0.8],
_padding2: [0.0; 4],
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion crates/rendering/src/layers/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ impl DisplayLayer {
},
);

queue.write_buffer(&self.uniforms_buffer, 0, bytemuck::cast_slice(&[uniforms]));
self.uniforms_buffer = uniforms.to_buffer(device);

self.bind_group = Some(self.pipeline.bind_group(
device,
&self.uniforms_buffer,
&self.frame_texture_view,
));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid per-frame uniform buffer reallocation and bind-group churn

Recreating the GPU buffer and bind group every frame is expensive and can hurt throughput. Prefer updating the existing buffer with queue.write_buffer and only rebind on resize or when the buffer is actually recreated.

Apply this diff:

-        self.uniforms_buffer = uniforms.to_buffer(device);
-
-        self.bind_group = Some(self.pipeline.bind_group(
-            device,
-            &self.uniforms_buffer,
-            &self.frame_texture_view,
-        ));
+        // Update existing uniform buffer in place; bind group remains valid.
+        uniforms.write_to_buffer(queue, &self.uniforms_buffer);

And add a helper on the uniforms type (in crates/rendering/src/composite_frame.rs) to encapsulate the write:

// in composite_frame.rs
impl CompositeVideoFrameUniforms {
    pub fn write_to_buffer(&self, queue: &wgpu::Queue, buffer: &wgpu::Buffer) {
        // Requires CompositeVideoFrameUniforms: Pod + Zeroable and #[repr(C)]
        queue.write_buffer(buffer, 0, bytemuck::bytes_of(self));
    }
}

If bytemuck traits are not yet derived on CompositeVideoFrameUniforms, derive them and ensure #[repr(C)] is set to keep WGSL layout parity.


pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) {
Expand Down
Loading
Loading