@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.
Files changed (210) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  11. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  12. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  13. package/corpus/core/src/mcp-client/index.ts +9 -0
  14. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  15. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  16. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  17. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  18. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  19. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  20. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  21. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  22. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  23. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  24. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  25. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  26. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  27. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  28. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  29. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
  30. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  31. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  32. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  33. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  34. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  35. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  47. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  51. package/corpus/templates/clips/desktop/package.json +1 -0
  52. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  53. package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
  54. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  57. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  58. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  60. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  61. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  62. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  63. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  64. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
  99. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  100. package/corpus/templates/content/AGENTS.md +4 -1
  101. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  102. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  103. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  104. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  105. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  106. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  107. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  108. package/corpus/templates/content/actions/delete-document.ts +233 -6
  109. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  110. package/corpus/templates/content/actions/get-document.ts +4 -1
  111. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  112. package/corpus/templates/content/actions/list-documents.ts +1 -0
  113. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  114. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  115. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  116. package/corpus/templates/content/actions/pull-document.ts +15 -2
  117. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  118. package/corpus/templates/content/actions/restore-document.ts +28 -0
  119. package/corpus/templates/content/actions/search-documents.ts +2 -1
  120. package/corpus/templates/content/actions/view-screen.ts +2 -1
  121. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  122. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  123. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  124. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  125. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  126. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  127. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  128. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  129. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  130. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  131. package/corpus/templates/content/app/i18n-data.ts +83 -2
  132. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  133. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  134. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  135. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  136. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  137. package/corpus/templates/content/parity/matrix.md +1 -1
  138. package/corpus/templates/content/parity/matrix.ts +3 -0
  139. package/corpus/templates/content/server/db/schema.ts +2 -0
  140. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  141. package/corpus/templates/content/server/plugins/db.ts +52 -0
  142. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  143. package/corpus/templates/content/shared/api.ts +10 -0
  144. package/corpus/toolkit/CHANGELOG.md +6 -0
  145. package/corpus/toolkit/package.json +1 -1
  146. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  147. package/dist/cli/mcp-config-writers.d.ts +2 -2
  148. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  149. package/dist/cli/mcp-config-writers.js +6 -6
  150. package/dist/cli/mcp-config-writers.js.map +1 -1
  151. package/dist/cli/mcp.d.ts +14 -0
  152. package/dist/cli/mcp.d.ts.map +1 -1
  153. package/dist/cli/mcp.js +79 -0
  154. package/dist/cli/mcp.js.map +1 -1
  155. package/dist/cli/skills-content/help.d.ts +1 -1
  156. package/dist/cli/skills-content/help.d.ts.map +1 -1
  157. package/dist/cli/skills-content/help.js +4 -3
  158. package/dist/cli/skills-content/help.js.map +1 -1
  159. package/dist/cli/skills-content/index.d.ts +1 -0
  160. package/dist/cli/skills-content/index.d.ts.map +1 -1
  161. package/dist/cli/skills-content/index.js +1 -0
  162. package/dist/cli/skills-content/index.js.map +1 -1
  163. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  164. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  165. package/dist/cli/skills-content/rewind-skill.js +53 -0
  166. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  167. package/dist/cli/skills.d.ts +6 -0
  168. package/dist/cli/skills.d.ts.map +1 -1
  169. package/dist/cli/skills.js +90 -14
  170. package/dist/cli/skills.js.map +1 -1
  171. package/dist/client/resources/ResourceTree.d.ts +3 -1
  172. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  173. package/dist/client/resources/ResourceTree.js +4 -4
  174. package/dist/client/resources/ResourceTree.js.map +1 -1
  175. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  176. package/dist/client/resources/ResourcesPanel.js +55 -6
  177. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  178. package/dist/collab/routes.d.ts +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  181. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.js +1014 -91
  183. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  184. package/dist/mcp-client/index.d.ts +1 -1
  185. package/dist/mcp-client/index.d.ts.map +1 -1
  186. package/dist/mcp-client/index.js +1 -1
  187. package/dist/mcp-client/index.js.map +1 -1
  188. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  189. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  190. package/dist/mcp-client/screen-memory-local.js +590 -12
  191. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  192. package/dist/notifications/routes.d.ts +2 -2
  193. package/dist/observability/routes.d.ts +5 -5
  194. package/dist/progress/routes.d.ts +1 -1
  195. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  196. package/dist/resources/handlers.d.ts +1 -1
  197. package/dist/secrets/routes.d.ts +9 -9
  198. package/dist/server/transcribe-voice.d.ts +1 -1
  199. package/package.json +2 -2
  200. package/src/cli/mcp-config-writers.ts +6 -4
  201. package/src/cli/mcp.ts +124 -0
  202. package/src/cli/skills-content/help.ts +4 -3
  203. package/src/cli/skills-content/index.ts +1 -0
  204. package/src/cli/skills-content/rewind-skill.ts +52 -0
  205. package/src/cli/skills.ts +115 -13
  206. package/src/client/resources/ResourceTree.tsx +11 -5
  207. package/src/client/resources/ResourcesPanel.tsx +141 -18
  208. package/src/mcp/screen-memory-stdio.ts +1360 -104
  209. package/src/mcp-client/index.ts +9 -0
  210. 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
- /// Owns the local recording file in segmented mode. The ObjC delegate appends
134
- /// every segment it receives; append order matches delivery order (delegate
135
- /// callbacks are serial, and the file mutex serializes any stragglers).
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
- file: Mutex<std::fs::File>,
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
- file: Mutex::new(file),
150
- failed: Mutex::new(None),
151
- segments: AtomicU64::new(0),
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 append(&self, bytes: &[u8]) {
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
- let Ok(mut file) = self.file.lock() else {
158
- return;
159
- };
160
- if let Err(err) = file.write_all(bytes) {
161
- if let Ok(mut failed) = self.failed.lock() {
162
- if failed.is_none() {
163
- eprintln!("[mixer] segment write failed: {err}");
164
- *failed = Some(format!("segment write failed: {err}"));
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
- let n = self.segments.fetch_add(1, Ordering::Relaxed) + 1;
170
- if n == 1 {
171
- eprintln!(
172
- "[mixer] first output segment written ({} bytes)",
173
- bytes.len()
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 failure(&self) -> Option<String> {
179
- self.failed.lock().ok().and_then(|guard| guard.clone())
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
- _segment_type: isize,
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
- let segmented = crate::remote_flags::current().custom_sck_pipeline_live_upload_enabled;
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
- let _: () =
486
- msg_send![&*writer, setOutputFileTypeProfile: AVFileTypeProfileMPEG4AppleHLS];
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
- // Live-mixing mode writes one combined audio track; otherwise each
541
- // captured source gets its own track (mixed later by ffmpeg, or
542
- // left as the single captured source).
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 = if capture_system_audio {
553
- Some(av_make_audio_writer_input(input_cls, &writer)?)
554
- } else {
555
- None
556
- };
557
- let mic_audio_input = if include_audio {
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
- guard.failed = Some(format!(
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
- guard.failed = Some(format!("AVAssetWriter startWriting raised: {detail}"));
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(super) fn start_custom_screencapturekit_backend_at(
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
- // Live-mix only when both audio sources are present; a single source needs
2530
- // no mixing and goes straight to its own track.
2531
- let mix_live = include_audio && capture_system_audio;
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
+ }