@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +15 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
- package/corpus/core/src/cli/mcp.ts +124 -0
- package/corpus/core/src/cli/skills-content/help.ts +4 -3
- package/corpus/core/src/cli/skills-content/index.ts +1 -0
- package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
- package/corpus/core/src/cli/skills.ts +115 -13
- package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
- package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
- package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
- package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
- package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
- package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
- package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
- package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
- package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
- package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
- package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
- package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
- package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
- package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
- package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
- package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
- package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
- package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
- package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
- package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
- package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
- package/corpus/templates/clips/desktop/src/styles.css +956 -134
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
- package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
- package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
- package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
- package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
- package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
- package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
- package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/dist/cli/mcp-config-writers.d.ts +2 -2
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +6 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +79 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills-content/help.d.ts +1 -1
- package/dist/cli/skills-content/help.d.ts.map +1 -1
- package/dist/cli/skills-content/help.js +4 -3
- package/dist/cli/skills-content/help.js.map +1 -1
- package/dist/cli/skills-content/index.d.ts +1 -0
- package/dist/cli/skills-content/index.d.ts.map +1 -1
- package/dist/cli/skills-content/index.js +1 -0
- package/dist/cli/skills-content/index.js.map +1 -1
- package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
- package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
- package/dist/cli/skills-content/rewind-skill.js +53 -0
- package/dist/cli/skills-content/rewind-skill.js.map +1 -0
- package/dist/cli/skills.d.ts +6 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +90 -14
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +365 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp/screen-memory-stdio.js +1014 -91
- package/dist/mcp/screen-memory-stdio.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +90 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
- package/dist/mcp-client/screen-memory-local.js +590 -12
- package/dist/mcp-client/screen-memory-local.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-chat-plugin.d.ts +5 -0
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +10 -9
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/mcp-config-writers.ts +6 -4
- package/src/cli/mcp.ts +124 -0
- package/src/cli/skills-content/help.ts +4 -3
- package/src/cli/skills-content/index.ts +1 -0
- package/src/cli/skills-content/rewind-skill.ts +52 -0
- package/src/cli/skills.ts +115 -13
- package/src/mcp/screen-memory-stdio.ts +1360 -104
- package/src/mcp-client/index.ts +9 -0
- package/src/mcp-client/screen-memory-local.ts +847 -13
- 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
|
-
|
|
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::
|
|
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
|
|
1904
|
+
let artifact = FinalizedNativeArtifact::from_session(&session, duration_ms);
|
|
1905
|
+
let result = upload_finalized_native_artifact(
|
|
1473
1906
|
&app,
|
|
1474
|
-
&
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
3403
|
+
pub(crate) fn save_finalized_native_artifact_to_local_export(
|
|
2992
3404
|
app: &AppHandle,
|
|
2993
|
-
|
|
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(
|
|
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(&
|
|
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:
|
|
3444
|
+
mime_type: artifact.mime_type.to_string(),
|
|
3034
3445
|
bytes,
|
|
3035
|
-
duration_ms,
|
|
3036
|
-
width:
|
|
3037
|
-
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
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
.
|
|
3687
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
&
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
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
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
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
|
-
|
|
3833
|
-
|
|
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
|
|
5777
|
-
return Err("
|
|
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
|
|
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 =
|
|
5828
|
-
|
|
5829
|
-
|
|
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) =
|
|
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) =
|
|
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
|
-
|
|
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();
|