From cda961ee8111d422db830052e298f1e31d5f7b35 Mon Sep 17 00:00:00 2001 From: AleksDoesCode Date: Thu, 24 Sep 2026 11:11:17 +0200 Subject: [PATCH 1/3] Wait for SCRecordingOutput to finish before moving the segment The fixed 400 ms sleep after stop_capture raced the asynchronous file finalisation; on slower (debug) builds the segment did not exist yet and stop failed with "Failed to move segment to final output: os error 2". Wait on the recording delegate's finish/fail callback instead (15 s cap). Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/capture.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 78c87c8..d556190 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -160,6 +160,8 @@ pub(crate) struct SegmentRecorder { // so the SCK delegate's `on_stop` firing is recognised as expected and // doesn't emit a duplicate "recording-stopped-externally" event. stop_emitted: Arc, + // Set once SCRecordingOutput has finished (or failed) writing the segment file. + finished: Arc, } #[cfg(target_os = "macos")] @@ -510,11 +512,19 @@ pub(crate) fn build_and_start_segment( stream_emit("stream.on_stop", err); }); + let finished = Arc::new(AtomicBool::new(false)); let rec_finish_emit = emit_external_stop.clone(); let rec_fail_emit = emit_external_stop.clone(); + let (finish_flag, fail_flag) = (Arc::clone(&finished), Arc::clone(&finished)); let rec_delegate = RecordingCallbacks::new() - .on_finish(move || rec_finish_emit("recording.on_finish", None)) - .on_fail(move |e| rec_fail_emit("recording.on_fail", Some(e))); + .on_finish(move || { + finish_flag.store(true, Ordering::SeqCst); + rec_finish_emit("recording.on_finish", None) + }) + .on_fail(move |e| { + fail_flag.store(true, Ordering::SeqCst); + rec_fail_emit("recording.on_fail", Some(e)) + }); let rec_output = SCRecordingOutput::new_with_delegate(&rec_config, rec_delegate) .ok_or_else(|| "Failed to create SCRecordingOutput (requires macOS 15+).".to_string())?; @@ -575,6 +585,7 @@ pub(crate) fn build_and_start_segment( system_tap, mic_tap, stop_emitted, + finished, }) } @@ -927,9 +938,11 @@ pub(crate) fn stop_active_segment(rec: &mut ActiveRecording) -> Result<(), Strin .stream .stop_capture() .map_err(|e| format!("stop_capture failed: {e:?}")); - // SCRecordingOutput finalises the moov atom asynchronously after stop; - // give it a beat so the segment file is playable when concatenated. - std::thread::sleep(Duration::from_millis(400)); + // SCRecordingOutput writes the file asynchronously after stop; wait for its finish callback. + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while !seg.finished.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } // Collect even when stop failed: the taps must be finalized either // way or their WAVs are left with invalid headers. collect_segment(rec, seg); From 43ad8913e5816cffc8edc50db625c1328777ff1c Mon Sep 17 00:00:00 2001 From: AleksDoesCode Date: Thu, 24 Sep 2026 11:11:22 +0200 Subject: [PATCH 2/3] Add clip editing: split, delete, reorder, trim and per-clip speed The editor now edits an ordered clip sequence (src/lib/clips.ts) instead of a single head/tail trim. Clips can be split at the playhead (S), deleted, dragged to reorder, trimmed from either edge, duplicated, muted and sped up or slowed down (0.25x-16x) for timelapses. Zoom/effect segments, camera keyframes, audio trims and the cursor sidecar stay in source time and are mapped per clip. Preview walks the clips with per-clip playbackRate; export renders output frames and mixes one audio piece per clip and track with an ffmpeg atempo chain. Pre-v4 projects convert their trim into a single clip on load. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + src-tauri/src/export.rs | 24 +- src/components/Editor/ExportDialog.tsx | 12 +- src/components/Editor/index.tsx | 922 ++++++++++++++++++------- src/components/icons.tsx | 2 + src/lib/clips.ts | 116 ++++ src/lib/exporter.ts | 56 +- src/lib/native.ts | 1 + src/styles/globals.css | 158 ++++- 9 files changed, 966 insertions(+), 326 deletions(-) create mode 100644 src/lib/clips.ts diff --git a/AGENTS.md b/AGENTS.md index 99bc3ad..1c6f4ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,7 @@ scripts/fetch-ffmpeg.sh downloads the platform ffmpeg binaries (run on p The SCK stream + recording output live in `RecordingState`; the `cpal` mic stream owns its own thread because `cpal::Stream` is `!Send` on macOS; `CursorTrack` runs a polling thread and serializes the sidecar on stop. - **Frontend ↔ Rust bridge** — [`src/lib/native.ts`](src/lib/native.ts). The `native` object wraps every command; listen helpers: `onMicLevel`, `onRecordingArtifact`, `onRecordingStoppedExternally`, `onPickerSelected`, `onPickerState`, `onPickerHover`, `onMenuSaveProject`, `onMenuOpenProject`. Cursor-sidecar TypeScript types (`CursorSidecar`, `CursorSidecarShapeName`, …) live here too. Add new commands here, keep return types narrow. - **Editor** — [`src/components/Editor/index.tsx`](src/components/Editor/index.tsx). The largest component. Owns video playback, timeline scrubbing, auto-zoom segments (via `lib/autoZoom.ts` + the cursor sidecar), the cursor overlay, preview-quality settings, and project save/open. +- **Clip sequence (cutting / speed)** — [`src/lib/clips.ts`](src/lib/clips.ts). `EditorState.clips` is the edited output: an ordered list of source ranges, each with its own `speed` (0.25–16×) and `muted` flag (empty = whole recording). The timeline axis and the editor's `currentTime` are **output** time; zoom/effect segments, camera keyframes, audio-track trims and the cursor sidecar stay in **source** time and are mapped per clip for display (`srcRangePieces`, `outToSrc`, `srcToOut`). Preview walks the clips in a rAF loop (seek + `playbackRate` per clip); export renders output frames and emits one audio spec per clip × track with an ffmpeg `atempo` for the speed. Pre-v4 projects' `trimStart`/`trimEnd` are converted to a single clip on load. - **Auto-zoom** — [`src/lib/autoZoom.ts`](src/lib/autoZoom.ts). Pure logic: derives `ZoomSegment`s from sidecar clicks; the editor renders/edits them. - **Plugin-based timeline video effects** — [`src/lib/effects.ts`](src/lib/effects.ts) is the plugin registry: each `EffectPluginDef` (Black & White, Sepia, Vintage Film, Vignette, Warm Glow, Cool Tone, Dreamy Soft, High Contrast, Film Grain, Invert Flash, …) declares only the `PostFxParams` keys it changes at full strength (brightness/contrast/saturation/temperature/hueRotate/sepia/invert/vignette/grain/blur — the param set lives in `lib/compositor.ts`); adding a look is one registry entry, no renderer changes. `EffectSegment`s place a plugin + 0–1 intensity over a time range on the timeline's Effects track (mirrors `ZoomSegment`, own crossfade at segment edges). `resolveEffectParams(tMs, segments)` blends the active segment toward neutral; `renderFrame` (Canvas2D) and `GLCompositor.render()` (WebGL, via an offscreen FBO + fullscreen grading shader) both apply the *same* resolved params as the last compositing step, so preview and export match. The DOM (non-GL) preview fallback applies only the CSS-filter-representable subset (`postFxCssFilter`) directly to the recorded-window element. - **Design system** — [`src/styles/globals.css`](src/styles/globals.css) and [`tokens.css`](src/styles/tokens.css) are the single source of truth for colors, spacing, and component classes. **Do not** restyle components inline; reuse existing class names. diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 9bff535..78b25a1 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -298,6 +298,27 @@ pub struct AudioTrackSpec { pub src_end: f64, pub delay: f64, pub gain: f64, + /// Playback speed of this piece (sped-up / slowed clips); 1 = realtime. + #[serde(default = "unit_tempo")] + pub tempo: f64, +} + +fn unit_tempo() -> f64 { + 1.0 +} + +/// atempo accepts 0.5..=100 per instance, so chain halvings for slower speeds. +fn atempo_chain(tempo: f64) -> String { + let mut t = tempo.clamp(0.0625, 100.0); + let mut out = String::new(); + while t < 0.5 { + out.push_str("atempo=0.5,"); + t /= 0.5; + } + if (t - 1.0).abs() > 1e-6 { + out.push_str(&format!("atempo={t:.4},")); + } + out } /// Append the audio trim/gain/delay filter graph and stream maps. Audio @@ -311,7 +332,7 @@ pub(crate) fn append_audio_mix_args(args: &mut Vec, audio_tracks: &[Audi for (i, t) in audio_tracks.iter().enumerate() { let delay_ms = (t.delay.max(0.0) * 1000.0).round() as u64; fc.push_str(&format!( - "[{input}:a]atrim=start={s0:.3}:end={s1:.3},asetpts=PTS-STARTPTS,volume={g:.3},adelay={d}:all=1[a{i}];", + "[{input}:a]atrim=start={s0:.3}:end={s1:.3},asetpts=PTS-STARTPTS,{tempo}volume={g:.3},adelay={d}:all=1[a{i}];", input = i + 1, s0 = t.src_start.max(0.0), s1 = t.src_end.max(t.src_start), @@ -319,6 +340,7 @@ pub(crate) fn append_audio_mix_args(args: &mut Vec, audio_tracks: &[Audi // ffmpeg volume filter accepts them — soft-clipping risk is on the user. g = t.gain.clamp(0.0, 8.0), d = delay_ms, + tempo = atempo_chain(t.tempo), )); } if audio_tracks.len() == 1 { diff --git a/src/components/Editor/ExportDialog.tsx b/src/components/Editor/ExportDialog.tsx index d71267c..7138097 100644 --- a/src/components/Editor/ExportDialog.tsx +++ b/src/components/Editor/ExportDialog.tsx @@ -3,6 +3,7 @@ import { Ico } from "../icons"; import type { CaptureArtifact, CursorSidecar } from "../../lib/native"; import type { ZoomSegment } from "../../lib/autoZoom"; import type { EffectSegment } from "../../lib/effects"; +import { outDuration, type PlacedClip } from "../../lib/clips"; import { computeFrameLayout, type CropRect } from "../../lib/compositor"; import { estimateExport, @@ -90,8 +91,7 @@ export function ExportDialog(props: { zoomEnabled: boolean; smoothing: number; effectSegments: EffectSegment[]; - trimStart: number; - trimEnd: number; + clips: PlacedClip[]; audioTracks: ExportAudioTrack[]; camera: ExportCameraTrack | null; onClose: () => void; @@ -146,10 +146,7 @@ export function ExportDialog(props: { [layout.ratio, resolution], ); - const durationSec = Math.max( - 0, - props.videoDurationSec - props.trimStart - props.trimEnd, - ); + const durationSec = outDuration(props.clips); const est = useMemo( () => estimateExport({ @@ -200,8 +197,7 @@ export function ExportDialog(props: { zoomEnabled: props.zoomEnabled, smoothing: props.smoothing, effectSegments: props.effectSegments, - trimStart: props.trimStart, - trimEnd: props.trimEnd, + clips: props.clips, audioTracks: props.audioTracks, camera: props.camera, viewportBox: box, diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index 850f6b4..8dd4122 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -40,6 +40,27 @@ import { resolveEffectParams, type EffectSegment, } from "../../lib/effects"; +import { + SPEED_MAX, + SPEED_MIN, + SPEED_PRESETS, + MIN_CLIP_SEC, + clampSpeed, + clipIndexAtOut, + duplicateClip, + fullClip, + layoutClips, + moveClip, + outDuration, + outToSrc, + patchClip, + removeClip, + splitAt, + srcRangePieces, + srcToOut, + type Clip, + type PlacedClip, +} from "../../lib/clips"; import { ExportDialog } from "./ExportDialog"; /** @@ -231,9 +252,8 @@ type EditorState = { padding: number; cropRect: CropRect | null; zoom: number; - trimStart: number; - trimEnd: number; - splits: number[]; + /** Edited sequence in output order; empty = the whole recording, untouched. */ + clips: Clip[]; audioTracks: Record; camera: CameraState; }; @@ -242,14 +262,17 @@ const ASPECT_ORDER: AspectKey[] = ["16:9", "1:1", "9:16", "system"]; type StatePatch = Partial; -const PROJECT_VERSION = 3; +const PROJECT_VERSION = 4; + +/** Pre-v4 projects stored a single head/tail trim instead of clips. */ +type LegacyTrim = { trimStart?: number; trimEnd?: number; splits?: number[] }; /** On-disk shape of a `.openscreen` project file. */ type ProjectFile = { version: number; app: "OpenScreen Studio"; artifact: CaptureArtifact; - editorState: EditorState; + editorState: EditorState & LegacyTrim; zoomSettings: ZoomSettings; zoomSegments: ZoomSegment[]; /** v3+ — plugin-based timeline video effects; absent in older saves. */ @@ -1367,6 +1390,129 @@ function EffectSegmentPanel({ ); } +type ClipPanelProps = { + clip: PlacedClip; + index: number; + count: number; + onPatch: (patch: Partial) => void; + onSplit: () => void; + onDelete: () => void; + onDuplicate: () => void; + onMove: (dir: -1 | 1) => void; +}; + +const fmtClock = (s: number) => { + const m = Math.floor(s / 60); + return `${String(m).padStart(2, "0")}:${(s - m * 60).toFixed(1).padStart(4, "0")}`; +}; +const fmtSpeed = (x: number) => `${Number(x.toFixed(2))}×`; + +function ClipPanel({ clip, index, count, onPatch, onSplit, onDelete, onDuplicate, onMove }: ClipPanelProps) { + const [speedText, setSpeedText] = useState(String(clip.speed)); + useEffect(() => setSpeedText(String(Number(clip.speed.toFixed(2)))), [clip.speed]); + const commitSpeed = () => { + const v = parseFloat(speedText.replace(",", ".")); + if (Number.isFinite(v)) onPatch({ speed: clampSpeed(v) }); + else setSpeedText(String(clip.speed)); + }; + + return ( +
+

