@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
|
@@ -0,0 +1,1650 @@
|
|
|
1
|
+
use crate::capture_graph::{CaptureConsumer, CaptureGraphState, CaptureInterval, CaptureSource};
|
|
2
|
+
use crate::native_screen::{
|
|
3
|
+
self, FinalizedNativeArtifact, NativeAudioSelection, NativeFullscreenSaveResult,
|
|
4
|
+
NativeFullscreenUploadResult, NativeMediaSlice, NativeUploadMode,
|
|
5
|
+
};
|
|
6
|
+
use crate::screen_memory::{self, ScreenMemorySegmentMetadata};
|
|
7
|
+
use chrono::{DateTime, Utc};
|
|
8
|
+
use serde::Serialize;
|
|
9
|
+
use std::collections::BTreeSet;
|
|
10
|
+
use std::path::PathBuf;
|
|
11
|
+
#[cfg(target_os = "macos")]
|
|
12
|
+
use std::process::{Command, Stdio};
|
|
13
|
+
use std::sync::Mutex;
|
|
14
|
+
use tauri::{AppHandle, Manager, State};
|
|
15
|
+
|
|
16
|
+
const RETROSPECTIVE_30_SECONDS: u64 = 30;
|
|
17
|
+
const RETROSPECTIVE_5_MINUTES: u64 = 5 * 60;
|
|
18
|
+
const REWIND_CLIP_AUDIO_OWNER: &str = "rewind-clip";
|
|
19
|
+
|
|
20
|
+
#[derive(Default)]
|
|
21
|
+
pub(crate) struct RewindClipState(Mutex<Option<ActiveRewindClip>>);
|
|
22
|
+
|
|
23
|
+
pub(crate) fn is_active(app: &AppHandle) -> bool {
|
|
24
|
+
app.try_state::<RewindClipState>()
|
|
25
|
+
.and_then(|state| {
|
|
26
|
+
state
|
|
27
|
+
.0
|
|
28
|
+
.lock()
|
|
29
|
+
.ok()
|
|
30
|
+
.map(|active| active.as_ref().is_some_and(|clip| clip.activated))
|
|
31
|
+
})
|
|
32
|
+
.unwrap_or(false)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
struct ActiveRewindClip {
|
|
36
|
+
activated: bool,
|
|
37
|
+
lease_id: Option<String>,
|
|
38
|
+
pin_id: String,
|
|
39
|
+
started_elapsed_ms: u64,
|
|
40
|
+
pinned_segments: BTreeSet<String>,
|
|
41
|
+
sources: Vec<CaptureSource>,
|
|
42
|
+
retrospective_seconds: u64,
|
|
43
|
+
intervals: Vec<CaptureInterval>,
|
|
44
|
+
paused: bool,
|
|
45
|
+
temporary_audio: Option<screen_memory::TemporaryAudioLease>,
|
|
46
|
+
#[cfg(target_os = "macos")]
|
|
47
|
+
shared_sink: Option<native_screen::SharedClipSink>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[derive(Clone, Debug, Serialize)]
|
|
51
|
+
#[serde(rename_all = "kebab-case")]
|
|
52
|
+
pub(crate) enum RewindClipCompatibility {
|
|
53
|
+
Compatible,
|
|
54
|
+
NotCompatible,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[derive(Clone, Debug, Serialize)]
|
|
58
|
+
#[serde(rename_all = "camelCase")]
|
|
59
|
+
pub(crate) struct RewindClipStatus {
|
|
60
|
+
compatibility: RewindClipCompatibility,
|
|
61
|
+
active: bool,
|
|
62
|
+
retrospective_seconds: u64,
|
|
63
|
+
paused: bool,
|
|
64
|
+
sources: Vec<CaptureSource>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn not_compatible(detail: impl AsRef<str>) -> String {
|
|
68
|
+
format!("rewind-not-compatible: {}", detail.as_ref())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[tauri::command]
|
|
72
|
+
pub(crate) fn rewind_clip_status(
|
|
73
|
+
app: AppHandle,
|
|
74
|
+
state: State<'_, RewindClipState>,
|
|
75
|
+
) -> Result<RewindClipStatus, String> {
|
|
76
|
+
let active_guard = state.0.lock().map_err(|error| error.to_string())?;
|
|
77
|
+
let active = active_guard.as_ref().is_some_and(|clip| clip.activated);
|
|
78
|
+
let compatible = screen_memory::rewind_clip_compatible(&app)?;
|
|
79
|
+
Ok(RewindClipStatus {
|
|
80
|
+
compatibility: if compatible {
|
|
81
|
+
RewindClipCompatibility::Compatible
|
|
82
|
+
} else {
|
|
83
|
+
RewindClipCompatibility::NotCompatible
|
|
84
|
+
},
|
|
85
|
+
active,
|
|
86
|
+
retrospective_seconds: active_guard
|
|
87
|
+
.as_ref()
|
|
88
|
+
.map(|active| active.retrospective_seconds)
|
|
89
|
+
.unwrap_or(0),
|
|
90
|
+
paused: active_guard.as_ref().is_some_and(|active| active.paused),
|
|
91
|
+
sources: active_guard
|
|
92
|
+
.as_ref()
|
|
93
|
+
.map(|active| active.sources.clone())
|
|
94
|
+
.unwrap_or_else(|| screen_memory::rewind_clip_sources(&app)),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Perform every potentially slow operation before the numeric countdown.
|
|
99
|
+
/// The resulting secondary writer shares Rewind's physical producer but is
|
|
100
|
+
/// still closed to samples until `rewind_clip_start` activates it at zero.
|
|
101
|
+
#[tauri::command]
|
|
102
|
+
pub(crate) fn rewind_clip_prepare(
|
|
103
|
+
app: AppHandle,
|
|
104
|
+
state: State<'_, RewindClipState>,
|
|
105
|
+
artifact_label: String,
|
|
106
|
+
server_url: Option<String>,
|
|
107
|
+
recording_id: Option<String>,
|
|
108
|
+
auth_token: Option<String>,
|
|
109
|
+
cookie: Option<String>,
|
|
110
|
+
include_mic: bool,
|
|
111
|
+
include_system_audio: bool,
|
|
112
|
+
has_camera: bool,
|
|
113
|
+
) -> Result<RewindClipStatus, String> {
|
|
114
|
+
if !screen_memory::rewind_clip_compatible(&app)? {
|
|
115
|
+
return Err(not_compatible(
|
|
116
|
+
"Screen Memory is unavailable or not recording",
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
if state.0.lock().map_err(|error| error.to_string())?.is_some() {
|
|
120
|
+
return Err("a Rewind-derived clip is already prepared or active".into());
|
|
121
|
+
}
|
|
122
|
+
let temporary_audio = if include_mic || include_system_audio {
|
|
123
|
+
screen_memory::acquire_temporary_audio_consumer(
|
|
124
|
+
&app,
|
|
125
|
+
REWIND_CLIP_AUDIO_OWNER,
|
|
126
|
+
CaptureConsumer::Clip,
|
|
127
|
+
include_mic,
|
|
128
|
+
include_system_audio,
|
|
129
|
+
)?
|
|
130
|
+
} else {
|
|
131
|
+
None
|
|
132
|
+
};
|
|
133
|
+
if (include_mic || include_system_audio) && temporary_audio.is_none() {
|
|
134
|
+
return Err(not_compatible("Screen Memory has no active audio producer"));
|
|
135
|
+
}
|
|
136
|
+
let sources = screen_memory::rewind_clip_sources(&app);
|
|
137
|
+
let output = artifact_path(&app, &artifact_label)?;
|
|
138
|
+
#[cfg(target_os = "macos")]
|
|
139
|
+
let shared_sink = match screen_memory::prepare_shared_clip_sink(
|
|
140
|
+
&app,
|
|
141
|
+
output,
|
|
142
|
+
include_mic,
|
|
143
|
+
include_system_audio,
|
|
144
|
+
has_camera,
|
|
145
|
+
native_screen::ClipLiveUploadConfig {
|
|
146
|
+
server_url,
|
|
147
|
+
recording_id,
|
|
148
|
+
auth_token,
|
|
149
|
+
cookie,
|
|
150
|
+
},
|
|
151
|
+
) {
|
|
152
|
+
Ok(sink) => sink,
|
|
153
|
+
Err(error) => {
|
|
154
|
+
release_temporary_audio(&app, temporary_audio);
|
|
155
|
+
return Err(error);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
#[cfg(not(target_os = "macos"))]
|
|
159
|
+
{
|
|
160
|
+
let _ = (
|
|
161
|
+
output,
|
|
162
|
+
server_url,
|
|
163
|
+
recording_id,
|
|
164
|
+
auth_token,
|
|
165
|
+
cookie,
|
|
166
|
+
has_camera,
|
|
167
|
+
);
|
|
168
|
+
release_temporary_audio(&app, temporary_audio);
|
|
169
|
+
return Err(not_compatible("shared Rewind Clip sinks require macOS"));
|
|
170
|
+
}
|
|
171
|
+
let response_sources = sources.clone();
|
|
172
|
+
let mut active = state.0.lock().map_err(|error| error.to_string())?;
|
|
173
|
+
if active.is_some() {
|
|
174
|
+
drop(active);
|
|
175
|
+
#[cfg(target_os = "macos")]
|
|
176
|
+
shared_sink.cancel();
|
|
177
|
+
release_temporary_audio(&app, temporary_audio);
|
|
178
|
+
return Err("a Rewind-derived clip is already prepared or active".into());
|
|
179
|
+
}
|
|
180
|
+
*active = Some(ActiveRewindClip {
|
|
181
|
+
activated: false,
|
|
182
|
+
pin_id: format!("rewind-clip-{}", Utc::now().timestamp_micros()),
|
|
183
|
+
lease_id: None,
|
|
184
|
+
started_elapsed_ms: 0,
|
|
185
|
+
pinned_segments: BTreeSet::new(),
|
|
186
|
+
sources,
|
|
187
|
+
retrospective_seconds: 0,
|
|
188
|
+
intervals: Vec::new(),
|
|
189
|
+
paused: false,
|
|
190
|
+
temporary_audio,
|
|
191
|
+
#[cfg(target_os = "macos")]
|
|
192
|
+
shared_sink: Some(shared_sink),
|
|
193
|
+
});
|
|
194
|
+
Ok(RewindClipStatus {
|
|
195
|
+
compatibility: RewindClipCompatibility::Compatible,
|
|
196
|
+
active: false,
|
|
197
|
+
retrospective_seconds: 0,
|
|
198
|
+
paused: false,
|
|
199
|
+
sources: response_sources,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Countdown zero/Enter boundary. Preparation has already installed the Clip
|
|
204
|
+
/// writer, so this path performs only in-memory graph and callback admission.
|
|
205
|
+
#[tauri::command]
|
|
206
|
+
pub(crate) fn rewind_clip_start(
|
|
207
|
+
app: AppHandle,
|
|
208
|
+
state: State<'_, RewindClipState>,
|
|
209
|
+
) -> Result<RewindClipStatus, String> {
|
|
210
|
+
let started = std::time::Instant::now();
|
|
211
|
+
let mut active_guard = state.0.lock().map_err(|error| error.to_string())?;
|
|
212
|
+
let active = active_guard
|
|
213
|
+
.as_mut()
|
|
214
|
+
.ok_or_else(|| "no prepared Rewind-derived clip is available".to_string())?;
|
|
215
|
+
if active.activated {
|
|
216
|
+
return Err("a Rewind-derived clip is already active".into());
|
|
217
|
+
}
|
|
218
|
+
let boundary = std::time::Instant::now();
|
|
219
|
+
let lease = app
|
|
220
|
+
.state::<CaptureGraphState>()
|
|
221
|
+
.0
|
|
222
|
+
.lock()
|
|
223
|
+
.map_err(|error| error.to_string())?
|
|
224
|
+
.start_consumer_at(
|
|
225
|
+
CaptureConsumer::Clip,
|
|
226
|
+
active.sources.iter().copied(),
|
|
227
|
+
boundary,
|
|
228
|
+
)
|
|
229
|
+
.map_err(|error| error.to_string())?;
|
|
230
|
+
#[cfg(target_os = "macos")]
|
|
231
|
+
if let Err(error) = active
|
|
232
|
+
.shared_sink
|
|
233
|
+
.as_mut()
|
|
234
|
+
.ok_or_else(|| "prepared Rewind Clip sink is missing".to_string())?
|
|
235
|
+
.activate()
|
|
236
|
+
{
|
|
237
|
+
end_clip_graph_lease(&app, &lease.id);
|
|
238
|
+
return Err(error);
|
|
239
|
+
}
|
|
240
|
+
active.lease_id = Some(lease.id);
|
|
241
|
+
active.started_elapsed_ms = lease.interval.started_elapsed_ms;
|
|
242
|
+
active.activated = true;
|
|
243
|
+
crate::logfile::diagnostic(&format!(
|
|
244
|
+
"[rewind-latency] start boundary accepted in {}ms",
|
|
245
|
+
started.elapsed().as_millis()
|
|
246
|
+
));
|
|
247
|
+
Ok(RewindClipStatus {
|
|
248
|
+
compatibility: RewindClipCompatibility::Compatible,
|
|
249
|
+
active: true,
|
|
250
|
+
retrospective_seconds: 0,
|
|
251
|
+
paused: false,
|
|
252
|
+
sources: active.sources.clone(),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn release_temporary_audio(app: &AppHandle, lease: Option<screen_memory::TemporaryAudioLease>) {
|
|
257
|
+
if let Some(lease) = lease {
|
|
258
|
+
if let Err(error) = screen_memory::release_temporary_audio_consumer(app, lease) {
|
|
259
|
+
eprintln!("[clips-tray] Rewind Clip audio lease release failed: {error}");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn end_clip_graph_lease(app: &AppHandle, lease_id: &str) {
|
|
265
|
+
if let Ok(mut graph) = app.state::<CaptureGraphState>().0.lock() {
|
|
266
|
+
let _ = graph.end_consumer(lease_id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#[tauri::command]
|
|
271
|
+
pub(crate) fn rewind_clip_extend(
|
|
272
|
+
app: AppHandle,
|
|
273
|
+
state: State<'_, RewindClipState>,
|
|
274
|
+
seconds: u64,
|
|
275
|
+
) -> Result<RewindClipStatus, String> {
|
|
276
|
+
if !matches!(seconds, RETROSPECTIVE_30_SECONDS | RETROSPECTIVE_5_MINUTES) {
|
|
277
|
+
return Err("retrospective extension must be exactly 30 or 300 seconds".into());
|
|
278
|
+
}
|
|
279
|
+
let lease_id = state
|
|
280
|
+
.0
|
|
281
|
+
.lock()
|
|
282
|
+
.map_err(|error| error.to_string())?
|
|
283
|
+
.as_ref()
|
|
284
|
+
.and_then(|active| active.lease_id.clone())
|
|
285
|
+
.ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
|
|
286
|
+
let lease = app
|
|
287
|
+
.state::<CaptureGraphState>()
|
|
288
|
+
.0
|
|
289
|
+
.lock()
|
|
290
|
+
.map_err(|error| error.to_string())?
|
|
291
|
+
.extend_retrospectively(&lease_id, std::time::Duration::from_secs(seconds))
|
|
292
|
+
.map_err(|error| error.to_string())?;
|
|
293
|
+
{
|
|
294
|
+
let mut active = state.0.lock().map_err(|error| error.to_string())?;
|
|
295
|
+
let active = active
|
|
296
|
+
.as_mut()
|
|
297
|
+
.ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
|
|
298
|
+
active.started_elapsed_ms = lease.interval.started_elapsed_ms;
|
|
299
|
+
active.retrospective_seconds = seconds;
|
|
300
|
+
for segment in screen_memory::finalized_segments_in_graph_interval(
|
|
301
|
+
&app,
|
|
302
|
+
lease.interval.started_elapsed_ms,
|
|
303
|
+
lease.interval.ended_elapsed_ms,
|
|
304
|
+
)? {
|
|
305
|
+
screen_memory::pin_segment(&app, &segment.id, &active.pin_id)?;
|
|
306
|
+
active.pinned_segments.insert(segment.id);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
Ok(RewindClipStatus {
|
|
310
|
+
compatibility: RewindClipCompatibility::Compatible,
|
|
311
|
+
active: true,
|
|
312
|
+
retrospective_seconds: lease.retrospective_extension_ms / 1000,
|
|
313
|
+
paused: false,
|
|
314
|
+
sources: state
|
|
315
|
+
.0
|
|
316
|
+
.lock()
|
|
317
|
+
.map_err(|error| error.to_string())?
|
|
318
|
+
.as_ref()
|
|
319
|
+
.map(|active| active.sources.clone())
|
|
320
|
+
.unwrap_or_default(),
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#[tauri::command]
|
|
325
|
+
pub(crate) fn rewind_clip_pause(
|
|
326
|
+
app: AppHandle,
|
|
327
|
+
state: State<'_, RewindClipState>,
|
|
328
|
+
) -> Result<RewindClipStatus, String> {
|
|
329
|
+
let mut active_guard = state.0.lock().map_err(|error| error.to_string())?;
|
|
330
|
+
let active = active_guard
|
|
331
|
+
.as_mut()
|
|
332
|
+
.ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
|
|
333
|
+
if !active.activated || active.paused {
|
|
334
|
+
return Err("Rewind-derived clip is already paused".into());
|
|
335
|
+
}
|
|
336
|
+
let lease_id = active
|
|
337
|
+
.lease_id
|
|
338
|
+
.clone()
|
|
339
|
+
.ok_or_else(|| "active Clip lease is missing".to_string())?;
|
|
340
|
+
let sources = active.sources.clone();
|
|
341
|
+
let retrospective_seconds = active.retrospective_seconds;
|
|
342
|
+
#[cfg(target_os = "macos")]
|
|
343
|
+
let sink = active
|
|
344
|
+
.shared_sink
|
|
345
|
+
.as_ref()
|
|
346
|
+
.ok_or_else(|| "active Rewind Clip sink is missing".to_string())?;
|
|
347
|
+
#[cfg(target_os = "macos")]
|
|
348
|
+
sink.pause()?;
|
|
349
|
+
|
|
350
|
+
let close_result = app
|
|
351
|
+
.state::<CaptureGraphState>()
|
|
352
|
+
.0
|
|
353
|
+
.lock()
|
|
354
|
+
.map_err(|error| error.to_string())
|
|
355
|
+
.and_then(|mut graph| {
|
|
356
|
+
graph
|
|
357
|
+
.end_consumer(&lease_id)
|
|
358
|
+
.map_err(|error| error.to_string())
|
|
359
|
+
});
|
|
360
|
+
let closed = match close_result {
|
|
361
|
+
Ok(closed) => closed,
|
|
362
|
+
Err(error) => {
|
|
363
|
+
#[cfg(target_os = "macos")]
|
|
364
|
+
if let Err(rollback_error) = sink.resume() {
|
|
365
|
+
return Err(format!(
|
|
366
|
+
"{error}. Clip pause rollback also failed: {rollback_error}"
|
|
367
|
+
));
|
|
368
|
+
}
|
|
369
|
+
return Err(error);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
let interval = closed.lease.interval;
|
|
373
|
+
active.intervals.push(interval);
|
|
374
|
+
active.lease_id = None;
|
|
375
|
+
active.paused = true;
|
|
376
|
+
Ok(RewindClipStatus {
|
|
377
|
+
compatibility: RewindClipCompatibility::Compatible,
|
|
378
|
+
active: true,
|
|
379
|
+
retrospective_seconds,
|
|
380
|
+
paused: true,
|
|
381
|
+
sources,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#[tauri::command]
|
|
386
|
+
pub(crate) fn rewind_clip_resume(
|
|
387
|
+
app: AppHandle,
|
|
388
|
+
state: State<'_, RewindClipState>,
|
|
389
|
+
) -> Result<RewindClipStatus, String> {
|
|
390
|
+
let sources = {
|
|
391
|
+
let active = state.0.lock().map_err(|e| e.to_string())?;
|
|
392
|
+
let active = active
|
|
393
|
+
.as_ref()
|
|
394
|
+
.ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
|
|
395
|
+
if !active.paused {
|
|
396
|
+
return Err("Rewind-derived clip is not paused".into());
|
|
397
|
+
}
|
|
398
|
+
active.sources.clone()
|
|
399
|
+
};
|
|
400
|
+
let lease = match app
|
|
401
|
+
.state::<CaptureGraphState>()
|
|
402
|
+
.0
|
|
403
|
+
.lock()
|
|
404
|
+
.map_err(|e| e.to_string())?
|
|
405
|
+
.start_consumer(CaptureConsumer::Clip, sources.iter().copied())
|
|
406
|
+
{
|
|
407
|
+
Ok(lease) => lease,
|
|
408
|
+
Err(error) => return Err(error.to_string()),
|
|
409
|
+
};
|
|
410
|
+
let mut active = state.0.lock().map_err(|e| e.to_string())?;
|
|
411
|
+
let active = active
|
|
412
|
+
.as_mut()
|
|
413
|
+
.ok_or_else(|| "no Rewind-derived clip is active".to_string())?;
|
|
414
|
+
#[cfg(target_os = "macos")]
|
|
415
|
+
if let Err(error) = active
|
|
416
|
+
.shared_sink
|
|
417
|
+
.as_ref()
|
|
418
|
+
.ok_or_else(|| "active Rewind Clip sink is missing".to_string())?
|
|
419
|
+
.resume()
|
|
420
|
+
{
|
|
421
|
+
end_clip_graph_lease(&app, &lease.id);
|
|
422
|
+
return Err(error);
|
|
423
|
+
}
|
|
424
|
+
active.lease_id = Some(lease.id);
|
|
425
|
+
active.started_elapsed_ms = lease.interval.started_elapsed_ms;
|
|
426
|
+
active.sources = sources;
|
|
427
|
+
active.paused = false;
|
|
428
|
+
Ok(RewindClipStatus {
|
|
429
|
+
compatibility: RewindClipCompatibility::Compatible,
|
|
430
|
+
active: true,
|
|
431
|
+
retrospective_seconds: active.retrospective_seconds,
|
|
432
|
+
paused: false,
|
|
433
|
+
sources: active.sources.clone(),
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
fn take_active(state: &State<'_, RewindClipState>) -> Result<ActiveRewindClip, String> {
|
|
438
|
+
state
|
|
439
|
+
.0
|
|
440
|
+
.lock()
|
|
441
|
+
.map_err(|error| error.to_string())?
|
|
442
|
+
.take()
|
|
443
|
+
.ok_or_else(|| "no Rewind-derived clip is active".to_string())
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn close_direct_clip_interval(
|
|
447
|
+
app: &AppHandle,
|
|
448
|
+
active: &mut ActiveRewindClip,
|
|
449
|
+
) -> Result<(), String> {
|
|
450
|
+
if !active.activated {
|
|
451
|
+
return Err("prepared Rewind-derived clip never reached countdown zero".into());
|
|
452
|
+
}
|
|
453
|
+
#[cfg(target_os = "macos")]
|
|
454
|
+
active
|
|
455
|
+
.shared_sink
|
|
456
|
+
.as_ref()
|
|
457
|
+
.ok_or_else(|| "active Rewind Clip sink is missing".to_string())?
|
|
458
|
+
.deactivate();
|
|
459
|
+
if let Some(lease_id) = active.lease_id.take() {
|
|
460
|
+
let closed = app
|
|
461
|
+
.state::<CaptureGraphState>()
|
|
462
|
+
.0
|
|
463
|
+
.lock()
|
|
464
|
+
.map_err(|error| error.to_string())?
|
|
465
|
+
.end_consumer(&lease_id)
|
|
466
|
+
.map_err(|error| error.to_string())?;
|
|
467
|
+
active.intervals.push(closed.lease.interval);
|
|
468
|
+
}
|
|
469
|
+
active.paused = false;
|
|
470
|
+
Ok(())
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn release_active_resources(app: &AppHandle, active: &mut ActiveRewindClip) {
|
|
474
|
+
for segment_id in std::mem::take(&mut active.pinned_segments) {
|
|
475
|
+
let _ = screen_memory::unpin_segment(app, &segment_id, &active.pin_id);
|
|
476
|
+
}
|
|
477
|
+
release_temporary_audio(app, active.temporary_audio.take());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn artifact_path(app: &AppHandle, label: &str) -> Result<PathBuf, String> {
|
|
481
|
+
let dir = app
|
|
482
|
+
.path()
|
|
483
|
+
.app_local_data_dir()
|
|
484
|
+
.map_err(|error| format!("local data directory unavailable: {error}"))?
|
|
485
|
+
.join("pending-recordings");
|
|
486
|
+
std::fs::create_dir_all(&dir)
|
|
487
|
+
.map_err(|error| format!("pending recording directory unavailable: {error}"))?;
|
|
488
|
+
Ok(dir.join(format!(
|
|
489
|
+
"rewind-{label}-{}.mp4",
|
|
490
|
+
chrono::Utc::now().timestamp_millis()
|
|
491
|
+
)))
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
fn validate_and_plan(
|
|
495
|
+
segments: &[ScreenMemorySegmentMetadata],
|
|
496
|
+
start_ms: u64,
|
|
497
|
+
end_ms: u64,
|
|
498
|
+
) -> Result<Vec<NativeMediaSlice>, String> {
|
|
499
|
+
const MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS: u64 = 2;
|
|
500
|
+
let mut segments = segments.to_vec();
|
|
501
|
+
segments.sort_by_key(|segment| segment.graph_started_elapsed_ms);
|
|
502
|
+
let mut cursor = start_ms;
|
|
503
|
+
let mut slices = Vec::new();
|
|
504
|
+
for segment in segments {
|
|
505
|
+
if segment.corrupt || segment.error.is_some() || segment.exclusion_tainted {
|
|
506
|
+
return Err("selected Rewind media is corrupt, tainted, or incomplete".into());
|
|
507
|
+
}
|
|
508
|
+
let overlap_start = start_ms.max(segment.graph_started_elapsed_ms);
|
|
509
|
+
let overlap_end = end_ms.min(segment.graph_ended_elapsed_ms);
|
|
510
|
+
if overlap_start >= overlap_end {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if overlap_start.saturating_sub(cursor) > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS {
|
|
514
|
+
return Err("selected Rewind interval contains a coverage gap".into());
|
|
515
|
+
}
|
|
516
|
+
slices.push(NativeMediaSlice {
|
|
517
|
+
path: segment.path,
|
|
518
|
+
system_audio_path: segment.system_audio_path,
|
|
519
|
+
microphone_path: segment.microphone_path,
|
|
520
|
+
start_ms: overlap_start - segment.graph_started_elapsed_ms,
|
|
521
|
+
end_ms: overlap_end - segment.graph_started_elapsed_ms,
|
|
522
|
+
});
|
|
523
|
+
cursor = cursor.max(overlap_end);
|
|
524
|
+
}
|
|
525
|
+
if end_ms.saturating_sub(cursor) > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS || slices.is_empty() {
|
|
526
|
+
return Err("selected Rewind interval is not fully covered".into());
|
|
527
|
+
}
|
|
528
|
+
Ok(slices)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
fn validate_and_plan_wall_clock(
|
|
532
|
+
segments: &[ScreenMemorySegmentMetadata],
|
|
533
|
+
started: DateTime<Utc>,
|
|
534
|
+
ended: DateTime<Utc>,
|
|
535
|
+
) -> Result<(Vec<NativeMediaSlice>, Vec<ScreenMemorySegmentMetadata>), String> {
|
|
536
|
+
const MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS: i64 = 2;
|
|
537
|
+
let mut timed = segments
|
|
538
|
+
.iter()
|
|
539
|
+
.filter_map(|segment| {
|
|
540
|
+
let segment_started = DateTime::parse_from_rfc3339(&segment.started_at)
|
|
541
|
+
.ok()?
|
|
542
|
+
.with_timezone(&Utc);
|
|
543
|
+
let segment_ended = DateTime::parse_from_rfc3339(&segment.ended_at)
|
|
544
|
+
.ok()?
|
|
545
|
+
.with_timezone(&Utc);
|
|
546
|
+
Some((segment_started, segment_ended, segment.clone()))
|
|
547
|
+
})
|
|
548
|
+
.collect::<Vec<_>>();
|
|
549
|
+
timed.sort_by_key(|(segment_started, _, _)| *segment_started);
|
|
550
|
+
|
|
551
|
+
let mut cursor = started;
|
|
552
|
+
let mut slices = Vec::new();
|
|
553
|
+
let mut selected = Vec::new();
|
|
554
|
+
let mut previous: Option<(Option<String>, u64, DateTime<Utc>)> = None;
|
|
555
|
+
for (wall_started, wall_ended, segment) in timed {
|
|
556
|
+
let graph_duration_ms = segment
|
|
557
|
+
.graph_ended_elapsed_ms
|
|
558
|
+
.checked_sub(segment.graph_started_elapsed_ms)
|
|
559
|
+
.filter(|duration| *duration > 0);
|
|
560
|
+
let duration_ms = graph_duration_ms.unwrap_or_else(|| {
|
|
561
|
+
wall_ended
|
|
562
|
+
.signed_duration_since(wall_started)
|
|
563
|
+
.num_milliseconds()
|
|
564
|
+
.max(0) as u64
|
|
565
|
+
});
|
|
566
|
+
let normalized_started = previous
|
|
567
|
+
.as_ref()
|
|
568
|
+
.and_then(
|
|
569
|
+
|(previous_epoch, previous_graph_end, previous_normalized_end)| {
|
|
570
|
+
let same_epoch = segment.graph_epoch_id.is_some()
|
|
571
|
+
&& segment.graph_epoch_id == *previous_epoch;
|
|
572
|
+
let graph_skew = segment
|
|
573
|
+
.graph_started_elapsed_ms
|
|
574
|
+
.abs_diff(*previous_graph_end);
|
|
575
|
+
(same_epoch && graph_skew <= 2).then_some(*previous_normalized_end)
|
|
576
|
+
},
|
|
577
|
+
)
|
|
578
|
+
.unwrap_or(wall_started);
|
|
579
|
+
let normalized_ended = normalized_started
|
|
580
|
+
+ chrono::Duration::milliseconds(i64::try_from(duration_ms).unwrap_or(i64::MAX));
|
|
581
|
+
previous = Some((
|
|
582
|
+
segment.graph_epoch_id.clone(),
|
|
583
|
+
segment.graph_ended_elapsed_ms,
|
|
584
|
+
normalized_ended,
|
|
585
|
+
));
|
|
586
|
+
|
|
587
|
+
let overlap_start = started.max(normalized_started).max(cursor);
|
|
588
|
+
let overlap_end = ended.min(normalized_ended);
|
|
589
|
+
if overlap_start >= overlap_end {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if segment.corrupt || segment.error.is_some() || segment.exclusion_tainted {
|
|
593
|
+
return Err("selected Rewind media is corrupt, tainted, or incomplete".into());
|
|
594
|
+
}
|
|
595
|
+
if overlap_start
|
|
596
|
+
.signed_duration_since(cursor)
|
|
597
|
+
.num_milliseconds()
|
|
598
|
+
> MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS
|
|
599
|
+
{
|
|
600
|
+
return Err("selected Rewind interval contains a coverage gap".into());
|
|
601
|
+
}
|
|
602
|
+
let start_ms = overlap_start
|
|
603
|
+
.signed_duration_since(normalized_started)
|
|
604
|
+
.num_milliseconds()
|
|
605
|
+
.max(0) as u64;
|
|
606
|
+
let end_ms = overlap_end
|
|
607
|
+
.signed_duration_since(normalized_started)
|
|
608
|
+
.num_milliseconds()
|
|
609
|
+
.max(0) as u64;
|
|
610
|
+
slices.push(NativeMediaSlice {
|
|
611
|
+
path: segment.path.clone(),
|
|
612
|
+
system_audio_path: segment.system_audio_path.clone(),
|
|
613
|
+
microphone_path: segment.microphone_path.clone(),
|
|
614
|
+
start_ms,
|
|
615
|
+
end_ms,
|
|
616
|
+
});
|
|
617
|
+
selected.push(segment);
|
|
618
|
+
cursor = cursor.max(overlap_end);
|
|
619
|
+
}
|
|
620
|
+
if ended.signed_duration_since(cursor).num_milliseconds() > MAX_SEGMENT_BOUNDARY_QUANTIZATION_MS
|
|
621
|
+
|| slices.is_empty()
|
|
622
|
+
{
|
|
623
|
+
return Err("selected Rewind interval is not fully covered".into());
|
|
624
|
+
}
|
|
625
|
+
Ok((slices, selected))
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fn select_audio(
|
|
629
|
+
sources: &[CaptureSource],
|
|
630
|
+
include_mic: bool,
|
|
631
|
+
include_system_audio: bool,
|
|
632
|
+
) -> Result<NativeAudioSelection, String> {
|
|
633
|
+
if (include_mic && !sources.contains(&CaptureSource::Microphone))
|
|
634
|
+
|| (include_system_audio && !sources.contains(&CaptureSource::SystemAudio))
|
|
635
|
+
{
|
|
636
|
+
return Err(not_compatible("requested audio was not captured by Rewind"));
|
|
637
|
+
}
|
|
638
|
+
Ok(match (include_mic, include_system_audio) {
|
|
639
|
+
(false, false) => NativeAudioSelection::None,
|
|
640
|
+
(false, true) => NativeAudioSelection::System,
|
|
641
|
+
(true, false) => NativeAudioSelection::Microphone,
|
|
642
|
+
(true, true) => NativeAudioSelection::MixBoth,
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/// Materialize an explicit recent range as one exact local MP4. This powers
|
|
647
|
+
/// “Save what just happened” without copying whole five-minute container
|
|
648
|
+
/// segments (which could otherwise retain media from before the chosen range).
|
|
649
|
+
pub(crate) fn materialize_recent_exact(
|
|
650
|
+
app: &AppHandle,
|
|
651
|
+
duration: std::time::Duration,
|
|
652
|
+
output: PathBuf,
|
|
653
|
+
) -> Result<FinalizedNativeArtifact, String> {
|
|
654
|
+
let duration_ms = u64::try_from(duration.as_millis())
|
|
655
|
+
.map_err(|_| "requested Rewind export is too long".to_string())?;
|
|
656
|
+
if duration_ms == 0 || duration > crate::capture_graph::MAX_RETROSPECTIVE_EXTENSION {
|
|
657
|
+
return Err("Rewind export must be between one millisecond and five minutes".into());
|
|
658
|
+
}
|
|
659
|
+
let fenced = screen_memory::fence_active_for_clip(app)?;
|
|
660
|
+
let end_ms = fenced.graph_ended_elapsed_ms;
|
|
661
|
+
let start_ms = end_ms
|
|
662
|
+
.checked_sub(duration_ms)
|
|
663
|
+
.ok_or_else(|| "Rewind does not yet contain the full requested range".to_string())?;
|
|
664
|
+
materialize_graph_interval_exact(
|
|
665
|
+
app,
|
|
666
|
+
start_ms,
|
|
667
|
+
end_ms,
|
|
668
|
+
fenced.started_at,
|
|
669
|
+
fenced.ended_at,
|
|
670
|
+
output,
|
|
671
|
+
"explicit recent Rewind export",
|
|
672
|
+
None,
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
fn materialize_wall_clock_exact(
|
|
677
|
+
app: &AppHandle,
|
|
678
|
+
started_at: &str,
|
|
679
|
+
ended_at: &str,
|
|
680
|
+
output: PathBuf,
|
|
681
|
+
include_mic: bool,
|
|
682
|
+
include_system_audio: bool,
|
|
683
|
+
) -> Result<FinalizedNativeArtifact, String> {
|
|
684
|
+
let started = DateTime::parse_from_rfc3339(started_at)
|
|
685
|
+
.map_err(|_| "Rewind handoff start must be an RFC3339 timestamp.".to_string())?
|
|
686
|
+
.with_timezone(&Utc);
|
|
687
|
+
let ended = DateTime::parse_from_rfc3339(ended_at)
|
|
688
|
+
.map_err(|_| "Rewind handoff end must be an RFC3339 timestamp.".to_string())?
|
|
689
|
+
.with_timezone(&Utc);
|
|
690
|
+
let duration = ended.signed_duration_since(started);
|
|
691
|
+
if duration.num_milliseconds() < 1
|
|
692
|
+
|| duration.num_milliseconds()
|
|
693
|
+
> i64::try_from(crate::capture_graph::MAX_RETROSPECTIVE_EXTENSION.as_millis())
|
|
694
|
+
.unwrap_or(i64::MAX)
|
|
695
|
+
{
|
|
696
|
+
return Err("Rewind handoff must be between one millisecond and five minutes.".into());
|
|
697
|
+
}
|
|
698
|
+
let materialize = || {
|
|
699
|
+
materialize_retained_wall_clock_exact(
|
|
700
|
+
app,
|
|
701
|
+
started,
|
|
702
|
+
ended,
|
|
703
|
+
output.clone(),
|
|
704
|
+
include_mic,
|
|
705
|
+
include_system_audio,
|
|
706
|
+
)
|
|
707
|
+
};
|
|
708
|
+
match materialize() {
|
|
709
|
+
Ok(artifact) => Ok(artifact),
|
|
710
|
+
Err(initial_error) => {
|
|
711
|
+
// A request ending near “now” may overlap the still-open segment.
|
|
712
|
+
// Fence once and retry. Older retained ranges never depend on the
|
|
713
|
+
// current capture graph, so they survive app restarts and pauses.
|
|
714
|
+
if Utc::now().signed_duration_since(ended).num_minutes() <= 6
|
|
715
|
+
&& screen_memory::fence_active_for_clip(app).is_ok()
|
|
716
|
+
{
|
|
717
|
+
materialize()
|
|
718
|
+
} else {
|
|
719
|
+
Err(initial_error)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
fn materialize_retained_wall_clock_exact(
|
|
726
|
+
app: &AppHandle,
|
|
727
|
+
started: DateTime<Utc>,
|
|
728
|
+
ended: DateTime<Utc>,
|
|
729
|
+
output: PathBuf,
|
|
730
|
+
include_mic: bool,
|
|
731
|
+
include_system_audio: bool,
|
|
732
|
+
) -> Result<FinalizedNativeArtifact, String> {
|
|
733
|
+
let segments = screen_memory::finalized_segments(app)?;
|
|
734
|
+
let (slices, selected) = validate_and_plan_wall_clock(&segments, started, ended)?;
|
|
735
|
+
let pin_id = format!("rewind-wall-clock-{}", Utc::now().timestamp_micros());
|
|
736
|
+
let mut pinned_segments = Vec::new();
|
|
737
|
+
let outcome = (|| {
|
|
738
|
+
for segment in &selected {
|
|
739
|
+
screen_memory::pin_segment(app, &segment.id, &pin_id)?;
|
|
740
|
+
pinned_segments.push(segment.id.clone());
|
|
741
|
+
}
|
|
742
|
+
let audio_available = selected
|
|
743
|
+
.iter()
|
|
744
|
+
.all(|segment| segment.capture_mode == crate::config::RewindCaptureMode::VisualsAudio);
|
|
745
|
+
if (include_mic || include_system_audio) && !audio_available {
|
|
746
|
+
return Err("The selected Rewind range does not contain the requested audio.".into());
|
|
747
|
+
}
|
|
748
|
+
let audio_selection = match (include_mic, include_system_audio) {
|
|
749
|
+
(false, false) => NativeAudioSelection::None,
|
|
750
|
+
(true, false) => NativeAudioSelection::Microphone,
|
|
751
|
+
(false, true) => NativeAudioSelection::System,
|
|
752
|
+
(true, true) => NativeAudioSelection::MixBoth,
|
|
753
|
+
};
|
|
754
|
+
native_screen::materialize_mp4_slices_exact(&slices, &output, audio_selection)?;
|
|
755
|
+
let first = selected
|
|
756
|
+
.first()
|
|
757
|
+
.ok_or_else(|| "no Rewind media selected".to_string())?;
|
|
758
|
+
Ok(FinalizedNativeArtifact::rewind_mp4(
|
|
759
|
+
output,
|
|
760
|
+
ended
|
|
761
|
+
.signed_duration_since(started)
|
|
762
|
+
.num_milliseconds()
|
|
763
|
+
.max(0) as u128,
|
|
764
|
+
first.width,
|
|
765
|
+
first.height,
|
|
766
|
+
include_mic,
|
|
767
|
+
include_system_audio,
|
|
768
|
+
))
|
|
769
|
+
})();
|
|
770
|
+
for segment_id in pinned_segments {
|
|
771
|
+
let _ = screen_memory::unpin_segment(app, &segment_id, &pin_id);
|
|
772
|
+
}
|
|
773
|
+
outcome
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
fn materialize_graph_interval_exact(
|
|
777
|
+
app: &AppHandle,
|
|
778
|
+
start_ms: u64,
|
|
779
|
+
end_ms: u64,
|
|
780
|
+
started_at: String,
|
|
781
|
+
ended_at: String,
|
|
782
|
+
output: PathBuf,
|
|
783
|
+
label: &str,
|
|
784
|
+
requested_audio: Option<(bool, bool)>,
|
|
785
|
+
) -> Result<FinalizedNativeArtifact, String> {
|
|
786
|
+
if end_ms <= start_ms {
|
|
787
|
+
return Err("The selected Rewind handoff range is empty.".into());
|
|
788
|
+
}
|
|
789
|
+
let interval = CaptureInterval {
|
|
790
|
+
started_at,
|
|
791
|
+
ended_at,
|
|
792
|
+
started_elapsed_ms: start_ms,
|
|
793
|
+
ended_elapsed_ms: end_ms,
|
|
794
|
+
};
|
|
795
|
+
let graph_state = app.state::<CaptureGraphState>();
|
|
796
|
+
let (pin_id, has_screen_gap) = {
|
|
797
|
+
let mut graph = graph_state.0.lock().map_err(|error| error.to_string())?;
|
|
798
|
+
let status = graph
|
|
799
|
+
.status_at(std::time::Instant::now())
|
|
800
|
+
.map_err(|error| error.to_string())?;
|
|
801
|
+
let has_gap = status.coverage_gaps.iter().any(|gap| {
|
|
802
|
+
gap.source == CaptureSource::Screen
|
|
803
|
+
&& gap.interval.started_elapsed_ms < end_ms
|
|
804
|
+
&& gap.interval.ended_elapsed_ms > start_ms
|
|
805
|
+
});
|
|
806
|
+
let pin = graph
|
|
807
|
+
.pin_interval(&interval, Some(label.into()))
|
|
808
|
+
.map_err(|error| error.to_string())?;
|
|
809
|
+
(pin.id, has_gap)
|
|
810
|
+
};
|
|
811
|
+
let mut pinned_segments = Vec::new();
|
|
812
|
+
let outcome = (|| {
|
|
813
|
+
if has_screen_gap {
|
|
814
|
+
return Err("selected Rewind interval contains a recorded coverage gap".into());
|
|
815
|
+
}
|
|
816
|
+
let segments = screen_memory::finalized_segments_in_graph_interval(app, start_ms, end_ms)?;
|
|
817
|
+
for segment in &segments {
|
|
818
|
+
screen_memory::pin_segment(app, &segment.id, &pin_id)?;
|
|
819
|
+
pinned_segments.push(segment.id.clone());
|
|
820
|
+
}
|
|
821
|
+
let slices = validate_and_plan(&segments, start_ms, end_ms)?;
|
|
822
|
+
let audio_available = segments
|
|
823
|
+
.iter()
|
|
824
|
+
.all(|segment| segment.capture_mode == crate::config::RewindCaptureMode::VisualsAudio);
|
|
825
|
+
let (include_mic, include_system_audio) =
|
|
826
|
+
requested_audio.unwrap_or((audio_available, audio_available));
|
|
827
|
+
if (include_mic || include_system_audio) && !audio_available {
|
|
828
|
+
return Err("The selected Rewind range does not contain the requested audio.".into());
|
|
829
|
+
}
|
|
830
|
+
let audio_selection = match (include_mic, include_system_audio) {
|
|
831
|
+
(false, false) => NativeAudioSelection::None,
|
|
832
|
+
(true, false) => NativeAudioSelection::Microphone,
|
|
833
|
+
(false, true) => NativeAudioSelection::System,
|
|
834
|
+
(true, true) => NativeAudioSelection::MixBoth,
|
|
835
|
+
};
|
|
836
|
+
native_screen::materialize_mp4_slices_exact(&slices, &output, audio_selection)?;
|
|
837
|
+
let first = segments
|
|
838
|
+
.first()
|
|
839
|
+
.ok_or_else(|| "no Rewind media selected".to_string())?;
|
|
840
|
+
Ok(FinalizedNativeArtifact::rewind_mp4(
|
|
841
|
+
output,
|
|
842
|
+
u128::from(end_ms.saturating_sub(start_ms)),
|
|
843
|
+
first.width,
|
|
844
|
+
first.height,
|
|
845
|
+
include_mic,
|
|
846
|
+
include_system_audio,
|
|
847
|
+
))
|
|
848
|
+
})();
|
|
849
|
+
for segment_id in pinned_segments {
|
|
850
|
+
let _ = screen_memory::unpin_segment(app, &segment_id, &pin_id);
|
|
851
|
+
}
|
|
852
|
+
if let Ok(mut graph) = graph_state.0.lock() {
|
|
853
|
+
graph.release_pin(&pin_id);
|
|
854
|
+
}
|
|
855
|
+
outcome
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
#[tauri::command]
|
|
859
|
+
pub(crate) async fn rewind_agent_handoff_upload(
|
|
860
|
+
app: AppHandle,
|
|
861
|
+
request_id: String,
|
|
862
|
+
started_at: String,
|
|
863
|
+
ended_at: String,
|
|
864
|
+
server_url: String,
|
|
865
|
+
recording_id: String,
|
|
866
|
+
auth_token: Option<String>,
|
|
867
|
+
cookie: Option<String>,
|
|
868
|
+
upload_mode: Option<String>,
|
|
869
|
+
include_mic: bool,
|
|
870
|
+
include_system_audio: bool,
|
|
871
|
+
) -> Result<NativeFullscreenUploadResult, String> {
|
|
872
|
+
let safe_request_id: String = request_id
|
|
873
|
+
.chars()
|
|
874
|
+
.filter(|character| {
|
|
875
|
+
character.is_ascii_alphanumeric() || *character == '-' || *character == '_'
|
|
876
|
+
})
|
|
877
|
+
.collect();
|
|
878
|
+
if safe_request_id != request_id || !safe_request_id.starts_with("handoff-") {
|
|
879
|
+
return Err("Invalid Rewind handoff request ID.".into());
|
|
880
|
+
}
|
|
881
|
+
let output_dir = app
|
|
882
|
+
.path()
|
|
883
|
+
.app_data_dir()
|
|
884
|
+
.map_err(|error| format!("app data directory unavailable: {error}"))?
|
|
885
|
+
.join("screen-memory")
|
|
886
|
+
.join("agent-handoffs");
|
|
887
|
+
std::fs::create_dir_all(&output_dir)
|
|
888
|
+
.map_err(|error| format!("agent handoff directory unavailable: {error}"))?;
|
|
889
|
+
let output = output_dir.join(format!("{safe_request_id}.mp4"));
|
|
890
|
+
let artifact = materialize_wall_clock_exact(
|
|
891
|
+
&app,
|
|
892
|
+
&started_at,
|
|
893
|
+
&ended_at,
|
|
894
|
+
output.clone(),
|
|
895
|
+
include_mic,
|
|
896
|
+
include_system_audio,
|
|
897
|
+
)?;
|
|
898
|
+
let result = native_screen::upload_finalized_native_artifact(
|
|
899
|
+
&app,
|
|
900
|
+
&artifact,
|
|
901
|
+
server_url,
|
|
902
|
+
recording_id,
|
|
903
|
+
auth_token.unwrap_or_default(),
|
|
904
|
+
cookie.unwrap_or_default(),
|
|
905
|
+
NativeUploadMode::from_option(upload_mode),
|
|
906
|
+
artifact.mic_captured || artifact.system_audio_captured,
|
|
907
|
+
false,
|
|
908
|
+
)
|
|
909
|
+
.await;
|
|
910
|
+
let _ = std::fs::remove_file(output);
|
|
911
|
+
result
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
#[tauri::command]
|
|
915
|
+
pub(crate) async fn rewind_agent_handoff_preview(
|
|
916
|
+
app: AppHandle,
|
|
917
|
+
request_id: String,
|
|
918
|
+
started_at: String,
|
|
919
|
+
ended_at: String,
|
|
920
|
+
include_mic: bool,
|
|
921
|
+
include_system_audio: bool,
|
|
922
|
+
) -> Result<String, String> {
|
|
923
|
+
let safe_request_id: String = request_id
|
|
924
|
+
.chars()
|
|
925
|
+
.filter(|character| {
|
|
926
|
+
character.is_ascii_alphanumeric() || *character == '-' || *character == '_'
|
|
927
|
+
})
|
|
928
|
+
.collect();
|
|
929
|
+
if safe_request_id != request_id || !safe_request_id.starts_with("handoff-") {
|
|
930
|
+
return Err("Invalid Rewind handoff request ID.".into());
|
|
931
|
+
}
|
|
932
|
+
let output_dir = app
|
|
933
|
+
.path()
|
|
934
|
+
.app_local_data_dir()
|
|
935
|
+
.map_err(|error| format!("local preview directory unavailable: {error}"))?
|
|
936
|
+
.join("rewind-previews");
|
|
937
|
+
cleanup_expired_preview_artifacts(&output_dir, std::time::Duration::from_secs(15 * 60))?;
|
|
938
|
+
std::fs::create_dir_all(&output_dir)
|
|
939
|
+
.map_err(|error| format!("local preview directory unavailable: {error}"))?;
|
|
940
|
+
let output = output_dir.join(format!("{safe_request_id}-preview.mp4"));
|
|
941
|
+
let preview = output.clone();
|
|
942
|
+
tauri::async_runtime::spawn_blocking(move || {
|
|
943
|
+
materialize_wall_clock_exact(
|
|
944
|
+
&app,
|
|
945
|
+
&started_at,
|
|
946
|
+
&ended_at,
|
|
947
|
+
preview,
|
|
948
|
+
include_mic,
|
|
949
|
+
include_system_audio,
|
|
950
|
+
)
|
|
951
|
+
})
|
|
952
|
+
.await
|
|
953
|
+
.map_err(|error| format!("local handoff preview worker failed: {error}"))??;
|
|
954
|
+
|
|
955
|
+
#[cfg(target_os = "macos")]
|
|
956
|
+
Command::new("/usr/bin/open")
|
|
957
|
+
.arg(&output)
|
|
958
|
+
.stdin(Stdio::null())
|
|
959
|
+
.stdout(Stdio::null())
|
|
960
|
+
.stderr(Stdio::null())
|
|
961
|
+
.spawn()
|
|
962
|
+
.map_err(|error| format!("could not open Rewind handoff preview: {error}"))?;
|
|
963
|
+
|
|
964
|
+
let expiring_output = output.clone();
|
|
965
|
+
std::thread::spawn(move || {
|
|
966
|
+
std::thread::sleep(std::time::Duration::from_secs(15 * 60));
|
|
967
|
+
let _ = std::fs::remove_file(expiring_output);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
Ok(output.to_string_lossy().to_string())
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
fn cleanup_expired_preview_artifacts(
|
|
974
|
+
directory: &std::path::Path,
|
|
975
|
+
max_age: std::time::Duration,
|
|
976
|
+
) -> Result<(), String> {
|
|
977
|
+
let now = std::time::SystemTime::now();
|
|
978
|
+
let Ok(entries) = std::fs::read_dir(directory) else {
|
|
979
|
+
return Ok(());
|
|
980
|
+
};
|
|
981
|
+
for entry in entries.flatten() {
|
|
982
|
+
let path = entry.path();
|
|
983
|
+
let expired = entry
|
|
984
|
+
.metadata()
|
|
985
|
+
.ok()
|
|
986
|
+
.and_then(|metadata| metadata.modified().ok())
|
|
987
|
+
.and_then(|modified| now.duration_since(modified).ok())
|
|
988
|
+
.is_some_and(|age| age >= max_age);
|
|
989
|
+
if path.is_file() && expired {
|
|
990
|
+
std::fs::remove_file(&path)
|
|
991
|
+
.map_err(|error| format!("could not remove expired preview: {error}"))?;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
Ok(())
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
pub(crate) fn clear_preview_artifacts(app: &AppHandle) -> Result<(), String> {
|
|
998
|
+
let directory = app
|
|
999
|
+
.path()
|
|
1000
|
+
.app_local_data_dir()
|
|
1001
|
+
.map_err(|error| format!("local preview directory unavailable: {error}"))?
|
|
1002
|
+
.join("rewind-previews");
|
|
1003
|
+
match std::fs::remove_dir_all(&directory) {
|
|
1004
|
+
Ok(()) => Ok(()),
|
|
1005
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
1006
|
+
Err(error) => Err(format!("could not clear local Rewind previews: {error}")),
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
fn materialize(
|
|
1011
|
+
app: &AppHandle,
|
|
1012
|
+
state: &State<'_, RewindClipState>,
|
|
1013
|
+
label: &str,
|
|
1014
|
+
include_mic: bool,
|
|
1015
|
+
include_system_audio: bool,
|
|
1016
|
+
) -> Result<FinalizedNativeArtifact, String> {
|
|
1017
|
+
let active = take_active(state)?;
|
|
1018
|
+
let mut intervals = active.intervals.clone();
|
|
1019
|
+
let mut lease_ended = active.lease_id.is_none();
|
|
1020
|
+
let mut graph_pin_ids = Vec::new();
|
|
1021
|
+
let mut pinned = BTreeSet::new();
|
|
1022
|
+
let outcome = (|| -> Result<FinalizedNativeArtifact, String> {
|
|
1023
|
+
if let Some(lease_id) = active.lease_id.as_deref() {
|
|
1024
|
+
let fenced = screen_memory::fence_active_for_clip(app)?;
|
|
1025
|
+
let closed = app
|
|
1026
|
+
.state::<CaptureGraphState>()
|
|
1027
|
+
.0
|
|
1028
|
+
.lock()
|
|
1029
|
+
.map_err(|e| e.to_string())?
|
|
1030
|
+
.end_consumer(lease_id)
|
|
1031
|
+
.map_err(|e| e.to_string())?;
|
|
1032
|
+
lease_ended = true;
|
|
1033
|
+
let mut interval = closed.lease.interval;
|
|
1034
|
+
interval.ended_elapsed_ms =
|
|
1035
|
+
interval.ended_elapsed_ms.min(fenced.graph_ended_elapsed_ms);
|
|
1036
|
+
intervals.push(interval);
|
|
1037
|
+
}
|
|
1038
|
+
if intervals.is_empty() {
|
|
1039
|
+
return Err("Rewind-derived clip has no recorded intervals".into());
|
|
1040
|
+
}
|
|
1041
|
+
let graph_status = app
|
|
1042
|
+
.state::<CaptureGraphState>()
|
|
1043
|
+
.0
|
|
1044
|
+
.lock()
|
|
1045
|
+
.map_err(|error| error.to_string())?
|
|
1046
|
+
.status_at(std::time::Instant::now())
|
|
1047
|
+
.map_err(|error| error.to_string())?;
|
|
1048
|
+
if graph_status.coverage_gaps.iter().any(|gap| {
|
|
1049
|
+
intervals.iter().any(|interval| {
|
|
1050
|
+
gap.interval.started_elapsed_ms < interval.ended_elapsed_ms
|
|
1051
|
+
&& gap.interval.ended_elapsed_ms > interval.started_elapsed_ms
|
|
1052
|
+
&& active.sources.contains(&gap.source)
|
|
1053
|
+
})
|
|
1054
|
+
}) {
|
|
1055
|
+
return Err("selected Rewind interval contains a recorded coverage gap".into());
|
|
1056
|
+
}
|
|
1057
|
+
let mut slices = Vec::new();
|
|
1058
|
+
let mut first_segment = None;
|
|
1059
|
+
let mut duration_ms = 0u128;
|
|
1060
|
+
for interval in &intervals {
|
|
1061
|
+
let pin = app
|
|
1062
|
+
.state::<CaptureGraphState>()
|
|
1063
|
+
.0
|
|
1064
|
+
.lock()
|
|
1065
|
+
.map_err(|e| e.to_string())?
|
|
1066
|
+
.pin_interval(interval, Some("rewind-derived clip".into()))
|
|
1067
|
+
.map_err(|e| e.to_string())?;
|
|
1068
|
+
graph_pin_ids.push(pin.id.clone());
|
|
1069
|
+
let segments = screen_memory::finalized_segments_in_graph_interval(
|
|
1070
|
+
app,
|
|
1071
|
+
interval.started_elapsed_ms,
|
|
1072
|
+
interval.ended_elapsed_ms,
|
|
1073
|
+
)?;
|
|
1074
|
+
for segment in &segments {
|
|
1075
|
+
screen_memory::pin_segment(app, &segment.id, &pin.id)?;
|
|
1076
|
+
pinned.insert((segment.id.clone(), pin.id.clone()));
|
|
1077
|
+
}
|
|
1078
|
+
if first_segment.is_none() {
|
|
1079
|
+
first_segment = segments.first().cloned();
|
|
1080
|
+
}
|
|
1081
|
+
slices.extend(validate_and_plan(
|
|
1082
|
+
&segments,
|
|
1083
|
+
interval.started_elapsed_ms,
|
|
1084
|
+
interval.ended_elapsed_ms,
|
|
1085
|
+
)?);
|
|
1086
|
+
duration_ms += (interval.ended_elapsed_ms - interval.started_elapsed_ms) as u128;
|
|
1087
|
+
}
|
|
1088
|
+
let output = artifact_path(app, label)?;
|
|
1089
|
+
let audio = select_audio(&active.sources, include_mic, include_system_audio)?;
|
|
1090
|
+
native_screen::materialize_mp4_slices_exact(&slices, &output, audio)?;
|
|
1091
|
+
let first = first_segment
|
|
1092
|
+
.as_ref()
|
|
1093
|
+
.ok_or_else(|| "no Rewind media selected".to_string())?;
|
|
1094
|
+
Ok(FinalizedNativeArtifact::rewind_mp4(
|
|
1095
|
+
output,
|
|
1096
|
+
duration_ms,
|
|
1097
|
+
first.width,
|
|
1098
|
+
first.height,
|
|
1099
|
+
include_mic,
|
|
1100
|
+
include_system_audio,
|
|
1101
|
+
))
|
|
1102
|
+
})();
|
|
1103
|
+
if !lease_ended {
|
|
1104
|
+
if let (Some(lease_id), Ok(mut graph)) = (
|
|
1105
|
+
active.lease_id.as_deref(),
|
|
1106
|
+
app.state::<CaptureGraphState>().0.lock(),
|
|
1107
|
+
) {
|
|
1108
|
+
let _ = graph.end_consumer(lease_id);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
for (segment_id, pin_id) in pinned {
|
|
1112
|
+
let _ = screen_memory::unpin_segment(app, &segment_id, &pin_id);
|
|
1113
|
+
}
|
|
1114
|
+
for pin_id in graph_pin_ids {
|
|
1115
|
+
if let Ok(mut graph) = app.state::<CaptureGraphState>().0.lock() {
|
|
1116
|
+
graph.release_pin(&pin_id);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
for segment_id in &active.pinned_segments {
|
|
1120
|
+
let _ = screen_memory::unpin_segment(app, segment_id, &active.pin_id);
|
|
1121
|
+
}
|
|
1122
|
+
release_temporary_audio(app, active.temporary_audio);
|
|
1123
|
+
outcome
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
#[tauri::command]
|
|
1127
|
+
pub(crate) async fn rewind_clip_stop_and_upload(
|
|
1128
|
+
app: AppHandle,
|
|
1129
|
+
state: State<'_, RewindClipState>,
|
|
1130
|
+
server_url: String,
|
|
1131
|
+
recording_id: String,
|
|
1132
|
+
auth_token: Option<String>,
|
|
1133
|
+
cookie: Option<String>,
|
|
1134
|
+
upload_mode: Option<String>,
|
|
1135
|
+
include_mic: bool,
|
|
1136
|
+
include_system_audio: bool,
|
|
1137
|
+
has_camera: bool,
|
|
1138
|
+
) -> Result<NativeFullscreenUploadResult, String> {
|
|
1139
|
+
#[cfg(target_os = "macos")]
|
|
1140
|
+
{
|
|
1141
|
+
let stop_started = std::time::Instant::now();
|
|
1142
|
+
let mut active = take_active(&state)?;
|
|
1143
|
+
if let Err(error) = close_direct_clip_interval(&app, &mut active) {
|
|
1144
|
+
if let Some(sink) = active.shared_sink.take() {
|
|
1145
|
+
sink.cancel();
|
|
1146
|
+
}
|
|
1147
|
+
release_active_resources(&app, &mut active);
|
|
1148
|
+
return Err(error);
|
|
1149
|
+
}
|
|
1150
|
+
let uploaded_before_stop = active
|
|
1151
|
+
.shared_sink
|
|
1152
|
+
.as_ref()
|
|
1153
|
+
.and_then(|sink| sink.uploaded_bytes_now());
|
|
1154
|
+
crate::logfile::diagnostic(&format!(
|
|
1155
|
+
"[rewind-latency] stop boundary accepted in {}ms",
|
|
1156
|
+
stop_started.elapsed().as_millis()
|
|
1157
|
+
));
|
|
1158
|
+
let mut sink = active
|
|
1159
|
+
.shared_sink
|
|
1160
|
+
.take()
|
|
1161
|
+
.ok_or_else(|| "active Rewind Clip sink is missing".to_string())?;
|
|
1162
|
+
if active.retrospective_seconds == 0 {
|
|
1163
|
+
let sink_path = sink.path().to_path_buf();
|
|
1164
|
+
let (sink_width, sink_height) = sink.dimensions();
|
|
1165
|
+
let logical_duration_ms = sink.duration_ms();
|
|
1166
|
+
let result = sink.finalize_writer();
|
|
1167
|
+
release_active_resources(&app, &mut active);
|
|
1168
|
+
let mut result = match result {
|
|
1169
|
+
Ok(result) => result,
|
|
1170
|
+
Err(error) => {
|
|
1171
|
+
let recovery_error = native_screen::persist_shared_clip_recording(
|
|
1172
|
+
&app,
|
|
1173
|
+
&sink_path,
|
|
1174
|
+
&server_url,
|
|
1175
|
+
&recording_id,
|
|
1176
|
+
logical_duration_ms as u128,
|
|
1177
|
+
sink_width,
|
|
1178
|
+
sink_height,
|
|
1179
|
+
include_mic,
|
|
1180
|
+
include_system_audio,
|
|
1181
|
+
has_camera,
|
|
1182
|
+
Some(&error),
|
|
1183
|
+
)
|
|
1184
|
+
.err();
|
|
1185
|
+
let error = match recovery_error {
|
|
1186
|
+
Some(recovery_error) => {
|
|
1187
|
+
format!("{error}. Recovery metadata also failed: {recovery_error}")
|
|
1188
|
+
}
|
|
1189
|
+
None => error,
|
|
1190
|
+
};
|
|
1191
|
+
native_screen::emit_native_upload_finished(
|
|
1192
|
+
&app,
|
|
1193
|
+
&server_url,
|
|
1194
|
+
&recording_id,
|
|
1195
|
+
false,
|
|
1196
|
+
Some(error.clone()),
|
|
1197
|
+
Some(&sink_path),
|
|
1198
|
+
);
|
|
1199
|
+
return Err(error);
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
if let Err(error) = native_screen::persist_shared_clip_recording(
|
|
1203
|
+
&app,
|
|
1204
|
+
&result.path,
|
|
1205
|
+
&server_url,
|
|
1206
|
+
&recording_id,
|
|
1207
|
+
result.duration_ms as u128,
|
|
1208
|
+
result.width,
|
|
1209
|
+
result.height,
|
|
1210
|
+
include_mic,
|
|
1211
|
+
include_system_audio,
|
|
1212
|
+
has_camera,
|
|
1213
|
+
None,
|
|
1214
|
+
) {
|
|
1215
|
+
sink.cancel_upload();
|
|
1216
|
+
native_screen::emit_native_upload_finished(
|
|
1217
|
+
&app,
|
|
1218
|
+
&server_url,
|
|
1219
|
+
&recording_id,
|
|
1220
|
+
false,
|
|
1221
|
+
Some(error.clone()),
|
|
1222
|
+
Some(&result.path),
|
|
1223
|
+
);
|
|
1224
|
+
return Err(error);
|
|
1225
|
+
}
|
|
1226
|
+
result.verification_pending = match sink.finalize_upload(result.duration_ms).await {
|
|
1227
|
+
Ok(pending) => pending,
|
|
1228
|
+
Err(error) => {
|
|
1229
|
+
let _ = native_screen::persist_shared_clip_recording(
|
|
1230
|
+
&app,
|
|
1231
|
+
&result.path,
|
|
1232
|
+
&server_url,
|
|
1233
|
+
&recording_id,
|
|
1234
|
+
result.duration_ms as u128,
|
|
1235
|
+
result.width,
|
|
1236
|
+
result.height,
|
|
1237
|
+
include_mic,
|
|
1238
|
+
include_system_audio,
|
|
1239
|
+
has_camera,
|
|
1240
|
+
Some(&error),
|
|
1241
|
+
);
|
|
1242
|
+
native_screen::emit_native_upload_finished(
|
|
1243
|
+
&app,
|
|
1244
|
+
&server_url,
|
|
1245
|
+
&recording_id,
|
|
1246
|
+
false,
|
|
1247
|
+
Some(error.clone()),
|
|
1248
|
+
Some(&result.path),
|
|
1249
|
+
);
|
|
1250
|
+
return Err(format!(
|
|
1251
|
+
"{error}. The clip was saved locally and can be retried from the Clips menu."
|
|
1252
|
+
));
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
if let Some(uploaded) = uploaded_before_stop {
|
|
1256
|
+
let percent = if result.bytes == 0 {
|
|
1257
|
+
0
|
|
1258
|
+
} else {
|
|
1259
|
+
uploaded.saturating_mul(100) / result.bytes
|
|
1260
|
+
};
|
|
1261
|
+
crate::logfile::diagnostic(&format!(
|
|
1262
|
+
"[rewind-latency] uploaded before Stop: {uploaded}/{} bytes ({percent}%)",
|
|
1263
|
+
result.bytes
|
|
1264
|
+
));
|
|
1265
|
+
}
|
|
1266
|
+
native_screen::emit_native_upload_finished(
|
|
1267
|
+
&app,
|
|
1268
|
+
&server_url,
|
|
1269
|
+
&recording_id,
|
|
1270
|
+
true,
|
|
1271
|
+
None,
|
|
1272
|
+
Some(&result.path),
|
|
1273
|
+
);
|
|
1274
|
+
if !result.verification_pending {
|
|
1275
|
+
native_screen::clear_shared_clip_recording(&app, &recording_id, &result.path);
|
|
1276
|
+
}
|
|
1277
|
+
return Ok(result.into_native_upload_result(recording_id));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if let Err(error) = sink.abandon_for_rewind_materialization().await {
|
|
1281
|
+
release_active_resources(&app, &mut active);
|
|
1282
|
+
return Err(error);
|
|
1283
|
+
}
|
|
1284
|
+
*state.0.lock().map_err(|error| error.to_string())? = Some(active);
|
|
1285
|
+
}
|
|
1286
|
+
let artifact = materialize(
|
|
1287
|
+
&app,
|
|
1288
|
+
&state,
|
|
1289
|
+
&recording_id,
|
|
1290
|
+
include_mic,
|
|
1291
|
+
include_system_audio,
|
|
1292
|
+
)?;
|
|
1293
|
+
native_screen::upload_finalized_native_artifact(
|
|
1294
|
+
&app,
|
|
1295
|
+
&artifact,
|
|
1296
|
+
server_url,
|
|
1297
|
+
recording_id,
|
|
1298
|
+
auth_token.unwrap_or_default(),
|
|
1299
|
+
cookie.unwrap_or_default(),
|
|
1300
|
+
NativeUploadMode::from_option(upload_mode),
|
|
1301
|
+
include_mic || include_system_audio,
|
|
1302
|
+
has_camera,
|
|
1303
|
+
)
|
|
1304
|
+
.await
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
#[tauri::command]
|
|
1308
|
+
pub(crate) async fn rewind_clip_stop_and_save(
|
|
1309
|
+
app: AppHandle,
|
|
1310
|
+
state: State<'_, RewindClipState>,
|
|
1311
|
+
folder_name: String,
|
|
1312
|
+
file_role: String,
|
|
1313
|
+
include_mic: bool,
|
|
1314
|
+
include_system_audio: bool,
|
|
1315
|
+
) -> Result<NativeFullscreenSaveResult, String> {
|
|
1316
|
+
#[cfg(target_os = "macos")]
|
|
1317
|
+
{
|
|
1318
|
+
let stop_started = std::time::Instant::now();
|
|
1319
|
+
let mut active = take_active(&state)?;
|
|
1320
|
+
if let Err(error) = close_direct_clip_interval(&app, &mut active) {
|
|
1321
|
+
if let Some(sink) = active.shared_sink.take() {
|
|
1322
|
+
sink.cancel();
|
|
1323
|
+
}
|
|
1324
|
+
release_active_resources(&app, &mut active);
|
|
1325
|
+
return Err(error);
|
|
1326
|
+
}
|
|
1327
|
+
eprintln!(
|
|
1328
|
+
"[rewind-latency] local stop boundary accepted in {}ms",
|
|
1329
|
+
stop_started.elapsed().as_millis()
|
|
1330
|
+
);
|
|
1331
|
+
let mut sink = active
|
|
1332
|
+
.shared_sink
|
|
1333
|
+
.take()
|
|
1334
|
+
.ok_or_else(|| "active Rewind Clip sink is missing".to_string())?;
|
|
1335
|
+
if active.retrospective_seconds == 0 {
|
|
1336
|
+
let result = sink.finalize_writer();
|
|
1337
|
+
release_active_resources(&app, &mut active);
|
|
1338
|
+
let result = result?;
|
|
1339
|
+
let artifact = FinalizedNativeArtifact::rewind_mp4(
|
|
1340
|
+
result.path,
|
|
1341
|
+
result.duration_ms as u128,
|
|
1342
|
+
Some(result.width),
|
|
1343
|
+
Some(result.height),
|
|
1344
|
+
include_mic,
|
|
1345
|
+
include_system_audio,
|
|
1346
|
+
);
|
|
1347
|
+
let saved = native_screen::save_finalized_native_artifact_to_local_export(
|
|
1348
|
+
&app,
|
|
1349
|
+
&artifact,
|
|
1350
|
+
&folder_name,
|
|
1351
|
+
&file_role,
|
|
1352
|
+
)?;
|
|
1353
|
+
native_screen::clear_shared_clip_recording(&app, &folder_name, &artifact.path);
|
|
1354
|
+
return Ok(saved);
|
|
1355
|
+
}
|
|
1356
|
+
if let Err(error) = sink.abandon_for_rewind_materialization().await {
|
|
1357
|
+
release_active_resources(&app, &mut active);
|
|
1358
|
+
return Err(error);
|
|
1359
|
+
}
|
|
1360
|
+
*state.0.lock().map_err(|error| error.to_string())? = Some(active);
|
|
1361
|
+
}
|
|
1362
|
+
let artifact = materialize(
|
|
1363
|
+
&app,
|
|
1364
|
+
&state,
|
|
1365
|
+
&folder_name,
|
|
1366
|
+
include_mic,
|
|
1367
|
+
include_system_audio,
|
|
1368
|
+
)?;
|
|
1369
|
+
native_screen::save_finalized_native_artifact_to_local_export(
|
|
1370
|
+
&app,
|
|
1371
|
+
&artifact,
|
|
1372
|
+
&folder_name,
|
|
1373
|
+
&file_role,
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
#[tauri::command]
|
|
1378
|
+
pub(crate) fn rewind_clip_cancel(
|
|
1379
|
+
app: AppHandle,
|
|
1380
|
+
state: State<'_, RewindClipState>,
|
|
1381
|
+
) -> Result<(), String> {
|
|
1382
|
+
let active = state.0.lock().map_err(|error| error.to_string())?.take();
|
|
1383
|
+
if let Some(mut active) = active {
|
|
1384
|
+
if let Some(lease_id) = active.lease_id.as_deref() {
|
|
1385
|
+
let _ = app
|
|
1386
|
+
.state::<CaptureGraphState>()
|
|
1387
|
+
.0
|
|
1388
|
+
.lock()
|
|
1389
|
+
.map_err(|error| error.to_string())?
|
|
1390
|
+
.end_consumer(lease_id);
|
|
1391
|
+
}
|
|
1392
|
+
#[cfg(target_os = "macos")]
|
|
1393
|
+
if let Some(sink) = active.shared_sink.take() {
|
|
1394
|
+
sink.cancel();
|
|
1395
|
+
}
|
|
1396
|
+
release_active_resources(&app, &mut active);
|
|
1397
|
+
}
|
|
1398
|
+
Ok(())
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/// Called by Screen Memory whenever a segment becomes finalized. Keeping this
|
|
1402
|
+
/// hook in the consumer module prevents rotation/pruning from racing a clip
|
|
1403
|
+
/// whose materialization has not happened yet.
|
|
1404
|
+
pub(crate) fn pin_finalized_segment_if_active(
|
|
1405
|
+
app: &AppHandle,
|
|
1406
|
+
segment: &ScreenMemorySegmentMetadata,
|
|
1407
|
+
) -> Result<(), String> {
|
|
1408
|
+
let Some(state) = app.try_state::<RewindClipState>() else {
|
|
1409
|
+
return Ok(());
|
|
1410
|
+
};
|
|
1411
|
+
let Ok(mut active) = state.0.try_lock() else {
|
|
1412
|
+
return Ok(());
|
|
1413
|
+
};
|
|
1414
|
+
let Some(active) = active.as_mut() else {
|
|
1415
|
+
return Ok(());
|
|
1416
|
+
};
|
|
1417
|
+
if !active.activated || active.paused {
|
|
1418
|
+
return Ok(());
|
|
1419
|
+
}
|
|
1420
|
+
if segment.graph_ended_elapsed_ms >= active.started_elapsed_ms {
|
|
1421
|
+
screen_memory::pin_segment(app, &segment.id, &active.pin_id)?;
|
|
1422
|
+
active.pinned_segments.insert(segment.id.clone());
|
|
1423
|
+
}
|
|
1424
|
+
Ok(())
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#[cfg(test)]
|
|
1428
|
+
mod tests {
|
|
1429
|
+
use super::*;
|
|
1430
|
+
use crate::config::RewindCaptureMode;
|
|
1431
|
+
|
|
1432
|
+
fn segment(id: &str, start: u64, end: u64) -> ScreenMemorySegmentMetadata {
|
|
1433
|
+
ScreenMemorySegmentMetadata {
|
|
1434
|
+
id: id.into(),
|
|
1435
|
+
path: PathBuf::from(format!("{id}.mp4")),
|
|
1436
|
+
file_name: format!("{id}.mp4"),
|
|
1437
|
+
mime_type: "video/mp4".into(),
|
|
1438
|
+
started_at: String::new(),
|
|
1439
|
+
ended_at: String::new(),
|
|
1440
|
+
duration_ms: (end - start) as u128,
|
|
1441
|
+
width: Some(1280),
|
|
1442
|
+
height: Some(720),
|
|
1443
|
+
bytes: 1,
|
|
1444
|
+
system_audio_path: None,
|
|
1445
|
+
microphone_path: None,
|
|
1446
|
+
corrupt: false,
|
|
1447
|
+
error: None,
|
|
1448
|
+
capture_mode: RewindCaptureMode::VisualsAudio,
|
|
1449
|
+
exclusion_tainted: false,
|
|
1450
|
+
graph_epoch_id: Some("test-epoch".into()),
|
|
1451
|
+
graph_started_elapsed_ms: start,
|
|
1452
|
+
graph_ended_elapsed_ms: end,
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
fn wall_segment(
|
|
1457
|
+
id: &str,
|
|
1458
|
+
started_at: &str,
|
|
1459
|
+
ended_at: &str,
|
|
1460
|
+
epoch: &str,
|
|
1461
|
+
) -> ScreenMemorySegmentMetadata {
|
|
1462
|
+
let mut value = segment(id, 0, 100);
|
|
1463
|
+
value.started_at = started_at.into();
|
|
1464
|
+
value.ended_at = ended_at.into();
|
|
1465
|
+
value.graph_epoch_id = Some(epoch.into());
|
|
1466
|
+
value
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
#[test]
|
|
1470
|
+
fn slice_math_has_no_pre_roll_and_exact_overlap() {
|
|
1471
|
+
let slices =
|
|
1472
|
+
validate_and_plan(&[segment("a", 0, 100), segment("b", 100, 200)], 50, 150).unwrap();
|
|
1473
|
+
assert_eq!(
|
|
1474
|
+
slices,
|
|
1475
|
+
vec![
|
|
1476
|
+
NativeMediaSlice {
|
|
1477
|
+
path: PathBuf::from("a.mp4"),
|
|
1478
|
+
system_audio_path: None,
|
|
1479
|
+
microphone_path: None,
|
|
1480
|
+
start_ms: 50,
|
|
1481
|
+
end_ms: 100
|
|
1482
|
+
},
|
|
1483
|
+
NativeMediaSlice {
|
|
1484
|
+
path: PathBuf::from("b.mp4"),
|
|
1485
|
+
system_audio_path: None,
|
|
1486
|
+
microphone_path: None,
|
|
1487
|
+
start_ms: 0,
|
|
1488
|
+
end_ms: 50
|
|
1489
|
+
}
|
|
1490
|
+
]
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
#[test]
|
|
1495
|
+
fn gaps_and_taint_fail_closed() {
|
|
1496
|
+
assert!(validate_and_plan(&[segment("a", 0, 100), segment("b", 101, 200)], 0, 200).is_ok());
|
|
1497
|
+
assert!(
|
|
1498
|
+
validate_and_plan(&[segment("a", 0, 100), segment("b", 103, 200)], 0, 200).is_err()
|
|
1499
|
+
);
|
|
1500
|
+
assert!(validate_and_plan(&[segment("a", 0, 90), segment("b", 100, 200)], 0, 200).is_err());
|
|
1501
|
+
let mut tainted = segment("a", 0, 100);
|
|
1502
|
+
tainted.exclusion_tainted = true;
|
|
1503
|
+
assert!(validate_and_plan(&[tainted], 0, 100).is_err());
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
#[test]
|
|
1507
|
+
fn retrospective_choices_are_strictly_bounded() {
|
|
1508
|
+
assert!(matches!(30, RETROSPECTIVE_30_SECONDS));
|
|
1509
|
+
assert!(matches!(300, RETROSPECTIVE_5_MINUTES));
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
#[test]
|
|
1513
|
+
fn visuals_reject_audio_and_audio_mode_preserves_each_choice() {
|
|
1514
|
+
assert!(select_audio(&[CaptureSource::Screen], true, false).is_err());
|
|
1515
|
+
let sources = [
|
|
1516
|
+
CaptureSource::Screen,
|
|
1517
|
+
CaptureSource::SystemAudio,
|
|
1518
|
+
CaptureSource::Microphone,
|
|
1519
|
+
];
|
|
1520
|
+
assert_eq!(
|
|
1521
|
+
select_audio(&sources, false, false).unwrap(),
|
|
1522
|
+
NativeAudioSelection::None
|
|
1523
|
+
);
|
|
1524
|
+
assert_eq!(
|
|
1525
|
+
select_audio(&sources, false, true).unwrap(),
|
|
1526
|
+
NativeAudioSelection::System
|
|
1527
|
+
);
|
|
1528
|
+
assert_eq!(
|
|
1529
|
+
select_audio(&sources, true, false).unwrap(),
|
|
1530
|
+
NativeAudioSelection::Microphone
|
|
1531
|
+
);
|
|
1532
|
+
assert_eq!(
|
|
1533
|
+
select_audio(&sources, true, true).unwrap(),
|
|
1534
|
+
NativeAudioSelection::MixBoth
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
#[test]
|
|
1539
|
+
fn ordered_intervals_exclude_paused_media() {
|
|
1540
|
+
let segments = [
|
|
1541
|
+
segment("before", 0, 100),
|
|
1542
|
+
segment("paused", 100, 200),
|
|
1543
|
+
segment("after", 200, 300),
|
|
1544
|
+
];
|
|
1545
|
+
let mut slices = validate_and_plan(&segments, 0, 100).unwrap();
|
|
1546
|
+
slices.extend(validate_and_plan(&segments, 200, 300).unwrap());
|
|
1547
|
+
assert_eq!(slices.len(), 2);
|
|
1548
|
+
assert_eq!(slices[0].path, PathBuf::from("before.mp4"));
|
|
1549
|
+
assert_eq!(slices[1].path, PathBuf::from("after.mp4"));
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
#[test]
|
|
1553
|
+
fn wall_clock_planning_crosses_capture_graph_epochs() {
|
|
1554
|
+
let started = DateTime::parse_from_rfc3339("2026-07-19T10:00:00Z")
|
|
1555
|
+
.unwrap()
|
|
1556
|
+
.with_timezone(&Utc);
|
|
1557
|
+
let ended = DateTime::parse_from_rfc3339("2026-07-19T10:00:00.200Z")
|
|
1558
|
+
.unwrap()
|
|
1559
|
+
.with_timezone(&Utc);
|
|
1560
|
+
let segments = [
|
|
1561
|
+
wall_segment(
|
|
1562
|
+
"before-restart",
|
|
1563
|
+
"2026-07-19T10:00:00Z",
|
|
1564
|
+
"2026-07-19T10:00:00.100Z",
|
|
1565
|
+
"epoch-before-restart",
|
|
1566
|
+
),
|
|
1567
|
+
wall_segment(
|
|
1568
|
+
"after-restart",
|
|
1569
|
+
"2026-07-19T10:00:00.100Z",
|
|
1570
|
+
"2026-07-19T10:00:00.200Z",
|
|
1571
|
+
"epoch-after-restart",
|
|
1572
|
+
),
|
|
1573
|
+
];
|
|
1574
|
+
let (slices, selected) = validate_and_plan_wall_clock(&segments, started, ended).unwrap();
|
|
1575
|
+
assert_eq!(slices.len(), 2);
|
|
1576
|
+
assert_eq!(selected[0].id, "before-restart");
|
|
1577
|
+
assert_eq!(selected[1].id, "after-restart");
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
#[test]
|
|
1581
|
+
fn wall_clock_planning_uses_graph_continuity_across_rotation_skew() {
|
|
1582
|
+
let started = DateTime::parse_from_rfc3339("2026-07-19T10:00:00Z")
|
|
1583
|
+
.unwrap()
|
|
1584
|
+
.with_timezone(&Utc);
|
|
1585
|
+
let ended = DateTime::parse_from_rfc3339("2026-07-19T10:00:00.200Z")
|
|
1586
|
+
.unwrap()
|
|
1587
|
+
.with_timezone(&Utc);
|
|
1588
|
+
let first = wall_segment(
|
|
1589
|
+
"before-rotation",
|
|
1590
|
+
"2026-07-19T10:00:00Z",
|
|
1591
|
+
"2026-07-19T10:00:00.100Z",
|
|
1592
|
+
"same-epoch",
|
|
1593
|
+
);
|
|
1594
|
+
let mut second = wall_segment(
|
|
1595
|
+
"after-rotation",
|
|
1596
|
+
"2026-07-19T10:00:00.150Z",
|
|
1597
|
+
"2026-07-19T10:00:00.250Z",
|
|
1598
|
+
"same-epoch",
|
|
1599
|
+
);
|
|
1600
|
+
second.graph_started_elapsed_ms = 100;
|
|
1601
|
+
second.graph_ended_elapsed_ms = 200;
|
|
1602
|
+
let (slices, selected) =
|
|
1603
|
+
validate_and_plan_wall_clock(&[first, second], started, ended).unwrap();
|
|
1604
|
+
assert_eq!(slices.len(), 2);
|
|
1605
|
+
assert_eq!(selected[1].id, "after-rotation");
|
|
1606
|
+
assert_eq!(slices[1].start_ms, 0);
|
|
1607
|
+
assert_eq!(slices[1].end_ms, 100);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
#[test]
|
|
1611
|
+
fn wall_clock_planning_rejects_real_gaps() {
|
|
1612
|
+
let started = DateTime::parse_from_rfc3339("2026-07-19T10:00:00Z")
|
|
1613
|
+
.unwrap()
|
|
1614
|
+
.with_timezone(&Utc);
|
|
1615
|
+
let ended = DateTime::parse_from_rfc3339("2026-07-19T10:00:00.200Z")
|
|
1616
|
+
.unwrap()
|
|
1617
|
+
.with_timezone(&Utc);
|
|
1618
|
+
let mut first = wall_segment(
|
|
1619
|
+
"a",
|
|
1620
|
+
"2026-07-19T10:00:00Z",
|
|
1621
|
+
"2026-07-19T10:00:00.090Z",
|
|
1622
|
+
"one",
|
|
1623
|
+
);
|
|
1624
|
+
first.graph_ended_elapsed_ms = 90;
|
|
1625
|
+
let segments = [
|
|
1626
|
+
first,
|
|
1627
|
+
wall_segment(
|
|
1628
|
+
"b",
|
|
1629
|
+
"2026-07-19T10:00:00.100Z",
|
|
1630
|
+
"2026-07-19T10:00:00.200Z",
|
|
1631
|
+
"two",
|
|
1632
|
+
),
|
|
1633
|
+
];
|
|
1634
|
+
assert!(validate_and_plan_wall_clock(&segments, started, ended).is_err());
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
#[test]
|
|
1638
|
+
fn expired_preview_cleanup_removes_files() {
|
|
1639
|
+
let directory = std::env::temp_dir().join(format!(
|
|
1640
|
+
"clips-rewind-preview-test-{}",
|
|
1641
|
+
Utc::now().timestamp_micros()
|
|
1642
|
+
));
|
|
1643
|
+
std::fs::create_dir_all(&directory).unwrap();
|
|
1644
|
+
let preview = directory.join("preview.mp4");
|
|
1645
|
+
std::fs::write(&preview, b"preview").unwrap();
|
|
1646
|
+
cleanup_expired_preview_artifacts(&directory, std::time::Duration::ZERO).unwrap();
|
|
1647
|
+
assert!(!preview.exists());
|
|
1648
|
+
let _ = std::fs::remove_dir_all(directory);
|
|
1649
|
+
}
|
|
1650
|
+
}
|