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/capture.rs b/src-tauri/src/capture.rs index 78c87c8..18af6b8 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: std::sync::mpsc::Receiver>, } #[cfg(target_os = "macos")] @@ -510,11 +512,19 @@ pub(crate) fn build_and_start_segment( stream_emit("stream.on_stop", err); }); + let (finish_tx, finished) = std::sync::mpsc::channel(); let rec_finish_emit = emit_external_stop.clone(); let rec_fail_emit = emit_external_stop.clone(); + let fail_tx = finish_tx.clone(); 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 || { + let _ = finish_tx.send(Ok(())); + rec_finish_emit("recording.on_finish", None) + }) + .on_fail(move |e| { + let _ = fail_tx.send(Err(format!("Recording output failed: {e:?}"))); + 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, }) } @@ -920,20 +931,26 @@ pub(crate) fn collect_segment(rec: &mut ActiveRecording, seg: SegmentRecorder) { /// Stop the active segment (if any) and push its file onto `segments`. /// Sets `stop_emitted` first so the SCK delegate's on_stop fires silently. #[cfg(target_os = "macos")] -pub(crate) fn stop_active_segment(rec: &mut ActiveRecording) -> Result<(), String> { +pub(crate) fn stop_active_segment(rec: &mut ActiveRecording, timeout: Duration) -> Result<(), String> { if let Some(seg) = rec.active.take() { seg.stop_emitted.store(true, Ordering::SeqCst); let stop_result = seg .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)); + // A failed stop cannot promise a delegate callback. + let finish_result = if stop_result.is_ok() { + seg.finished.recv_timeout(timeout) + .map_err(|e| format!("Waiting for recording finalization failed: {e}")) + .and_then(|result| result) + } else { + Ok(()) + }; // Collect even when stop failed: the taps must be finalized either // way or their WAVs are left with invalid headers. collect_segment(rec, seg); stop_result?; + finish_result?; } Ok(()) } @@ -1050,7 +1067,7 @@ pub(crate) fn finalize_recording(mut rec: ActiveRecording) -> Result) -> Result<(), Stri if let Some(cam) = &rec.camera { cam.pause(); } - stop_active_segment(rec) + stop_active_segment(rec, Duration::from_secs(2)) } #[cfg(target_os = "macos")] @@ -1220,7 +1237,7 @@ pub(crate) fn resume_capture( } #[cfg(target_os = "macos")] -#[tauri::command] +#[tauri::command(async)] pub(crate) fn cancel_capture(state: State<'_, RecordingState>) -> Result<(), String> { let rec = state .0 @@ -1232,7 +1249,7 @@ pub(crate) fn cancel_capture(state: State<'_, RecordingState>) -> Result<(), Str } #[cfg(target_os = "macos")] -#[tauri::command] +#[tauri::command(async)] pub(crate) fn restart_capture( app: AppHandle, state: State<'_, RecordingState>, @@ -1241,6 +1258,7 @@ pub(crate) fn restart_capture( let mut guard = state.0.lock(); let rec = guard.take().ok_or_else(|| "No active recording.".to_string())?; let args = rec.args.clone(); + drop(guard); discard_recording(rec); args }; @@ -1275,13 +1293,13 @@ pub(crate) fn resume_capture( } #[cfg(not(target_os = "macos"))] -#[tauri::command] +#[tauri::command(async)] pub(crate) fn cancel_capture(_state: State<'_, RecordingState>) -> Result<(), String> { Err("Screen capture is only implemented on macOS.".into()) } #[cfg(not(target_os = "macos"))] -#[tauri::command] +#[tauri::command(async)] pub(crate) fn restart_capture( _app: AppHandle, _state: State<'_, RecordingState>, 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..ff063ed 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -40,6 +40,28 @@ import { resolveEffectParams, type EffectSegment, } from "../../lib/effects"; +import { + SPEED_MAX, + SPEED_MIN, + SPEED_PRESETS, + MIN_CLIP_SEC, + clampSpeed, + mapSegmentPiecePatch, + 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 +253,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 +263,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 +1391,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 +1747,9 @@ function Inspector({ setEffectSegments, selectedEffectSeg, onSelectEffect, + clipPanel, }: { + clipPanel: ClipPanelProps | null; active: string; state: EditorState; set: (p: StatePatch) => void; @@ -1626,7 +1775,9 @@ function Inspector({ }) { return (