@agent-native/core 0.114.5 → 0.114.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
- package/corpus/core/src/cli/mcp.ts +124 -0
- package/corpus/core/src/cli/skills-content/help.ts +4 -3
- package/corpus/core/src/cli/skills-content/index.ts +1 -0
- package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
- package/corpus/core/src/cli/skills.ts +115 -13
- package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
- package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
- package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
- package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
- package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
- package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
- package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
- package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
- package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
- package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
- package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
- package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
- package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
- package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
- package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
- package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
- package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
- package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
- package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
- package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
- package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
- package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
- package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
- package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
- package/corpus/templates/clips/desktop/src/styles.css +956 -134
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
- package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
- package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
- package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
- package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
- package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
- package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
- package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
- package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
- package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
- package/corpus/templates/content/AGENTS.md +4 -1
- package/corpus/templates/content/actions/_content-spaces.ts +6 -1
- package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
- package/corpus/templates/content/actions/_database-utils.ts +83 -15
- package/corpus/templates/content/actions/add-database-item.ts +3 -0
- package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
- package/corpus/templates/content/actions/delete-content-database.ts +12 -9
- package/corpus/templates/content/actions/delete-database-items.ts +3 -2
- package/corpus/templates/content/actions/delete-document.ts +233 -6
- package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
- package/corpus/templates/content/actions/get-document.ts +4 -1
- package/corpus/templates/content/actions/list-content-databases.ts +1 -0
- package/corpus/templates/content/actions/list-documents.ts +1 -0
- package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
- package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
- package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
- package/corpus/templates/content/actions/pull-document.ts +15 -2
- package/corpus/templates/content/actions/restore-content-database.ts +48 -11
- package/corpus/templates/content/actions/restore-document.ts +28 -0
- package/corpus/templates/content/actions/search-documents.ts +2 -1
- package/corpus/templates/content/actions/view-screen.ts +2 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
- package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
- package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
- package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
- package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
- package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
- package/corpus/templates/content/app/i18n-data.ts +83 -2
- package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
- package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
- package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
- package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
- package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +3 -0
- package/corpus/templates/content/server/db/schema.ts +2 -0
- package/corpus/templates/content/server/lib/public-documents.ts +8 -2
- package/corpus/templates/content/server/plugins/db.ts +52 -0
- package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
- package/corpus/templates/content/shared/api.ts +10 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/ui/progress.tsx +3 -2
- package/dist/cli/mcp-config-writers.d.ts +2 -2
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +6 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +79 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills-content/help.d.ts +1 -1
- package/dist/cli/skills-content/help.d.ts.map +1 -1
- package/dist/cli/skills-content/help.js +4 -3
- package/dist/cli/skills-content/help.js.map +1 -1
- package/dist/cli/skills-content/index.d.ts +1 -0
- package/dist/cli/skills-content/index.d.ts.map +1 -1
- package/dist/cli/skills-content/index.js +1 -0
- package/dist/cli/skills-content/index.js.map +1 -1
- package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
- package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
- package/dist/cli/skills-content/rewind-skill.js +53 -0
- package/dist/cli/skills-content/rewind-skill.js.map +1 -0
- package/dist/cli/skills.d.ts +6 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +90 -14
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/resources/ResourceTree.d.ts +3 -1
- package/dist/client/resources/ResourceTree.d.ts.map +1 -1
- package/dist/client/resources/ResourceTree.js +4 -4
- package/dist/client/resources/ResourceTree.js.map +1 -1
- package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
- package/dist/client/resources/ResourcesPanel.js +55 -6
- package/dist/client/resources/ResourcesPanel.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +365 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp/screen-memory-stdio.js +1014 -91
- package/dist/mcp/screen-memory-stdio.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +90 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
- package/dist/mcp-client/screen-memory-local.js +590 -12
- package/dist/mcp-client/screen-memory-local.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +2 -2
- package/src/cli/mcp-config-writers.ts +6 -4
- package/src/cli/mcp.ts +124 -0
- package/src/cli/skills-content/help.ts +4 -3
- package/src/cli/skills-content/index.ts +1 -0
- package/src/cli/skills-content/rewind-skill.ts +52 -0
- package/src/cli/skills.ts +115 -13
- package/src/client/resources/ResourceTree.tsx +11 -5
- package/src/client/resources/ResourcesPanel.tsx +141 -18
- package/src/mcp/screen-memory-stdio.ts +1360 -104
- package/src/mcp-client/index.ts +9 -0
- package/src/mcp-client/screen-memory-local.ts +847 -13
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
use super::*;
|
|
12
12
|
use screencapturekit::error::SCError;
|
|
13
13
|
use screencapturekit::stream::delegate_trait::SCStreamDelegateTrait;
|
|
14
|
+
use std::io::{Seek, SeekFrom, Write};
|
|
14
15
|
|
|
15
16
|
/// `AVAssetWriter.status` raw value for `.completed`.
|
|
16
17
|
const AV_WRITER_STATUS_COMPLETED: i64 = 2;
|
|
@@ -38,6 +39,13 @@ const CAPTURE_VIDEO_BPP: f64 = 0.15;
|
|
|
38
39
|
const CAPTURE_STALL_TIMEOUT: Duration = Duration::from_secs(4);
|
|
39
40
|
// Watchdog poll cadence.
|
|
40
41
|
const CAPTURE_WATCHDOG_POLL: Duration = Duration::from_millis(1000);
|
|
42
|
+
/// A stream is not healthy merely because `start_capture` returned. Rewind's
|
|
43
|
+
/// fragmented writer must receive a usable video frame and emit real media.
|
|
44
|
+
const CAPTURE_FIRST_SAMPLE_TIMEOUT: Duration = Duration::from_secs(4);
|
|
45
|
+
const CAPTURE_FIRST_FRAGMENT_TIMEOUT: Duration = Duration::from_secs(3);
|
|
46
|
+
/// Keep a small PCM tail unwritten so a fence can still divide callbacks that
|
|
47
|
+
/// arrived ahead of the corresponding encoded video fragment report.
|
|
48
|
+
const AUDIO_FENCE_LOOKBEHIND_SECONDS: f64 = 2.0;
|
|
41
49
|
// Consecutive failed restarts (rebuild or start error, or an immediate
|
|
42
50
|
// re-stall) before giving up and finalizing whatever was captured. A single
|
|
43
51
|
// successful stretch of frames resets the counter.
|
|
@@ -62,6 +70,10 @@ pub(crate) struct CaptureWatch {
|
|
|
62
70
|
/// the watchdog must not read the silence as a stall and rebuild — resume
|
|
63
71
|
/// brings a fresh stream back and clears this.
|
|
64
72
|
paused: AtomicBool,
|
|
73
|
+
screen_samples: AtomicU64,
|
|
74
|
+
usable_screen_samples: AtomicU64,
|
|
75
|
+
system_audio_samples: AtomicU64,
|
|
76
|
+
microphone_samples: AtomicU64,
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
impl CaptureWatch {
|
|
@@ -71,6 +83,10 @@ impl CaptureWatch {
|
|
|
71
83
|
stream_stopped: Mutex::new(None),
|
|
72
84
|
user_stopped: AtomicBool::new(false),
|
|
73
85
|
paused: AtomicBool::new(false),
|
|
86
|
+
screen_samples: AtomicU64::new(0),
|
|
87
|
+
usable_screen_samples: AtomicU64::new(0),
|
|
88
|
+
system_audio_samples: AtomicU64::new(0),
|
|
89
|
+
microphone_samples: AtomicU64::new(0),
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
|
|
@@ -80,6 +96,33 @@ impl CaptureWatch {
|
|
|
80
96
|
}
|
|
81
97
|
}
|
|
82
98
|
|
|
99
|
+
fn note_sample(&self, of_type: SCStreamOutputType, usable_screen: bool) {
|
|
100
|
+
let (counter, label) = match of_type {
|
|
101
|
+
SCStreamOutputType::Screen => (&self.screen_samples, "screen"),
|
|
102
|
+
SCStreamOutputType::Audio => (&self.system_audio_samples, "system-audio"),
|
|
103
|
+
SCStreamOutputType::Microphone => (&self.microphone_samples, "microphone"),
|
|
104
|
+
};
|
|
105
|
+
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
|
106
|
+
if count == 1 {
|
|
107
|
+
crate::logfile::diagnostic(&format!("[capture-health] first {label} sample callback"));
|
|
108
|
+
}
|
|
109
|
+
if usable_screen {
|
|
110
|
+
let usable = self.usable_screen_samples.fetch_add(1, Ordering::Relaxed) + 1;
|
|
111
|
+
if usable == 1 {
|
|
112
|
+
crate::logfile::diagnostic("[capture-health] first usable screen sample");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn sample_counts(&self) -> (u64, u64, u64, u64) {
|
|
118
|
+
(
|
|
119
|
+
self.screen_samples.load(Ordering::Relaxed),
|
|
120
|
+
self.usable_screen_samples.load(Ordering::Relaxed),
|
|
121
|
+
self.system_audio_samples.load(Ordering::Relaxed),
|
|
122
|
+
self.microphone_samples.load(Ordering::Relaxed),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
83
126
|
fn since_activity(&self) -> Duration {
|
|
84
127
|
self.last_activity
|
|
85
128
|
.lock()
|
|
@@ -130,15 +173,460 @@ impl CaptureWatch {
|
|
|
130
173
|
// can safely tail it forever.
|
|
131
174
|
// ---------------------------------------------------------------------------
|
|
132
175
|
|
|
133
|
-
///
|
|
134
|
-
///
|
|
135
|
-
///
|
|
176
|
+
/// Apple's `AVAssetWriterSegmentType` values. Apple documents initialization
|
|
177
|
+
/// data as `1` and separable media data as `2`; keeping this distinction is
|
|
178
|
+
/// what lets each logical output remain a standalone fMP4.
|
|
179
|
+
const AV_ASSET_WRITER_SEGMENT_TYPE_INITIALIZATION: isize = 1;
|
|
180
|
+
const AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE: isize = 2;
|
|
181
|
+
|
|
182
|
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
183
|
+
enum CustomWriterOutput {
|
|
184
|
+
Standard,
|
|
185
|
+
RewindCmaf,
|
|
186
|
+
ClipHls,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
impl CustomWriterOutput {
|
|
190
|
+
fn segmented(self) -> bool {
|
|
191
|
+
!matches!(self, Self::Standard)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fn preserves_separate_audio(self) -> bool {
|
|
195
|
+
matches!(self, Self::RewindCmaf)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn segmented_output_enabled(output: CustomWriterOutput, live_upload_enabled: bool) -> bool {
|
|
200
|
+
output.segmented() || live_upload_enabled
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
fn live_audio_mixing_enabled(
|
|
204
|
+
output: CustomWriterOutput,
|
|
205
|
+
include_audio: bool,
|
|
206
|
+
capture_system_audio: bool,
|
|
207
|
+
) -> bool {
|
|
208
|
+
include_audio && capture_system_audio && !output.preserves_separate_audio()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
pub(crate) fn audio_sidecar_path(video_path: &Path, source: &str) -> PathBuf {
|
|
212
|
+
let stem = video_path
|
|
213
|
+
.file_stem()
|
|
214
|
+
.and_then(|value| value.to_str())
|
|
215
|
+
.unwrap_or("segment");
|
|
216
|
+
video_path.with_file_name(format!("{stem}.{source}.wav"))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
struct FloatWavWriter {
|
|
220
|
+
file: std::fs::File,
|
|
221
|
+
data_bytes: u32,
|
|
222
|
+
sample_rate: u32,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
impl FloatWavWriter {
|
|
226
|
+
fn create(path: &Path, sample_rate: u32) -> Result<Self, String> {
|
|
227
|
+
let mut file = std::fs::File::create(path)
|
|
228
|
+
.map_err(|error| format!("audio sidecar create failed: {error}"))?;
|
|
229
|
+
file.write_all(&[0u8; 44])
|
|
230
|
+
.map_err(|error| format!("audio sidecar header reserve failed: {error}"))?;
|
|
231
|
+
Ok(Self {
|
|
232
|
+
file,
|
|
233
|
+
data_bytes: 0,
|
|
234
|
+
sample_rate,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn append_at(&mut self, samples: &[f32], start_seconds: f64) -> Result<(), String> {
|
|
239
|
+
let target = (start_seconds.max(0.0) * self.sample_rate as f64).round() as usize;
|
|
240
|
+
let current = self.data_bytes as usize / 4;
|
|
241
|
+
if target > current {
|
|
242
|
+
for _ in 0..target.saturating_sub(current) {
|
|
243
|
+
self.file
|
|
244
|
+
.write_all(&0.0f32.to_le_bytes())
|
|
245
|
+
.map_err(|error| format!("audio sidecar silence write failed: {error}"))?;
|
|
246
|
+
}
|
|
247
|
+
self.data_bytes = self
|
|
248
|
+
.data_bytes
|
|
249
|
+
.saturating_add((target.saturating_sub(current) as u32).saturating_mul(4));
|
|
250
|
+
}
|
|
251
|
+
let current = self.data_bytes as usize / 4;
|
|
252
|
+
let skip = current.saturating_sub(target).min(samples.len());
|
|
253
|
+
for sample in &samples[skip..] {
|
|
254
|
+
self.file
|
|
255
|
+
.write_all(&sample.to_le_bytes())
|
|
256
|
+
.map_err(|error| format!("audio sidecar write failed: {error}"))?;
|
|
257
|
+
}
|
|
258
|
+
self.data_bytes = self
|
|
259
|
+
.data_bytes
|
|
260
|
+
.saturating_add(((samples.len() - skip) as u32).saturating_mul(4));
|
|
261
|
+
Ok(())
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn finish(mut self) -> Result<(), String> {
|
|
265
|
+
let riff_size = 36u32.saturating_add(self.data_bytes);
|
|
266
|
+
let byte_rate = self.sample_rate.saturating_mul(4);
|
|
267
|
+
let mut header = Vec::with_capacity(44);
|
|
268
|
+
header.extend_from_slice(b"RIFF");
|
|
269
|
+
header.extend_from_slice(&riff_size.to_le_bytes());
|
|
270
|
+
header.extend_from_slice(b"WAVEfmt ");
|
|
271
|
+
header.extend_from_slice(&16u32.to_le_bytes());
|
|
272
|
+
header.extend_from_slice(&3u16.to_le_bytes());
|
|
273
|
+
header.extend_from_slice(&1u16.to_le_bytes());
|
|
274
|
+
header.extend_from_slice(&self.sample_rate.to_le_bytes());
|
|
275
|
+
header.extend_from_slice(&byte_rate.to_le_bytes());
|
|
276
|
+
header.extend_from_slice(&4u16.to_le_bytes());
|
|
277
|
+
header.extend_from_slice(&32u16.to_le_bytes());
|
|
278
|
+
header.extend_from_slice(b"data");
|
|
279
|
+
header.extend_from_slice(&self.data_bytes.to_le_bytes());
|
|
280
|
+
self.file
|
|
281
|
+
.seek(SeekFrom::Start(0))
|
|
282
|
+
.and_then(|_| self.file.write_all(&header))
|
|
283
|
+
.and_then(|_| self.file.flush())
|
|
284
|
+
.and_then(|_| self.file.sync_all())
|
|
285
|
+
.map_err(|error| format!("audio sidecar finalize failed: {error}"))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
struct AudioSidecarState {
|
|
290
|
+
system: Option<FloatWavWriter>,
|
|
291
|
+
microphone: Option<FloatWavWriter>,
|
|
292
|
+
failed: Option<String>,
|
|
293
|
+
segment_base_pts: Option<f64>,
|
|
294
|
+
latest_end_pts: Option<f64>,
|
|
295
|
+
session_start_pts: Option<f64>,
|
|
296
|
+
fence_pending: bool,
|
|
297
|
+
queued: std::collections::VecDeque<QueuedAudio>,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
struct QueuedAudio {
|
|
301
|
+
microphone: bool,
|
|
302
|
+
samples: Vec<f32>,
|
|
303
|
+
sample_rate: f64,
|
|
304
|
+
pts_seconds: f64,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
struct AudioSidecarManager {
|
|
308
|
+
state: Mutex<AudioSidecarState>,
|
|
309
|
+
sources: crate::capture_audio_bus::AudioSources,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
impl AudioSidecarManager {
|
|
313
|
+
fn create(
|
|
314
|
+
video_path: &Path,
|
|
315
|
+
sources: crate::capture_audio_bus::AudioSources,
|
|
316
|
+
) -> Result<Arc<Self>, String> {
|
|
317
|
+
let system = sources
|
|
318
|
+
.system
|
|
319
|
+
.then(|| FloatWavWriter::create(&audio_sidecar_path(video_path, "system"), 48_000))
|
|
320
|
+
.transpose()?;
|
|
321
|
+
let microphone = sources
|
|
322
|
+
.microphone
|
|
323
|
+
.then(|| FloatWavWriter::create(&audio_sidecar_path(video_path, "microphone"), 48_000))
|
|
324
|
+
.transpose()?;
|
|
325
|
+
Ok(Arc::new(Self {
|
|
326
|
+
state: Mutex::new(AudioSidecarState {
|
|
327
|
+
system,
|
|
328
|
+
microphone,
|
|
329
|
+
failed: None,
|
|
330
|
+
segment_base_pts: None,
|
|
331
|
+
latest_end_pts: None,
|
|
332
|
+
session_start_pts: None,
|
|
333
|
+
fence_pending: false,
|
|
334
|
+
queued: std::collections::VecDeque::new(),
|
|
335
|
+
}),
|
|
336
|
+
sources,
|
|
337
|
+
}))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fn append(
|
|
341
|
+
&self,
|
|
342
|
+
microphone: bool,
|
|
343
|
+
samples: &[f32],
|
|
344
|
+
sample_rate: f64,
|
|
345
|
+
pts_seconds: f64,
|
|
346
|
+
session_start_seconds: f64,
|
|
347
|
+
) {
|
|
348
|
+
let Ok(mut state) = self.state.lock() else {
|
|
349
|
+
return;
|
|
350
|
+
};
|
|
351
|
+
if state.failed.is_some() {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (sample_rate - 48_000.0).abs() > 1.0 {
|
|
355
|
+
state.failed = Some(format!(
|
|
356
|
+
"audio sidecar received unsupported sample rate {sample_rate}"
|
|
357
|
+
));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
state.session_start_pts.get_or_insert(session_start_seconds);
|
|
361
|
+
state.queued.push_back(QueuedAudio {
|
|
362
|
+
microphone,
|
|
363
|
+
samples: samples.to_vec(),
|
|
364
|
+
sample_rate,
|
|
365
|
+
pts_seconds,
|
|
366
|
+
});
|
|
367
|
+
if !state.fence_pending {
|
|
368
|
+
let cutoff = pts_seconds - AUDIO_FENCE_LOOKBEHIND_SECONDS;
|
|
369
|
+
Self::flush_complete_buffers_before(&mut state, cutoff, session_start_seconds);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fn flush_complete_buffers_before(
|
|
374
|
+
state: &mut AudioSidecarState,
|
|
375
|
+
cutoff_pts: f64,
|
|
376
|
+
session_start_seconds: f64,
|
|
377
|
+
) {
|
|
378
|
+
while state.queued.front().is_some_and(|buffer| {
|
|
379
|
+
buffer.pts_seconds + buffer.samples.len() as f64 / buffer.sample_rate <= cutoff_pts
|
|
380
|
+
}) {
|
|
381
|
+
let buffer = state.queued.pop_front().expect("front checked above");
|
|
382
|
+
Self::append_locked(
|
|
383
|
+
state,
|
|
384
|
+
buffer.microphone,
|
|
385
|
+
&buffer.samples,
|
|
386
|
+
buffer.sample_rate,
|
|
387
|
+
buffer.pts_seconds,
|
|
388
|
+
session_start_seconds,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
fn flush_all(state: &mut AudioSidecarState, session_start_seconds: f64) {
|
|
394
|
+
let queued = std::mem::take(&mut state.queued);
|
|
395
|
+
for buffer in queued {
|
|
396
|
+
Self::append_locked(
|
|
397
|
+
state,
|
|
398
|
+
buffer.microphone,
|
|
399
|
+
&buffer.samples,
|
|
400
|
+
buffer.sample_rate,
|
|
401
|
+
buffer.pts_seconds,
|
|
402
|
+
session_start_seconds,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn append_locked(
|
|
408
|
+
state: &mut AudioSidecarState,
|
|
409
|
+
microphone: bool,
|
|
410
|
+
samples: &[f32],
|
|
411
|
+
sample_rate: f64,
|
|
412
|
+
pts_seconds: f64,
|
|
413
|
+
session_start_seconds: f64,
|
|
414
|
+
) {
|
|
415
|
+
let base = *state.segment_base_pts.get_or_insert(session_start_seconds);
|
|
416
|
+
// ScreenCaptureKit callbacks from different output queues can arrive
|
|
417
|
+
// after a fragment fence even when their PTS belongs to the preceding
|
|
418
|
+
// fragment. Discard only the portion before this segment's video
|
|
419
|
+
// boundary instead of incorrectly prepending it to the new sidecar.
|
|
420
|
+
let skip = (((base - pts_seconds) * sample_rate).ceil() as isize)
|
|
421
|
+
.clamp(0, samples.len() as isize) as usize;
|
|
422
|
+
let samples = &samples[skip..];
|
|
423
|
+
if samples.is_empty() {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let pts_seconds = pts_seconds + skip as f64 / sample_rate;
|
|
427
|
+
let start_seconds = (pts_seconds - base).max(0.0);
|
|
428
|
+
state.latest_end_pts = Some(
|
|
429
|
+
state
|
|
430
|
+
.latest_end_pts
|
|
431
|
+
.unwrap_or(pts_seconds)
|
|
432
|
+
.max(pts_seconds + samples.len() as f64 / sample_rate),
|
|
433
|
+
);
|
|
434
|
+
let writer = if microphone {
|
|
435
|
+
state.microphone.as_mut()
|
|
436
|
+
} else {
|
|
437
|
+
state.system.as_mut()
|
|
438
|
+
};
|
|
439
|
+
if let Some(writer) = writer {
|
|
440
|
+
if let Err(error) = writer.append_at(samples, start_seconds) {
|
|
441
|
+
state.failed = Some(error);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn begin_fence(&self) -> Result<(), String> {
|
|
447
|
+
let mut state = self.state.lock().map_err(|error| error.to_string())?;
|
|
448
|
+
if let Some(error) = state.failed.clone() {
|
|
449
|
+
return Err(error);
|
|
450
|
+
}
|
|
451
|
+
if state.fence_pending {
|
|
452
|
+
return Err("an audio sidecar fence is already pending".into());
|
|
453
|
+
}
|
|
454
|
+
state.fence_pending = true;
|
|
455
|
+
Ok(())
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn complete_fence(&self, next_video_path: &Path, boundary_seconds: f64) -> Result<(), String> {
|
|
459
|
+
let mut state = self.state.lock().map_err(|error| error.to_string())?;
|
|
460
|
+
if let Some(error) = state.failed.clone() {
|
|
461
|
+
return Err(error);
|
|
462
|
+
}
|
|
463
|
+
if !state.fence_pending {
|
|
464
|
+
return Err("audio sidecar fence completed without a pending fence".into());
|
|
465
|
+
}
|
|
466
|
+
let session_start = state.session_start_pts.unwrap_or(0.0);
|
|
467
|
+
let boundary_pts = session_start + boundary_seconds;
|
|
468
|
+
let queued = std::mem::take(&mut state.queued);
|
|
469
|
+
for buffer in &queued {
|
|
470
|
+
let before = (((boundary_pts - buffer.pts_seconds) * buffer.sample_rate).round()
|
|
471
|
+
as isize)
|
|
472
|
+
.clamp(0, buffer.samples.len() as isize) as usize;
|
|
473
|
+
if before > 0 {
|
|
474
|
+
Self::append_locked(
|
|
475
|
+
&mut state,
|
|
476
|
+
buffer.microphone,
|
|
477
|
+
&buffer.samples[..before],
|
|
478
|
+
buffer.sample_rate,
|
|
479
|
+
buffer.pts_seconds,
|
|
480
|
+
session_start,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if let Some(writer) = state.system.take() {
|
|
485
|
+
writer.finish()?;
|
|
486
|
+
}
|
|
487
|
+
if let Some(writer) = state.microphone.take() {
|
|
488
|
+
writer.finish()?;
|
|
489
|
+
}
|
|
490
|
+
state.system = self
|
|
491
|
+
.sources
|
|
492
|
+
.system
|
|
493
|
+
.then(|| FloatWavWriter::create(&audio_sidecar_path(next_video_path, "system"), 48_000))
|
|
494
|
+
.transpose()?;
|
|
495
|
+
state.microphone = self
|
|
496
|
+
.sources
|
|
497
|
+
.microphone
|
|
498
|
+
.then(|| {
|
|
499
|
+
FloatWavWriter::create(&audio_sidecar_path(next_video_path, "microphone"), 48_000)
|
|
500
|
+
})
|
|
501
|
+
.transpose()?;
|
|
502
|
+
state.segment_base_pts = Some(boundary_pts);
|
|
503
|
+
state.fence_pending = false;
|
|
504
|
+
for buffer in queued {
|
|
505
|
+
let after = (((boundary_pts - buffer.pts_seconds) * buffer.sample_rate).round()
|
|
506
|
+
as isize)
|
|
507
|
+
.clamp(0, buffer.samples.len() as isize) as usize;
|
|
508
|
+
if after < buffer.samples.len() {
|
|
509
|
+
Self::append_locked(
|
|
510
|
+
&mut state,
|
|
511
|
+
buffer.microphone,
|
|
512
|
+
&buffer.samples[after..],
|
|
513
|
+
buffer.sample_rate,
|
|
514
|
+
buffer.pts_seconds + after as f64 / buffer.sample_rate,
|
|
515
|
+
boundary_pts,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
crate::logfile::diagnostic(&format!(
|
|
520
|
+
"[capture-health] audio sidecars fenced at writer PTS {boundary_seconds:.6}s"
|
|
521
|
+
));
|
|
522
|
+
Ok(())
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
fn cancel_fence(&self) {
|
|
526
|
+
let Ok(mut state) = self.state.lock() else {
|
|
527
|
+
return;
|
|
528
|
+
};
|
|
529
|
+
if !state.fence_pending {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
let session_start = state.session_start_pts.unwrap_or(0.0);
|
|
533
|
+
state.fence_pending = false;
|
|
534
|
+
Self::flush_all(&mut state, session_start);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
fn finish(&self) -> Result<(), String> {
|
|
538
|
+
self.cancel_fence();
|
|
539
|
+
let mut state = self.state.lock().map_err(|error| error.to_string())?;
|
|
540
|
+
let session_start = state.session_start_pts.unwrap_or(0.0);
|
|
541
|
+
Self::flush_all(&mut state, session_start);
|
|
542
|
+
if let Some(writer) = state.system.take() {
|
|
543
|
+
writer.finish()?;
|
|
544
|
+
}
|
|
545
|
+
if let Some(writer) = state.microphone.take() {
|
|
546
|
+
writer.finish()?;
|
|
547
|
+
}
|
|
548
|
+
state.failed.clone().map_or(Ok(()), Err)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// Metadata for a logical fMP4 closed at a media-fragment boundary.
|
|
553
|
+
#[derive(Clone, Debug, PartialEq)]
|
|
554
|
+
pub(crate) struct ClosedSegmentFile {
|
|
555
|
+
pub path: PathBuf,
|
|
556
|
+
pub sequence: u64,
|
|
557
|
+
pub media_fragments: u64,
|
|
558
|
+
pub bytes_written: u64,
|
|
559
|
+
pub boundary_seconds: Option<f64>,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/// A non-blocking fence request. Await it off AVFoundation's delegate queue.
|
|
563
|
+
pub(crate) struct SegmentFence {
|
|
564
|
+
result: std::sync::mpsc::Receiver<Result<ClosedSegmentFile, String>>,
|
|
565
|
+
audio_sidecars: Option<(Arc<AudioSidecarManager>, PathBuf)>,
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
impl SegmentFence {
|
|
569
|
+
pub(crate) fn wait(self, timeout: Duration) -> Result<ClosedSegmentFile, String> {
|
|
570
|
+
match self.result.recv_timeout(timeout) {
|
|
571
|
+
Ok(Ok(closed)) => {
|
|
572
|
+
if let Some((sidecars, next_path)) = self.audio_sidecars {
|
|
573
|
+
let boundary = closed.boundary_seconds.ok_or_else(|| {
|
|
574
|
+
sidecars.cancel_fence();
|
|
575
|
+
"AVAssetWriter did not report the video fragment boundary".to_string()
|
|
576
|
+
})?;
|
|
577
|
+
sidecars.complete_fence(&next_path, boundary)?;
|
|
578
|
+
}
|
|
579
|
+
Ok(closed)
|
|
580
|
+
}
|
|
581
|
+
Ok(Err(error)) => {
|
|
582
|
+
if let Some((sidecars, _)) = self.audio_sidecars {
|
|
583
|
+
sidecars.cancel_fence();
|
|
584
|
+
}
|
|
585
|
+
Err(error)
|
|
586
|
+
}
|
|
587
|
+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
|
588
|
+
if let Some((sidecars, _)) = self.audio_sidecars {
|
|
589
|
+
sidecars.cancel_fence();
|
|
590
|
+
}
|
|
591
|
+
Err("fragment fence timed out".into())
|
|
592
|
+
}
|
|
593
|
+
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
|
594
|
+
if let Some((sidecars, _)) = self.audio_sidecars {
|
|
595
|
+
sidecars.cancel_fence();
|
|
596
|
+
}
|
|
597
|
+
Err("fragment fence was cancelled before completion".into())
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
struct PendingFence {
|
|
604
|
+
next_path: PathBuf,
|
|
605
|
+
completion: std::sync::mpsc::Sender<Result<ClosedSegmentFile, String>>,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
struct SegmentSinkState {
|
|
609
|
+
file: std::fs::File,
|
|
610
|
+
path: PathBuf,
|
|
611
|
+
sequence: u64,
|
|
612
|
+
bytes_written: u64,
|
|
613
|
+
media_fragments: u64,
|
|
614
|
+
init_segment: Option<Vec<u8>>,
|
|
615
|
+
pending_fences: std::collections::VecDeque<PendingFence>,
|
|
616
|
+
failed: Option<String>,
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#[derive(Clone, Copy, Debug, Default)]
|
|
620
|
+
struct SegmentProgress {
|
|
621
|
+
bytes_written: u64,
|
|
622
|
+
media_fragments: u64,
|
|
623
|
+
has_initialization: bool,
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/// Delegate callbacks and fence requests share one mutex, making every media
|
|
627
|
+
/// fragment an indivisible routing unit. No fragment can cross two files.
|
|
136
628
|
pub(super) struct SegmentSink {
|
|
137
|
-
|
|
138
|
-
/// First write error, if any; surfaced by `finish()` so a failing disk
|
|
139
|
-
/// turns into a reported error instead of a silently truncated file.
|
|
140
|
-
failed: Mutex<Option<String>>,
|
|
141
|
-
segments: AtomicU64,
|
|
629
|
+
state: Mutex<SegmentSinkState>,
|
|
142
630
|
}
|
|
143
631
|
|
|
144
632
|
impl SegmentSink {
|
|
@@ -146,37 +634,182 @@ impl SegmentSink {
|
|
|
146
634
|
let file = std::fs::File::create(path)
|
|
147
635
|
.map_err(|e| format!("could not create recording file {}: {e}", path.display()))?;
|
|
148
636
|
Ok(Arc::new(Self {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
637
|
+
state: Mutex::new(SegmentSinkState {
|
|
638
|
+
file,
|
|
639
|
+
path: path.to_path_buf(),
|
|
640
|
+
sequence: 0,
|
|
641
|
+
bytes_written: 0,
|
|
642
|
+
media_fragments: 0,
|
|
643
|
+
init_segment: None,
|
|
644
|
+
pending_fences: std::collections::VecDeque::new(),
|
|
645
|
+
failed: None,
|
|
646
|
+
}),
|
|
152
647
|
}))
|
|
153
648
|
}
|
|
154
649
|
|
|
155
|
-
fn
|
|
650
|
+
fn fail(state: &mut SegmentSinkState, error: String) {
|
|
651
|
+
if state.failed.is_none() {
|
|
652
|
+
state.failed = Some(error.clone());
|
|
653
|
+
}
|
|
654
|
+
while let Some(fence) = state.pending_fences.pop_front() {
|
|
655
|
+
let _ = fence.completion.send(Err(error.clone()));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
fn write_current(state: &mut SegmentSinkState, bytes: &[u8]) -> Result<(), String> {
|
|
156
660
|
use std::io::Write;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
661
|
+
state
|
|
662
|
+
.file
|
|
663
|
+
.write_all(bytes)
|
|
664
|
+
.map_err(|err| format!("segment write failed: {err}"))?;
|
|
665
|
+
state.bytes_written = state.bytes_written.saturating_add(bytes.len() as u64);
|
|
666
|
+
Ok(())
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
fn failure(&self) -> Option<String> {
|
|
670
|
+
self.state
|
|
671
|
+
.lock()
|
|
672
|
+
.ok()
|
|
673
|
+
.and_then(|guard| guard.failed.clone())
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
fn progress(&self) -> SegmentProgress {
|
|
677
|
+
self.state
|
|
678
|
+
.lock()
|
|
679
|
+
.map(|state| SegmentProgress {
|
|
680
|
+
bytes_written: state.bytes_written,
|
|
681
|
+
media_fragments: state.media_fragments,
|
|
682
|
+
has_initialization: state.init_segment.is_some(),
|
|
683
|
+
})
|
|
684
|
+
.unwrap_or_default()
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
fn cancel_pending(&self, error: &str) {
|
|
688
|
+
if let Ok(mut state) = self.state.lock() {
|
|
689
|
+
while let Some(fence) = state.pending_fences.pop_front() {
|
|
690
|
+
let _ = fence.completion.send(Err(error.to_owned()));
|
|
166
691
|
}
|
|
167
|
-
return;
|
|
168
692
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
pub(crate) fn fence(&self, next_path: PathBuf) -> Result<SegmentFence, String> {
|
|
696
|
+
let mut state = self.state.lock().map_err(|e| e.to_string())?;
|
|
697
|
+
if let Some(error) = state.failed.clone() {
|
|
698
|
+
return Err(error);
|
|
175
699
|
}
|
|
700
|
+
if next_path == state.path
|
|
701
|
+
|| state
|
|
702
|
+
.pending_fences
|
|
703
|
+
.iter()
|
|
704
|
+
.any(|f| f.next_path == next_path)
|
|
705
|
+
{
|
|
706
|
+
return Err("fragment fence path is already active or pending".into());
|
|
707
|
+
}
|
|
708
|
+
let (completion, result) = std::sync::mpsc::channel();
|
|
709
|
+
state.pending_fences.push_back(PendingFence {
|
|
710
|
+
next_path,
|
|
711
|
+
completion,
|
|
712
|
+
});
|
|
713
|
+
Ok(SegmentFence {
|
|
714
|
+
result,
|
|
715
|
+
audio_sidecars: None,
|
|
716
|
+
})
|
|
176
717
|
}
|
|
177
718
|
|
|
178
|
-
fn
|
|
179
|
-
self.
|
|
719
|
+
fn append(&self, bytes: &[u8], segment_type: isize, boundary_seconds: Option<f64>) {
|
|
720
|
+
let Ok(mut state) = self.state.lock() else {
|
|
721
|
+
return;
|
|
722
|
+
};
|
|
723
|
+
if state.failed.is_some() {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
match segment_type {
|
|
727
|
+
AV_ASSET_WRITER_SEGMENT_TYPE_INITIALIZATION => {
|
|
728
|
+
state.init_segment = Some(bytes.to_vec());
|
|
729
|
+
crate::logfile::diagnostic(&format!(
|
|
730
|
+
"[capture-health] writer initialization segment: {} bytes",
|
|
731
|
+
bytes.len()
|
|
732
|
+
));
|
|
733
|
+
if state.media_fragments == 0 && state.bytes_written == 0 {
|
|
734
|
+
if let Err(error) = Self::write_current(&mut state, bytes) {
|
|
735
|
+
Self::fail(&mut state, error);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE => {
|
|
740
|
+
let Some(init) = state.init_segment.clone() else {
|
|
741
|
+
Self::fail(
|
|
742
|
+
&mut state,
|
|
743
|
+
"media fragment arrived before initialization segment".into(),
|
|
744
|
+
);
|
|
745
|
+
return;
|
|
746
|
+
};
|
|
747
|
+
// Do not close an init-only file. Once the current logical
|
|
748
|
+
// file has media, service exactly one request at each later
|
|
749
|
+
// boundary; repeated fences then make consecutive non-empty
|
|
750
|
+
// files without splitting a fragment.
|
|
751
|
+
if state.media_fragments > 0 && !state.pending_fences.is_empty() {
|
|
752
|
+
let fence = state.pending_fences.pop_front().expect("checked above");
|
|
753
|
+
use std::io::Write;
|
|
754
|
+
let closed = ClosedSegmentFile {
|
|
755
|
+
path: state.path.clone(),
|
|
756
|
+
sequence: state.sequence,
|
|
757
|
+
media_fragments: state.media_fragments,
|
|
758
|
+
bytes_written: state.bytes_written,
|
|
759
|
+
boundary_seconds,
|
|
760
|
+
};
|
|
761
|
+
let rotate = (|| -> Result<(), String> {
|
|
762
|
+
state
|
|
763
|
+
.file
|
|
764
|
+
.flush()
|
|
765
|
+
.map_err(|e| format!("segment flush failed: {e}"))?;
|
|
766
|
+
state
|
|
767
|
+
.file
|
|
768
|
+
.sync_all()
|
|
769
|
+
.map_err(|e| format!("segment sync failed: {e}"))?;
|
|
770
|
+
let mut next = std::fs::File::create(&fence.next_path).map_err(|e| {
|
|
771
|
+
format!(
|
|
772
|
+
"could not create fenced recording file {}: {e}",
|
|
773
|
+
fence.next_path.display()
|
|
774
|
+
)
|
|
775
|
+
})?;
|
|
776
|
+
next.write_all(&init)
|
|
777
|
+
.map_err(|e| format!("segment init write failed: {e}"))?;
|
|
778
|
+
state.file = next;
|
|
779
|
+
state.path = fence.next_path.clone();
|
|
780
|
+
state.sequence = state.sequence.saturating_add(1);
|
|
781
|
+
state.bytes_written = init.len() as u64;
|
|
782
|
+
state.media_fragments = 0;
|
|
783
|
+
Self::write_current(&mut state, bytes)?;
|
|
784
|
+
state.media_fragments = 1;
|
|
785
|
+
Ok(())
|
|
786
|
+
})();
|
|
787
|
+
match rotate {
|
|
788
|
+
Ok(()) => {
|
|
789
|
+
let _ = fence.completion.send(Ok(closed));
|
|
790
|
+
}
|
|
791
|
+
Err(error) => {
|
|
792
|
+
let _ = fence.completion.send(Err(error.clone()));
|
|
793
|
+
Self::fail(&mut state, error);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} else if let Err(error) = Self::write_current(&mut state, bytes) {
|
|
797
|
+
Self::fail(&mut state, error);
|
|
798
|
+
} else {
|
|
799
|
+
state.media_fragments = state.media_fragments.saturating_add(1);
|
|
800
|
+
if state.media_fragments == 1 {
|
|
801
|
+
crate::logfile::diagnostic(&format!(
|
|
802
|
+
"[capture-health] first writer media fragment: {} bytes",
|
|
803
|
+
bytes.len()
|
|
804
|
+
));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
_ => Self::fail(
|
|
809
|
+
&mut state,
|
|
810
|
+
format!("unsupported AVAssetWriter segment type {segment_type}"),
|
|
811
|
+
),
|
|
812
|
+
}
|
|
180
813
|
}
|
|
181
814
|
}
|
|
182
815
|
|
|
@@ -196,12 +829,13 @@ objc2::define_class!(
|
|
|
196
829
|
impl SegmentWriterDelegate {
|
|
197
830
|
/// `AVAssetWriterDelegate` — receives each fMP4 segment (type 1 =
|
|
198
831
|
/// initialization, 2 = separable media) as it is produced.
|
|
199
|
-
#[unsafe(method(assetWriter:didOutputSegmentData:segmentType:))]
|
|
832
|
+
#[unsafe(method(assetWriter:didOutputSegmentData:segmentType:segmentReport:))]
|
|
200
833
|
fn did_output_segment(
|
|
201
834
|
&self,
|
|
202
835
|
_writer: *mut objc2::runtime::AnyObject,
|
|
203
836
|
data: *mut objc2::runtime::AnyObject,
|
|
204
|
-
|
|
837
|
+
segment_type: isize,
|
|
838
|
+
segment_report: *mut objc2::runtime::AnyObject,
|
|
205
839
|
) {
|
|
206
840
|
// Crossing the ObjC boundary: a Rust panic here would abort the
|
|
207
841
|
// whole process, so contain it.
|
|
@@ -219,8 +853,24 @@ objc2::define_class!(
|
|
|
219
853
|
return;
|
|
220
854
|
}
|
|
221
855
|
let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len) };
|
|
856
|
+
let boundary_seconds = if segment_report.is_null() {
|
|
857
|
+
None
|
|
858
|
+
} else {
|
|
859
|
+
let reports: *mut objc2::runtime::AnyObject = unsafe {
|
|
860
|
+
objc2::msg_send![&*segment_report, trackReports]
|
|
861
|
+
};
|
|
862
|
+
let count: usize = if reports.is_null() { 0 } else { unsafe { objc2::msg_send![&*reports, count] } };
|
|
863
|
+
if count == 0 {
|
|
864
|
+
None
|
|
865
|
+
} else {
|
|
866
|
+
let track: *mut objc2::runtime::AnyObject = unsafe { objc2::msg_send![&*reports, objectAtIndex: 0usize] };
|
|
867
|
+
let earliest: ObjcCMTime = unsafe { objc2::msg_send![&*track, earliestPresentationTimeStamp] };
|
|
868
|
+
((earliest.flags & 1) != 0 && earliest.timescale > 0)
|
|
869
|
+
.then_some(earliest.value as f64 / earliest.timescale as f64)
|
|
870
|
+
}
|
|
871
|
+
};
|
|
222
872
|
use objc2::DefinedClass;
|
|
223
|
-
self.ivars().sink.append(bytes);
|
|
873
|
+
self.ivars().sink.append(bytes, segment_type, boundary_seconds);
|
|
224
874
|
}));
|
|
225
875
|
}
|
|
226
876
|
}
|
|
@@ -265,6 +915,15 @@ pub(crate) struct CustomScreenCaptureWriter {
|
|
|
265
915
|
/// append-only file. Zero until the first resume. Read by the video
|
|
266
916
|
/// retime path and the live audio mixer.
|
|
267
917
|
pause_offset_bits: Arc<AtomicU64>,
|
|
918
|
+
/// Present for Rewind's one physical ScreenCaptureKit producer. Clones
|
|
919
|
+
/// share one registration across watchdog rebuilds; `finish` deactivates
|
|
920
|
+
/// it before capture teardown so new consumers cannot attach to a dying
|
|
921
|
+
/// stream.
|
|
922
|
+
audio_producer: Option<crate::capture_audio_bus::AudioProducer>,
|
|
923
|
+
/// Rewind persists independently selectable microphone and system PCM
|
|
924
|
+
/// beside the video-only fMP4 because AVAssetWriter's segmented profiles
|
|
925
|
+
/// reject a writer graph containing two AAC inputs.
|
|
926
|
+
audio_sidecars: Option<Arc<AudioSidecarManager>>,
|
|
268
927
|
}
|
|
269
928
|
|
|
270
929
|
/// The lock-guarded half of the writer: the retained AVFoundation objects
|
|
@@ -320,11 +979,260 @@ unsafe impl Send for CustomScreenCaptureWriterState {}
|
|
|
320
979
|
/// keeps receiving samples over the whole recording.
|
|
321
980
|
struct CustomScreenCaptureOutputHandler {
|
|
322
981
|
writer: CustomScreenCaptureWriter,
|
|
982
|
+
/// At most one temporary Clips writer may mirror this physical producer.
|
|
983
|
+
/// It deliberately lives beside the callback handler rather than the
|
|
984
|
+
/// audio bus: the bus has one producer contract and must never publish a
|
|
985
|
+
/// second copy just because a Clip starts.
|
|
986
|
+
clip_sink: Arc<Mutex<Option<ClipSinkSlot>>>,
|
|
323
987
|
recording_enabled: Arc<AtomicBool>,
|
|
324
988
|
mic_ready: Option<Arc<AtomicBool>>,
|
|
325
989
|
watch: Arc<CaptureWatch>,
|
|
326
990
|
}
|
|
327
991
|
|
|
992
|
+
pub(crate) struct ClipSinkSlot {
|
|
993
|
+
writer: CustomScreenCaptureWriter,
|
|
994
|
+
gate: Arc<ClipSinkGate>,
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
fn install_only_slot<T>(slot: &mut Option<T>, value: T) -> Result<(), String> {
|
|
998
|
+
if slot.is_some() {
|
|
999
|
+
return Err("a Clip sink is already attached to this capture producer".into());
|
|
1000
|
+
}
|
|
1001
|
+
*slot = Some(value);
|
|
1002
|
+
Ok(())
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
1006
|
+
#[repr(u8)]
|
|
1007
|
+
enum ClipSinkState {
|
|
1008
|
+
Prepared = 0,
|
|
1009
|
+
Active = 1,
|
|
1010
|
+
Paused = 2,
|
|
1011
|
+
Closed = 3,
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
struct ClipSinkGate {
|
|
1015
|
+
state: AtomicU64,
|
|
1016
|
+
activated_at: Mutex<Option<Instant>>,
|
|
1017
|
+
paused_at: Mutex<Option<Instant>>,
|
|
1018
|
+
paused_total: Mutex<Duration>,
|
|
1019
|
+
logical_end: Mutex<Option<Instant>>,
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
impl ClipSinkGate {
|
|
1023
|
+
fn new() -> Self {
|
|
1024
|
+
Self {
|
|
1025
|
+
state: AtomicU64::new(ClipSinkState::Prepared as u64),
|
|
1026
|
+
activated_at: Mutex::new(None),
|
|
1027
|
+
paused_at: Mutex::new(None),
|
|
1028
|
+
paused_total: Mutex::new(Duration::ZERO),
|
|
1029
|
+
logical_end: Mutex::new(None),
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
fn state(&self) -> ClipSinkState {
|
|
1034
|
+
match self.state.load(Ordering::SeqCst) {
|
|
1035
|
+
1 => ClipSinkState::Active,
|
|
1036
|
+
2 => ClipSinkState::Paused,
|
|
1037
|
+
3 => ClipSinkState::Closed,
|
|
1038
|
+
_ => ClipSinkState::Prepared,
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
fn accepts(&self) -> bool {
|
|
1043
|
+
self.state() == ClipSinkState::Active
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
fn media_duration_ms(&self) -> u64 {
|
|
1047
|
+
let started = self.activated_at.lock().ok().and_then(|value| *value);
|
|
1048
|
+
let Some(started) = started else {
|
|
1049
|
+
return 0;
|
|
1050
|
+
};
|
|
1051
|
+
let paused_total = self
|
|
1052
|
+
.paused_total
|
|
1053
|
+
.lock()
|
|
1054
|
+
.map(|value| *value)
|
|
1055
|
+
.unwrap_or_default();
|
|
1056
|
+
let current_pause = self
|
|
1057
|
+
.paused_at
|
|
1058
|
+
.lock()
|
|
1059
|
+
.ok()
|
|
1060
|
+
.and_then(|value| *value)
|
|
1061
|
+
.map(|value| value.elapsed())
|
|
1062
|
+
.unwrap_or_default();
|
|
1063
|
+
started
|
|
1064
|
+
.elapsed()
|
|
1065
|
+
.saturating_sub(paused_total)
|
|
1066
|
+
.saturating_sub(current_pause)
|
|
1067
|
+
.as_millis() as u64
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/// Handle for the one temporary Clip writer attached to an existing custom
|
|
1072
|
+
/// ScreenCaptureKit producer. Preparation allocates the writer but does not
|
|
1073
|
+
/// admit callbacks; activation is a single in-memory state transition.
|
|
1074
|
+
pub(crate) struct PreparedClipSink {
|
|
1075
|
+
slot: Arc<Mutex<Option<ClipSinkSlot>>>,
|
|
1076
|
+
writer: CustomScreenCaptureWriter,
|
|
1077
|
+
gate: Arc<ClipSinkGate>,
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
impl PreparedClipSink {
|
|
1081
|
+
pub(crate) fn activate(&self) -> Result<(), String> {
|
|
1082
|
+
let slot = self.slot.lock().map_err(|error| error.to_string())?;
|
|
1083
|
+
let installed = slot
|
|
1084
|
+
.as_ref()
|
|
1085
|
+
.is_some_and(|installed| Arc::ptr_eq(&installed.gate, &self.gate));
|
|
1086
|
+
if !installed || self.gate.state() != ClipSinkState::Prepared {
|
|
1087
|
+
return Err("Clip sink is not prepared".to_string());
|
|
1088
|
+
}
|
|
1089
|
+
*self
|
|
1090
|
+
.gate
|
|
1091
|
+
.activated_at
|
|
1092
|
+
.lock()
|
|
1093
|
+
.map_err(|error| error.to_string())? = Some(Instant::now());
|
|
1094
|
+
// Publish Active last, while holding the same slot lock callbacks use.
|
|
1095
|
+
self.gate
|
|
1096
|
+
.state
|
|
1097
|
+
.store(ClipSinkState::Active as u64, Ordering::SeqCst);
|
|
1098
|
+
Ok(())
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
pub(crate) fn pause(&self) -> Result<(), String> {
|
|
1102
|
+
let _slot = self.slot.lock().map_err(|error| error.to_string())?;
|
|
1103
|
+
if self.gate.state() != ClipSinkState::Active {
|
|
1104
|
+
return Err("Clip sink is not active".into());
|
|
1105
|
+
}
|
|
1106
|
+
*self
|
|
1107
|
+
.gate
|
|
1108
|
+
.paused_at
|
|
1109
|
+
.lock()
|
|
1110
|
+
.map_err(|error| error.to_string())? = Some(Instant::now());
|
|
1111
|
+
// Close callback admission only after the pause boundary is durable.
|
|
1112
|
+
self.gate
|
|
1113
|
+
.state
|
|
1114
|
+
.store(ClipSinkState::Paused as u64, Ordering::SeqCst);
|
|
1115
|
+
Ok(())
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
pub(crate) fn resume(&self) -> Result<(), String> {
|
|
1119
|
+
let _slot = self.slot.lock().map_err(|error| error.to_string())?;
|
|
1120
|
+
if self.gate.state() != ClipSinkState::Paused {
|
|
1121
|
+
return Err("Clip sink is no longer paused".into());
|
|
1122
|
+
}
|
|
1123
|
+
let paused_at = self
|
|
1124
|
+
.gate
|
|
1125
|
+
.paused_at
|
|
1126
|
+
.lock()
|
|
1127
|
+
.map_err(|e| e.to_string())?
|
|
1128
|
+
.take()
|
|
1129
|
+
.ok_or_else(|| "Clip sink is not paused".to_string())?;
|
|
1130
|
+
let paused_for = paused_at.elapsed();
|
|
1131
|
+
let mut paused_total = self
|
|
1132
|
+
.gate
|
|
1133
|
+
.paused_total
|
|
1134
|
+
.lock()
|
|
1135
|
+
.map_err(|error| error.to_string())?;
|
|
1136
|
+
*paused_total = paused_total.saturating_add(paused_for);
|
|
1137
|
+
self.writer
|
|
1138
|
+
.set_pause_offset(self.writer.pause_offset() + paused_for.as_secs_f64());
|
|
1139
|
+
// Re-open callback admission only after every timestamp offset is in
|
|
1140
|
+
// place. The slot lock prevents an in-flight append from observing a
|
|
1141
|
+
// half-resumed writer.
|
|
1142
|
+
self.gate
|
|
1143
|
+
.state
|
|
1144
|
+
.store(ClipSinkState::Active as u64, Ordering::SeqCst);
|
|
1145
|
+
Ok(())
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/// Logical close is synchronous and precedes the potentially slow writer
|
|
1149
|
+
/// finalization. This makes Stop's accepted-sample boundary exact.
|
|
1150
|
+
pub(crate) fn deactivate(&self) {
|
|
1151
|
+
// The callback holds this same lock across its accepted append. Taking
|
|
1152
|
+
// it here makes the return from deactivate the exact no-more-samples
|
|
1153
|
+
// boundary: a callback either completed before logical end or sees
|
|
1154
|
+
// Closed after it. No disk/network/finalize work occurs under it.
|
|
1155
|
+
if let Ok(_slot) = self.slot.lock() {
|
|
1156
|
+
self.gate
|
|
1157
|
+
.state
|
|
1158
|
+
.store(ClipSinkState::Closed as u64, Ordering::SeqCst);
|
|
1159
|
+
}
|
|
1160
|
+
if let Ok(mut end) = self.gate.logical_end.lock() {
|
|
1161
|
+
*end = Some(Instant::now());
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
pub(crate) fn duration_ms(&self) -> u64 {
|
|
1166
|
+
self.gate.media_duration_ms()
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
pub(crate) fn finalize(&self) -> Result<(), String> {
|
|
1170
|
+
self.deactivate();
|
|
1171
|
+
let result = self.writer.finish(true);
|
|
1172
|
+
self.remove();
|
|
1173
|
+
result
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
pub(crate) fn cancel(&self) {
|
|
1177
|
+
self.deactivate();
|
|
1178
|
+
let _ = self.writer.finish(false);
|
|
1179
|
+
self.remove();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
fn remove(&self) {
|
|
1183
|
+
if let Ok(mut slot) = self.slot.lock() {
|
|
1184
|
+
let same_sink = slot
|
|
1185
|
+
.as_ref()
|
|
1186
|
+
.is_some_and(|installed| Arc::ptr_eq(&installed.gate, &self.gate));
|
|
1187
|
+
if same_sink {
|
|
1188
|
+
*slot = None;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/// Allocate an Apple-HLS, append-only Clip writer and install it as the only
|
|
1195
|
+
/// secondary sink on a running physical producer. This does not start any
|
|
1196
|
+
/// ScreenCaptureKit or audio input and is deliberately independent of the
|
|
1197
|
+
/// ordinary remote-live-upload feature flag.
|
|
1198
|
+
pub(crate) fn prepare_clip_sink(
|
|
1199
|
+
slot: Arc<Mutex<Option<ClipSinkSlot>>>,
|
|
1200
|
+
output_path: &Path,
|
|
1201
|
+
width: u32,
|
|
1202
|
+
height: u32,
|
|
1203
|
+
include_mic: bool,
|
|
1204
|
+
include_system_audio: bool,
|
|
1205
|
+
) -> Result<PreparedClipSink, String> {
|
|
1206
|
+
let mut installed = slot.lock().map_err(|e| e.to_string())?;
|
|
1207
|
+
if installed.is_some() {
|
|
1208
|
+
return Err("a Clip sink is already attached to this capture producer".into());
|
|
1209
|
+
}
|
|
1210
|
+
let writer = CustomScreenCaptureWriter::new(
|
|
1211
|
+
output_path,
|
|
1212
|
+
width,
|
|
1213
|
+
height,
|
|
1214
|
+
include_system_audio,
|
|
1215
|
+
include_mic,
|
|
1216
|
+
live_audio_mixing_enabled(
|
|
1217
|
+
CustomWriterOutput::ClipHls,
|
|
1218
|
+
include_mic,
|
|
1219
|
+
include_system_audio,
|
|
1220
|
+
),
|
|
1221
|
+
CustomWriterOutput::ClipHls,
|
|
1222
|
+
None,
|
|
1223
|
+
)?;
|
|
1224
|
+
let gate = Arc::new(ClipSinkGate::new());
|
|
1225
|
+
install_only_slot(
|
|
1226
|
+
&mut installed,
|
|
1227
|
+
ClipSinkSlot {
|
|
1228
|
+
writer: writer.clone(),
|
|
1229
|
+
gate: Arc::clone(&gate),
|
|
1230
|
+
},
|
|
1231
|
+
)?;
|
|
1232
|
+
drop(installed);
|
|
1233
|
+
Ok(PreparedClipSink { slot, writer, gate })
|
|
1234
|
+
}
|
|
1235
|
+
|
|
328
1236
|
impl Clone for CustomScreenCaptureWriter {
|
|
329
1237
|
fn clone(&self) -> Self {
|
|
330
1238
|
Self {
|
|
@@ -335,6 +1243,8 @@ impl Clone for CustomScreenCaptureWriter {
|
|
|
335
1243
|
appends_closed: Arc::clone(&self.appends_closed),
|
|
336
1244
|
dropped_samples: Arc::clone(&self.dropped_samples),
|
|
337
1245
|
pause_offset_bits: Arc::clone(&self.pause_offset_bits),
|
|
1246
|
+
audio_producer: self.audio_producer.clone(),
|
|
1247
|
+
audio_sidecars: self.audio_sidecars.clone(),
|
|
338
1248
|
}
|
|
339
1249
|
}
|
|
340
1250
|
}
|
|
@@ -350,6 +1260,22 @@ impl SCStreamOutputTrait for CustomScreenCaptureOutputHandler {
|
|
|
350
1260
|
mic_ready.store(true, Ordering::Relaxed);
|
|
351
1261
|
}
|
|
352
1262
|
}
|
|
1263
|
+
// Rewind may already own both physical audio inputs before a meeting
|
|
1264
|
+
// begins. Publish decoded PCM even while recording output is deferred;
|
|
1265
|
+
// the bus is about source ownership, not writer attachment.
|
|
1266
|
+
if matches!(
|
|
1267
|
+
of_type,
|
|
1268
|
+
SCStreamOutputType::Audio | SCStreamOutputType::Microphone
|
|
1269
|
+
) {
|
|
1270
|
+
let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
1271
|
+
let _ = objc2::exception::catch(std::panic::AssertUnwindSafe(|| {
|
|
1272
|
+
self.writer.publish_audio_sample(&sample_buffer, of_type);
|
|
1273
|
+
}));
|
|
1274
|
+
}));
|
|
1275
|
+
if panic_result.is_err() {
|
|
1276
|
+
eprintln!("[mixer] panic while publishing shared {of_type:?} PCM");
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
353
1279
|
if !self.recording_enabled.load(Ordering::SeqCst) {
|
|
354
1280
|
return;
|
|
355
1281
|
}
|
|
@@ -357,6 +1283,10 @@ impl SCStreamOutputTrait for CustomScreenCaptureOutputHandler {
|
|
|
357
1283
|
// is still alive; record it so the watchdog can tell a genuinely stalled
|
|
358
1284
|
// stream apart from a quiet one.
|
|
359
1285
|
self.watch.note_activity();
|
|
1286
|
+
self.watch.note_sample(
|
|
1287
|
+
of_type,
|
|
1288
|
+
matches!(of_type, SCStreamOutputType::Screen) && sample_buffer.image_buffer().is_some(),
|
|
1289
|
+
);
|
|
360
1290
|
if self.writer.appends_closed.load(Ordering::SeqCst) {
|
|
361
1291
|
return;
|
|
362
1292
|
}
|
|
@@ -391,6 +1321,16 @@ impl SCStreamOutputTrait for CustomScreenCaptureOutputHandler {
|
|
|
391
1321
|
);
|
|
392
1322
|
}
|
|
393
1323
|
}
|
|
1324
|
+
// Mirror into the temporary Clip writer only after its atomic
|
|
1325
|
+
// activation. Do not call `publish_audio_sample` here: this is a
|
|
1326
|
+
// second consumer of existing callbacks, not another audio producer.
|
|
1327
|
+
if let Ok(slot) = self.clip_sink.lock() {
|
|
1328
|
+
if let Some(clip) = slot.as_ref().filter(|clip| clip.gate.accepts()) {
|
|
1329
|
+
let _ = objc2::exception::catch(std::panic::AssertUnwindSafe(|| {
|
|
1330
|
+
clip.writer.append_sample(&sample_buffer, of_type);
|
|
1331
|
+
}));
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
394
1334
|
// ScreenCaptureKit invokes this from its own dispatch queues through an
|
|
395
1335
|
// Objective-C boundary. Two failure modes can abort the whole process:
|
|
396
1336
|
// - an Objective-C exception (e.g. AVFoundation), which `catch_unwind`
|
|
@@ -430,6 +1370,8 @@ impl CustomScreenCaptureWriter {
|
|
|
430
1370
|
capture_system_audio: bool,
|
|
431
1371
|
include_audio: bool,
|
|
432
1372
|
mix_live: bool,
|
|
1373
|
+
output: CustomWriterOutput,
|
|
1374
|
+
audio_producer: Option<crate::capture_audio_bus::AudioProducer>,
|
|
433
1375
|
) -> Result<Self, String> {
|
|
434
1376
|
use objc2::msg_send;
|
|
435
1377
|
use objc2::runtime::AnyObject;
|
|
@@ -439,13 +1381,19 @@ impl CustomScreenCaptureWriter {
|
|
|
439
1381
|
static AVFileTypeMPEG4: *const AnyObject;
|
|
440
1382
|
static AVMediaTypeVideo: *const AnyObject;
|
|
441
1383
|
static AVFileTypeProfileMPEG4AppleHLS: *const AnyObject;
|
|
1384
|
+
static AVFileTypeProfileMPEG4CMAFCompliant: *const AnyObject;
|
|
442
1385
|
}
|
|
443
1386
|
// Force-load UniformTypeIdentifiers so the runtime UTType lookup in
|
|
444
1387
|
// segmented mode resolves.
|
|
445
1388
|
#[link(name = "UniformTypeIdentifiers", kind = "framework")]
|
|
446
1389
|
extern "C" {}
|
|
447
1390
|
|
|
448
|
-
|
|
1391
|
+
// Rewind's local rolling buffer always needs append-only fMP4 output,
|
|
1392
|
+
// independent of whether the remote live uploader is enabled.
|
|
1393
|
+
let segmented = segmented_output_enabled(
|
|
1394
|
+
output,
|
|
1395
|
+
crate::remote_flags::current().custom_sck_pipeline_live_upload_enabled,
|
|
1396
|
+
);
|
|
449
1397
|
unsafe {
|
|
450
1398
|
let writer_cls = av_class_named("AVAssetWriter")
|
|
451
1399
|
.ok_or_else(|| "AVAssetWriter missing".to_string())?;
|
|
@@ -482,8 +1430,21 @@ impl CustomScreenCaptureWriter {
|
|
|
482
1430
|
epoch: 0,
|
|
483
1431
|
};
|
|
484
1432
|
let _: () = msg_send![&*writer, setPreferredOutputSegmentInterval: interval];
|
|
485
|
-
|
|
486
|
-
|
|
1433
|
+
// Rewind's video-only rolling artifact uses CMAF, with its
|
|
1434
|
+
// independent audio persisted in adjacent PCM sidecars below.
|
|
1435
|
+
// Ordinary uploaded Clips must retain Apple HLS: those files
|
|
1436
|
+
// contain a live-mixed AAC track, and applying Rewind's CMAF
|
|
1437
|
+
// profile to that writer graph makes AVAssetWriter reject
|
|
1438
|
+
// startWriting before the first byte reaches disk.
|
|
1439
|
+
let output_profile = if output.preserves_separate_audio() {
|
|
1440
|
+
AVFileTypeProfileMPEG4CMAFCompliant
|
|
1441
|
+
} else {
|
|
1442
|
+
AVFileTypeProfileMPEG4AppleHLS
|
|
1443
|
+
};
|
|
1444
|
+
let _: () = msg_send![
|
|
1445
|
+
&*writer,
|
|
1446
|
+
setOutputFileTypeProfile: output_profile
|
|
1447
|
+
];
|
|
487
1448
|
|
|
488
1449
|
let sink = SegmentSink::create(output_path)?;
|
|
489
1450
|
let delegate = SegmentWriterDelegate::new(Arc::clone(&sink));
|
|
@@ -537,9 +1498,17 @@ impl CustomScreenCaptureWriter {
|
|
|
537
1498
|
}
|
|
538
1499
|
let _: () = msg_send![&*writer, addInput: &*video_input];
|
|
539
1500
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1501
|
+
let sidecar_sources = crate::capture_audio_bus::AudioSources::new(
|
|
1502
|
+
include_audio && output.preserves_separate_audio(),
|
|
1503
|
+
capture_system_audio && output.preserves_separate_audio(),
|
|
1504
|
+
);
|
|
1505
|
+
let audio_sidecars = (sidecar_sources.microphone || sidecar_sources.system)
|
|
1506
|
+
.then(|| AudioSidecarManager::create(output_path, sidecar_sources))
|
|
1507
|
+
.transpose()?;
|
|
1508
|
+
|
|
1509
|
+
// AVAssetWriter's fragmented profiles reject two independent AAC
|
|
1510
|
+
// inputs. Rewind therefore writes video-only fMP4 plus local PCM
|
|
1511
|
+
// sidecars; ordinary recordings retain their existing inputs.
|
|
543
1512
|
let (system_audio_input, mic_audio_input, mixed_audio_input, mixer) = if mix_live {
|
|
544
1513
|
let mixed = av_make_audio_writer_input(input_cls, &writer)?;
|
|
545
1514
|
(
|
|
@@ -549,12 +1518,13 @@ impl CustomScreenCaptureWriter {
|
|
|
549
1518
|
Some(LiveAudioMixer::new(segmented)?),
|
|
550
1519
|
)
|
|
551
1520
|
} else {
|
|
552
|
-
let system_audio_input =
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1521
|
+
let system_audio_input =
|
|
1522
|
+
if capture_system_audio && !output.preserves_separate_audio() {
|
|
1523
|
+
Some(av_make_audio_writer_input(input_cls, &writer)?)
|
|
1524
|
+
} else {
|
|
1525
|
+
None
|
|
1526
|
+
};
|
|
1527
|
+
let mic_audio_input = if include_audio && !output.preserves_separate_audio() {
|
|
558
1528
|
Some(av_make_audio_writer_input(input_cls, &writer)?)
|
|
559
1529
|
} else {
|
|
560
1530
|
None
|
|
@@ -582,10 +1552,59 @@ impl CustomScreenCaptureWriter {
|
|
|
582
1552
|
appends_closed: Arc::new(AtomicBool::new(false)),
|
|
583
1553
|
dropped_samples: Arc::new(AtomicU64::new(0)),
|
|
584
1554
|
pause_offset_bits: Arc::new(AtomicU64::new(0.0_f64.to_bits())),
|
|
1555
|
+
audio_producer,
|
|
1556
|
+
audio_sidecars,
|
|
585
1557
|
})
|
|
586
1558
|
}
|
|
587
1559
|
}
|
|
588
1560
|
|
|
1561
|
+
fn publish_audio_sample(
|
|
1562
|
+
&self,
|
|
1563
|
+
sample: &screencapturekit::cm::CMSampleBuffer,
|
|
1564
|
+
of_type: SCStreamOutputType,
|
|
1565
|
+
) {
|
|
1566
|
+
if self.audio_producer.is_none() && self.audio_sidecars.is_none() {
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
let label = match of_type {
|
|
1570
|
+
SCStreamOutputType::Audio => "shared-system",
|
|
1571
|
+
SCStreamOutputType::Microphone => "shared-mic",
|
|
1572
|
+
_ => return,
|
|
1573
|
+
};
|
|
1574
|
+
let Some((interleaved, pts_seconds)) = extract_interleaved_stereo(sample, label) else {
|
|
1575
|
+
return;
|
|
1576
|
+
};
|
|
1577
|
+
let mono: Vec<f32> = interleaved
|
|
1578
|
+
.chunks_exact(2)
|
|
1579
|
+
.map(|channels| (channels[0] + channels[1]) * 0.5)
|
|
1580
|
+
.collect();
|
|
1581
|
+
if let Some(sidecars) = self.audio_sidecars.as_ref() {
|
|
1582
|
+
let session_start_seconds =
|
|
1583
|
+
f64::from_bits(self.session_start_bits.load(Ordering::SeqCst));
|
|
1584
|
+
if !session_start_seconds.is_nan() {
|
|
1585
|
+
sidecars.append(
|
|
1586
|
+
matches!(of_type, SCStreamOutputType::Microphone),
|
|
1587
|
+
&mono,
|
|
1588
|
+
AUDIO_OUTPUT_SAMPLE_RATE as f64,
|
|
1589
|
+
pts_seconds,
|
|
1590
|
+
session_start_seconds,
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
let Some(producer) = self.audio_producer.as_ref() else {
|
|
1595
|
+
return;
|
|
1596
|
+
};
|
|
1597
|
+
match of_type {
|
|
1598
|
+
SCStreamOutputType::Audio => {
|
|
1599
|
+
producer.publish_system(&mono, AUDIO_OUTPUT_SAMPLE_RATE as f64)
|
|
1600
|
+
}
|
|
1601
|
+
SCStreamOutputType::Microphone => {
|
|
1602
|
+
producer.publish_microphone(&mono, AUDIO_OUTPUT_SAMPLE_RATE as f64)
|
|
1603
|
+
}
|
|
1604
|
+
_ => {}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
589
1608
|
/// Whether the writer runs the segmented (zero-based, append-only) output
|
|
590
1609
|
/// used by live upload. Only that mode rebases sample timestamps, so it's
|
|
591
1610
|
/// the only mode where a fresh SCStream can be spliced onto the same file.
|
|
@@ -593,11 +1612,56 @@ impl CustomScreenCaptureWriter {
|
|
|
593
1612
|
self.inner.lock().map(|g| g.segmented).unwrap_or(false)
|
|
594
1613
|
}
|
|
595
1614
|
|
|
1615
|
+
pub(crate) fn request_fragment_fence(
|
|
1616
|
+
&self,
|
|
1617
|
+
next_path: PathBuf,
|
|
1618
|
+
) -> Result<SegmentFence, String> {
|
|
1619
|
+
let guard = self.inner.lock().map_err(|e| e.to_string())?;
|
|
1620
|
+
if guard.finished || guard.failed.is_some() {
|
|
1621
|
+
return Err("fragment fences require an active custom capture writer".into());
|
|
1622
|
+
}
|
|
1623
|
+
let sink = guard
|
|
1624
|
+
.segment_sink
|
|
1625
|
+
.clone()
|
|
1626
|
+
.ok_or_else(|| "fragment fences require segmented custom capture".to_string())?;
|
|
1627
|
+
if let Some(sidecars) = self.audio_sidecars.as_ref() {
|
|
1628
|
+
sidecars.begin_fence()?;
|
|
1629
|
+
}
|
|
1630
|
+
let mut fence = match sink.fence(next_path.clone()) {
|
|
1631
|
+
Ok(fence) => fence,
|
|
1632
|
+
Err(error) => {
|
|
1633
|
+
if let Some(sidecars) = self.audio_sidecars.as_ref() {
|
|
1634
|
+
sidecars.cancel_fence();
|
|
1635
|
+
}
|
|
1636
|
+
return Err(error);
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
if let Some(sidecars) = self.audio_sidecars.as_ref() {
|
|
1640
|
+
fence.audio_sidecars = Some((Arc::clone(sidecars), next_path));
|
|
1641
|
+
}
|
|
1642
|
+
Ok(fence)
|
|
1643
|
+
}
|
|
1644
|
+
|
|
596
1645
|
/// Whether the writer session has begun (first video frame appended).
|
|
597
1646
|
pub(super) fn is_started(&self) -> bool {
|
|
598
1647
|
self.started.load(Ordering::SeqCst)
|
|
599
1648
|
}
|
|
600
1649
|
|
|
1650
|
+
fn segment_progress(&self) -> SegmentProgress {
|
|
1651
|
+
self.inner
|
|
1652
|
+
.lock()
|
|
1653
|
+
.ok()
|
|
1654
|
+
.and_then(|guard| guard.segment_sink.as_ref().map(|sink| sink.progress()))
|
|
1655
|
+
.unwrap_or_default()
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
fn failure(&self) -> Option<String> {
|
|
1659
|
+
self.inner
|
|
1660
|
+
.lock()
|
|
1661
|
+
.ok()
|
|
1662
|
+
.and_then(|guard| guard.failed.clone())
|
|
1663
|
+
}
|
|
1664
|
+
|
|
601
1665
|
/// Accumulated pause offset in seconds. Every appended sample skips this
|
|
602
1666
|
/// much wall-clock time so a pause/resume leaves no gap in the file.
|
|
603
1667
|
pub(super) fn pause_offset(&self) -> f64 {
|
|
@@ -821,21 +1885,30 @@ impl CustomScreenCaptureWriter {
|
|
|
821
1885
|
.store(secs.to_bits(), Ordering::SeqCst);
|
|
822
1886
|
}
|
|
823
1887
|
self.started.store(true, Ordering::SeqCst);
|
|
1888
|
+
crate::logfile::diagnostic("[capture-health] AVAssetWriter session started");
|
|
824
1889
|
true
|
|
825
1890
|
}
|
|
826
1891
|
Ok(false) => {
|
|
827
1892
|
self.appends_closed.store(true, Ordering::SeqCst);
|
|
828
|
-
|
|
1893
|
+
let error = format!(
|
|
829
1894
|
"AVAssetWriter startWriting failed{}",
|
|
830
1895
|
av_writer_error_suffix(&guard.writer)
|
|
1896
|
+
);
|
|
1897
|
+
crate::logfile::diagnostic(&format!(
|
|
1898
|
+
"[capture-health] {error} (segmented={segmented})"
|
|
831
1899
|
));
|
|
1900
|
+
guard.failed = Some(error);
|
|
832
1901
|
false
|
|
833
1902
|
}
|
|
834
1903
|
Err(exc) => {
|
|
835
1904
|
self.appends_closed.store(true, Ordering::SeqCst);
|
|
836
1905
|
let detail = describe_objc_exception(exc);
|
|
837
1906
|
eprintln!("[mixer] startWriting raised Objective-C exception: {detail}");
|
|
838
|
-
|
|
1907
|
+
let error = format!("AVAssetWriter startWriting raised: {detail}");
|
|
1908
|
+
crate::logfile::diagnostic(&format!(
|
|
1909
|
+
"[capture-health] {error} (segmented={segmented})"
|
|
1910
|
+
));
|
|
1911
|
+
guard.failed = Some(error);
|
|
839
1912
|
false
|
|
840
1913
|
}
|
|
841
1914
|
}
|
|
@@ -895,6 +1968,15 @@ impl CustomScreenCaptureWriter {
|
|
|
895
1968
|
/// writer status; without it, finalization completes in the background
|
|
896
1969
|
/// (the fragmented file is already playable up to the last fragment).
|
|
897
1970
|
pub(super) fn finish(&self, wait_for_finalize: bool) -> Result<(), String> {
|
|
1971
|
+
// Close shared subscriptions before stopping SCK. A meeting start that
|
|
1972
|
+
// races teardown will now see no producer and may safely use the legacy
|
|
1973
|
+
// physical path, instead of attaching to a stream that is going away.
|
|
1974
|
+
if let Some(producer) = self.audio_producer.as_ref() {
|
|
1975
|
+
producer.deactivate();
|
|
1976
|
+
}
|
|
1977
|
+
if let Some(sidecars) = self.audio_sidecars.as_ref() {
|
|
1978
|
+
sidecars.finish()?;
|
|
1979
|
+
}
|
|
898
1980
|
self.appends_closed.store(true, Ordering::SeqCst);
|
|
899
1981
|
let dropped = self.dropped_samples.load(Ordering::Relaxed);
|
|
900
1982
|
eprintln!(
|
|
@@ -961,6 +2043,9 @@ impl CustomScreenCaptureWriter {
|
|
|
961
2043
|
)
|
|
962
2044
|
};
|
|
963
2045
|
if let Some(err) = failed {
|
|
2046
|
+
if let Some(sink) = segment_sink.as_ref() {
|
|
2047
|
+
sink.cancel_pending("fragment fence cancelled because writer finalization failed");
|
|
2048
|
+
}
|
|
964
2049
|
return Err(err);
|
|
965
2050
|
}
|
|
966
2051
|
unsafe {
|
|
@@ -971,6 +2056,11 @@ impl CustomScreenCaptureWriter {
|
|
|
971
2056
|
|
|
972
2057
|
if !started {
|
|
973
2058
|
let _: () = msg_send![&*writer, cancelWriting];
|
|
2059
|
+
if let Some(sink) = segment_sink.as_ref() {
|
|
2060
|
+
sink.cancel_pending(
|
|
2061
|
+
"fragment fence cancelled because writer received no samples",
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
974
2064
|
return Err("AVAssetWriter received no samples".into());
|
|
975
2065
|
}
|
|
976
2066
|
let _: () = msg_send![&*video_input, markAsFinished];
|
|
@@ -991,11 +2081,21 @@ impl CustomScreenCaptureWriter {
|
|
|
991
2081
|
let _: () = msg_send![&*writer, finishWritingWithCompletionHandler: &*block];
|
|
992
2082
|
if wait_for_finalize && rx.recv_timeout(StdDuration::from_secs(15)).is_err() {
|
|
993
2083
|
let _: () = msg_send![&*writer, cancelWriting];
|
|
2084
|
+
if let Some(sink) = segment_sink.as_ref() {
|
|
2085
|
+
sink.cancel_pending(
|
|
2086
|
+
"fragment fence cancelled because writer finalization timed out",
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
994
2089
|
return Err("AVAssetWriter finalize timed out".into());
|
|
995
2090
|
}
|
|
996
2091
|
if wait_for_finalize {
|
|
997
2092
|
let status: i64 = msg_send![&*writer, status];
|
|
998
2093
|
if status != AV_WRITER_STATUS_COMPLETED {
|
|
2094
|
+
if let Some(sink) = segment_sink.as_ref() {
|
|
2095
|
+
sink.cancel_pending(
|
|
2096
|
+
"fragment fence cancelled because writer finalization failed",
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
999
2099
|
return Err(format!(
|
|
1000
2100
|
"AVAssetWriter finalize failed (status={status}{})",
|
|
1001
2101
|
av_writer_error_suffix(&writer)
|
|
@@ -1008,8 +2108,12 @@ impl CustomScreenCaptureWriter {
|
|
|
1008
2108
|
// unless a disk write failed along the way.
|
|
1009
2109
|
if let Some(sink) = segment_sink.as_ref() {
|
|
1010
2110
|
if let Some(err) = sink.failure() {
|
|
2111
|
+
sink.cancel_pending("fragment fence cancelled because segment output failed");
|
|
1011
2112
|
return Err(err);
|
|
1012
2113
|
}
|
|
2114
|
+
sink.cancel_pending(
|
|
2115
|
+
"fragment fence reached writer finalization before a media boundary",
|
|
2116
|
+
);
|
|
1013
2117
|
}
|
|
1014
2118
|
eprintln!("[mixer] writer finish completed (wait={wait_for_finalize})");
|
|
1015
2119
|
Ok(())
|
|
@@ -1615,9 +2719,52 @@ fn extract_interleaved_stereo(
|
|
|
1615
2719
|
// Normalize every source to the mixer's output rate so contiguous placement
|
|
1616
2720
|
// on the timeline matches real time (otherwise the mic is pitch-shifted).
|
|
1617
2721
|
out = resample_interleaved_stereo(&out, src_rate, AUDIO_OUTPUT_SAMPLE_RATE as f64);
|
|
2722
|
+
log_decoded_audio_signal_once(label, &out);
|
|
1618
2723
|
Some((out, pts_seconds))
|
|
1619
2724
|
}
|
|
1620
2725
|
|
|
2726
|
+
/// Record whether decoded source PCM contains a real signal before it reaches
|
|
2727
|
+
/// the timeline mixer. This distinguishes capture/format failures from mixer
|
|
2728
|
+
/// or writer failures without persisting any audio content.
|
|
2729
|
+
fn log_decoded_audio_signal_once(label: &str, samples: &[f32]) {
|
|
2730
|
+
use std::sync::atomic::AtomicBool;
|
|
2731
|
+
static SYS_SIGNAL_LOGGED: AtomicBool = AtomicBool::new(false);
|
|
2732
|
+
static MIC_SIGNAL_LOGGED: AtomicBool = AtomicBool::new(false);
|
|
2733
|
+
let peak = samples
|
|
2734
|
+
.iter()
|
|
2735
|
+
.copied()
|
|
2736
|
+
.map(f32::abs)
|
|
2737
|
+
.fold(0.0_f32, f32::max);
|
|
2738
|
+
// Initial SCK audio callbacks are normally zero-filled while the device
|
|
2739
|
+
// warms. Wait for the first real signal so this diagnostic proves source
|
|
2740
|
+
// health instead of permanently recording that uninteresting pre-roll.
|
|
2741
|
+
if peak <= 0.000_01 {
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
let flag = if label.contains("mic") {
|
|
2745
|
+
&MIC_SIGNAL_LOGGED
|
|
2746
|
+
} else {
|
|
2747
|
+
&SYS_SIGNAL_LOGGED
|
|
2748
|
+
};
|
|
2749
|
+
if flag.swap(true, Ordering::SeqCst) {
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
let rms = if samples.is_empty() {
|
|
2753
|
+
0.0
|
|
2754
|
+
} else {
|
|
2755
|
+
(samples
|
|
2756
|
+
.iter()
|
|
2757
|
+
.map(|sample| (*sample as f64) * (*sample as f64))
|
|
2758
|
+
.sum::<f64>()
|
|
2759
|
+
/ samples.len() as f64)
|
|
2760
|
+
.sqrt()
|
|
2761
|
+
};
|
|
2762
|
+
crate::logfile::diagnostic(&format!(
|
|
2763
|
+
"[capture-health] decoded {label} PCM: samples={} peak={peak:.6} rms={rms:.6}",
|
|
2764
|
+
samples.len()
|
|
2765
|
+
));
|
|
2766
|
+
}
|
|
2767
|
+
|
|
1621
2768
|
/// Logs the decoded audio format the first time each source is seen, so format
|
|
1622
2769
|
/// mismatches (rate / channels / int-vs-float / interleaving) are diagnosable.
|
|
1623
2770
|
fn log_audio_format_once(
|
|
@@ -2297,6 +3444,10 @@ fn build_custom_scstream(
|
|
|
2297
3444
|
config.set_source_rect(rect);
|
|
2298
3445
|
}
|
|
2299
3446
|
if let Some(device) = selected_mic.as_ref() {
|
|
3447
|
+
crate::logfile::diagnostic(&format!(
|
|
3448
|
+
"[capture-health] microphone selected: {} ({})",
|
|
3449
|
+
device.name, device.id
|
|
3450
|
+
));
|
|
2300
3451
|
config.set_microphone_capture_device_id(&device.id);
|
|
2301
3452
|
}
|
|
2302
3453
|
config.set_stream_name(Some("Clips custom full-screen recording"));
|
|
@@ -2315,6 +3466,75 @@ fn build_custom_scstream(
|
|
|
2315
3466
|
Ok(stream)
|
|
2316
3467
|
}
|
|
2317
3468
|
|
|
3469
|
+
fn await_segmented_capture_readiness(
|
|
3470
|
+
stream: &SCStream,
|
|
3471
|
+
writer: &CustomScreenCaptureWriter,
|
|
3472
|
+
watch: &CaptureWatch,
|
|
3473
|
+
requested_system_audio: bool,
|
|
3474
|
+
requested_microphone: bool,
|
|
3475
|
+
) -> Result<(), String> {
|
|
3476
|
+
let sample_deadline = Instant::now() + CAPTURE_FIRST_SAMPLE_TIMEOUT;
|
|
3477
|
+
while Instant::now() < sample_deadline {
|
|
3478
|
+
if let Some(error) = watch.take_stream_stopped() {
|
|
3479
|
+
return Err(error);
|
|
3480
|
+
}
|
|
3481
|
+
if let Some(error) = writer.failure() {
|
|
3482
|
+
return Err(error);
|
|
3483
|
+
}
|
|
3484
|
+
let (_, usable_screen, _, _) = watch.sample_counts();
|
|
3485
|
+
if usable_screen > 0 && writer.is_started() {
|
|
3486
|
+
break;
|
|
3487
|
+
}
|
|
3488
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
let (screen, usable_screen, system, mic) = watch.sample_counts();
|
|
3492
|
+
if usable_screen == 0 || !writer.is_started() {
|
|
3493
|
+
let _ = stream.stop_capture();
|
|
3494
|
+
let finish_error = writer.finish(true).err();
|
|
3495
|
+
return Err(format!(
|
|
3496
|
+
"capture startup timed out before the first usable video sample/writer session (screen={screen}, usable_screen={usable_screen}, system_audio={system}, microphone={mic}){}",
|
|
3497
|
+
finish_error
|
|
3498
|
+
.map(|error| format!("; writer: {error}"))
|
|
3499
|
+
.unwrap_or_default()
|
|
3500
|
+
));
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
let fragment_deadline = Instant::now() + CAPTURE_FIRST_FRAGMENT_TIMEOUT;
|
|
3504
|
+
while Instant::now() < fragment_deadline {
|
|
3505
|
+
if let Some(error) = watch.take_stream_stopped() {
|
|
3506
|
+
return Err(error);
|
|
3507
|
+
}
|
|
3508
|
+
if let Some(error) = writer.failure() {
|
|
3509
|
+
return Err(error);
|
|
3510
|
+
}
|
|
3511
|
+
let progress = writer.segment_progress();
|
|
3512
|
+
if progress.has_initialization && progress.media_fragments > 0 && progress.bytes_written > 0
|
|
3513
|
+
{
|
|
3514
|
+
crate::logfile::diagnostic(&format!(
|
|
3515
|
+
"[capture-health] segmented capture ready: {} bytes, {} media fragment(s)",
|
|
3516
|
+
progress.bytes_written, progress.media_fragments
|
|
3517
|
+
));
|
|
3518
|
+
return Ok(());
|
|
3519
|
+
}
|
|
3520
|
+
std::thread::sleep(Duration::from_millis(25));
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
let (screen, usable_screen, system, mic) = watch.sample_counts();
|
|
3524
|
+
let progress = writer.segment_progress();
|
|
3525
|
+
let _ = stream.stop_capture();
|
|
3526
|
+
let finish_error = writer.finish(true).err();
|
|
3527
|
+
Err(format!(
|
|
3528
|
+
"capture startup timed out waiting for the first fragmented-media bytes (screen={screen}, usable_screen={usable_screen}, system_audio={system}, microphone={mic}, requested_system_audio={requested_system_audio}, requested_microphone={requested_microphone}, initialization={}, media_fragments={}, bytes={}){}",
|
|
3529
|
+
progress.has_initialization,
|
|
3530
|
+
progress.media_fragments,
|
|
3531
|
+
progress.bytes_written,
|
|
3532
|
+
finish_error
|
|
3533
|
+
.map(|error| format!("; writer: {error}"))
|
|
3534
|
+
.unwrap_or_default()
|
|
3535
|
+
))
|
|
3536
|
+
}
|
|
3537
|
+
|
|
2318
3538
|
/// Supervise a running custom capture and rebuild the SCStream when it stops or
|
|
2319
3539
|
/// goes silent (the Spaces/full-screen interruption). Runs on its own thread;
|
|
2320
3540
|
/// exits when recording is torn down (`shutdown`) or the writer is closed.
|
|
@@ -2355,6 +3575,12 @@ fn spawn_capture_watchdog(
|
|
|
2355
3575
|
eprintln!(
|
|
2356
3576
|
"[mixer] appends closed without a stop request; treating as fatal capture failure and finalizing partial recording"
|
|
2357
3577
|
);
|
|
3578
|
+
let writer_error = writer
|
|
3579
|
+
.failure()
|
|
3580
|
+
.unwrap_or_else(|| "unknown writer failure".to_string());
|
|
3581
|
+
crate::logfile::diagnostic(&format!(
|
|
3582
|
+
"[capture-health] writer closed unexpectedly; finalizing partial recording: {writer_error}"
|
|
3583
|
+
));
|
|
2358
3584
|
if let Ok(guard) = stream.lock() {
|
|
2359
3585
|
let _ = guard.stop_capture();
|
|
2360
3586
|
}
|
|
@@ -2483,7 +3709,7 @@ fn spawn_capture_watchdog(
|
|
|
2483
3709
|
/// build + start the SCStream from rebuildable params, and spawn the
|
|
2484
3710
|
/// capture watchdog that supervises it. Returns the backend handle plus
|
|
2485
3711
|
/// the output dimensions.
|
|
2486
|
-
pub(
|
|
3712
|
+
pub(crate) fn start_custom_screencapturekit_backend_at(
|
|
2487
3713
|
app: &AppHandle,
|
|
2488
3714
|
output_path: &Path,
|
|
2489
3715
|
include_audio: bool,
|
|
@@ -2493,6 +3719,7 @@ pub(super) fn start_custom_screencapturekit_backend_at(
|
|
|
2493
3719
|
target_display_id: Option<u32>,
|
|
2494
3720
|
capture_region: Option<NativeCaptureRegion>,
|
|
2495
3721
|
defer_recording_output: bool,
|
|
3722
|
+
force_segmented_output: bool,
|
|
2496
3723
|
) -> Result<(NativeFullscreenBackend, Option<u32>, Option<u32>), String> {
|
|
2497
3724
|
eprintln!("[clips-tray] starting custom screen capture backend");
|
|
2498
3725
|
let content =
|
|
@@ -2526,9 +3753,26 @@ pub(super) fn start_custom_screencapturekit_backend_at(
|
|
|
2526
3753
|
height,
|
|
2527
3754
|
};
|
|
2528
3755
|
|
|
2529
|
-
//
|
|
2530
|
-
//
|
|
2531
|
-
|
|
3756
|
+
// Rewind keeps microphone and system audio on separate writer tracks for
|
|
3757
|
+
// later local transcription. The ordinary recorder retains its existing
|
|
3758
|
+
// live-mix behavior (needed for its remote upload path).
|
|
3759
|
+
let output = if force_segmented_output {
|
|
3760
|
+
CustomWriterOutput::RewindCmaf
|
|
3761
|
+
} else {
|
|
3762
|
+
CustomWriterOutput::Standard
|
|
3763
|
+
};
|
|
3764
|
+
let mix_live = live_audio_mixing_enabled(output, include_audio, capture_system_audio);
|
|
3765
|
+
// The active custom capture is the single physical owner of mic/system
|
|
3766
|
+
// audio for both Rewind and ordinary Clips. Live transcription subscribes
|
|
3767
|
+
// to this producer instead of opening a competing SCK/AVAudioEngine input,
|
|
3768
|
+
// which can make every microphone consumer receive digital silence.
|
|
3769
|
+
let audio_producer = (include_audio || capture_system_audio)
|
|
3770
|
+
.then(|| {
|
|
3771
|
+
crate::capture_audio_bus::AudioProducer::register(
|
|
3772
|
+
crate::capture_audio_bus::AudioSources::new(include_audio, capture_system_audio),
|
|
3773
|
+
)
|
|
3774
|
+
})
|
|
3775
|
+
.transpose()?;
|
|
2532
3776
|
let writer = CustomScreenCaptureWriter::new(
|
|
2533
3777
|
output_path,
|
|
2534
3778
|
width,
|
|
@@ -2536,12 +3780,16 @@ pub(super) fn start_custom_screencapturekit_backend_at(
|
|
|
2536
3780
|
capture_system_audio,
|
|
2537
3781
|
include_audio,
|
|
2538
3782
|
mix_live,
|
|
3783
|
+
output,
|
|
3784
|
+
audio_producer,
|
|
2539
3785
|
)?;
|
|
2540
3786
|
let recording_enabled = Arc::new(AtomicBool::new(!defer_recording_output));
|
|
3787
|
+
let clip_sink = Arc::new(Mutex::new(None));
|
|
2541
3788
|
let mic_ready = include_audio.then(|| Arc::new(AtomicBool::new(false)));
|
|
2542
3789
|
let watch = Arc::new(CaptureWatch::new());
|
|
2543
3790
|
let handler = CustomScreenCaptureOutputHandler {
|
|
2544
3791
|
writer: writer.clone(),
|
|
3792
|
+
clip_sink: Arc::clone(&clip_sink),
|
|
2545
3793
|
recording_enabled: Arc::clone(&recording_enabled),
|
|
2546
3794
|
mic_ready: mic_ready.clone(),
|
|
2547
3795
|
watch: Arc::clone(&watch),
|
|
@@ -2552,6 +3800,26 @@ pub(super) fn start_custom_screencapturekit_backend_at(
|
|
|
2552
3800
|
let _ = std::fs::remove_file(output_path);
|
|
2553
3801
|
return Err(format!("custom capture start failed: {err:?}"));
|
|
2554
3802
|
}
|
|
3803
|
+
crate::logfile::diagnostic(&format!(
|
|
3804
|
+
"[capture-health] ScreenCaptureKit start_capture returned success: system_audio={capture_system_audio} microphone={include_audio} segmented={force_segmented_output} deferred={defer_recording_output}"
|
|
3805
|
+
));
|
|
3806
|
+
if force_segmented_output && !defer_recording_output {
|
|
3807
|
+
if let Err(error) = await_segmented_capture_readiness(
|
|
3808
|
+
&stream,
|
|
3809
|
+
&writer,
|
|
3810
|
+
&watch,
|
|
3811
|
+
capture_system_audio,
|
|
3812
|
+
include_audio,
|
|
3813
|
+
) {
|
|
3814
|
+
let _ = std::fs::remove_file(output_path);
|
|
3815
|
+
let _ = std::fs::remove_file(audio_sidecar_path(output_path, "system"));
|
|
3816
|
+
let _ = std::fs::remove_file(audio_sidecar_path(output_path, "microphone"));
|
|
3817
|
+
crate::logfile::diagnostic(&format!(
|
|
3818
|
+
"[capture-health] segmented capture readiness failed: {error}"
|
|
3819
|
+
));
|
|
3820
|
+
return Err(error);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
2555
3823
|
eprintln!(
|
|
2556
3824
|
"[clips-tray] custom ScreenCaptureKit recording started: {width}x{height} @ {NATIVE_CAPTURE_FPS}fps from {capture_width}x{capture_height} (display {source_width}x{source_height}), mic={include_audio} system_audio={capture_system_audio} deferred_output={defer_recording_output}"
|
|
2557
3825
|
);
|
|
@@ -2586,8 +3854,194 @@ pub(super) fn start_custom_screencapturekit_backend_at(
|
|
|
2586
3854
|
recording_enabled,
|
|
2587
3855
|
watchdog_shutdown,
|
|
2588
3856
|
resume,
|
|
3857
|
+
clip_sink,
|
|
2589
3858
|
},
|
|
2590
3859
|
Some(width),
|
|
2591
3860
|
Some(height),
|
|
2592
3861
|
))
|
|
2593
3862
|
}
|
|
3863
|
+
|
|
3864
|
+
#[cfg(test)]
|
|
3865
|
+
mod fragment_fence_tests {
|
|
3866
|
+
use super::*;
|
|
3867
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
3868
|
+
|
|
3869
|
+
fn path(label: &str) -> PathBuf {
|
|
3870
|
+
static NEXT: AtomicU64 = AtomicU64::new(0);
|
|
3871
|
+
std::env::temp_dir().join(format!(
|
|
3872
|
+
"clips-fragment-fence-{label}-{}-{}.mp4",
|
|
3873
|
+
std::process::id(),
|
|
3874
|
+
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
3875
|
+
))
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
fn remove(paths: &[&Path]) {
|
|
3879
|
+
for path in paths {
|
|
3880
|
+
let _ = std::fs::remove_file(path);
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
#[test]
|
|
3885
|
+
fn caches_init_and_replays_it_for_each_fenced_file() {
|
|
3886
|
+
let first = path("init-first");
|
|
3887
|
+
let second = path("init-second");
|
|
3888
|
+
let sink = SegmentSink::create(&first).unwrap();
|
|
3889
|
+
sink.append(b"INIT", AV_ASSET_WRITER_SEGMENT_TYPE_INITIALIZATION, None);
|
|
3890
|
+
sink.append(b"M0", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(0.0));
|
|
3891
|
+
let fence = sink.fence(second.clone()).unwrap();
|
|
3892
|
+
sink.append(b"M1", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(1.0));
|
|
3893
|
+
let closed = fence.wait(Duration::from_secs(1)).unwrap();
|
|
3894
|
+
assert_eq!(closed.path, first);
|
|
3895
|
+
sink.append(b"M2", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(2.0));
|
|
3896
|
+
assert_eq!(std::fs::read(&first).unwrap(), b"INITM0");
|
|
3897
|
+
assert_eq!(std::fs::read(&second).unwrap(), b"INITM1M2");
|
|
3898
|
+
remove(&[&first, &second]);
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3901
|
+
#[test]
|
|
3902
|
+
fn routes_each_media_fragment_exactly_once() {
|
|
3903
|
+
let first = path("once-first");
|
|
3904
|
+
let second = path("once-second");
|
|
3905
|
+
let sink = SegmentSink::create(&first).unwrap();
|
|
3906
|
+
sink.append(b"I", AV_ASSET_WRITER_SEGMENT_TYPE_INITIALIZATION, None);
|
|
3907
|
+
sink.append(b"A", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(0.0));
|
|
3908
|
+
let fence = sink.fence(second.clone()).unwrap();
|
|
3909
|
+
sink.append(b"B", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(1.0));
|
|
3910
|
+
sink.append(b"C", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(2.0));
|
|
3911
|
+
assert_eq!(
|
|
3912
|
+
fence.wait(Duration::from_secs(1)).unwrap().media_fragments,
|
|
3913
|
+
1
|
|
3914
|
+
);
|
|
3915
|
+
assert_eq!(std::fs::read(&first).unwrap(), b"IA");
|
|
3916
|
+
assert_eq!(std::fs::read(&second).unwrap(), b"IBC");
|
|
3917
|
+
remove(&[&first, &second]);
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
#[test]
|
|
3921
|
+
fn repeated_fences_complete_in_fragment_order() {
|
|
3922
|
+
let first = path("order-first");
|
|
3923
|
+
let second = path("order-second");
|
|
3924
|
+
let third = path("order-third");
|
|
3925
|
+
let sink = SegmentSink::create(&first).unwrap();
|
|
3926
|
+
sink.append(b"I", AV_ASSET_WRITER_SEGMENT_TYPE_INITIALIZATION, None);
|
|
3927
|
+
sink.append(b"Z", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(0.0));
|
|
3928
|
+
let one = sink.fence(second.clone()).unwrap();
|
|
3929
|
+
let two = sink.fence(third.clone()).unwrap();
|
|
3930
|
+
sink.append(b"A", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(1.0));
|
|
3931
|
+
assert_eq!(one.wait(Duration::from_secs(1)).unwrap().sequence, 0);
|
|
3932
|
+
sink.append(b"B", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(2.0));
|
|
3933
|
+
assert_eq!(two.wait(Duration::from_secs(1)).unwrap().sequence, 1);
|
|
3934
|
+
assert_eq!(std::fs::read(&first).unwrap(), b"IZ");
|
|
3935
|
+
assert_eq!(std::fs::read(&second).unwrap(), b"IA");
|
|
3936
|
+
assert_eq!(std::fs::read(&third).unwrap(), b"IB");
|
|
3937
|
+
remove(&[&first, &second, &third]);
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
#[test]
|
|
3941
|
+
fn fence_creation_failure_fails_closed() {
|
|
3942
|
+
let first = path("error-first");
|
|
3943
|
+
let directory = std::env::temp_dir();
|
|
3944
|
+
let sink = SegmentSink::create(&first).unwrap();
|
|
3945
|
+
sink.append(b"I", AV_ASSET_WRITER_SEGMENT_TYPE_INITIALIZATION, None);
|
|
3946
|
+
sink.append(b"Z", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(0.0));
|
|
3947
|
+
let fence = sink.fence(directory).unwrap();
|
|
3948
|
+
sink.append(b"A", AV_ASSET_WRITER_SEGMENT_TYPE_SEPARABLE, Some(1.0));
|
|
3949
|
+
assert!(fence.wait(Duration::from_secs(1)).is_err());
|
|
3950
|
+
assert!(sink.failure().is_some());
|
|
3951
|
+
remove(&[&first]);
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
#[test]
|
|
3955
|
+
fn forced_segmented_rewind_disables_live_mix_for_selectable_sidecars() {
|
|
3956
|
+
assert!(segmented_output_enabled(
|
|
3957
|
+
CustomWriterOutput::RewindCmaf,
|
|
3958
|
+
false
|
|
3959
|
+
));
|
|
3960
|
+
assert!(!live_audio_mixing_enabled(
|
|
3961
|
+
CustomWriterOutput::RewindCmaf,
|
|
3962
|
+
true,
|
|
3963
|
+
true
|
|
3964
|
+
));
|
|
3965
|
+
assert!(live_audio_mixing_enabled(
|
|
3966
|
+
CustomWriterOutput::Standard,
|
|
3967
|
+
true,
|
|
3968
|
+
true
|
|
3969
|
+
));
|
|
3970
|
+
// Clip HLS is forced segmented regardless of the ordinary remote flag,
|
|
3971
|
+
// but keeps a single live-mixed AAC track and no Rewind sidecars.
|
|
3972
|
+
assert!(segmented_output_enabled(CustomWriterOutput::ClipHls, false));
|
|
3973
|
+
assert!(live_audio_mixing_enabled(
|
|
3974
|
+
CustomWriterOutput::ClipHls,
|
|
3975
|
+
true,
|
|
3976
|
+
true
|
|
3977
|
+
));
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
#[test]
|
|
3981
|
+
fn clip_sink_gate_orders_prepare_activate_and_logical_close() {
|
|
3982
|
+
let gate = ClipSinkGate::new();
|
|
3983
|
+
assert!(!gate.accepts(), "prepared sinks ignore callbacks");
|
|
3984
|
+
gate.state
|
|
3985
|
+
.store(ClipSinkState::Active as u64, Ordering::SeqCst);
|
|
3986
|
+
assert!(gate.accepts(), "activated sink accepts callbacks");
|
|
3987
|
+
gate.state
|
|
3988
|
+
.store(ClipSinkState::Closed as u64, Ordering::SeqCst);
|
|
3989
|
+
assert!(!gate.accepts(), "logical close wins before finalization");
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
#[test]
|
|
3993
|
+
fn clip_sink_slot_rejects_second_install() {
|
|
3994
|
+
let mut slot = None;
|
|
3995
|
+
install_only_slot(&mut slot, 1_u8).unwrap();
|
|
3996
|
+
assert!(install_only_slot(&mut slot, 2_u8).is_err());
|
|
3997
|
+
assert_eq!(slot, Some(1));
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
#[test]
|
|
4001
|
+
fn audio_sidecars_preserve_sources_and_rotate_as_valid_float_wav() {
|
|
4002
|
+
let first = path("audio-first");
|
|
4003
|
+
let second = path("audio-second");
|
|
4004
|
+
let sidecars = AudioSidecarManager::create(
|
|
4005
|
+
&first,
|
|
4006
|
+
crate::capture_audio_bus::AudioSources::new(true, true),
|
|
4007
|
+
)
|
|
4008
|
+
.unwrap();
|
|
4009
|
+
sidecars.append(false, &[0.25; 480], 48_000.0, 10.010, 10.0);
|
|
4010
|
+
sidecars.append(true, &[0.5; 480], 48_000.0, 10.020, 10.0);
|
|
4011
|
+
sidecars.begin_fence().unwrap();
|
|
4012
|
+
// These two buffers cross the exact 30 ms video-fragment boundary.
|
|
4013
|
+
sidecars.append(false, &[0.1; 960], 48_000.0, 10.025, 10.0);
|
|
4014
|
+
sidecars.append(true, &[0.2; 960], 48_000.0, 10.025, 10.0);
|
|
4015
|
+
sidecars.complete_fence(&second, 0.030).unwrap();
|
|
4016
|
+
sidecars.append(false, &[0.1; 480], 48_000.0, 10.040, 10.0);
|
|
4017
|
+
sidecars.append(true, &[0.2; 480], 48_000.0, 10.040, 10.0);
|
|
4018
|
+
sidecars.finish().unwrap();
|
|
4019
|
+
|
|
4020
|
+
let first_system = audio_sidecar_path(&first, "system");
|
|
4021
|
+
let first_microphone = audio_sidecar_path(&first, "microphone");
|
|
4022
|
+
let second_system = audio_sidecar_path(&second, "system");
|
|
4023
|
+
let second_microphone = audio_sidecar_path(&second, "microphone");
|
|
4024
|
+
for wav in [
|
|
4025
|
+
&first_system,
|
|
4026
|
+
&first_microphone,
|
|
4027
|
+
&second_system,
|
|
4028
|
+
&second_microphone,
|
|
4029
|
+
] {
|
|
4030
|
+
let bytes = std::fs::read(wav).unwrap();
|
|
4031
|
+
assert_eq!(&bytes[..4], b"RIFF");
|
|
4032
|
+
assert_eq!(&bytes[8..12], b"WAVE");
|
|
4033
|
+
assert!(bytes.len() > 44);
|
|
4034
|
+
}
|
|
4035
|
+
let wav_samples = |path: &Path| (std::fs::metadata(path).unwrap().len() - 44) / 4;
|
|
4036
|
+
assert_eq!(wav_samples(&first_system), 1_440);
|
|
4037
|
+
assert_eq!(wav_samples(&first_microphone), 1_440);
|
|
4038
|
+
assert_eq!(wav_samples(&second_system), 960);
|
|
4039
|
+
assert_eq!(wav_samples(&second_microphone), 960);
|
|
4040
|
+
remove(&[
|
|
4041
|
+
&first_system,
|
|
4042
|
+
&first_microphone,
|
|
4043
|
+
&second_system,
|
|
4044
|
+
&second_microphone,
|
|
4045
|
+
]);
|
|
4046
|
+
}
|
|
4047
|
+
}
|