diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6938d899e..f57affa690 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", @@ -171,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: @@ -188,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 @@ -229,12 +237,61 @@ jobs: echo 'VITE_SERVER_URL=${{ secrets.NEXT_PUBLIC_WEB_URL }}' >> .env echo 'RUST_TARGET_TRIPLE=${{ matrix.settings.target }}' >> .env + - name: Prepare Tauri build config + if: ${{ env.RUN_BUILD == 'true' }} + shell: node {0} + env: + RAW_TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + const fs = require("node:fs"); + const path = require("node:path"); + + const workspace = process.env.GITHUB_WORKSPACE; + const sourceConfigPath = path.join(workspace, "apps/desktop/src-tauri/tauri.prod.conf.json"); + const buildConfigPath = path.join(workspace, "apps/desktop/src-tauri/tauri.build.conf.json"); + const config = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8")); + + const raw = (process.env.RAW_TAURI_SIGNING_PRIVATE_KEY || "").trim(); + const candidates = raw + ? new Set([raw, raw.replace(/\\n/g, "\n")]) + : new Set(); + + if (raw && /^[A-Za-z0-9+/=\r\n]+$/.test(raw) && !raw.includes("untrusted comment:")) { + try { + const decoded = Buffer.from(raw.replace(/\s+/g, ""), "base64").toString("utf8"); + candidates.add(decoded); + candidates.add(decoded.replace(/\\n/g, "\n")); + } catch {} + } + + const key = [...candidates].find( + (value) => + value.includes("untrusted comment:") && + value.toLowerCase().includes("minisign secret key"), + ); + + if (key) { + fs.appendFileSync( + process.env.GITHUB_ENV, + `TAURI_SIGNING_PRIVATE_KEY<<__TAURI_KEY__\n${key}\n__TAURI_KEY__\n`, + ); + } else { + config.plugins ??= {}; + config.plugins.updater ??= {}; + config.plugins.updater.active = false; + + config.bundle ??= {}; + config.bundle.createUpdaterArtifacts = false; + } + + fs.writeFileSync(buildConfigPath, `${JSON.stringify(config, null, 2)}\n`); + - name: Build app if: ${{ env.RUN_BUILD == 'true' }} working-directory: apps/desktop run: | pnpm -w cap-setup - pnpm build:tauri --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json + pnpm build:tauri --target ${{ matrix.settings.target }} --config src-tauri/tauri.build.conf.json env: # https://github.com/tauri-apps/tauri-action/issues/740 CI: false @@ -248,7 +305,6 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ github.workspace }}/api.p8 APPLE_KEYCHAIN: ${{ runner.temp }}/build.keychain - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} # - name: Upload unsigned Windows installer @@ -297,15 +353,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 +394,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 +404,6 @@ jobs: interactionId: "${{ inputs.interactionId }}", version: "${{ needs.draft.outputs.version }}", releaseUrl: "${{ needs.draft.outputs.gh_release_url }}", - cnReleaseId, }), headers: { "Content-Type": "application/json", diff --git a/Cargo.lock b/Cargo.lock index 337df96fac..ad633152e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,7 +1172,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.83" +version = "0.4.87" dependencies = [ "anyhow", "async-stream", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a6cf8cfe17..26bb9000fe 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.4.87" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index bada34367c..99d1ab11e6 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -44,7 +44,7 @@ use crate::{ auth::AuthStore, create_screenshot, general_settings::{ - self, GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour, + GeneralSettingsStore, PostDeletionBehaviour, PostStudioRecordingBehaviour, }, open_external_link, presets::PresetsStore, @@ -502,7 +502,9 @@ pub async fn start_recording( general_settings .map(|s| s.custom_cursor_capture) .unwrap_or_default(), - ); + ) + .with_fragmented(false) + .with_max_fps(60); #[cfg(target_os = "macos")] { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/app.tsx similarity index 100% rename from apps/desktop/src/App.tsx rename to apps/desktop/src/app.tsx diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 496ac6d5dd..f6f1d5500b 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -57,6 +57,9 @@ impl Default for Platform { #[cfg(target_os = "macos")] return Self::MacOS; + + #[cfg(not(any(windows, target_os = "macos")))] + return Self::Windows; } } diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 2730274274..41fb95d4eb 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1,361 +1,68 @@ -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, -} - -#[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, - }) - } - 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(), - }) - } - 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, -} +use anyhow::Context as _; +use cap_project::{ + AudioMeta, SingleSegment, StudioRecordingMeta, VideoMeta, +}; +use relative_path::RelativePathBuf; +use serde::Serialize; +use specta::Type; -struct Pipeline { - pub start_time: Timestamps, - // sources - pub screen: OutputPipeline, - pub microphone: Option, - pub camera: Option, - pub system_audio: Option, - pub cursor: Option, -} +use crate::instant_recording; -struct FinishedPipeline { - pub start_time: Timestamps, - // sources - pub screen: FinishedOutputPipeline, - pub microphone: Option, - pub camera: Option, - pub system_audio: Option, - pub cursor: Option, +pub struct ActorHandle { + inner: instant_recording::ActorHandle, + pub capture_target: crate::sources::screen_capture::ScreenCaptureTarget, + project_path: PathBuf, } -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(); - } +impl ActorHandle { + pub async fn stop(&self) -> anyhow::Result { + let _ = self.inner.stop().await?; - 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, + Ok(CompletedRecording { + project_path: self.project_path.clone(), + meta: default_studio_meta(), }) } - 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(); - } - }); - } - - 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))); - } - } - }); + pub fn done_fut(&self) -> crate::DoneFut { + self.inner.done_fut() } -} -struct CursorPipeline { - output_path: PathBuf, - actor: CursorActor, -} - -impl ActorHandle { - pub async fn stop(&self) -> anyhow::Result { - Ok(self.actor_ref.ask(Stop).await?) + pub async fn pause(&self) -> anyhow::Result<()> { + self.inner.pause().await } - pub fn done_fut(&self) -> DoneFut { - self.done_fut.clone() + pub async fn resume(&self) -> anyhow::Result<()> { + self.inner.resume().await } - pub async fn pause(&self) -> anyhow::Result<()> { - Ok(self.actor_ref.ask(Pause).await?) + pub async fn cancel(&self) -> anyhow::Result<()> { + self.inner.cancel().await } - pub async fn resume(&self) -> anyhow::Result<()> { - Ok(self.actor_ref.ask(Resume).await?) + pub async fn is_paused(&self) -> anyhow::Result { + self.inner.is_paused().await } +} - pub async fn cancel(&self) -> anyhow::Result<()> { - Ok(self.actor_ref.ask(Cancel).await?) +impl Drop for ActorHandle { + fn drop(&mut self) { + let _ = &self.inner; } } +pub struct Actor; + impl Actor { pub fn builder( output: PathBuf, - capture_target: screen_capture::ScreenCaptureTarget, + capture_target: crate::sources::screen_capture::ScreenCaptureTarget, ) -> ActorBuilder { ActorBuilder::new(output, capture_target) } @@ -363,17 +70,22 @@ impl Actor { pub struct ActorBuilder { output_path: PathBuf, - capture_target: screen_capture::ScreenCaptureTarget, + capture_target: crate::sources::screen_capture::ScreenCaptureTarget, system_audio: bool, - mic_feed: Option>, - camera_feed: Option>, + mic_feed: Option>, + camera_feed: Option>, custom_cursor: bool, + fragmented: bool, + max_fps: u32, #[cfg(target_os = "macos")] excluded_windows: Vec, } impl ActorBuilder { - pub fn new(output: PathBuf, capture_target: screen_capture::ScreenCaptureTarget) -> Self { + pub fn new( + output: PathBuf, + capture_target: crate::sources::screen_capture::ScreenCaptureTarget, + ) -> Self { Self { output_path: output, capture_target, @@ -381,6 +93,8 @@ impl ActorBuilder { mic_feed: None, camera_feed: None, custom_cursor: false, + fragmented: false, + max_fps: 60, #[cfg(target_os = "macos")] excluded_windows: Vec::new(), } @@ -391,12 +105,18 @@ impl ActorBuilder { self } - pub fn with_mic_feed(mut self, mic_feed: Arc) -> 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 { + pub fn with_camera_feed( + mut self, + camera_feed: Arc, + ) -> Self { self.camera_feed = Some(camera_feed); self } @@ -406,6 +126,16 @@ impl ActorBuilder { self } + pub fn with_fragmented(mut self, fragmented: bool) -> Self { + self.fragmented = fragmented; + self + } + + pub fn with_max_fps(mut self, max_fps: u32) -> Self { + self.max_fps = max_fps.clamp(1, 120); + self + } + #[cfg(target_os = "macos")] pub fn with_excluded_windows(mut self, excluded_windows: Vec) -> Self { self.excluded_windows = excluded_windows; @@ -414,416 +144,323 @@ impl ActorBuilder { pub async fn build( self, - #[cfg(target_os = "macos")] shareable_content: cidre::arc::R, + #[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, + let mut builder = instant_recording::Actor::builder( + self.output_path.clone(), + self.capture_target.clone(), ) - .await - } -} - -#[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"); + .with_system_audio(self.system_audio) + .with_max_fps(self.max_fps); - 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"))?; + if let Some(mic_feed) = self.mic_feed { + builder = builder.with_mic_feed(mic_feed); + } - let start_time = Timestamps::now(); + #[cfg(target_os = "macos")] + { + builder = builder.with_excluded_windows(self.excluded_windows); + } - let (completion_tx, completion_rx) = - watch::channel::>>(None); + let inner = builder.build( + #[cfg(target_os = "macos")] + _shareable_content, + ) + .await + .context("spawn instant recording actor")?; - 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()); + Ok(ActorHandle { + inner, + capture_target: self.capture_target, + project_path: self.output_path, + }) } - - 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)] pub struct CompletedRecording { pub project_path: PathBuf, pub meta: StudioRecordingMeta, - pub cursor_data: cap_project::CursorImages, } -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() - }; - - 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), +fn default_studio_meta() -> StudioRecordingMeta { + StudioRecordingMeta::SingleSegment { + segment: SingleSegment { + display: VideoMeta { + path: RelativePathBuf::from("content/output.mp4"), + fps: 30, + start_time: None, + }, + camera: None, + audio: None, + cursor: None, }, - }; - - 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, - }) + } } -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, +#[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, } -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(), - } - } - - 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?; +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 stream_duration = stream.duration(); + let stream_frames = stream.frames(); + + 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; + } + } - self.index += 1; + 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 { + if stream_duration > 0 && stream_duration != i64::MIN { + duration = (stream_duration as f64 * stream_time_base.numerator() as f64) + / stream_time_base.denominator() as f64; + } + } - pipeline.spawn_watcher(self.completion_tx.clone()); + 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; + } + } + } + } - Ok(pipeline) - } -} + if last_ts >= 0 { + duration = (last_ts as f64 * stream_time_base.numerator() as f64) + / stream_time_base.denominator() as f64; + } + } -fn completion_rx_to_done_fut( - mut rx: watch::Receiver>>, -) -> DoneFut { - async move { - loop { - if let Some(result) = rx.borrow().clone() { - return result; + if duration <= 0.0 && stream_frames > 0 && fps > 0.0 { + duration = stream_frames as f64 / fps; } - if rx.changed().await.is_err() { - return Ok(()); + if !duration.is_finite() || duration <= 0.0 { + tracing::warn!( + ?path, + container_duration, + stream_duration, + frames = stream_frames, + fps, + "Failed to determine video duration; defaulting to zero" + ); + duration = 0.0; } + + Ok(Video { + width: video_decoder.width(), + height: video_decoder.height(), + duration, + fps: if fps > 0.0 { fps.round() as u32 } else { 0 }, + start_time, + }) } + + inner(path.as_ref(), start_time) + } + + pub fn fps(&self) -> u32 { + self.fps } - .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, Copy, Serialize, Type)] +pub struct Audio { + pub duration: f64, + pub sample_rate: u32, + pub channels: u16, + pub start_time: f64, } -#[tracing::instrument(skip_all, name = "segment", fields(index = index))] -#[allow(clippy::too_many_arguments)] -async fn create_segment_pipeline( - segments_dir: &Path, - cursors_dir: &Path, - index: u32, - base_inputs: RecordingBaseInputs, - prev_cursors: Cursors, - next_cursors_id: u32, - custom_cursor_capture: bool, - start_time: Timestamps, - #[cfg(windows)] encoder_preferences: crate::capture_pipeline::EncoderPreferences, -) -> anyhow::Result { - #[cfg(windows)] - let d3d_device = crate::capture_pipeline::create_d3d_device().unwrap(); - - let (display, crop) = - target_to_display_and_crop(&base_inputs.capture_target).context("target_display_crop")?; - - let screen_config = ScreenCaptureConfig::::init( - display, - crop, - !custom_cursor_capture, - 120, - start_time.system_time(), - base_inputs.capture_system_audio, - #[cfg(windows)] - d3d_device, - #[cfg(target_os = "macos")] - base_inputs.shareable_content, - #[cfg(target_os = "macos")] - base_inputs.excluded_windows, - ) - .await - .context("screen capture init")?; - - let (capture_source, system_audio) = screen_config.to_sources().await?; - - let dir = ensure_dir(&segments_dir.join(format!("segment-{index}")))?; - - let screen_output_path = dir.join("display.mp4"); - - trace!("preparing segment pipeline {index}"); - - let screen = ScreenCaptureMethod::make_studio_mode_pipeline( - capture_source, - screen_output_path.clone(), - start_time, - #[cfg(windows)] - encoder_preferences, - ) - .instrument(error_span!("screen-out")) - .await - .context("screen pipeline setup")?; - - let camera = OptionFuture::from(base_inputs.camera_feed.map(|camera_feed| { - OutputPipeline::builder(dir.join("camera.mp4")) - .with_video::(camera_feed) - .with_timestamps(start_time) - .build::(()) - .instrument(error_span!("camera-out")) - })) - .await - .transpose() - .context("camera pipeline setup")?; - - let microphone = OptionFuture::from(base_inputs.mic_feed.map(|mic_feed| { - OutputPipeline::builder(dir.join("audio-input.ogg")) - .with_audio_source::(mic_feed) - .with_timestamps(start_time) - .build::(()) - .instrument(error_span!("mic-out")) - })) - .await - .transpose() - .context("microphone pipeline setup")?; - - let system_audio = OptionFuture::from(system_audio.map(|system_audio| { - OutputPipeline::builder(dir.join("system_audio.ogg")) - .with_audio_source::(system_audio) - .with_timestamps(start_time) - .build::(()) - .instrument(error_span!("system-audio-out")) - })) - .await - .transpose() - .context("microphone pipeline setup")?; - - let cursor = custom_cursor_capture - .then(move || { - let cursor_crop_bounds = base_inputs - .capture_target - .cursor_crop() - .ok_or(CreateSegmentPipelineError::NoBounds)?; - - let cursor = spawn_cursor_recorder( - cursor_crop_bounds, - display, - cursors_dir.to_path_buf(), - prev_cursors, - next_cursors_id, +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, - ); - - Ok::<_, CreateSegmentPipelineError>(CursorPipeline { - output_path: dir.join("cursor.json"), - actor: cursor, }) - }) - .transpose()?; - - info!("pipeline playing"); - - Ok(Pipeline { - start_time, - screen, - microphone, - camera, - cursor, - system_audio, - }) + } + + inner(path.as_ref(), start_time) + } +} + +#[derive(Debug, Clone, Serialize, Type)] +pub struct ProjectRecordingsMeta { + pub segments: Vec, +} + +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() + .map_err(|e| format!("audio / {e}"))?; + + 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 + }) + }; + + 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) + }) + }; + + 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) + }) + }; + + 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::>()?, + }; + + Ok(Self { segments }) + } + + pub fn duration(&self) -> f64 { + self.segments.iter().map(|s| s.duration()).sum() + } + + pub fn get_source_duration(&self, path: &PathBuf) -> Result { + Video::new(path, 0.0).map(|v| v.duration) + } } -fn ensure_dir(path: &PathBuf) -> Result { - std::fs::create_dir_all(path)?; - Ok(path.clone()) +#[derive(Debug, Clone, Serialize, Type)] +pub struct SegmentRecordings { + pub display: Video, + pub camera: Option