+ Clip {index + 1} of {count} +

+
+
+ Source + {fmtClock(clip.srcStart)} → {fmtClock(clip.srcEnd)} +
+
+ On timeline + {fmtClock(clip.outEnd - clip.outStart)} +
+
+ +
+ Speed · {fmtSpeed(clip.speed)} +
+
+ {SPEED_PRESETS.map((x) => ( + + ))} +
+
+ Custom + setSpeedText(e.target.value)} + onBlur={commitSpeed} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + aria-label="Custom speed" + /> + × ({SPEED_MIN}–{SPEED_MAX}) +
+ +
+
+
Mute audio
+
+ Silences every audio track while this clip plays. Handy for timelapses, since sped-up + audio also plays faster. +
+
+
+ +
+ Edit +
+
+ + +
+ + +
+ +
+
+ Drag a clip to reorder it. Drag its edges to trim. The gap closes automatically. +
+
+ ); +} + function AudioPanel({ rows, onChange, @@ -1600,7 +1746,9 @@ function Inspector({ setEffectSegments, selectedEffectSeg, onSelectEffect, + clipPanel, }: { + clipPanel: ClipPanelProps | null; active: string; state: EditorState; set: (p: StatePatch) => void; @@ -1626,7 +1774,9 @@ function Inspector({ }) { return (