Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 31 additions & 13 deletions src-tauri/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AtomicBool>,
// Set once SCRecordingOutput has finished (or failed) writing the segment file.
finished: std::sync::mpsc::Receiver<Result<(), String>>,
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -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())?;
Expand Down Expand Up @@ -575,6 +585,7 @@ pub(crate) fn build_and_start_segment(
system_tap,
mic_tap,
stop_emitted,
finished,
})
}

Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -1050,7 +1067,7 @@ pub(crate) fn finalize_recording(mut rec: ActiveRecording) -> Result<CaptureArti

// Stop the active segment (if `stop_capture` didn't already) so every
// segment file is on disk and finalised.
stop_active_segment(&mut rec)?;
stop_active_segment(&mut rec, Duration::from_secs(15))?;

// Duration excludes paused intervals — match the concatenated MP4.
let duration_ms = cursor_t_ms(&rec.cursor_track) as u64;
Expand Down Expand Up @@ -1118,7 +1135,7 @@ pub(crate) fn discard_recording(mut rec: ActiveRecording) {
if let Some(cam) = rec.camera.take() {
cam.discard();
}
let _ = stop_active_segment(&mut rec);
let _ = stop_active_segment(&mut rec, Duration::from_secs(15));
let _ = drain_cursor_track(&mut rec);
for seg in rec
.segments
Expand Down Expand Up @@ -1191,7 +1208,7 @@ pub(crate) fn pause_capture(state: State<'_, RecordingState>) -> 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")]
Expand Down Expand Up @@ -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
Expand All @@ -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>,
Expand All @@ -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
};
Expand Down Expand Up @@ -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>,
Expand Down
24 changes: 23 additions & 1 deletion src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -311,14 +332,15 @@ pub(crate) fn append_audio_mix_args(args: &mut Vec<String>, 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),
// Unity-plus gains come from the editor's auto-level feature; the
// 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 {
Expand Down
12 changes: 4 additions & 8 deletions src/components/Editor/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading