@agent-native/core 0.114.6 → 0.114.8

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 (155) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +15 -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/mcp/screen-memory-stdio.ts +1360 -104
  11. package/corpus/core/src/mcp-client/index.ts +9 -0
  12. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  13. package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
  14. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
  15. package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
  16. package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
  17. package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
  19. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  20. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  21. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  22. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  23. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  24. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  25. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  26. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  27. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  28. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  29. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  30. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  31. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  32. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  33. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  34. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  35. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  36. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  37. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  38. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  50. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  53. package/corpus/templates/clips/desktop/package.json +1 -0
  54. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  55. package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
  56. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  57. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  58. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  59. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  60. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  61. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  62. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  63. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  64. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  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/toolkit/CHANGELOG.md +6 -0
  99. package/corpus/toolkit/package.json +1 -1
  100. package/dist/cli/mcp-config-writers.d.ts +2 -2
  101. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  102. package/dist/cli/mcp-config-writers.js +6 -6
  103. package/dist/cli/mcp-config-writers.js.map +1 -1
  104. package/dist/cli/mcp.d.ts +14 -0
  105. package/dist/cli/mcp.d.ts.map +1 -1
  106. package/dist/cli/mcp.js +79 -0
  107. package/dist/cli/mcp.js.map +1 -1
  108. package/dist/cli/skills-content/help.d.ts +1 -1
  109. package/dist/cli/skills-content/help.d.ts.map +1 -1
  110. package/dist/cli/skills-content/help.js +4 -3
  111. package/dist/cli/skills-content/help.js.map +1 -1
  112. package/dist/cli/skills-content/index.d.ts +1 -0
  113. package/dist/cli/skills-content/index.d.ts.map +1 -1
  114. package/dist/cli/skills-content/index.js +1 -0
  115. package/dist/cli/skills-content/index.js.map +1 -1
  116. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  117. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  118. package/dist/cli/skills-content/rewind-skill.js +53 -0
  119. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  120. package/dist/cli/skills.d.ts +6 -0
  121. package/dist/cli/skills.d.ts.map +1 -1
  122. package/dist/cli/skills.js +90 -14
  123. package/dist/cli/skills.js.map +1 -1
  124. package/dist/collab/struct-routes.d.ts +1 -1
  125. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  126. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  127. package/dist/mcp/screen-memory-stdio.js +1014 -91
  128. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  129. package/dist/mcp-client/index.d.ts +1 -1
  130. package/dist/mcp-client/index.d.ts.map +1 -1
  131. package/dist/mcp-client/index.js +1 -1
  132. package/dist/mcp-client/index.js.map +1 -1
  133. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  134. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  135. package/dist/mcp-client/screen-memory-local.js +590 -12
  136. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  137. package/dist/observability/routes.d.ts +1 -1
  138. package/dist/progress/routes.d.ts +1 -1
  139. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  140. package/dist/resources/handlers.d.ts +1 -1
  141. package/dist/server/agent-chat-plugin.d.ts +5 -0
  142. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  143. package/dist/server/agent-chat-plugin.js +10 -9
  144. package/dist/server/agent-chat-plugin.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/cli/mcp-config-writers.ts +6 -4
  147. package/src/cli/mcp.ts +124 -0
  148. package/src/cli/skills-content/help.ts +4 -3
  149. package/src/cli/skills-content/index.ts +1 -0
  150. package/src/cli/skills-content/rewind-skill.ts +52 -0
  151. package/src/cli/skills.ts +115 -13
  152. package/src/mcp/screen-memory-stdio.ts +1360 -104
  153. package/src/mcp-client/index.ts +9 -0
  154. package/src/mcp-client/screen-memory-local.ts +847 -13
  155. package/src/server/agent-chat-plugin.ts +16 -9
@@ -105,6 +105,20 @@ fn audio_filter_chain(downmix: bool, denoise: bool, mic_pregain: bool) -> String
105
105
  filters.push(AUDIO_LOUDNESS_FILTER);
106
106
  filters.join(",")
107
107
  }
108
+
109
+ #[cfg(all(test, target_os = "macos"))]
110
+ mod fragment_fence_backend_tests {
111
+ use super::*;
112
+
113
+ #[test]
114
+ fn fragment_fence_rejects_unsupported_backend() {
115
+ let child = Command::new("/usr/bin/true").spawn().unwrap();
116
+ let backend = NativeFullscreenBackend::Screencapture { child };
117
+ assert!(backend
118
+ .request_fragment_fence(PathBuf::from("/tmp/next.mp4"))
119
+ .is_err());
120
+ }
121
+ }
108
122
  const NATIVE_CAPTURE_MAX_LONG_EDGE: u32 = 1280;
109
123
  const NATIVE_CAPTURE_FPS: u32 = 24;
110
124
 
@@ -115,14 +129,20 @@ const NATIVE_CAPTURE_FPS: u32 = 24;
115
129
  mod custom_capture;
116
130
  #[cfg(target_os = "macos")]
117
131
  use custom_capture::{
118
- start_custom_screencapturekit_backend_at, CustomCaptureResume, CustomScreenCaptureWriter,
132
+ prepare_clip_sink, start_custom_screencapturekit_backend_at, ClosedSegmentFile,
133
+ CustomCaptureResume, CustomScreenCaptureWriter, PreparedClipSink, SegmentFence,
119
134
  };
120
135
  // Live chunk uploader: tails the fragmented MP4 and streams it during
121
136
  // recording; attached/finalized/abandoned from the session logic here.
122
137
  #[cfg(target_os = "macos")]
123
138
  mod live_upload;
124
139
  #[cfg(target_os = "macos")]
125
- use live_upload::{attach_live_uploader_to_session, LiveUpload};
140
+ pub(crate) use live_upload::ClipLiveUploadConfig;
141
+ #[cfg(target_os = "macos")]
142
+ use live_upload::{
143
+ attach_live_uploader_to_session, cancel_clip_live_upload, finalize_clip_live_upload,
144
+ start_clip_live_uploader, LiveUpload,
145
+ };
126
146
  const AVCONVERT_PATH: &str = "/usr/bin/avconvert";
127
147
  const AVCONVERT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
128
148
  const FFMPEG_TIMEOUT: Duration = Duration::from_secs(8 * 60);
@@ -151,13 +171,13 @@ const DISK_MONITOR_CRITICAL_BYTES: u64 = 250 * 1024 * 1024;
151
171
  const DISK_MONITOR_INTERVAL_SECS: u64 = 30;
152
172
 
153
173
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
154
- enum NativeUploadMode {
174
+ pub(crate) enum NativeUploadMode {
155
175
  Buffered,
156
176
  Streaming,
157
177
  }
158
178
 
159
179
  impl NativeUploadMode {
160
- fn from_option(value: Option<String>) -> Self {
180
+ pub(crate) fn from_option(value: Option<String>) -> Self {
161
181
  match value.as_deref() {
162
182
  Some("streaming") => Self::Streaming,
163
183
  _ => Self::Buffered,
@@ -323,9 +343,307 @@ pub(crate) enum NativeFullscreenBackend {
323
343
  /// resume, keeping one continuous append-only file so live upload is
324
344
  /// never interrupted.
325
345
  resume: CustomCaptureResume,
346
+ /// Single temporary Clip writer slot sharing this producer's sample
347
+ /// callbacks. It is intentionally absent from the audio bus.
348
+ clip_sink: Arc<Mutex<Option<custom_capture::ClipSinkSlot>>>,
326
349
  },
327
350
  }
328
351
 
352
+ /// Metadata and explicit remote-upload authority for a temporary Clip sink.
353
+ /// Passing `upload.server_url: None` creates a local-only artifact.
354
+ #[cfg(target_os = "macos")]
355
+ pub(crate) struct SharedClipSinkRequest {
356
+ pub(crate) path: PathBuf,
357
+ pub(crate) width: u32,
358
+ pub(crate) height: u32,
359
+ pub(crate) include_mic: bool,
360
+ pub(crate) include_system_audio: bool,
361
+ pub(crate) has_camera: bool,
362
+ pub(crate) upload: ClipLiveUploadConfig,
363
+ }
364
+
365
+ #[cfg(target_os = "macos")]
366
+ pub(crate) struct SharedClipSinkResult {
367
+ pub(crate) path: PathBuf,
368
+ pub(crate) bytes: u64,
369
+ pub(crate) verification_pending: bool,
370
+ pub(crate) duration_ms: u64,
371
+ pub(crate) width: u32,
372
+ pub(crate) height: u32,
373
+ }
374
+
375
+ #[cfg(target_os = "macos")]
376
+ impl SharedClipSinkResult {
377
+ pub(crate) fn into_native_upload_result(
378
+ self,
379
+ recording_id: String,
380
+ ) -> NativeFullscreenUploadResult {
381
+ NativeFullscreenUploadResult {
382
+ recording_id,
383
+ duration_ms: self.duration_ms as u128,
384
+ width: Some(self.width),
385
+ height: Some(self.height),
386
+ bytes: self.bytes,
387
+ verification_pending: self.verification_pending,
388
+ }
389
+ }
390
+ }
391
+
392
+ /// Narrow handle exposed to Rewind orchestration. It mirrors the callbacks of
393
+ /// an already-running physical producer; it never owns an SCStream or audio
394
+ /// bus producer of its own.
395
+ #[cfg(target_os = "macos")]
396
+ pub(crate) struct SharedClipSink {
397
+ sink: PreparedClipSink,
398
+ path: PathBuf,
399
+ width: u32,
400
+ height: u32,
401
+ live_upload: Option<LiveUpload>,
402
+ upload_reset: Option<ClipLiveUploadConfig>,
403
+ }
404
+
405
+ #[cfg(target_os = "macos")]
406
+ impl SharedClipSink {
407
+ pub(crate) fn path(&self) -> &Path {
408
+ &self.path
409
+ }
410
+
411
+ pub(crate) fn dimensions(&self) -> (u32, u32) {
412
+ (self.width, self.height)
413
+ }
414
+
415
+ pub(crate) fn duration_ms(&self) -> u64 {
416
+ self.sink.duration_ms()
417
+ }
418
+
419
+ pub(crate) fn activate(&mut self) -> Result<(), String> {
420
+ self.sink.activate()?;
421
+ Ok(())
422
+ }
423
+
424
+ pub(crate) fn pause(&self) -> Result<(), String> {
425
+ self.sink.pause()
426
+ }
427
+
428
+ pub(crate) fn resume(&self) -> Result<(), String> {
429
+ self.sink.resume()
430
+ }
431
+
432
+ /// Close callback admission now; callers may defer `finalize` to a worker
433
+ /// without extending the logical capture interval.
434
+ pub(crate) fn deactivate(&self) {
435
+ self.sink.deactivate();
436
+ }
437
+
438
+ /// Return only bytes the server has acknowledged so far.
439
+ pub(crate) fn uploaded_bytes_now(&self) -> Option<u64> {
440
+ self.live_upload
441
+ .as_ref()
442
+ .map(|live| live.ctrl.uploaded_bytes.load(Ordering::SeqCst))
443
+ }
444
+
445
+ /// Finalize and verify the local artifact before any server finalization.
446
+ /// The caller can persist retry metadata at this durable boundary, so an
447
+ /// app exit or network failure while awaiting the server never strands an
448
+ /// unindexed file.
449
+ pub(crate) fn finalize_writer(&mut self) -> Result<SharedClipSinkResult, String> {
450
+ if let Err(error) = self.sink.finalize() {
451
+ if let Some(live) = self.live_upload.as_ref() {
452
+ cancel_clip_live_upload(live);
453
+ }
454
+ return Err(error);
455
+ }
456
+ let bytes = std::fs::metadata(&self.path)
457
+ .map(|metadata| metadata.len())
458
+ .unwrap_or(0);
459
+ if bytes == 0 {
460
+ if let Some(live) = self.live_upload.as_ref() {
461
+ cancel_clip_live_upload(live);
462
+ }
463
+ return Err("shared Clip writer produced an empty local artifact".into());
464
+ }
465
+ let logical_duration_ms = self.sink.duration_ms() as u128;
466
+ let measured_duration_ms = match probe_local_media_duration_ms(&self.path) {
467
+ Ok(duration_ms) => duration_ms,
468
+ Err(error) => {
469
+ if let Some(live) = self.live_upload.as_ref() {
470
+ cancel_clip_live_upload(live);
471
+ }
472
+ return Err(error);
473
+ }
474
+ };
475
+ if !media_durations_materially_match(logical_duration_ms, measured_duration_ms) {
476
+ if let Some(live) = self.live_upload.as_ref() {
477
+ cancel_clip_live_upload(live);
478
+ }
479
+ return Err(format!(
480
+ "Clip may be incomplete. The local media duration ({measured_duration_ms} ms) did not match the recorded duration ({logical_duration_ms} ms)."
481
+ ));
482
+ }
483
+ Ok(SharedClipSinkResult {
484
+ path: self.path.clone(),
485
+ bytes,
486
+ verification_pending: false,
487
+ duration_ms: measured_duration_ms as u64,
488
+ width: self.width,
489
+ height: self.height,
490
+ })
491
+ }
492
+
493
+ pub(crate) async fn finalize_upload(&mut self, duration_ms: u64) -> Result<bool, String> {
494
+ match self.live_upload.take() {
495
+ Some(live) => {
496
+ let result = finalize_clip_live_upload(live, duration_ms).await?;
497
+ Ok(result.verification_pending)
498
+ }
499
+ None => Ok(false),
500
+ }
501
+ }
502
+
503
+ pub(crate) fn cancel_upload(&mut self) {
504
+ if let Some(live) = self.live_upload.take() {
505
+ cancel_clip_live_upload(&live);
506
+ }
507
+ }
508
+
509
+ pub(crate) fn cancel(mut self) {
510
+ if let Some(live) = self.live_upload.take() {
511
+ cancel_clip_live_upload(&live);
512
+ }
513
+ self.sink.cancel();
514
+ }
515
+
516
+ /// Use when Add previous 30s/5m switches to Rewind's exact local
517
+ /// materializer. A partially streamed live file must never be combined
518
+ /// with pre-roll; cancel it and reset the server sequence before the
519
+ /// materialized whole artifact takes over upload.
520
+ pub(crate) async fn abandon_for_rewind_materialization(mut self) -> Result<(), String> {
521
+ if let Some(live) = self.live_upload.take() {
522
+ cancel_clip_live_upload(&live);
523
+ }
524
+ self.sink.cancel();
525
+ if let Some(config) = self.upload_reset.take() {
526
+ let Some((server_url, recording_id, auth_token, cookie)) = config.validated()? else {
527
+ return Ok(());
528
+ };
529
+ reset_upload_chunks(
530
+ &server_url,
531
+ &recording_id,
532
+ MP4_RECORDING_MIME_TYPE,
533
+ &auth_token,
534
+ &cookie,
535
+ )
536
+ .await?;
537
+ }
538
+ Ok(())
539
+ }
540
+ }
541
+
542
+ #[cfg(target_os = "macos")]
543
+ pub(crate) fn prepare_shared_clip_sink(
544
+ app: &AppHandle,
545
+ backend: &NativeFullscreenBackend,
546
+ request: SharedClipSinkRequest,
547
+ ) -> Result<SharedClipSink, String> {
548
+ let NativeFullscreenBackend::CustomScreenCaptureKit { clip_sink, .. } = backend else {
549
+ return Err("shared Clip sinks require the active custom ScreenCaptureKit producer".into());
550
+ };
551
+ let has_audio = request.include_mic || request.include_system_audio;
552
+ // Validate remote authority before allocating/installing the sink, so a
553
+ // malformed remote request cannot leave a prepared local writer attached.
554
+ let upload_reset = request.upload.clone();
555
+ let live_upload = start_clip_live_uploader(
556
+ app,
557
+ request.path.clone(),
558
+ MP4_RECORDING_MIME_TYPE.to_string(),
559
+ Some(request.width),
560
+ Some(request.height),
561
+ has_audio,
562
+ request.has_camera,
563
+ request.upload,
564
+ )?;
565
+ let sink = match prepare_clip_sink(
566
+ Arc::clone(clip_sink),
567
+ &request.path,
568
+ request.width,
569
+ request.height,
570
+ request.include_mic,
571
+ request.include_system_audio,
572
+ ) {
573
+ Ok(sink) => sink,
574
+ Err(error) => {
575
+ if let Some(live) = live_upload.as_ref() {
576
+ cancel_clip_live_upload(live);
577
+ }
578
+ return Err(error);
579
+ }
580
+ };
581
+ Ok(SharedClipSink {
582
+ sink,
583
+ path: request.path,
584
+ width: request.width,
585
+ height: request.height,
586
+ live_upload,
587
+ upload_reset: Some(upload_reset),
588
+ })
589
+ }
590
+
591
+ #[cfg(target_os = "macos")]
592
+ impl NativeFullscreenBackend {
593
+ /// Queue a local fMP4 fence without stopping the stream or recreating its
594
+ /// writer. Only the custom segmented backend supports this; callers must
595
+ /// treat unsupported capture backends as a hard local-buffer failure.
596
+ pub(crate) fn request_fragment_fence(
597
+ &self,
598
+ next_path: PathBuf,
599
+ ) -> Result<SegmentFence, String> {
600
+ match self {
601
+ Self::CustomScreenCaptureKit { writer, .. } => writer.request_fragment_fence(next_path),
602
+ _ => Err("fragment fences are unsupported by this capture backend".into()),
603
+ }
604
+ }
605
+
606
+ /// Await a queued fence from a worker thread, never from an AVFoundation
607
+ /// delegate callback. A timeout fails closed: callers must not consume the
608
+ /// next path as if a file had been finalized.
609
+ pub(crate) fn await_fragment_fence(
610
+ fence: SegmentFence,
611
+ timeout: Duration,
612
+ ) -> Result<ClosedSegmentFile, String> {
613
+ fence.wait(timeout)
614
+ }
615
+ }
616
+
617
+ /// Start the custom ScreenCaptureKit path in local rolling-buffer mode.
618
+ /// Unlike the ordinary recorder, this always selects delegate-fed fMP4 output
619
+ /// and preserves microphone/system audio as separate tracks, regardless of
620
+ /// the remote live-upload feature flag.
621
+ #[cfg(target_os = "macos")]
622
+ pub(crate) fn start_segmented_custom_screencapturekit_backend_at(
623
+ app: &AppHandle,
624
+ output_path: &Path,
625
+ include_audio: bool,
626
+ capture_system_audio: bool,
627
+ mic_device_id: Option<&str>,
628
+ mic_device_label: Option<&str>,
629
+ target_display_id: Option<u32>,
630
+ capture_region: Option<NativeCaptureRegion>,
631
+ defer_recording_output: bool,
632
+ ) -> Result<(NativeFullscreenBackend, Option<u32>, Option<u32>), String> {
633
+ start_custom_screencapturekit_backend_at(
634
+ app,
635
+ output_path,
636
+ include_audio,
637
+ capture_system_audio,
638
+ mic_device_id,
639
+ mic_device_label,
640
+ target_display_id,
641
+ capture_region,
642
+ defer_recording_output,
643
+ true,
644
+ )
645
+ }
646
+
329
647
  /// Safety net for the `screencapture` fallback: if a session carrying a live
330
648
  /// `screencapture` child is ever dropped without going through
331
649
  /// `stop_native_recording`/`stop_screencapture` (app quit, crash unwind, or an
@@ -596,7 +914,7 @@ pub fn native_fullscreen_claim_upload_open(recording_id: String) -> bool {
596
914
  true
597
915
  }
598
916
 
599
- fn emit_native_upload_finished(
917
+ pub(crate) fn emit_native_upload_finished(
600
918
  app: &AppHandle,
601
919
  server_url: &str,
602
920
  recording_id: &str,
@@ -687,12 +1005,12 @@ pub struct NativeFullscreenStartInfo {
687
1005
  #[derive(Serialize)]
688
1006
  #[serde(rename_all = "camelCase")]
689
1007
  pub struct NativeFullscreenUploadResult {
690
- recording_id: String,
691
- duration_ms: u128,
692
- width: Option<u32>,
693
- height: Option<u32>,
694
- bytes: u64,
695
- verification_pending: bool,
1008
+ pub(crate) recording_id: String,
1009
+ pub(crate) duration_ms: u128,
1010
+ pub(crate) width: Option<u32>,
1011
+ pub(crate) height: Option<u32>,
1012
+ pub(crate) bytes: u64,
1013
+ pub(crate) verification_pending: bool,
696
1014
  }
697
1015
 
698
1016
  #[derive(Serialize, Clone)]
@@ -726,6 +1044,120 @@ pub struct NativeFullscreenSaveResult {
726
1044
  file: NativeLocalRecordingFile,
727
1045
  }
728
1046
 
1047
+ /// One exact, source-local interval from a finalized native MP4. `start_ms`
1048
+ /// and `end_ms` are half-open (`[start_ms, end_ms)`) and are never expanded to
1049
+ /// an enclosing rolling-buffer segment by this layer.
1050
+ #[derive(Clone, Debug, PartialEq, Eq)]
1051
+ pub(crate) struct NativeMediaSlice {
1052
+ pub path: PathBuf,
1053
+ pub system_audio_path: Option<PathBuf>,
1054
+ pub microphone_path: Option<PathBuf>,
1055
+ pub start_ms: u64,
1056
+ pub end_ms: u64,
1057
+ }
1058
+
1059
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1060
+ pub(crate) enum NativeAudioSelection {
1061
+ None,
1062
+ System,
1063
+ Microphone,
1064
+ MixBoth,
1065
+ }
1066
+
1067
+ /// Final media facts after capture/segment consolidation. This is deliberately
1068
+ /// independent from a live session so Rewind can hand a materialized artifact
1069
+ /// to the same local-save/upload continuations later.
1070
+ #[derive(Clone, Debug)]
1071
+ pub(crate) struct FinalizedNativeArtifact {
1072
+ pub path: PathBuf,
1073
+ pub mime_type: &'static str,
1074
+ pub duration_ms: u128,
1075
+ pub width: Option<u32>,
1076
+ pub height: Option<u32>,
1077
+ pub mic_captured: bool,
1078
+ pub system_audio_captured: bool,
1079
+ pub has_camera: bool,
1080
+ pub custom_pipeline: bool,
1081
+ }
1082
+
1083
+ impl FinalizedNativeArtifact {
1084
+ fn from_session(session: &NativeFullscreenSession, duration_ms: u128) -> Self {
1085
+ Self {
1086
+ path: session.path.clone(),
1087
+ mime_type: session.mime_type,
1088
+ duration_ms,
1089
+ width: session.width,
1090
+ height: session.height,
1091
+ mic_captured: session.restart.mic_captured_in_file,
1092
+ system_audio_captured: session.restart.capture_system_audio,
1093
+ has_camera: false,
1094
+ custom_pipeline: session.custom_pipeline,
1095
+ }
1096
+ }
1097
+
1098
+ pub(crate) fn rewind_mp4(
1099
+ path: PathBuf,
1100
+ duration_ms: u128,
1101
+ width: Option<u32>,
1102
+ height: Option<u32>,
1103
+ mic_captured: bool,
1104
+ system_audio_captured: bool,
1105
+ ) -> Self {
1106
+ Self {
1107
+ path,
1108
+ mime_type: MP4_RECORDING_MIME_TYPE,
1109
+ duration_ms,
1110
+ width,
1111
+ height,
1112
+ mic_captured,
1113
+ system_audio_captured,
1114
+ has_camera: false,
1115
+ custom_pipeline: true,
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ #[derive(Clone, Debug, PartialEq, Eq)]
1121
+ pub(crate) struct PlannedNativeSlice {
1122
+ path: PathBuf,
1123
+ start_ms: u64,
1124
+ end_ms: u64,
1125
+ }
1126
+
1127
+ /// Validate exact source-local ranges against independently inspected asset
1128
+ /// durations. A gap is rejected rather than silently bridging it: materialized
1129
+ /// Rewind output must not imply continuous coverage it does not have.
1130
+ pub(crate) fn plan_native_media_slices(
1131
+ slices: &[NativeMediaSlice],
1132
+ source_durations_ms: &[u64],
1133
+ ) -> Result<Vec<PlannedNativeSlice>, String> {
1134
+ if slices.is_empty() || slices.len() != source_durations_ms.len() {
1135
+ return Err("media slice plan requires one non-empty duration per slice".into());
1136
+ }
1137
+ let mut planned = Vec::with_capacity(slices.len());
1138
+ let mut expected_start = None;
1139
+ for (slice, duration_ms) in slices.iter().zip(source_durations_ms) {
1140
+ if slice.path.as_os_str().is_empty() || *duration_ms == 0 {
1141
+ return Err("media slice has a missing or empty source".into());
1142
+ }
1143
+ if slice.start_ms >= slice.end_ms || slice.end_ms > *duration_ms {
1144
+ return Err("media slice range is invalid or exceeds its source".into());
1145
+ }
1146
+ if let Some(expected) = expected_start {
1147
+ if slice.start_ms != expected {
1148
+ return Err("media slice plan contains a coverage gap or overlap".into());
1149
+ }
1150
+ }
1151
+ expected_start = Some(slice.end_ms);
1152
+ planned.push(PlannedNativeSlice {
1153
+ path: slice.path.clone(),
1154
+ start_ms: slice.start_ms,
1155
+ end_ms: slice.end_ms,
1156
+ });
1157
+ }
1158
+ Ok(planned)
1159
+ }
1160
+
729
1161
  impl From<&SavedNativeRecording> for PendingNativeRecording {
730
1162
  fn from(saved: &SavedNativeRecording) -> Self {
731
1163
  Self {
@@ -1469,15 +1901,15 @@ pub async fn native_fullscreen_recording_stop_and_upload(
1469
1901
  }
1470
1902
  }
1471
1903
 
1472
- let result = upload_recording_file(
1904
+ let artifact = FinalizedNativeArtifact::from_session(&session, duration_ms);
1905
+ let result = upload_finalized_native_artifact(
1473
1906
  &app,
1474
- &session,
1907
+ &artifact,
1475
1908
  server_url.clone(),
1476
1909
  recording_id.clone(),
1477
1910
  auth_token,
1478
1911
  cookie,
1479
1912
  upload_mode,
1480
- duration_ms,
1481
1913
  has_audio,
1482
1914
  has_camera,
1483
1915
  )
@@ -1582,7 +2014,8 @@ pub async fn native_fullscreen_recording_stop_and_save(
1582
2014
  );
1583
2015
  }
1584
2016
 
1585
- save_native_recording_to_local_export(&app, &session, &folder_name, &file_role, duration_ms)
2017
+ let artifact = FinalizedNativeArtifact::from_session(&session, duration_ms);
2018
+ save_finalized_native_artifact_to_local_export(&app, &artifact, &folder_name, &file_role)
1586
2019
  }
1587
2020
 
1588
2021
  #[tauri::command]
@@ -2180,6 +2613,7 @@ fn start_segment_backend(
2180
2613
  target_display_id,
2181
2614
  capture_region,
2182
2615
  false,
2616
+ false,
2183
2617
  )
2184
2618
  } else {
2185
2619
  start_screencapturekit_backend_at(
@@ -2640,6 +3074,17 @@ pub async fn native_fullscreen_recording_mark_upload_error(
2640
3074
  Ok(())
2641
3075
  }
2642
3076
 
3077
+ #[tauri::command]
3078
+ pub async fn native_fullscreen_recording_clear_upload(
3079
+ app: AppHandle,
3080
+ recording_id: String,
3081
+ ) -> Result<(), String> {
3082
+ let saved = read_saved_recording_metadata(&app, &recording_id)?;
3083
+ clear_saved_recording(&app, &saved)?;
3084
+ let _ = app.emit("clips:pending-uploads-changed", ());
3085
+ Ok(())
3086
+ }
3087
+
2643
3088
  #[tauri::command]
2644
3089
  pub async fn native_fullscreen_recording_dismiss_upload(
2645
3090
  app: AppHandle,
@@ -2876,37 +3321,6 @@ fn names_match(a: &str, b: &str) -> bool {
2876
3321
  short.iter().all(|token| long.contains(token))
2877
3322
  }
2878
3323
 
2879
- #[cfg(target_os = "macos")]
2880
- fn is_built_in_input_name(value: &str) -> bool {
2881
- let value = normalize_audio_device_name(value);
2882
- value.contains("macbook")
2883
- || value.contains("built in")
2884
- || value.contains("builtin")
2885
- || value.contains("internal microphone")
2886
- }
2887
-
2888
- #[cfg(target_os = "macos")]
2889
- fn is_phone_input_name(value: &str) -> bool {
2890
- let value = normalize_audio_device_name(value);
2891
- value.contains("iphone")
2892
- || value.contains("ipad")
2893
- || value.contains("continuity")
2894
- || value.contains("phone microphone")
2895
- }
2896
-
2897
- #[cfg(target_os = "macos")]
2898
- fn preferred_default_microphone_device(devices: &[AudioInputDevice]) -> Option<AudioInputDevice> {
2899
- devices
2900
- .iter()
2901
- .find(|device| is_built_in_input_name(&device.name))
2902
- .or_else(|| {
2903
- devices
2904
- .iter()
2905
- .find(|device| !is_phone_input_name(&device.name))
2906
- })
2907
- .cloned()
2908
- }
2909
-
2910
3324
  #[cfg(target_os = "macos")]
2911
3325
  pub(crate) fn resolve_microphone_capture_device(
2912
3326
  device_id: Option<&str>,
@@ -2926,15 +3340,13 @@ pub(crate) fn resolve_microphone_capture_device(
2926
3340
  });
2927
3341
 
2928
3342
  if device_id.is_none() && device_label.is_none() {
2929
- let resolved = preferred_default_microphone_device(&devices);
3343
+ // "Default mic" must remain the actual macOS default. Do not pin a
3344
+ // convenient-looking built-in device: studio/USB users may be speaking
3345
+ // into the system-selected input while that laptop mic records silence.
2930
3346
  eprintln!(
2931
- "[clips-tray] mic resolve: no explicit input provided -> {}",
2932
- match &resolved {
2933
- Some(device) => format!("using {} ({})", device.name, device.id),
2934
- None => "using macOS default input".to_string(),
2935
- }
3347
+ "[clips-tray] mic resolve: no explicit input provided -> using macOS default input"
2936
3348
  );
2937
- return Ok(resolved);
3349
+ return Ok(None);
2938
3350
  }
2939
3351
 
2940
3352
  let resolved = device_id
@@ -2988,12 +3400,11 @@ fn move_or_copy_file(from: &Path, to: &Path) -> Result<(), String> {
2988
3400
  Ok(())
2989
3401
  }
2990
3402
 
2991
- fn save_native_recording_to_local_export(
3403
+ pub(crate) fn save_finalized_native_artifact_to_local_export(
2992
3404
  app: &AppHandle,
2993
- session: &NativeFullscreenSession,
3405
+ artifact: &FinalizedNativeArtifact,
2994
3406
  folder_name: &str,
2995
3407
  file_role: &str,
2996
- duration_ms: u128,
2997
3408
  ) -> Result<NativeFullscreenSaveResult, String> {
2998
3409
  let safe_folder_name = sanitize_path_component(folder_name, "clip");
2999
3410
  let safe_role = match file_role {
@@ -3009,11 +3420,11 @@ fn save_native_recording_to_local_export(
3009
3420
  std::fs::create_dir_all(&folder)
3010
3421
  .map_err(|e| format!("local recording folder unavailable: {e}"))?;
3011
3422
 
3012
- let extension = native_extension_for_mime_type(session.mime_type);
3423
+ let extension = native_extension_for_mime_type(artifact.mime_type);
3013
3424
  let file_name = format!("{}.{}", local_role_file_stem(safe_role), extension);
3014
3425
  let destination = folder.join(&file_name);
3015
3426
  let _ = std::fs::remove_file(&destination);
3016
- move_or_copy_file(&session.path, &destination)?;
3427
+ move_or_copy_file(&artifact.path, &destination)?;
3017
3428
 
3018
3429
  let bytes = std::fs::metadata(&destination)
3019
3430
  .map_err(|e| format!("local recording metadata unavailable: {e}"))?
@@ -3030,11 +3441,11 @@ fn save_native_recording_to_local_export(
3030
3441
  role: safe_role.to_string(),
3031
3442
  path: destination.to_string_lossy().to_string(),
3032
3443
  file_name,
3033
- mime_type: session.mime_type.to_string(),
3444
+ mime_type: artifact.mime_type.to_string(),
3034
3445
  bytes,
3035
- duration_ms,
3036
- width: session.width,
3037
- height: session.height,
3446
+ duration_ms: artifact.duration_ms,
3447
+ width: artifact.width,
3448
+ height: artifact.height,
3038
3449
  },
3039
3450
  })
3040
3451
  }
@@ -3331,6 +3742,67 @@ fn persist_saved_recording_error(app: &AppHandle, saved: &mut SavedNativeRecordi
3331
3742
  let _ = write_saved_recording_metadata(app, saved);
3332
3743
  }
3333
3744
 
3745
+ /// Index a finalized shared-producer Clip in the ordinary pending-upload
3746
+ /// store. The file may live in Rewind's temporary artifact directory; the
3747
+ /// metadata is the durable pointer that makes restart/retry and user-visible
3748
+ /// recovery use the same flow as every other native recording.
3749
+ pub(crate) fn persist_shared_clip_recording(
3750
+ app: &AppHandle,
3751
+ path: &Path,
3752
+ server_url: &str,
3753
+ recording_id: &str,
3754
+ duration_ms: u128,
3755
+ width: u32,
3756
+ height: u32,
3757
+ include_mic: bool,
3758
+ include_system_audio: bool,
3759
+ has_camera: bool,
3760
+ error: Option<&str>,
3761
+ ) -> Result<(), String> {
3762
+ let bytes = std::fs::metadata(path)
3763
+ .map_err(|source| format!("shared Clip local artifact unavailable: {source}"))?
3764
+ .len();
3765
+ if bytes == 0 {
3766
+ return Err("shared Clip local artifact is empty".into());
3767
+ }
3768
+ let saved = SavedNativeRecording {
3769
+ recording_id: recording_id.to_string(),
3770
+ server_url: server_url.trim_end_matches('/').to_string(),
3771
+ file_path: path.to_path_buf(),
3772
+ segment_paths: Vec::new(),
3773
+ mime_type: MP4_RECORDING_MIME_TYPE.to_string(),
3774
+ duration_ms,
3775
+ width: Some(width),
3776
+ height: Some(height),
3777
+ bytes,
3778
+ has_audio: include_mic || include_system_audio,
3779
+ mic_captured: include_mic,
3780
+ system_audio_captured: include_system_audio,
3781
+ has_camera,
3782
+ saved_at: now_iso(),
3783
+ last_attempt_at: error.map(|_| now_iso()),
3784
+ last_error: error.map(ToString::to_string),
3785
+ retry_count: u32::from(error.is_some()),
3786
+ custom_pipeline: true,
3787
+ corrupt: false,
3788
+ };
3789
+ write_saved_recording_metadata(app, &saved)?;
3790
+ let _ = app.emit("clips:pending-uploads-changed", ());
3791
+ Ok(())
3792
+ }
3793
+
3794
+ /// Remove the shared Clip's temporary artifact and pending metadata after a
3795
+ /// conclusive server success (or after a local export has its own copy).
3796
+ pub(crate) fn clear_shared_clip_recording(app: &AppHandle, recording_id: &str, path: &Path) {
3797
+ let saved = read_saved_recording_metadata(app, recording_id).ok();
3798
+ if let Some(saved) = saved {
3799
+ clear_saved_recording_after_success(app, &saved);
3800
+ } else if let Err(error) = remove_saved_file(path, "shared Clip temporary artifact") {
3801
+ eprintln!("[clips-tray] shared Clip cleanup failed: {error}");
3802
+ }
3803
+ let _ = app.emit("clips:pending-uploads-changed", ());
3804
+ }
3805
+
3334
3806
  #[cfg(target_os = "macos")]
3335
3807
  /// Return the `CGDirectDisplayID` of the display containing the centre of
3336
3808
  /// the last-clicked tray icon. Uses the Tauri monitor list to locate the
@@ -3433,6 +3905,7 @@ fn start_screencapturekit_recording(
3433
3905
  target_display_id,
3434
3906
  capture_region,
3435
3907
  defer_recording_output,
3908
+ false,
3436
3909
  )?
3437
3910
  } else {
3438
3911
  start_screencapturekit_backend_at(
@@ -3654,6 +4127,66 @@ fn region_source_rect(
3654
4127
  #[cfg(target_os = "macos")]
3655
4128
  const SCK_FINALIZE_TIMEOUT: Duration = Duration::from_secs(10);
3656
4129
 
4130
+ /// `SCStream::stop_capture()` occasionally stops the underlying capture but
4131
+ /// never returns from ScreenCaptureKit's synchronous completion wait. Keep
4132
+ /// teardown bounded so the writer can still close its inputs, flush the final
4133
+ /// fragment, and let the upload path validate the playable file on disk.
4134
+ #[cfg(target_os = "macos")]
4135
+ const CUSTOM_SCK_STOP_TIMEOUT: Duration = Duration::from_secs(3);
4136
+
4137
+ #[cfg(target_os = "macos")]
4138
+ fn run_bounded_capture_stop<F>(stop: F, timeout: Duration) -> Result<(), String>
4139
+ where
4140
+ F: FnOnce() -> Result<(), String> + Send + 'static,
4141
+ {
4142
+ let (tx, rx) = std::sync::mpsc::sync_channel(1);
4143
+ std::thread::spawn(move || {
4144
+ let _ = tx.send(stop());
4145
+ });
4146
+ match rx.recv_timeout(timeout) {
4147
+ Ok(result) => result,
4148
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(format!(
4149
+ "custom ScreenCaptureKit stop timed out after {}s; continuing finalization",
4150
+ timeout.as_secs()
4151
+ )),
4152
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
4153
+ Err("custom ScreenCaptureKit stop worker disconnected".into())
4154
+ }
4155
+ }
4156
+ }
4157
+
4158
+ #[cfg(all(test, target_os = "macos"))]
4159
+ mod bounded_capture_stop_tests {
4160
+ use super::run_bounded_capture_stop;
4161
+ use std::time::{Duration, Instant};
4162
+
4163
+ #[test]
4164
+ fn returns_the_capture_stop_result() {
4165
+ assert_eq!(
4166
+ run_bounded_capture_stop(|| Ok(()), Duration::from_millis(50)),
4167
+ Ok(())
4168
+ );
4169
+ assert_eq!(
4170
+ run_bounded_capture_stop(|| Err("stop failed".to_string()), Duration::from_millis(50)),
4171
+ Err("stop failed".to_string())
4172
+ );
4173
+ }
4174
+
4175
+ #[test]
4176
+ fn releases_the_caller_when_capture_stop_hangs() {
4177
+ let started = Instant::now();
4178
+ let result = run_bounded_capture_stop(
4179
+ || {
4180
+ std::thread::sleep(Duration::from_secs(1));
4181
+ Ok(())
4182
+ },
4183
+ Duration::from_millis(20),
4184
+ );
4185
+ assert!(result.unwrap_err().contains("timed out"));
4186
+ assert!(started.elapsed() < Duration::from_millis(250));
4187
+ }
4188
+ }
4189
+
3657
4190
  /// Stop the active recording. When `wait_for_finalize` is set (save/upload
3658
4191
  /// paths — the file is about to be moved) this blocks until ScreenCaptureKit
3659
4192
  /// signals the recording finished, so the caller never moves a half-written
@@ -3678,14 +4211,20 @@ pub(crate) fn stop_native_recording(
3678
4211
  // Stop the watchdog first so it can't rebuild the stream out from
3679
4212
  // under us mid-teardown.
3680
4213
  watchdog_shutdown.store(true, Ordering::SeqCst);
3681
- let stop_result = stream
3682
- .lock()
3683
- .map_err(|e| format!("custom ScreenCaptureKit stop lock poisoned: {e}"))
3684
- .and_then(|guard| {
3685
- guard
3686
- .stop_capture()
3687
- .map_err(|e| format!("custom ScreenCaptureKit stop failed: {e:?}"))
3688
- });
4214
+ let stream_for_stop = Arc::clone(stream);
4215
+ let stop_result = run_bounded_capture_stop(
4216
+ move || {
4217
+ stream_for_stop
4218
+ .lock()
4219
+ .map_err(|e| format!("custom ScreenCaptureKit stop lock poisoned: {e}"))
4220
+ .and_then(|guard| {
4221
+ guard
4222
+ .stop_capture()
4223
+ .map_err(|e| format!("custom ScreenCaptureKit stop failed: {e:?}"))
4224
+ })
4225
+ },
4226
+ CUSTOM_SCK_STOP_TIMEOUT,
4227
+ );
3689
4228
  if let Err(err) = &stop_result {
3690
4229
  eprintln!("[clips-tray] custom capture stop_capture error: {err}");
3691
4230
  }
@@ -3796,29 +4335,31 @@ fn stop_screencapture(child: &mut Child) -> Result<(), String> {
3796
4335
  }
3797
4336
  }
3798
4337
 
3799
- async fn upload_recording_file(
4338
+ /// Upload a finalized local artifact through the ordinary native preparation
4339
+ /// and response path. Rewind materializers can call this after they have
4340
+ /// produced a bounded local MP4; it owns no capture session or live uploader.
4341
+ pub(crate) async fn upload_finalized_native_artifact(
3800
4342
  app: &AppHandle,
3801
- session: &NativeFullscreenSession,
4343
+ artifact: &FinalizedNativeArtifact,
3802
4344
  server_url: String,
3803
4345
  recording_id: String,
3804
4346
  auth_token: String,
3805
4347
  cookie: String,
3806
4348
  upload_mode: NativeUploadMode,
3807
- duration_ms: u128,
3808
4349
  has_audio: bool,
3809
4350
  has_camera: bool,
3810
4351
  ) -> Result<NativeFullscreenUploadResult, String> {
3811
4352
  let prepared = prepare_recording_file(
3812
4353
  app,
3813
- &session.path,
3814
- session.mime_type,
3815
- session.width,
3816
- session.height,
3817
- Some(duration_ms),
4354
+ &artifact.path,
4355
+ artifact.mime_type,
4356
+ artifact.width,
4357
+ artifact.height,
4358
+ Some(artifact.duration_ms),
3818
4359
  has_audio,
3819
- session.restart.mic_captured_in_file,
3820
- session.restart.capture_system_audio,
3821
- session.custom_pipeline,
4360
+ artifact.mic_captured,
4361
+ artifact.system_audio_captured,
4362
+ artifact.custom_pipeline,
3822
4363
  )?;
3823
4364
  let upload_result = upload_prepared_recording_file(
3824
4365
  app,
@@ -3828,9 +4369,9 @@ async fn upload_recording_file(
3828
4369
  auth_token,
3829
4370
  cookie,
3830
4371
  upload_mode,
3831
- duration_ms,
3832
- session.width,
3833
- session.height,
4372
+ artifact.duration_ms,
4373
+ artifact.width,
4374
+ artifact.height,
3834
4375
  has_audio,
3835
4376
  has_camera,
3836
4377
  )
@@ -5646,8 +6187,225 @@ fn validate_recording_segment_file(path: &Path) -> Result<(), String> {
5646
6187
  Ok(())
5647
6188
  }
5648
6189
 
6190
+ /// Compose exact source-local MP4 ranges. Passthrough exports can begin video
6191
+ /// on the preceding sync sample; callers requiring byte/sample-exact exclusion
6192
+ /// at a trimmed first boundary must request a transcode materialization rather
6193
+ /// than treating this passthrough composition as an isolation boundary.
6194
+ #[cfg(target_os = "macos")]
6195
+ pub(crate) fn compose_mp4_slices(slices: &[NativeMediaSlice], output: &Path) -> Result<(), String> {
6196
+ compose_mp4_slices_impl(slices, output, false)
6197
+ }
6198
+
6199
+ /// Materialize bounded Rewind media through a fresh encode. Unlike the
6200
+ /// passthrough compositor, this is the required path when no pre-start frame
6201
+ /// may survive a trimmed boundary. It is intentionally separate from ordinary
6202
+ /// recorder segment concat, which remains fast passthrough.
6203
+ #[cfg(target_os = "macos")]
6204
+ pub(crate) fn materialize_mp4_slices_exact(
6205
+ slices: &[NativeMediaSlice],
6206
+ output: &Path,
6207
+ audio: NativeAudioSelection,
6208
+ ) -> Result<(), String> {
6209
+ let temporary = output.with_extension("bounded-compose.mp4");
6210
+ let _ = std::fs::remove_file(&temporary);
6211
+ compose_mp4_slices(slices, &temporary)?;
6212
+ let ffmpeg = resolve_ffmpeg_path().ok_or_else(|| {
6213
+ "exact bounded materialization requires ffmpeg; passthrough composition is not private enough for this range".to_string()
6214
+ })?;
6215
+ let mut command = Command::new(ffmpeg);
6216
+ command
6217
+ .arg("-y")
6218
+ .arg("-i")
6219
+ .arg(&temporary)
6220
+ .arg("-map")
6221
+ .arg("0:v:0?")
6222
+ .arg("-c:v")
6223
+ .arg("libx264")
6224
+ // Exact Rewind exports must be freshly encoded to guarantee that no
6225
+ // pre-boundary keyframe survives, but the default x264 preset makes
6226
+ // Stop look hung for roughly the full duration of the Clip. This path
6227
+ // favors interactive finalization speed; the ordinary upload
6228
+ // transcode still owns the product's size/quality policy.
6229
+ .arg("-preset")
6230
+ .arg("ultrafast");
6231
+ match audio {
6232
+ NativeAudioSelection::None => {
6233
+ command.arg("-an");
6234
+ }
6235
+ NativeAudioSelection::System => {
6236
+ command.args(["-map", "0:a:0", "-c:a", "aac"]);
6237
+ }
6238
+ NativeAudioSelection::Microphone => {
6239
+ command.args(["-map", "0:a:1", "-c:a", "aac"]);
6240
+ }
6241
+ NativeAudioSelection::MixBoth => {
6242
+ command.args([
6243
+ "-filter_complex",
6244
+ "[0:a:0][0:a:1]amix=inputs=2:normalize=0[a]",
6245
+ "-map",
6246
+ "[a]",
6247
+ "-c:a",
6248
+ "aac",
6249
+ ]);
6250
+ }
6251
+ }
6252
+ let child = command
6253
+ .arg("-movflags")
6254
+ .arg("+faststart")
6255
+ .arg(output)
6256
+ .stdout(Stdio::null())
6257
+ .stderr(Stdio::piped())
6258
+ .spawn()
6259
+ .map_err(|e| format!("exact bounded ffmpeg spawn failed: {e}"))?;
6260
+ let result = wait_for_transcode_child(
6261
+ child,
6262
+ FFMPEG_TIMEOUT,
6263
+ "ffmpeg exact bounded materialization",
6264
+ );
6265
+ let _ = std::fs::remove_file(&temporary);
6266
+ result
6267
+ }
6268
+
6269
+ #[cfg(not(target_os = "macos"))]
6270
+ pub(crate) fn materialize_mp4_slices_exact(
6271
+ _slices: &[NativeMediaSlice],
6272
+ _output: &Path,
6273
+ _audio: NativeAudioSelection,
6274
+ ) -> Result<(), String> {
6275
+ Err("exact bounded Rewind materialization is available only on macOS".into())
6276
+ }
6277
+
5649
6278
  #[cfg(target_os = "macos")]
5650
6279
  fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String> {
6280
+ let slices = segments
6281
+ .iter()
6282
+ .cloned()
6283
+ .map(|path| NativeMediaSlice {
6284
+ path,
6285
+ system_audio_path: None,
6286
+ microphone_path: None,
6287
+ start_ms: 0,
6288
+ end_ms: u64::MAX,
6289
+ })
6290
+ .collect::<Vec<_>>();
6291
+ compose_mp4_slices_impl(&slices, output, true)
6292
+ }
6293
+
6294
+ #[cfg(target_os = "macos")]
6295
+ struct PreparedSliceFiles(Vec<PathBuf>);
6296
+
6297
+ #[cfg(target_os = "macos")]
6298
+ impl Drop for PreparedSliceFiles {
6299
+ fn drop(&mut self) {
6300
+ for path in &self.0 {
6301
+ let _ = std::fs::remove_file(path);
6302
+ }
6303
+ }
6304
+ }
6305
+
6306
+ #[cfg(target_os = "macos")]
6307
+ fn prepare_audio_sidecar_slices(
6308
+ slices: &[NativeMediaSlice],
6309
+ output: &Path,
6310
+ ) -> Result<(Vec<NativeMediaSlice>, PreparedSliceFiles), String> {
6311
+ let ffmpeg = resolve_ffmpeg_path()
6312
+ .ok_or_else(|| "Rewind audio materialization requires ffmpeg".to_string())?;
6313
+ let mut prepared = Vec::with_capacity(slices.len());
6314
+ let mut temporary = Vec::new();
6315
+ for (index, slice) in slices.iter().enumerate() {
6316
+ if slice.system_audio_path.is_none() && slice.microphone_path.is_none() {
6317
+ prepared.push(slice.clone());
6318
+ continue;
6319
+ }
6320
+ let system = slice
6321
+ .system_audio_path
6322
+ .as_ref()
6323
+ .ok_or_else(|| "Rewind system-audio sidecar is missing".to_string())?;
6324
+ let microphone = slice
6325
+ .microphone_path
6326
+ .as_ref()
6327
+ .ok_or_else(|| "Rewind microphone sidecar is missing".to_string())?;
6328
+ for path in [&slice.path, system, microphone] {
6329
+ if !path.exists() {
6330
+ return Err(format!(
6331
+ "Rewind media unit is incomplete: {}",
6332
+ path.display()
6333
+ ));
6334
+ }
6335
+ }
6336
+ let muxed = output.with_extension(format!("sidecar-{index}.mp4"));
6337
+ let _ = std::fs::remove_file(&muxed);
6338
+ let child = Command::new(&ffmpeg)
6339
+ .args(["-y", "-v", "error", "-nostdin", "-i"])
6340
+ .arg(&slice.path)
6341
+ .arg("-i")
6342
+ .arg(system)
6343
+ .arg("-i")
6344
+ .arg(microphone)
6345
+ .args([
6346
+ "-map",
6347
+ "0:v:0",
6348
+ "-map",
6349
+ "1:a:0",
6350
+ "-map",
6351
+ "2:a:0",
6352
+ "-c:v",
6353
+ "copy",
6354
+ "-c:a",
6355
+ "aac",
6356
+ "-af",
6357
+ "apad",
6358
+ "-shortest",
6359
+ "-movflags",
6360
+ "+faststart",
6361
+ ])
6362
+ .arg(&muxed)
6363
+ .stdout(Stdio::null())
6364
+ .stderr(Stdio::piped())
6365
+ .spawn()
6366
+ .map_err(|error| format!("Rewind sidecar mux spawn failed: {error}"))?;
6367
+ wait_for_transcode_child(child, FFMPEG_TIMEOUT, "Rewind sidecar mux")?;
6368
+ let mut prepared_slice = slice.clone();
6369
+ prepared_slice.path = muxed.clone();
6370
+ prepared_slice.system_audio_path = None;
6371
+ prepared_slice.microphone_path = None;
6372
+ prepared.push(prepared_slice);
6373
+ temporary.push(muxed);
6374
+ }
6375
+ Ok((prepared, PreparedSliceFiles(temporary)))
6376
+ }
6377
+
6378
+ const MAX_PREPARED_SLICE_DURATION_DRIFT_MS: u64 = 1_000;
6379
+
6380
+ fn bounded_slice_range_ms(
6381
+ start_ms: u64,
6382
+ end_ms: u64,
6383
+ asset_duration_ms: u64,
6384
+ ) -> Result<(u64, u64), String> {
6385
+ if start_ms >= end_ms || start_ms >= asset_duration_ms {
6386
+ return Err("media slice range is invalid or exceeds source".to_string());
6387
+ }
6388
+ if end_ms <= asset_duration_ms {
6389
+ return Ok((start_ms, end_ms));
6390
+ }
6391
+ let drift_ms = end_ms.saturating_sub(asset_duration_ms);
6392
+ if drift_ms > MAX_PREPARED_SLICE_DURATION_DRIFT_MS {
6393
+ return Err("media slice range is invalid or exceeds source".to_string());
6394
+ }
6395
+ // Muxing the local PCM sidecars to AAC can shorten the container, while
6396
+ // graph-clock segment boundaries can also lead the encoded media PTS by a
6397
+ // fraction of a second during writer startup/finalization. Clamp only that
6398
+ // bounded trailing drift; larger mismatches still fail closed so a
6399
+ // genuinely wrong source/range cannot be materialized.
6400
+ Ok((start_ms, asset_duration_ms))
6401
+ }
6402
+
6403
+ #[cfg(target_os = "macos")]
6404
+ fn compose_mp4_slices_impl(
6405
+ slices: &[NativeMediaSlice],
6406
+ output: &Path,
6407
+ allow_full_source_wrapper: bool,
6408
+ ) -> Result<(), String> {
5651
6409
  use std::ffi::CString;
5652
6410
  use std::sync::mpsc;
5653
6411
  use std::time::Duration as StdDuration;
@@ -5765,6 +6523,23 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5765
6523
  }
5766
6524
  }
5767
6525
 
6526
+ unsafe fn track_at(
6527
+ asset: &AnyObject,
6528
+ media_type: *const AnyObject,
6529
+ index: usize,
6530
+ ) -> Option<*mut AnyObject> {
6531
+ let tracks: *mut AnyObject = msg_send![asset, tracksWithMediaType: media_type];
6532
+ if tracks.is_null() {
6533
+ return None;
6534
+ }
6535
+ let count: usize = msg_send![tracks, count];
6536
+ if index >= count {
6537
+ return None;
6538
+ }
6539
+ let track: *mut AnyObject = msg_send![tracks, objectAtIndex: index];
6540
+ (!track.is_null()).then_some(track)
6541
+ }
6542
+
5768
6543
  unsafe fn cmtime_add(a: CMTime, b: CMTime) -> CMTime {
5769
6544
  #[link(name = "CoreMedia", kind = "framework")]
5770
6545
  extern "C" {
@@ -5773,10 +6548,12 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5773
6548
  CMTimeAdd(a, b)
5774
6549
  }
5775
6550
 
5776
- if segments.is_empty() {
5777
- return Err("concat called with no segments".into());
6551
+ if slices.is_empty() {
6552
+ return Err("compose called with no media slices".into());
5778
6553
  }
5779
6554
 
6555
+ let (slices, _prepared_slice_files) = prepare_audio_sidecar_slices(slices, output)?;
6556
+
5780
6557
  unsafe {
5781
6558
  let composition_cls = class_named("AVMutableComposition")
5782
6559
  .ok_or_else(|| "AVMutableComposition missing".to_string())?;
@@ -5797,6 +6574,11 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5797
6574
  addMutableTrackWithMediaType: AVMediaTypeAudio,
5798
6575
  preferredTrackID: KCM_PERSISTENT_TRACK_ID_INVALID
5799
6576
  ];
6577
+ let audio_track_2: *mut AnyObject = msg_send![
6578
+ &*composition,
6579
+ addMutableTrackWithMediaType: AVMediaTypeAudio,
6580
+ preferredTrackID: KCM_PERSISTENT_TRACK_ID_INVALID
6581
+ ];
5800
6582
  if video_track.is_null() && audio_track.is_null() {
5801
6583
  return Err("composition has no tracks to write into".into());
5802
6584
  }
@@ -5805,8 +6587,10 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5805
6587
  let asset_cls =
5806
6588
  class_named("AVURLAsset").ok_or_else(|| "AVURLAsset missing".to_string())?;
5807
6589
  let mut appended_any = false;
6590
+ let mut expected_track_shape: Option<(bool, bool, bool)> = None;
5808
6591
 
5809
- for path in segments {
6592
+ for slice in &slices {
6593
+ let path = &slice.path;
5810
6594
  validate_recording_segment_file(path)?;
5811
6595
  let url = file_url(path)
5812
6596
  .ok_or_else(|| format!("could not build NSURL for {}", path.display()))?;
@@ -5824,13 +6608,71 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5824
6608
  path.display()
5825
6609
  ));
5826
6610
  }
5827
- let range = CMTimeRange {
5828
- start: CM_TIME_ZERO,
5829
- duration,
6611
+ let range = if allow_full_source_wrapper && slice.end_ms == u64::MAX {
6612
+ CMTimeRange {
6613
+ start: CM_TIME_ZERO,
6614
+ duration,
6615
+ }
6616
+ } else {
6617
+ let asset_duration_ms = (duration.value as i128)
6618
+ .saturating_mul(1000)
6619
+ .checked_div(duration.timescale as i128)
6620
+ .unwrap_or(0)
6621
+ .max(0) as u64;
6622
+ let (start_ms, end_ms) =
6623
+ bounded_slice_range_ms(slice.start_ms, slice.end_ms, asset_duration_ms)
6624
+ .map_err(|message| {
6625
+ format!(
6626
+ "{message}: {} (requested={}..{}ms source={}ms)",
6627
+ path.display(),
6628
+ slice.start_ms,
6629
+ slice.end_ms,
6630
+ asset_duration_ms
6631
+ )
6632
+ })?;
6633
+ CMTimeRange {
6634
+ start: CMTime {
6635
+ value: start_ms as i64,
6636
+ timescale: 1000,
6637
+ flags: 1,
6638
+ epoch: 0,
6639
+ },
6640
+ duration: CMTime {
6641
+ value: end_ms.saturating_sub(start_ms) as i64,
6642
+ timescale: 1000,
6643
+ flags: 1,
6644
+ epoch: 0,
6645
+ },
6646
+ }
5830
6647
  };
5831
6648
 
6649
+ let seg_video = first_track(&*asset, AVMediaTypeVideo);
6650
+ let seg_audio = first_track(&*asset, AVMediaTypeAudio);
6651
+ let seg_audio_2 = track_at(&*asset, AVMediaTypeAudio, 1);
6652
+ let shape = (
6653
+ seg_video.is_some(),
6654
+ seg_audio.is_some(),
6655
+ seg_audio_2.is_some(),
6656
+ );
6657
+ if shape == (false, false, false) {
6658
+ return Err(format!(
6659
+ "media slice has no compatible tracks: {}",
6660
+ path.display()
6661
+ ));
6662
+ }
6663
+ if let Some(expected) = expected_track_shape {
6664
+ if shape != expected {
6665
+ return Err(format!(
6666
+ "media slices have incompatible audio/video tracks: {}",
6667
+ path.display()
6668
+ ));
6669
+ }
6670
+ } else {
6671
+ expected_track_shape = Some(shape);
6672
+ }
6673
+
5832
6674
  if !video_track.is_null() {
5833
- if let Some(seg_video) = first_track(&*asset, AVMediaTypeVideo) {
6675
+ if let Some(seg_video) = seg_video {
5834
6676
  let mut err_ptr: *mut AnyObject = std::ptr::null_mut();
5835
6677
  let ok: bool = msg_send![
5836
6678
  video_track,
@@ -5848,7 +6690,7 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5848
6690
  }
5849
6691
  }
5850
6692
  if !audio_track.is_null() {
5851
- if let Some(seg_audio) = first_track(&*asset, AVMediaTypeAudio) {
6693
+ if let Some(seg_audio) = seg_audio {
5852
6694
  let mut err_ptr: *mut AnyObject = std::ptr::null_mut();
5853
6695
  let ok: bool = msg_send![
5854
6696
  audio_track,
@@ -5865,7 +6707,16 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5865
6707
  }
5866
6708
  }
5867
6709
  }
5868
- cursor = cmtime_add(cursor, duration);
6710
+ if !audio_track_2.is_null() {
6711
+ if let Some(seg_audio) = seg_audio_2 {
6712
+ let mut err_ptr: *mut AnyObject = std::ptr::null_mut();
6713
+ let ok: bool = msg_send![audio_track_2, insertTimeRange: range, ofTrack: seg_audio, atTime: cursor, error: &mut err_ptr];
6714
+ if !ok {
6715
+ return Err(format!("AVMutableCompositionTrack insertTimeRange (second audio) failed for {}", path.display()));
6716
+ }
6717
+ }
6718
+ }
6719
+ cursor = cmtime_add(cursor, range.duration);
5869
6720
  appended_any = true;
5870
6721
  }
5871
6722
 
@@ -5928,6 +6779,64 @@ fn concat_mp4_segments(segments: &[PathBuf], output: &Path) -> Result<(), String
5928
6779
  Ok(())
5929
6780
  }
5930
6781
 
6782
+ #[cfg(test)]
6783
+ mod native_media_slice_tests {
6784
+ use super::*;
6785
+
6786
+ fn slice(path: &str, start_ms: u64, end_ms: u64) -> NativeMediaSlice {
6787
+ NativeMediaSlice {
6788
+ path: PathBuf::from(path),
6789
+ system_audio_path: None,
6790
+ microphone_path: None,
6791
+ start_ms,
6792
+ end_ms,
6793
+ }
6794
+ }
6795
+
6796
+ #[test]
6797
+ fn plans_adjacent_exact_ranges_and_duration() {
6798
+ let plan = plan_native_media_slices(
6799
+ &[slice("one.mp4", 0, 125), slice("two.mp4", 125, 300)],
6800
+ &[125, 300],
6801
+ )
6802
+ .unwrap();
6803
+ assert_eq!(
6804
+ plan.iter()
6805
+ .map(|slice| slice.end_ms - slice.start_ms)
6806
+ .sum::<u64>(),
6807
+ 300
6808
+ );
6809
+ }
6810
+
6811
+ #[test]
6812
+ fn clamps_only_small_trailing_mux_duration_drift() {
6813
+ assert_eq!(bounded_slice_range_ms(100, 900, 900).unwrap(), (100, 900));
6814
+ assert_eq!(bounded_slice_range_ms(100, 900, 867).unwrap(), (100, 867));
6815
+ assert_eq!(
6816
+ bounded_slice_range_ms(100, 1_172, 1_000).unwrap(),
6817
+ (100, 1_000)
6818
+ );
6819
+ assert!(bounded_slice_range_ms(100, 1_200, 199).is_err());
6820
+ assert!(bounded_slice_range_ms(900, 950, 900).is_err());
6821
+ }
6822
+
6823
+ #[test]
6824
+ fn rejects_gap_overlap_missing_and_out_of_bounds_ranges() {
6825
+ assert!(plan_native_media_slices(
6826
+ &[slice("one.mp4", 0, 100), slice("two.mp4", 125, 200)],
6827
+ &[100, 200]
6828
+ )
6829
+ .is_err());
6830
+ assert!(plan_native_media_slices(
6831
+ &[slice("one.mp4", 0, 100), slice("two.mp4", 90, 200)],
6832
+ &[100, 200]
6833
+ )
6834
+ .is_err());
6835
+ assert!(plan_native_media_slices(&[slice("", 0, 10)], &[10]).is_err());
6836
+ assert!(plan_native_media_slices(&[slice("one.mp4", 0, 11)], &[10]).is_err());
6837
+ }
6838
+ }
6839
+
5931
6840
  #[cfg(test)]
5932
6841
  mod audio_track_probe_tests {
5933
6842
  use super::{
@@ -5959,14 +6868,16 @@ mod audio_track_probe_tests {
5959
6868
  }
5960
6869
 
5961
6870
  fn write_temp_mp4(bytes: &[u8]) -> std::path::PathBuf {
6871
+ static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
5962
6872
  let mut path = std::env::temp_dir();
5963
6873
  path.push(format!(
5964
- "clips-audio-probe-test-{}-{}.mp4",
6874
+ "clips-audio-probe-test-{}-{}-{}.mp4",
5965
6875
  std::process::id(),
5966
6876
  std::time::SystemTime::now()
5967
6877
  .duration_since(std::time::UNIX_EPOCH)
5968
6878
  .unwrap()
5969
- .as_nanos()
6879
+ .as_nanos(),
6880
+ TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
5970
6881
  ));
5971
6882
  let mut f = std::fs::File::create(&path).unwrap();
5972
6883
  f.write_all(bytes).unwrap();