Skip to content

Commit 1506972

Browse files
Merge pull request #1432 from CapSoftware/screenshot-ux
feat: Screenshot editor overhaul - layers panel, keyboard shortcuts, and sharper rendering
2 parents eed3f76 + 6edcee7 commit 1506972

File tree

58 files changed

+1849
-658
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1849
-658
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,8 @@ jobs:
103103
settings:
104104
- target: aarch64-apple-darwin
105105
runner: macos-latest
106-
# Windows can't take the disk usage lol
107-
# - target: x86_64-pc-windows-msvc
108-
# runner: windows-latest
106+
- target: x86_64-pc-windows-msvc
107+
runner: windows-latest
109108
runs-on: ${{ matrix.settings.runner }}
110109
permissions:
111110
contents: read

.vscode/extensions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
2+
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer"]
33
}

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@
2222
- Runtime: Node 20, pnpm 10.x, Rust 1.88+, Docker for MySQL/MinIO.
2323
- **NO COMMENTS**: Never add comments to code (`//`, `/* */`, `///`, `//!`, `#`, etc.). Code must be self-explanatory through naming, types, and structure. This applies to all languages (TypeScript, Rust, JavaScript, etc.).
2424

25+
## Rust Clippy Rules (Workspace Lints)
26+
All Rust code must respect these workspace-level lints defined in `Cargo.toml`:
27+
28+
**Rust compiler lints:**
29+
- `unused_must_use = "deny"` — Always handle `Result`/`Option` or types marked `#[must_use]`; never ignore them.
30+
31+
**Clippy lints (all denied):**
32+
- `dbg_macro` — Never use `dbg!()` in code; use proper logging instead.
33+
- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them.
34+
- `unchecked_duration_subtraction` — Use `saturating_sub` instead of `-` for `Duration` to avoid panics.
35+
- `collapsible_if` — Merge nested `if` statements: use `if a && b { }` instead of `if a { if b { } }`.
36+
- `clone_on_copy` — Don't call `.clone()` on `Copy` types; just copy them directly.
37+
- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`.
38+
- `ptr_arg` — Accept `&[T]` or `&str` instead of `&Vec<T>` or `&String` in function parameters.
39+
- `len_zero` — Use `.is_empty()` instead of `.len() == 0` or `.len() > 0`.
40+
- `let_unit_value` — Don't assign `()` to a variable: write `foo();` instead of `let _ = foo();` when return is unit.
41+
- `unnecessary_lazy_evaluations` — Use `.unwrap_or(val)` instead of `.unwrap_or_else(|| val)` for cheap values.
42+
- `needless_range_loop` — Use `for item in &collection` instead of `for i in 0..collection.len()` when index isn't needed.
43+
- `manual_clamp` — Use `.clamp(min, max)` instead of manual `if` chains or `.min().max()` patterns.
44+
2545
## Testing
2646
- TS/JS: Vitest where present (e.g., desktop). Name tests `*.test.ts(x)` near sources.
2747
- Rust: `cargo test` per crate; tests in `src` or `tests`.

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,66 @@ Minimize `useEffect` usage: compute during render, handle logic in event handler
371371
- Strict TypeScript; avoid `any`; leverage shared types
372372
- Use Biome for linting/formatting; match existing formatting
373373

374+
## Rust Clippy Rules (Workspace Lints)
375+
All Rust code must respect these workspace-level lints defined in `Cargo.toml`. Violating any of these will fail CI:
376+
377+
**Rust compiler lints:**
378+
- `unused_must_use = "deny"` — Always handle `Result`/`Option` or types marked `#[must_use]`; never ignore them.
379+
380+
**Clippy lints (all denied — code MUST NOT contain these patterns):**
381+
- `dbg_macro` — Never use `dbg!()` in code; use proper logging (`tracing::debug!`, etc.) instead.
382+
- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them.
383+
- `unchecked_duration_subtraction` — Use `duration.saturating_sub(other)` instead of `duration - other` to avoid panics on underflow.
384+
- `collapsible_if` — Merge nested `if` statements: write `if a && b { }` instead of `if a { if b { } }`.
385+
- `clone_on_copy` — Don't call `.clone()` on `Copy` types (integers, bools, etc.); just copy them directly.
386+
- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`.
387+
- `ptr_arg` — Accept `&[T]` or `&str` instead of `&Vec<T>` or `&String` in function parameters for flexibility.
388+
- `len_zero` — Use `.is_empty()` instead of `.len() == 0` or `.len() > 0` / `.len() != 0`.
389+
- `let_unit_value` — Don't assign `()` to a variable: write `foo();` instead of `let _ = foo();` or `let x = foo();` when return is unit.
390+
- `unnecessary_lazy_evaluations` — Use `.unwrap_or(val)` instead of `.unwrap_or_else(|| val)` when the default is a simple/cheap value.
391+
- `needless_range_loop` — Use `for item in &collection` or `for (i, item) in collection.iter().enumerate()` instead of `for i in 0..collection.len()`.
392+
- `manual_clamp` — Use `value.clamp(min, max)` instead of manual `if` chains or `.min(max).max(min)` patterns.
393+
394+
**Examples of violations to avoid:**
395+
396+
```rust
397+
dbg!(value);
398+
let _ = some_async_function();
399+
let duration = duration_a - duration_b;
400+
if condition {
401+
if other_condition {
402+
do_something();
403+
}
404+
}
405+
let x = 5.clone();
406+
vec.iter().map(|x| process(x))
407+
fn example(v: &Vec<i32>) { }
408+
if vec.len() == 0 { }
409+
let _ = returns_unit();
410+
option.unwrap_or_else(|| 42)
411+
for i in 0..vec.len() { println!("{}", vec[i]); }
412+
value.min(max).max(min)
413+
```
414+
415+
**Correct alternatives:**
416+
417+
```rust
418+
tracing::debug!(?value);
419+
some_async_function().await;
420+
let duration = duration_a.saturating_sub(duration_b);
421+
if condition && other_condition {
422+
do_something();
423+
}
424+
let x = 5;
425+
vec.iter().map(process)
426+
fn example(v: &[i32]) { }
427+
if vec.is_empty() { }
428+
returns_unit();
429+
option.unwrap_or(42)
430+
for item in &vec { println!("{}", item); }
431+
value.clamp(min, max)
432+
```
433+
374434
## Security & Privacy Considerations
375435

376436
### Data Handling

apps/desktop/src-tauri/src/audio_meter.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,6 @@ fn samples_to_f64(samples: &MicrophoneSamples) -> impl Iterator<Item = f64> + us
140140
SampleFormat::F64 => f64::from_ne_bytes([
141141
data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
142142
]),
143-
_ => todo!(),
143+
_ => 0.0,
144144
})
145145
}

apps/desktop/src-tauri/src/camera.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ pub enum CameraPreviewShape {
4343

4444
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
4545
pub struct CameraPreviewState {
46-
size: f32,
47-
shape: CameraPreviewShape,
48-
mirrored: bool,
46+
pub size: f32,
47+
pub shape: CameraPreviewShape,
48+
pub mirrored: bool,
4949
}
5050

5151
impl Default for CameraPreviewState {

apps/desktop/src-tauri/src/captions.rs

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -115,30 +115,28 @@ async fn extract_audio_from_video(video_path: &str, output_path: &PathBuf) -> Re
115115
if mixed_samples.is_empty() {
116116
mixed_samples = audio.samples().to_vec();
117117
channel_count = audio.channels() as usize;
118-
} else {
119-
if audio.channels() as usize != channel_count {
120-
log::info!(
121-
"Channel count mismatch: {} vs {}, mixing to mono",
122-
channel_count,
123-
audio.channels()
124-
);
125-
126-
if channel_count > 1 {
127-
let mono_samples = convert_to_mono(&mixed_samples, channel_count);
128-
mixed_samples = mono_samples;
129-
channel_count = 1;
130-
}
118+
} else if audio.channels() as usize != channel_count {
119+
log::info!(
120+
"Channel count mismatch: {} vs {}, mixing to mono",
121+
channel_count,
122+
audio.channels()
123+
);
131124

132-
let samples = if audio.channels() > 1 {
133-
convert_to_mono(audio.samples(), audio.channels() as usize)
134-
} else {
135-
audio.samples().to_vec()
136-
};
125+
if channel_count > 1 {
126+
let mono_samples = convert_to_mono(&mixed_samples, channel_count);
127+
mixed_samples = mono_samples;
128+
channel_count = 1;
129+
}
137130

138-
mix_samples(&mut mixed_samples, &samples);
131+
let samples = if audio.channels() > 1 {
132+
convert_to_mono(audio.samples(), audio.channels() as usize)
139133
} else {
140-
mix_samples(&mut mixed_samples, audio.samples());
141-
}
134+
audio.samples().to_vec()
135+
};
136+
137+
mix_samples(&mut mixed_samples, &samples);
138+
} else {
139+
mix_samples(&mut mixed_samples, audio.samples());
142140
}
143141
}
144142
Err(e) => {
@@ -1012,13 +1010,11 @@ fn start_whisperx_server(
10121010
std::thread::spawn(move || {
10131011
use std::io::BufRead;
10141012
let reader = std::io::BufReader::new(stderr);
1015-
for line in reader.lines() {
1016-
if let Ok(line) = line {
1017-
if line.starts_with("STDERR:") {
1018-
log::info!("[WhisperX] {}", &line[7..]);
1019-
} else {
1020-
log::info!("[WhisperX stderr] {}", line);
1021-
}
1013+
for line in reader.lines().flatten() {
1014+
if let Some(stripped) = line.strip_prefix("STDERR:") {
1015+
log::info!("[WhisperX] {}", stripped);
1016+
} else {
1017+
log::info!("[WhisperX stderr] {}", line);
10221018
}
10231019
}
10241020
});

apps/desktop/src-tauri/src/export.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::{FramesRendered, get_video_metadata};
22
use cap_export::ExporterBase;
3-
use cap_project::{RecordingMeta, XY};
3+
use cap_project::RecordingMeta;
44
use serde::Deserialize;
55
use specta::Type;
66
use std::path::PathBuf;

apps/desktop/src-tauri/src/recording.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ use cap_fail::fail;
33
use cap_project::CursorMoveEvent;
44
use cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS;
55
use cap_project::{
6-
CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, ProjectConfiguration,
7-
RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, StudioRecordingStatus,
8-
TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, ZoomSegment,
9-
cursor::CursorEvents,
6+
CameraShape, CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform,
7+
ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta,
8+
StudioRecordingStatus, TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode,
9+
ZoomSegment, cursor::CursorEvents,
1010
};
1111
use cap_recording::feeds::camera::CameraFeedLock;
1212
#[cfg(target_os = "macos")]
@@ -42,6 +42,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder};
4242
use tauri_specta::Event;
4343
use tracing::*;
4444

45+
use crate::camera::{CameraPreviewManager, CameraPreviewShape};
4546
use crate::web_api::AuthedApiError;
4647
use crate::{
4748
App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState,
@@ -1680,6 +1681,24 @@ fn project_config_from_recording(
16801681

16811682
let mut config = default_config.unwrap_or_default();
16821683

1684+
let camera_preview_manager = CameraPreviewManager::new(app);
1685+
if let Ok(camera_preview_state) = camera_preview_manager.get_state() {
1686+
match camera_preview_state.shape {
1687+
CameraPreviewShape::Round => {
1688+
config.camera.shape = CameraShape::Square;
1689+
config.camera.rounding = 100.0;
1690+
}
1691+
CameraPreviewShape::Square => {
1692+
config.camera.shape = CameraShape::Square;
1693+
config.camera.rounding = 25.0;
1694+
}
1695+
CameraPreviewShape::Full => {
1696+
config.camera.shape = CameraShape::Source;
1697+
config.camera.rounding = 25.0;
1698+
}
1699+
}
1700+
}
1701+
16831702
let timeline_segments = recordings
16841703
.segments
16851704
.iter()

apps/desktop/src-tauri/src/screenshot_editor.rs

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub struct ScreenshotEditorInstance {
3030
pub ws_shutdown_token: CancellationToken,
3131
pub config_tx: watch::Sender<ProjectConfiguration>,
3232
pub path: PathBuf,
33+
pub pretty_name: String,
3334
}
3435

3536
impl ScreenshotEditorInstance {
@@ -140,8 +141,31 @@ impl ScreenshotEditorInstances {
140141
let rgba_img: image::RgbaImage = rgb_img.convert();
141142
(rgba_img.into_raw(), width, height)
142143
} else {
143-
let img =
144-
image::open(&path).map_err(|e| format!("Failed to open image: {e}"))?;
144+
let image_path = if path.is_dir() {
145+
let original = path.join("original.png");
146+
if original.exists() {
147+
original
148+
} else {
149+
std::fs::read_dir(&path)
150+
.ok()
151+
.and_then(|dir| {
152+
dir.flatten()
153+
.find(|e| {
154+
e.path().extension().and_then(|s| s.to_str())
155+
== Some("png")
156+
})
157+
.map(|e| e.path())
158+
})
159+
.ok_or_else(|| {
160+
format!("No PNG file found in directory: {:?}", path)
161+
})?
162+
}
163+
} else {
164+
path.clone()
165+
};
166+
167+
let img = image::open(&image_path)
168+
.map_err(|e| format!("Failed to open image: {e}"))?;
145169
let (w, h) = img.dimensions();
146170

147171
if w > MAX_DIMENSION || h > MAX_DIMENSION {
@@ -156,15 +180,22 @@ impl ScreenshotEditorInstances {
156180
}
157181
};
158182

159-
// Try to load existing meta if in a .cap directory
160-
let (recording_meta, loaded_config) = if let Some(parent) = path.parent() {
183+
let cap_dir = if path.extension().and_then(|s| s.to_str()) == Some("cap") {
184+
Some(path.clone())
185+
} else if let Some(parent) = path.parent() {
161186
if parent.extension().and_then(|s| s.to_str()) == Some("cap") {
162-
let meta = RecordingMeta::load_for_project(parent).ok();
163-
let config = ProjectConfiguration::load(parent).ok();
164-
(meta, config)
187+
Some(parent.to_path_buf())
165188
} else {
166-
(None, None)
189+
None
167190
}
191+
} else {
192+
None
193+
};
194+
195+
let (recording_meta, loaded_config) = if let Some(cap_dir) = &cap_dir {
196+
let meta = RecordingMeta::load_for_project(cap_dir).ok();
197+
let config = ProjectConfiguration::load(cap_dir).ok();
198+
(meta, config)
168199
} else {
169200
(None, None)
170201
};
@@ -264,6 +295,7 @@ impl ScreenshotEditorInstances {
264295
ws_shutdown_token,
265296
config_tx,
266297
path: path.clone(),
298+
pretty_name: recording_meta.pretty_name.clone(),
267299
});
268300

269301
// Spawn render loop
@@ -375,6 +407,7 @@ pub struct SerializedScreenshotEditorInstance {
375407
pub frames_socket_url: String,
376408
pub path: PathBuf,
377409
pub config: Option<ProjectConfiguration>,
410+
pub pretty_name: String,
378411
}
379412

380413
#[tauri::command]
@@ -404,6 +437,7 @@ pub async fn create_screenshot_editor_instance(
404437
frames_socket_url: format!("ws://localhost:{}", instance.ws_port),
405438
path: instance.path.clone(),
406439
config: Some(config),
440+
pretty_name: instance.pretty_name.clone(),
407441
})
408442
}
409443

0 commit comments

Comments
 (0)