From 18c0ba431f2a86ea51f62a97711e83bcbb66aa61 Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:09:59 -0400 Subject: [PATCH 01/26] Refactor publish workflow to remove CN release Removed CN release creation step and related environment variable. --- .github/workflows/publish.yml | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6938d899e..881cc964ea 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,6 @@ on: type: boolean env: - CN_APPLICATION: cap/cap APP_CARGO_TOML: apps/desktop/src-tauri/Cargo.toml SENTRY_ORG: cap-s2 SENTRY_PROJECT: cap-desktop @@ -35,7 +34,6 @@ jobs: outputs: version: ${{ steps.read_version.outputs.value }} needs_release: ${{ steps.create_tag.outputs.tag_existed != 'true' }} - cn_release_stdout: ${{ steps.create_cn_release.outputs.stdout }} gh_release_url: ${{ steps.create_gh_release.outputs.url }} permissions: contents: write @@ -100,13 +98,6 @@ jobs: await main(); - - name: Create draft CN release - id: create_cn_release - uses: crabnebula-dev/cloud-release@v0 - with: - command: release draft ${{ env.CN_APPLICATION }} ${{ steps.read_version.outputs.value }} --framework tauri - api-key: ${{ secrets.CN_API_KEY }} - - name: Create draft GH release id: create_gh_release # TODO: Change to stable version when available @@ -124,7 +115,6 @@ jobs: script: | async function main() { const token = await core.getIDToken("cap-discord-bot"); - const cnReleaseId = JSON.parse(`${{ steps.create_cn_release.outputs.stdout }}`).id; const resp = await fetch( "https://cap-discord-bot.brendonovich.workers.dev/github-workflow", @@ -136,7 +126,6 @@ jobs: version: "${{ steps.read_version.outputs.value }}", releaseUrl: "${{ steps.create_gh_release.outputs.url }}", interactionId: "${{ inputs.interactionId }}", - cnReleaseId, }), headers: { "Content-Type": "application/json", @@ -297,15 +286,13 @@ jobs: # Write-Host "Files in bundle directory after signing:" # Get-ChildItem -Path $bundleDir -Filter *.exe | ForEach-Object { Write-Host " - $($_.Name)" } - - name: Upload assets - if: ${{ env.RUN_BUILD == 'true' }} - uses: crabnebula-dev/cloud-release@v0 + - name: Upload Windows ARM installer artifact + if: ${{ env.RUN_BUILD == 'true' && matrix.settings.platform == 'windows' && matrix.settings.arch == 'arm64' }} + uses: actions/upload-artifact@v4 with: - working-directory: apps/desktop - command: release upload ${{ env.CN_APPLICATION }} "${{ needs.draft.outputs.version }}" --framework tauri - api-key: ${{ secrets.CN_API_KEY }} - env: - TAURI_BUNDLE_PATH: ../.. + name: windows-arm-installer + path: target/${{ matrix.settings.target }}/release/bundle/nsis/*.exe + if-no-files-found: error - uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e if: ${{ env.RUN_BUILD == 'true' }} @@ -340,7 +327,6 @@ jobs: script: | async function main() { const token = await core.getIDToken("cap-discord-bot"); - const cnReleaseId = JSON.parse(`${{ needs.draft.outputs.cn_release_stdout }}`).id; const resp = await fetch( "https://cap-discord-bot.brendonovich.workers.dev/github-workflow", @@ -351,7 +337,6 @@ jobs: interactionId: "${{ inputs.interactionId }}", version: "${{ needs.draft.outputs.version }}", releaseUrl: "${{ needs.draft.outputs.gh_release_url }}", - cnReleaseId, }), headers: { "Content-Type": "application/json", From 0b9513a611a985dd8d9b7e0c0cbabcead1acb3cd Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:14:13 -0400 Subject: [PATCH 02/26] Bump version from 0.3.83 to 0.3.84 --- apps/desktop/src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a6cf8cfe17..46024fd0ab 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.83" +version = "0.3.84" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" From e2eb942f25334e158737ed83b30582a9508fbb8e Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:15:24 -0400 Subject: [PATCH 03/26] Update package version to cap-v0.4.81 --- apps/desktop/src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 46024fd0ab..58fd666a90 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.84" +version = "cap-v0.4.81" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" From 83f251313fb8727980de3a168067e28397b71405 Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:13:57 -0400 Subject: [PATCH 04/26] Fix runner names in publish.yml for Windows targets --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 881cc964ea..3dccb28c87 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -160,11 +160,11 @@ jobs: platform: macos arch: arm64 - target: x86_64-pc-windows-msvc - runner: windows-latest-l + runner: windows-latest platform: windows arch: x64 - target: aarch64-pc-windows-msvc - runner: windows-latest-l + runner: windows-latest platform: windows arch: arm64 env: From 9ec7f2bb6692e9244856c862e932b24d37eee2f0 Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:22:51 -0400 Subject: [PATCH 05/26] Update Cargo.toml --- apps/desktop/src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 58fd666a90..dfdcadf9f9 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "cap-v0.4.81" +version = "cap-v0.4.82" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" From 261120ce02d26fcdf4cdf5ffaff0c11886561f05 Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:41:24 -0400 Subject: [PATCH 06/26] Normalize Cargo version in publish workflow Add a step to normalize the Cargo version in the workflow. --- .github/workflows/publish.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3dccb28c87..995718b1a6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -177,6 +177,25 @@ jobs: if: ${{ env.RUN_BUILD == 'true' }} uses: actions/checkout@v4 + - name: Normalize Cargo version + if: ${{ env.RUN_BUILD == 'true' }} + shell: bash + run: | + node <<'NODE' + const fs = require('node:fs'); + const cargoTomlPath = 'apps/desktop/src-tauri/Cargo.toml'; + const contents = fs.readFileSync(cargoTomlPath, 'utf8'); + const normalized = contents.replace( + /^version\s*=\s*"cap-v([^"]+)"$/m, + 'version = "$1"', + ); + + if (normalized !== contents) { + fs.writeFileSync(cargoTomlPath, normalized); + console.log(`Normalized ${cargoTomlPath} to semver`); + } + NODE + - name: Create API Key File if: ${{ env.RUN_BUILD == 'true' }} run: echo "${{ secrets.APPLE_API_KEY_FILE }}" > api.p8 From fcf4d55e27a660feb2b8f247a58dee982c78729e Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:45:38 -0400 Subject: [PATCH 07/26] Update Cargo.toml --- apps/desktop/src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index dfdcadf9f9..8fdb02c765 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "cap-v0.4.82" +version = "cap-v0.4.83" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" From 4fab583c842fc3a4b921ee4d28ce4644475ec9c2 Mon Sep 17 00:00:00 2001 From: Volko61 <147256076+Volko61@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:22:40 -0400 Subject: [PATCH 08/26] Refactor studio recording module with new structures --- crates/recording/src/studio_recording.rs | 1013 +++++----------------- 1 file changed, 231 insertions(+), 782 deletions(-) diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 2730274274..0fa49a8e88 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1,829 +1,278 @@ -use crate::{ - ActorError, MediaError, RecordingBaseInputs, RecordingError, - capture_pipeline::{ - MakeCapturePipeline, ScreenCaptureMethod, Stop, target_to_display_and_crop, - }, - cursor::{CursorActor, Cursors, spawn_cursor_recorder}, - feeds::{camera::CameraFeedLock, microphone::MicrophoneFeedLock}, - ffmpeg::{Mp4Muxer, OggMuxer}, - output_pipeline::{DoneFut, FinishedOutputPipeline, OutputPipeline, PipelineDoneError}, - screen_capture::ScreenCaptureConfig, - sources::{self, screen_capture}, -}; -use anyhow::{Context as _, anyhow}; -use cap_media_info::VideoInfo; -use cap_project::{CursorEvents, StudioRecordingMeta}; -use cap_timestamp::{Timestamp, Timestamps}; -use futures::{FutureExt, StreamExt, future::OptionFuture, stream::FuturesUnordered}; -use kameo::{Actor as _, prelude::*}; -use relative_path::RelativePathBuf; use std::{ + cell::RefCell, path::{Path, PathBuf}, - sync::Arc, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use tokio::sync::watch; -use tracing::{Instrument, debug, error_span, info, trace}; - -#[allow(clippy::large_enum_variant)] -enum ActorState { - Recording { - pipeline: Pipeline, - // pipeline_done_rx: oneshot::Receiver>, - index: u32, - segment_start_time: f64, - segment_start_instant: Instant, - }, - Paused { - next_index: u32, - cursors: Cursors, - next_cursor_id: u32, - }, -} -#[derive(Clone)] -pub struct ActorHandle { - actor_ref: kameo::actor::ActorRef, - pub capture_target: screen_capture::ScreenCaptureTarget, - done_fut: DoneFut, - // pub bounds: Bounds, +use cap_project::{AudioMeta, StudioRecordingMeta, VideoMeta}; +use serde::Serialize; +use specta::Type; + +#[derive(Debug, Clone, Copy, Serialize, Type)] +pub struct Video { + pub duration: f64, + pub width: u32, + pub height: u32, + pub fps: u32, + pub start_time: f64, } -#[derive(kameo::Actor)] -pub struct Actor { - recording_dir: PathBuf, - state: Option, - segment_factory: SegmentPipelineFactory, - segments: Vec, - completion_tx: watch::Sender>>, -} - -impl Actor { - async fn stop_pipeline( - &mut self, - pipeline: Pipeline, - segment_start_time: f64, - ) -> anyhow::Result<(Cursors, u32)> { - tracing::info!("pipeline shuting down"); - - let mut pipeline = pipeline.stop().await?; - - tracing::info!("pipeline shutdown"); - - let segment_stop_time = current_time_f64(); - - let cursors = if let Some(cursor) = pipeline.cursor.as_mut() - && let Ok(res) = cursor.actor.rx.clone().await - { - std::fs::write( - &cursor.output_path, - serde_json::to_string_pretty(&CursorEvents { - clicks: res.clicks, - moves: res.moves, - })?, - )?; - - (res.cursors, res.next_cursor_id) - } else { - (Default::default(), 0) - }; - - self.segments.push(RecordingSegment { - start: segment_start_time, - end: segment_stop_time, - pipeline, - }); - - Ok(cursors) - } - - fn notify_completion_ok(&self) { - if self.completion_tx.borrow().is_none() { - let _ = self.completion_tx.send(Some(Ok(()))); - } - } -} - -impl Message for Actor { - type Reply = anyhow::Result; - - async fn handle(&mut self, _: Stop, ctx: &mut Context) -> Self::Reply { - let cursors = match self.state.take() { - Some(ActorState::Recording { - pipeline, - segment_start_time, - segment_start_instant, - .. - }) => { - // Wait for minimum segment duration - tokio::time::sleep_until((segment_start_instant + Duration::from_secs(1)).into()) - .await; - - let (cursors, _) = self.stop_pipeline(pipeline, segment_start_time).await?; - - cursors - } - Some(ActorState::Paused { cursors, .. }) => cursors, - _ => return Err(anyhow!("Not recording")), - }; - - ctx.actor_ref().stop_gracefully().await?; - - let recording = stop_recording( - self.recording_dir.clone(), - std::mem::take(&mut self.segments), - cursors, - ) - .await?; - - self.notify_completion_ok(); - - Ok(recording) - } -} - -struct Pause; - -impl Message for Actor { - type Reply = anyhow::Result<()>; - - async fn handle(&mut self, _: Pause, _: &mut Context) -> Self::Reply { - self.state = match self.state.take() { - Some(ActorState::Recording { - pipeline, - segment_start_time, - index, - .. - }) => { - let (cursors, next_cursor_id) = self - .stop_pipeline(pipeline, segment_start_time) - .await - .context("stop_pipeline")?; - - Some(ActorState::Paused { - next_index: index + 1, - cursors, - next_cursor_id, - }) +impl Video { + pub fn new(path: impl AsRef, start_time: f64) -> Result { + fn inner(path: &Path, start_time: f64) -> Result { + let mut input = + ffmpeg::format::input(path).map_err(|e| format!("Failed to open video: {e}"))?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .ok_or_else(|| "No video stream found".to_string())?; + let stream_index = stream.index(); + let stream_time_base = stream.time_base(); + + let video_decoder = ffmpeg::codec::Context::from_parameters(stream.parameters()) + .map_err(|e| format!("Failed to create decoder: {e}"))? + .decoder() + .video() + .map_err(|e| format!("Failed to get video decoder: {e}"))?; + + let rate = stream.avg_frame_rate(); + let mut fps = if rate.denominator() != 0 && rate.numerator() != 0 { + rate.numerator() as f64 / rate.denominator() as f64 + } else { + 0.0 + }; + + if fps <= 0.0 { + let r_rate = stream.rate(); + if r_rate.denominator() != 0 && r_rate.numerator() != 0 { + fps = r_rate.numerator() as f64 / r_rate.denominator() as f64; + } } - state => state, - }; - - Ok(()) - } -} -struct Resume; - -impl Message for Actor { - type Reply = anyhow::Result<()>; - - async fn handle(&mut self, _: Resume, _: &mut Context) -> Self::Reply { - self.state = match self.state.take() { - Some(ActorState::Paused { - next_index, - cursors, - next_cursor_id, - }) => { - let pipeline = self - .segment_factory - .create_next(cursors, next_cursor_id) - .await?; - - Some(ActorState::Recording { - pipeline, - // pipeline_done_rx, - index: next_index, - segment_start_time: current_time_f64(), - segment_start_instant: Instant::now(), - }) + let container_duration = input.duration(); + let mut duration = if container_duration > 0 && container_duration != i64::MIN { + container_duration as f64 / 1_000_000.0 + } else { + 0.0 + }; + + if duration <= 0.0 { + let stream_duration = stream.duration(); + if stream_duration > 0 && stream_duration != i64::MIN { + let time_base = stream.time_base(); + duration = (stream_duration as f64 * time_base.numerator() as f64) + / time_base.denominator() as f64; + } } - state => state, - }; - - Ok(()) - } -} - -struct Cancel; - -impl Message for Actor { - type Reply = anyhow::Result<()>; - - async fn handle(&mut self, _: Cancel, _: &mut Context) -> Self::Reply { - if let Some(ActorState::Recording { pipeline, .. }) = self.state.take() { - let _ = pipeline.stop().await; - - self.notify_completion_ok(); - } - Ok(()) - } -} - -pub struct RecordingSegment { - pub start: f64, - pub end: f64, - pipeline: FinishedPipeline, -} - -pub struct ScreenPipelineOutput { - pub inner: OutputPipeline, - pub video_info: VideoInfo, -} - -struct Pipeline { - pub start_time: Timestamps, - // sources - pub screen: OutputPipeline, - pub microphone: Option, - pub camera: Option, - pub system_audio: Option, - pub cursor: Option, -} - -struct FinishedPipeline { - pub start_time: Timestamps, - // sources - pub screen: FinishedOutputPipeline, - pub microphone: Option, - pub camera: Option, - pub system_audio: Option, - pub cursor: Option, -} - -impl Pipeline { - pub async fn stop(mut self) -> anyhow::Result { - let (screen, microphone, camera, system_audio) = futures::join!( - self.screen.stop(), - OptionFuture::from(self.microphone.map(|s| s.stop())), - OptionFuture::from(self.camera.map(|s| s.stop())), - OptionFuture::from(self.system_audio.map(|s| s.stop())) - ); - - if let Some(cursor) = self.cursor.as_mut() { - cursor.actor.stop(); - } - - Ok(FinishedPipeline { - start_time: self.start_time, - screen: screen.context("screen")?, - microphone: microphone.transpose().context("microphone")?, - camera: camera.transpose().context("camera")?, - system_audio: system_audio.transpose().context("system_audio")?, - cursor: self.cursor, - }) - } - - fn spawn_watcher(&self, completion_tx: watch::Sender>>) { - let mut futures = FuturesUnordered::new(); - futures.push(self.screen.done_fut()); - - if let Some(ref microphone) = self.microphone { - futures.push(microphone.done_fut()); - } - - if let Some(ref camera) = self.camera { - futures.push(camera.done_fut()); - } - - if let Some(ref system_audio) = self.system_audio { - futures.push(system_audio.done_fut()); - } - - // Ensure non-video pipelines stop promptly when the video pipeline completes - { - let mic_cancel = self.microphone.as_ref().map(|p| p.cancel_token()); - let cam_cancel = self.camera.as_ref().map(|p| p.cancel_token()); - let sys_cancel = self.system_audio.as_ref().map(|p| p.cancel_token()); - - let screen_done = self.screen.done_fut(); - tokio::spawn(async move { - // When screen (video) finishes, cancel the other pipelines - let _ = screen_done.await; - if let Some(token) = mic_cancel.as_ref() { - token.cancel(); - } - if let Some(token) = cam_cancel.as_ref() { - token.cancel(); - } - if let Some(token) = sys_cancel.as_ref() { - token.cancel(); + if duration <= 0.0 { + let mut last_ts: i64 = -1; + for (s, packet) in input.packets() { + if s.index() == stream_index { + if let Some(pts) = packet.pts() { + if pts > last_ts { + last_ts = pts; + } + } else if let Some(dts) = packet.dts() { + if dts > last_ts { + last_ts = dts; + } + } + } } - }); - } - tokio::spawn(async move { - while let Some(res) = futures.next().await { - if let Err(err) = res - && completion_tx.borrow().is_none() - { - let _ = completion_tx.send(Some(Err(err))); + if last_ts >= 0 { + duration = (last_ts as f64 * stream_time_base.numerator() as f64) + / stream_time_base.denominator() as f64; } } - }); - } -} -struct CursorPipeline { - output_path: PathBuf, - actor: CursorActor, -} - -impl ActorHandle { - pub async fn stop(&self) -> anyhow::Result { - Ok(self.actor_ref.ask(Stop).await?) - } + if duration <= 0.0 { + let frames = stream.frames(); + if frames > 0 && fps > 0.0 { + duration = frames as f64 / fps; + } + } - pub fn done_fut(&self) -> DoneFut { - self.done_fut.clone() - } + if !duration.is_finite() || duration <= 0.0 { + tracing::warn!( + ?path, + container_duration, + stream_duration = stream.duration(), + frames = stream.frames(), + fps, + "Failed to determine video duration; defaulting to zero" + ); + duration = 0.0; + } - pub async fn pause(&self) -> anyhow::Result<()> { - Ok(self.actor_ref.ask(Pause).await?) - } + Ok(Video { + width: video_decoder.width(), + height: video_decoder.height(), + duration, + fps: if fps > 0.0 { fps.round() as u32 } else { 0 }, + start_time, + }) + } - pub async fn resume(&self) -> anyhow::Result<()> { - Ok(self.actor_ref.ask(Resume).await?) + inner(path.as_ref(), start_time) } - pub async fn cancel(&self) -> anyhow::Result<()> { - Ok(self.actor_ref.ask(Cancel).await?) + pub fn fps(&self) -> u32 { + self.fps } } -impl Actor { - pub fn builder( - output: PathBuf, - capture_target: screen_capture::ScreenCaptureTarget, - ) -> ActorBuilder { - ActorBuilder::new(output, capture_target) - } +#[derive(Debug, Clone, Copy, Serialize, Type)] +pub struct Audio { + pub duration: f64, + pub sample_rate: u32, + pub channels: u16, + pub start_time: f64, } -pub struct ActorBuilder { - output_path: PathBuf, - capture_target: screen_capture::ScreenCaptureTarget, - system_audio: bool, - mic_feed: Option>, - camera_feed: Option>, - custom_cursor: bool, - #[cfg(target_os = "macos")] - excluded_windows: Vec, -} - -impl ActorBuilder { - pub fn new(output: PathBuf, capture_target: screen_capture::ScreenCaptureTarget) -> Self { - Self { - output_path: output, - capture_target, - system_audio: false, - mic_feed: None, - camera_feed: None, - custom_cursor: false, - #[cfg(target_os = "macos")] - excluded_windows: Vec::new(), +impl Audio { + pub fn new(path: impl AsRef, start_time: f64) -> Result { + fn inner(path: &Path, start_time: f64) -> Result { + let input = + ffmpeg::format::input(path).map_err(|e| format!("Failed to open audio: {e}"))?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Audio) + .ok_or_else(|| "No audio stream found".to_string())?; + + let audio_decoder = ffmpeg::codec::Context::from_parameters(stream.parameters()) + .map_err(|e| format!("Failed to create decoder: {e}"))? + .decoder() + .audio() + .map_err(|e| format!("Failed to get audio decoder: {e}"))?; + + Ok(Audio { + duration: input.duration() as f64 / 1_000_000.0, + sample_rate: audio_decoder.rate(), + channels: audio_decoder.channels(), + start_time, + }) } - } - - pub fn with_system_audio(mut self, system_audio: bool) -> Self { - self.system_audio = system_audio; - self - } - - pub fn with_mic_feed(mut self, mic_feed: Arc) -> Self { - self.mic_feed = Some(mic_feed); - self - } - pub fn with_camera_feed(mut self, camera_feed: Arc) -> Self { - self.camera_feed = Some(camera_feed); - self - } - - pub fn with_custom_cursor(mut self, custom_cursor: bool) -> Self { - self.custom_cursor = custom_cursor; - self - } - - #[cfg(target_os = "macos")] - pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { - self.excluded_windows = excluded_windows; - self - } - - pub async fn build( - self, - #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, - ) -> anyhow::Result { - spawn_studio_recording_actor( - self.output_path, - RecordingBaseInputs { - capture_target: self.capture_target, - capture_system_audio: self.system_audio, - mic_feed: self.mic_feed, - camera_feed: self.camera_feed, - #[cfg(target_os = "macos")] - shareable_content, - #[cfg(target_os = "macos")] - excluded_windows: self.excluded_windows, - }, - self.custom_cursor, - ) - .await + inner(path.as_ref(), start_time) } } -#[tracing::instrument("studio_recording", skip_all)] -async fn spawn_studio_recording_actor( - recording_dir: PathBuf, - base_inputs: RecordingBaseInputs, - custom_cursor_capture: bool, -) -> anyhow::Result { - ensure_dir(&recording_dir)?; - - trace!("creating recording actor"); - - let content_dir = ensure_dir(&recording_dir.join("content"))?; - - let segments_dir = ensure_dir(&content_dir.join("segments"))?; - let cursors_dir = ensure_dir(&content_dir.join("cursors"))?; - - let start_time = Timestamps::now(); - - let (completion_tx, completion_rx) = - watch::channel::>>(None); - - if let Some(camera_feed) = &base_inputs.camera_feed { - debug!("camera device info: {:#?}", camera_feed.camera_info()); - debug!("camera video info: {:#?}", camera_feed.video_info()); - } - - if let Some(mic_feed) = &base_inputs.mic_feed { - debug!("mic audio info: {:#?}", mic_feed.audio_info()); - }; - - let mut segment_pipeline_factory = SegmentPipelineFactory::new( - segments_dir, - cursors_dir, - base_inputs.clone(), - custom_cursor_capture, - start_time, - completion_tx.clone(), - ); - - let index = 0; - let pipeline = segment_pipeline_factory - .create_next(Default::default(), 0) - .await?; - - let done_fut = completion_rx_to_done_fut(completion_rx); - - let segment_start_time = current_time_f64(); - - trace!("spawning recording actor"); - - let base_inputs = base_inputs.clone(); - - let actor_ref = Actor::spawn(Actor { - recording_dir, - state: Some(ActorState::Recording { - pipeline, - /*pipeline_done_rx,*/ - index, - segment_start_time, - segment_start_instant: Instant::now(), - }), - segment_factory: segment_pipeline_factory, - segments: Vec::new(), - completion_tx: completion_tx.clone(), - }); - - Ok(ActorHandle { - actor_ref, - capture_target: base_inputs.capture_target, - done_fut, - }) +#[derive(Debug, Clone, Serialize, Type)] +pub struct ProjectRecordingsMeta { + pub segments: Vec, } -pub struct CompletedRecording { - pub project_path: PathBuf, - pub meta: StudioRecordingMeta, - pub cursor_data: cap_project::CursorImages, -} +impl ProjectRecordingsMeta { + pub fn new(recording_path: &PathBuf, meta: &StudioRecordingMeta) -> Result { + let segments = match &meta { + StudioRecordingMeta::SingleSegment { segment: s } => { + let display = Video::new(s.display.path.to_path(recording_path), 0.0) + .expect("Failed to read display video"); + let camera = s.camera.as_ref().map(|camera| { + Video::new(camera.path.to_path(recording_path), 0.0) + .expect("Failed to read camera video") + }); + let mic = s + .audio + .as_ref() + .map(|audio| Audio::new(audio.path.to_path(recording_path), 0.0)) + .transpose() + .expect("Failed to read audio"); + + vec![SegmentRecordings { + display, + camera, + mic, + system_audio: None, + }] + } + StudioRecordingMeta::MultipleSegments { inner, .. } => inner + .segments + .iter() + .map(|s| { + let has_start_times = RefCell::new(None); + + let ensure_start_time = |time: Option| { + let Some(has_start_times) = *has_start_times.borrow_mut() else { + *has_start_times.borrow_mut() = Some(time.is_some()); + return Ok(time.unwrap_or_default()); + }; + + Ok(if has_start_times { + if let Some(time) = time { + time + } else { + return Err("Missing start time".to_string()); + } + } else if time.is_some() { + return Err("Start time mismatch".to_string()); + } else { + 0.0 + }) + }; -async fn stop_recording( - recording_dir: PathBuf, - segments: Vec, - cursors: Cursors, -) -> Result { - use cap_project::*; - - let make_relative = |path: &PathBuf| { - RelativePathBuf::from_path(path.strip_prefix(&recording_dir).unwrap()).unwrap() - }; - - let meta = StudioRecordingMeta::MultipleSegments { - inner: MultipleSegments { - segments: futures::stream::iter(segments) - .then(async |s| { - let to_start_time = |timestamp: Timestamp| { - timestamp - .duration_since(s.pipeline.start_time) - .as_secs_f64() + let load_video = |meta: &VideoMeta| { + ensure_start_time(meta.start_time).and_then(|start_time| { + Video::new(meta.path.to_path(recording_path), start_time) + }) }; - MultipleSegment { - display: VideoMeta { - path: make_relative(&s.pipeline.screen.path), - fps: s.pipeline.screen.video_info.unwrap().fps(), - start_time: Some(to_start_time(s.pipeline.screen.first_timestamp)), - }, - camera: s.pipeline.camera.map(|camera| VideoMeta { - path: make_relative(&camera.path), - fps: camera.video_info.unwrap().fps(), - start_time: Some(to_start_time(camera.first_timestamp)), - }), - mic: s.pipeline.microphone.map(|mic| AudioMeta { - path: make_relative(&mic.path), - start_time: Some(to_start_time(mic.first_timestamp)), - }), - system_audio: s.pipeline.system_audio.map(|audio| AudioMeta { - path: make_relative(&audio.path), - start_time: Some(to_start_time(audio.first_timestamp)), - }), - cursor: s - .pipeline - .cursor - .as_ref() - .map(|cursor| make_relative(&cursor.output_path)), - } - }) - .collect::>() - .await, - cursors: cap_project::Cursors::Correct( - cursors - .into_values() - .map(|cursor| { - ( - cursor.id.to_string(), - CursorMeta { - image_path: RelativePathBuf::from("content/cursors") - .join(&cursor.file_name), - hotspot: cursor.hotspot, - shape: cursor.shape, - }, - ) - }) - .collect(), - ), - status: Some(StudioRecordingStatus::Complete), - }, - }; - - let project_config = cap_project::ProjectConfiguration::default(); - project_config - .write(&recording_dir) - .map_err(RecordingError::from)?; - - Ok(CompletedRecording { - project_path: recording_dir, - meta, - cursor_data: Default::default(), - // display_source: actor.options.capture_target, - // segments: actor.segments, - }) -} + let load_audio = |meta: &AudioMeta| { + ensure_start_time(meta.start_time).and_then(|start_time| { + Audio::new(meta.path.to_path(recording_path), start_time) + }) + }; -struct SegmentPipelineFactory { - segments_dir: PathBuf, - cursors_dir: PathBuf, - base_inputs: RecordingBaseInputs, - custom_cursor_capture: bool, - start_time: Timestamps, - index: u32, - completion_tx: watch::Sender>>, - #[cfg(windows)] - encoder_preferences: crate::capture_pipeline::EncoderPreferences, -} + Ok::<_, String>(SegmentRecordings { + display: load_video(&s.display).map_err(|e| format!("video / {e}"))?, + camera: Option::map(s.camera.as_ref(), load_video) + .transpose() + .map_err(|e| format!("camera / {e}"))?, + mic: Option::map(s.mic.as_ref(), load_audio) + .transpose() + .map_err(|e| format!("mic / {e}"))?, + system_audio: Option::map(s.system_audio.as_ref(), load_audio) + .transpose() + .map_err(|e| format!("system audio / {e}"))?, + }) + }) + .enumerate() + .map(|(i, v)| v.map_err(|e| format!("segment {i} / {e}"))) + .collect::>()?, + }; -impl SegmentPipelineFactory { - #[allow(clippy::too_many_arguments)] - pub fn new( - segments_dir: PathBuf, - cursors_dir: PathBuf, - base_inputs: RecordingBaseInputs, - custom_cursor_capture: bool, - start_time: Timestamps, - completion_tx: watch::Sender>>, - ) -> Self { - Self { - segments_dir, - cursors_dir, - base_inputs, - custom_cursor_capture, - start_time, - index: 0, - completion_tx, - #[cfg(windows)] - encoder_preferences: crate::capture_pipeline::EncoderPreferences::new(), - } + Ok(Self { segments }) } - pub async fn create_next( - &mut self, - cursors: Cursors, - next_cursors_id: u32, - ) -> anyhow::Result { - let pipeline = create_segment_pipeline( - &self.segments_dir, - &self.cursors_dir, - self.index, - self.base_inputs.clone(), - cursors, - next_cursors_id, - self.custom_cursor_capture, - self.start_time, - #[cfg(windows)] - self.encoder_preferences.clone(), - ) - .await?; - - self.index += 1; - - pipeline.spawn_watcher(self.completion_tx.clone()); - - Ok(pipeline) + pub fn duration(&self) -> f64 { + self.segments.iter().map(|s| s.duration()).sum() } -} -fn completion_rx_to_done_fut( - mut rx: watch::Receiver>>, -) -> DoneFut { - async move { - loop { - if let Some(result) = rx.borrow().clone() { - return result; - } - - if rx.changed().await.is_err() { - return Ok(()); - } - } + pub fn get_source_duration(&self, path: &PathBuf) -> Result { + Video::new(path, 0.0).map(|v| v.duration) } - .boxed() - .shared() } -#[derive(Debug, thiserror::Error)] -pub enum CreateSegmentPipelineError { - #[error("NoDisplay")] - NoDisplay, - #[error("NoBounds")] - NoBounds, - #[error("PipelineBuild/{0}")] - PipelineBuild(MediaError), - #[error("PipelinePlay/{0}")] - PipelinePlay(MediaError), - #[error("Actor/{0}")] - Actor(#[from] ActorError), - #[error("{0}")] - Recording(#[from] RecordingError), - #[error("{0}")] - Media(#[from] MediaError), +#[derive(Debug, Clone, Serialize, Type)] +pub struct SegmentRecordings { + pub display: Video, + pub camera: Option