@agent-native/core 0.175.2-nightly-20260826165737 → 0.175.3
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 +1 -1
- package/corpus/templates/clips/app/components/player/comments-panel.tsx +1 -1
- package/corpus/templates/clips/desktop/src/app.tsx +171 -51
- package/corpus/templates/clips/desktop/src/lib/pill-completion.ts +111 -0
- package/corpus/templates/clips/desktop/src/lib/pill-session.ts +34 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +181 -79
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +28 -4
- package/corpus/templates/clips/desktop/src/main.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/record-pill.tsx +1320 -0
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +1 -1
- package/corpus/templates/clips/desktop/src/styles.css +20 -308
- package/corpus/templates/clips/desktop/src/tailwind.css +85 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +153 -70
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +15 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +25 -7
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +375 -24
- package/corpus/templates/clips/desktop/src-tauri/src/recording_indicator.rs +22 -30
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +5 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +134 -14
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +233 -4
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +8 -5
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +50 -28
- package/corpus/templates/slides/app/context/DeckContext.tsx +78 -23
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +5 -5
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/package.json +3 -3
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +0 -458
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { invoke } from "@tauri-apps/api/core";
|
|
51
51
|
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
52
|
-
import { open as openExternal } from "@tauri-apps/plugin-shell";
|
|
53
52
|
|
|
54
53
|
import {
|
|
55
54
|
waitForAcceptedRecordingAfterFinalizeError,
|
|
@@ -269,6 +268,12 @@ export interface StartParams {
|
|
|
269
268
|
preAcquiredDisplayStream?: MediaStream | null;
|
|
270
269
|
/** Live microphone capture handed over on restart. See `preAcquiredDisplayStream`. */
|
|
271
270
|
preAcquiredAudioStream?: MediaStream | null;
|
|
271
|
+
/**
|
|
272
|
+
* The replaced take's transcription teardown, handed over on restart. See
|
|
273
|
+
* `RestartHandoff.transcriptionTornDown` — the engine is process-global, so
|
|
274
|
+
* this session must await it immediately before starting transcription.
|
|
275
|
+
*/
|
|
276
|
+
pendingTranscriptionTeardown?: Promise<void> | null;
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
const REWIND_CLIP_ORIGINS_KEY = "clips.rewindClipOrigins.v1";
|
|
@@ -338,6 +343,16 @@ export interface RestartHandoff {
|
|
|
338
343
|
/** Null when the backend re-acquires capture natively (no user gesture needed). */
|
|
339
344
|
displayStream: MediaStream | null;
|
|
340
345
|
audioStream: MediaStream | null;
|
|
346
|
+
/**
|
|
347
|
+
* Settles when the discarded take's transcription engine has fully stopped.
|
|
348
|
+
* The engine is process-global (one session slot), so the retake must await
|
|
349
|
+
* this immediately before its own `startTranscriptionCapture` — and no
|
|
350
|
+
* earlier, so Whisper's final flush hides under the retake's countdown
|
|
351
|
+
* instead of delaying it. Every creation site attaches its own `.catch`,
|
|
352
|
+
* so this promise never rejects. Absent/null when the discarded take had
|
|
353
|
+
* no transcription running.
|
|
354
|
+
*/
|
|
355
|
+
transcriptionTornDown?: Promise<void> | null;
|
|
341
356
|
}
|
|
342
357
|
|
|
343
358
|
export interface RecorderHandle {
|
|
@@ -2463,16 +2478,43 @@ async function runRecordingCountdown(
|
|
|
2463
2478
|
}
|
|
2464
2479
|
|
|
2465
2480
|
function showFinalizingFeedback() {
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2468
|
-
// result while its event listener is still mounting.
|
|
2481
|
+
// Clear the previous completion record so a new stop cannot consume an old
|
|
2482
|
+
// result while listeners are still mounting.
|
|
2469
2483
|
try {
|
|
2470
2484
|
window.localStorage.removeItem(FINALIZING_RESULT_STORAGE_KEY);
|
|
2471
2485
|
} catch {
|
|
2472
2486
|
// Storage is a best-effort event-race fallback only.
|
|
2473
2487
|
}
|
|
2474
|
-
|
|
2475
|
-
|
|
2488
|
+
// The recording pill renders the completion card in place — it holds its
|
|
2489
|
+
// window open via `set_toolbar_finishing` before emitting stop and consumes
|
|
2490
|
+
// the same upload progress/finished events — so the separate finalizing
|
|
2491
|
+
// window is no longer shown here.
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/**
|
|
2495
|
+
* Tell the recording pill which clip this session will become, so Stop can
|
|
2496
|
+
* copy the share link the instant it's clicked instead of waiting for the
|
|
2497
|
+
* upload to finish. Re-emitted from every `clips:toolbar-ready` handshake so
|
|
2498
|
+
* a pill that mounts late still learns its session.
|
|
2499
|
+
*
|
|
2500
|
+
* `recordingId` is also the pill's session identity: its window is reused
|
|
2501
|
+
* across a restart, so it needs this to tell its own completion event from a
|
|
2502
|
+
* previous take's late one. It is sent for local-only takes too, where it is
|
|
2503
|
+
* the export folder name — the same value the save command echoes back as
|
|
2504
|
+
* `recordingId`, so the comparison holds on both sides. Only `serverUrl` is
|
|
2505
|
+
* withheld for local, which is what keeps `viewUrl` null.
|
|
2506
|
+
*/
|
|
2507
|
+
function emitRecorderSession(
|
|
2508
|
+
serverUrl: string | null,
|
|
2509
|
+
recordingId: string | null,
|
|
2510
|
+
localOnly: boolean,
|
|
2511
|
+
) {
|
|
2512
|
+
const viewUrl =
|
|
2513
|
+
serverUrl && recordingId
|
|
2514
|
+
? `${serverUrl.replace(/\/+$/, "")}/r/${recordingId}`
|
|
2515
|
+
: null;
|
|
2516
|
+
emit("clips:recorder-session", { viewUrl, recordingId, localOnly }).catch(
|
|
2517
|
+
() => {},
|
|
2476
2518
|
);
|
|
2477
2519
|
}
|
|
2478
2520
|
|
|
@@ -2515,24 +2557,6 @@ async function publishFinalizingResult(params: {
|
|
|
2515
2557
|
});
|
|
2516
2558
|
}
|
|
2517
2559
|
|
|
2518
|
-
async function claimNativeUploadOpen(recordingId: string): Promise<boolean> {
|
|
2519
|
-
return invoke<boolean>("native_fullscreen_claim_upload_open", {
|
|
2520
|
-
recordingId,
|
|
2521
|
-
}).catch(() => true);
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
async function openNativeUploadUrl(
|
|
2525
|
-
recordingId: string,
|
|
2526
|
-
url: string,
|
|
2527
|
-
): Promise<void> {
|
|
2528
|
-
if (!(await claimNativeUploadOpen(recordingId))) return;
|
|
2529
|
-
try {
|
|
2530
|
-
await openExternal(url);
|
|
2531
|
-
} catch (err) {
|
|
2532
|
-
console.error("[clips-recorder] openExternal failed:", err);
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
2560
|
/**
|
|
2537
2561
|
* Hosted native start sequencing helper: overlap Whisper start, create-recording,
|
|
2538
2562
|
* and deferred SCK warm so Skip no longer waits serially on Whisper then warm.
|
|
@@ -2731,11 +2755,17 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2731
2755
|
const canTranscribeLocally = !localOnly && includeMic;
|
|
2732
2756
|
const startRewindTranscription = async () => {
|
|
2733
2757
|
if (!canTranscribeLocally || transcriptionCapture) return;
|
|
2758
|
+
// The engine is process-global: on a restart the replaced take's stop must
|
|
2759
|
+
// land before this start, or it attaches to the dying capture. The wait
|
|
2760
|
+
// runs here, under the countdown, instead of inside the discard.
|
|
2761
|
+
await params.pendingTranscriptionTeardown;
|
|
2762
|
+
if (transcriptionAborted) return;
|
|
2734
2763
|
// Runs after `rewind_clip_prepare`, so the Rewind producer already carries
|
|
2735
2764
|
// mic (+system) and whisper attaches to it instead of opening its own
|
|
2736
2765
|
// ScreenCaptureKit stream — which would mute the clip's own audio legs.
|
|
2737
|
-
// This runs
|
|
2738
|
-
// must never reject
|
|
2766
|
+
// This runs at the end of prepare(), under the live countdown: a
|
|
2767
|
+
// transcription failure must never reject here, or it would cancel the
|
|
2768
|
+
// countdown and abort the recording itself.
|
|
2739
2769
|
const capture = await startTranscriptionCapture(
|
|
2740
2770
|
{ deviceId: params.micId, label: params.micLabel },
|
|
2741
2771
|
includeSystemAudio,
|
|
@@ -2756,9 +2786,6 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2756
2786
|
void saveTranscriptFailure(TRANSCRIPTION_START_FAILURE);
|
|
2757
2787
|
}
|
|
2758
2788
|
};
|
|
2759
|
-
await guardRecordingStart(invoke("show_preparing"), {
|
|
2760
|
-
signal: params.signal,
|
|
2761
|
-
});
|
|
2762
2789
|
const recordingPromise = localOnly
|
|
2763
2790
|
? Promise.resolve<{ id: string; uploadMode: UploadMode }>({
|
|
2764
2791
|
id: folderName,
|
|
@@ -2786,6 +2813,17 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2786
2813
|
},
|
|
2787
2814
|
);
|
|
2788
2815
|
})();
|
|
2816
|
+
// Prepare and the countdown start concurrently, and `runRecordingCountdown`
|
|
2817
|
+
// installs its Tauri listeners asynchronously. A prepare that fails inside
|
|
2818
|
+
// that window has nothing listening for `clips:countdown-cancel`, and the
|
|
2819
|
+
// dropped event leaves the countdown on screen for its full timeout before
|
|
2820
|
+
// the real error surfaces. An abort signal is durable where an event is not
|
|
2821
|
+
// — the waiter checks `aborted` as it registers — so the controller is
|
|
2822
|
+
// created before either phase starts.
|
|
2823
|
+
const countdownAbort = new AbortController();
|
|
2824
|
+
const abortCountdown = () => countdownAbort.abort();
|
|
2825
|
+
if (params.signal?.aborted) abortCountdown();
|
|
2826
|
+
else params.signal?.addEventListener("abort", abortCountdown, { once: true });
|
|
2789
2827
|
try {
|
|
2790
2828
|
const recording = await prepareRewindRecordingStart({
|
|
2791
2829
|
async prepare() {
|
|
@@ -2806,19 +2844,25 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2806
2844
|
}),
|
|
2807
2845
|
{ signal: params.signal },
|
|
2808
2846
|
);
|
|
2847
|
+
// Whisper stays after `rewind_clip_prepare` (it attaches to the
|
|
2848
|
+
// producer that call registers) and inside prepare so it has settled
|
|
2849
|
+
// before activation, while the countdown runs over all of it.
|
|
2850
|
+
await startRewindTranscription();
|
|
2809
2851
|
return preparedRecording;
|
|
2810
2852
|
},
|
|
2811
2853
|
async countdown() {
|
|
2812
|
-
console.log("[rewind-latency] countdown shown
|
|
2813
|
-
await
|
|
2814
|
-
// Overlap whisper startup with the countdown so the capture boundary
|
|
2815
|
-
// never waits on it, but have both settled before activation.
|
|
2816
|
-
await Promise.all([
|
|
2817
|
-
runRecordingCountdown(true, params.signal),
|
|
2818
|
-
startRewindTranscription(),
|
|
2819
|
-
]);
|
|
2854
|
+
console.log("[rewind-latency] countdown shown; preparation overlapped");
|
|
2855
|
+
await runRecordingCountdown(true, countdownAbort.signal);
|
|
2820
2856
|
console.log("[rewind-latency] countdown completed");
|
|
2821
2857
|
},
|
|
2858
|
+
cancelCountdown() {
|
|
2859
|
+
// Abort first: it is the half that cannot be lost to a countdown that
|
|
2860
|
+
// has not finished registering. The event stays so a countdown that
|
|
2861
|
+
// IS listening tears its chrome down through the same path Escape
|
|
2862
|
+
// uses, and so any other listener still sees the cancel.
|
|
2863
|
+
abortCountdown();
|
|
2864
|
+
void emit("clips:countdown-cancel", { cause: "prepare-failed" });
|
|
2865
|
+
},
|
|
2822
2866
|
async activate(preparedRecording) {
|
|
2823
2867
|
const activationStarted = performance.now();
|
|
2824
2868
|
await guardRecordingStart(
|
|
@@ -2842,6 +2886,7 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2842
2886
|
void audioCue.playBeforeCapture();
|
|
2843
2887
|
},
|
|
2844
2888
|
});
|
|
2889
|
+
params.signal?.removeEventListener("abort", abortCountdown);
|
|
2845
2890
|
id = recording.id;
|
|
2846
2891
|
const originalStartedAt = new Date().toISOString();
|
|
2847
2892
|
if (!localOnly) {
|
|
@@ -2854,7 +2899,7 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2854
2899
|
});
|
|
2855
2900
|
}
|
|
2856
2901
|
} catch (err) {
|
|
2857
|
-
|
|
2902
|
+
params.signal?.removeEventListener("abort", abortCountdown);
|
|
2858
2903
|
transcriptionAborted = true;
|
|
2859
2904
|
// Cast: `transcriptionCapture` is only assigned inside the
|
|
2860
2905
|
// `startRewindTranscription` closure, which TS's control-flow analysis
|
|
@@ -2927,7 +2972,7 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2927
2972
|
await clearRecordingState();
|
|
2928
2973
|
};
|
|
2929
2974
|
|
|
2930
|
-
const discardTake = async (forRestart: boolean) => {
|
|
2975
|
+
const discardTake = async (forRestart: boolean): Promise<RestartHandoff> => {
|
|
2931
2976
|
stopped = true;
|
|
2932
2977
|
cleanupUi();
|
|
2933
2978
|
transcriptionAborted = true;
|
|
@@ -2945,13 +2990,27 @@ async function tryStartRewindFullscreenRecording(
|
|
|
2945
2990
|
}
|
|
2946
2991
|
if (!localOnly && id) {
|
|
2947
2992
|
forgetRewindClipOrigin(id);
|
|
2948
|
-
|
|
2949
|
-
() => {
|
|
2993
|
+
void cleanupCancelledRemoteRecording(params.serverUrl, id).catch(
|
|
2994
|
+
(err) => {
|
|
2995
|
+
console.warn(
|
|
2996
|
+
"[clips-recorder] cancelled recording cleanup failed:",
|
|
2997
|
+
err,
|
|
2998
|
+
);
|
|
2999
|
+
},
|
|
2950
3000
|
);
|
|
2951
3001
|
}
|
|
2952
3002
|
// The transcription engine is process-global, so this stop must land
|
|
2953
|
-
// before the replacement session starts it again.
|
|
2954
|
-
|
|
3003
|
+
// before the replacement session starts it again. Hand the pending
|
|
3004
|
+
// teardown to the retake — it awaits it right before its own
|
|
3005
|
+
// transcription start, hiding the flush under the new countdown instead
|
|
3006
|
+
// of delaying it here.
|
|
3007
|
+
return {
|
|
3008
|
+
displayStream: null,
|
|
3009
|
+
audioStream: null,
|
|
3010
|
+
transcriptionTornDown: forRestart
|
|
3011
|
+
? (transcriptionTornDown ?? null)
|
|
3012
|
+
: null,
|
|
3013
|
+
};
|
|
2955
3014
|
};
|
|
2956
3015
|
|
|
2957
3016
|
const handle: RecorderHandle = {
|
|
@@ -3037,10 +3096,6 @@ async function tryStartRewindFullscreenRecording(
|
|
|
3037
3096
|
: Promise.resolve(true);
|
|
3038
3097
|
await invoke("hide_recording_chrome").catch(() => {});
|
|
3039
3098
|
if (wantsCamera) await invoke("close_bubble").catch(() => {});
|
|
3040
|
-
await openNativeUploadUrl(
|
|
3041
|
-
id,
|
|
3042
|
-
`${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
|
|
3043
|
-
);
|
|
3044
3099
|
try {
|
|
3045
3100
|
const uploaded = await uploadPromise;
|
|
3046
3101
|
if (uploaded.verificationPending) {
|
|
@@ -3099,7 +3154,7 @@ async function tryStartRewindFullscreenRecording(
|
|
|
3099
3154
|
return cancelPromise;
|
|
3100
3155
|
}
|
|
3101
3156
|
if (stopped) return;
|
|
3102
|
-
cancelPromise = discardTake(false);
|
|
3157
|
+
cancelPromise = discardTake(false).then(() => {});
|
|
3103
3158
|
return cancelPromise;
|
|
3104
3159
|
},
|
|
3105
3160
|
async discardForRestart() {
|
|
@@ -3112,11 +3167,8 @@ async function tryStartRewindFullscreenRecording(
|
|
|
3112
3167
|
throw new Error("Recording already finished — nothing to restart");
|
|
3113
3168
|
}
|
|
3114
3169
|
// The Rewind producer re-acquires capture natively, so the retake needs
|
|
3115
|
-
//
|
|
3116
|
-
discardPromise = discardTake(true)
|
|
3117
|
-
displayStream: null,
|
|
3118
|
-
audioStream: null,
|
|
3119
|
-
}));
|
|
3170
|
+
// no streams handed to it — only the pending transcription teardown.
|
|
3171
|
+
discardPromise = discardTake(true);
|
|
3120
3172
|
return discardPromise;
|
|
3121
3173
|
},
|
|
3122
3174
|
};
|
|
@@ -3163,12 +3215,22 @@ async function tryStartRewindFullscreenRecording(
|
|
|
3163
3215
|
}),
|
|
3164
3216
|
listen("clips:toolbar-ready", () => {
|
|
3165
3217
|
emit("clips:toolbar-enabled", !stopped).catch(() => {});
|
|
3218
|
+
emitRecorderSession(
|
|
3219
|
+
localOnly ? null : params.serverUrl,
|
|
3220
|
+
id || null,
|
|
3221
|
+
localOnly,
|
|
3222
|
+
);
|
|
3166
3223
|
emitState();
|
|
3167
3224
|
}),
|
|
3168
3225
|
]);
|
|
3169
3226
|
tickHandle = window.setInterval(emitState, 500);
|
|
3170
3227
|
emit("clips:toolbar-sync").catch(() => {});
|
|
3171
3228
|
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
3229
|
+
emitRecorderSession(
|
|
3230
|
+
localOnly ? null : params.serverUrl,
|
|
3231
|
+
id || null,
|
|
3232
|
+
localOnly,
|
|
3233
|
+
);
|
|
3172
3234
|
emitState();
|
|
3173
3235
|
return handle;
|
|
3174
3236
|
}
|
|
@@ -3218,6 +3280,10 @@ async function startNativeFullscreenRecording(
|
|
|
3218
3280
|
};
|
|
3219
3281
|
const startNativeTranscriptionBeforeRecording = async () => {
|
|
3220
3282
|
if (localOnly || !canTranscribeLocally || transcriptionCapture) return;
|
|
3283
|
+
// The engine is process-global: on a restart the replaced take's stop must
|
|
3284
|
+
// land before this start, or it attaches to the dying capture. The wait
|
|
3285
|
+
// runs here, under the countdown, instead of inside the discard.
|
|
3286
|
+
await params.pendingTranscriptionTeardown;
|
|
3221
3287
|
throwIfRecordingStartAborted(params.signal);
|
|
3222
3288
|
transcriptionCapture = await guardRecordingStart(
|
|
3223
3289
|
startTranscriptionCapture(
|
|
@@ -3503,6 +3569,11 @@ async function startNativeFullscreenRecording(
|
|
|
3503
3569
|
// timer baseline so the toolbar clock lines up with the real start.
|
|
3504
3570
|
startedAt = Date.now();
|
|
3505
3571
|
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
3572
|
+
emitRecorderSession(
|
|
3573
|
+
localOnly ? null : params.serverUrl,
|
|
3574
|
+
id || null,
|
|
3575
|
+
localOnly,
|
|
3576
|
+
);
|
|
3506
3577
|
emit("clips:recorder-state", {
|
|
3507
3578
|
paused: false,
|
|
3508
3579
|
elapsedMs: 0,
|
|
@@ -3625,7 +3696,7 @@ async function startNativeFullscreenRecording(
|
|
|
3625
3696
|
await invoke("hide_overlays").catch(() => {});
|
|
3626
3697
|
};
|
|
3627
3698
|
|
|
3628
|
-
const discardTake = async (forRestart: boolean) => {
|
|
3699
|
+
const discardTake = async (forRestart: boolean): Promise<RestartHandoff> => {
|
|
3629
3700
|
stopped = true;
|
|
3630
3701
|
clearSegmentRotator();
|
|
3631
3702
|
pauseQueue?.dispose();
|
|
@@ -3684,8 +3755,17 @@ async function startNativeFullscreenRecording(
|
|
|
3684
3755
|
);
|
|
3685
3756
|
}
|
|
3686
3757
|
// The transcription engine is process-global, so this stop must land
|
|
3687
|
-
// before the replacement session starts it again.
|
|
3688
|
-
|
|
3758
|
+
// before the replacement session starts it again. Hand the pending
|
|
3759
|
+
// teardown to the retake — it awaits it right before its own
|
|
3760
|
+
// transcription start, hiding the flush under the new countdown instead
|
|
3761
|
+
// of delaying it here.
|
|
3762
|
+
return {
|
|
3763
|
+
displayStream: null,
|
|
3764
|
+
audioStream: null,
|
|
3765
|
+
transcriptionTornDown: forRestart
|
|
3766
|
+
? (transcriptionTornDown ?? null)
|
|
3767
|
+
: null,
|
|
3768
|
+
};
|
|
3689
3769
|
};
|
|
3690
3770
|
|
|
3691
3771
|
const handle: RecorderHandle = {
|
|
@@ -3822,13 +3902,6 @@ async function startNativeFullscreenRecording(
|
|
|
3822
3902
|
},
|
|
3823
3903
|
);
|
|
3824
3904
|
uploadPromise.catch(() => {});
|
|
3825
|
-
// The recording row already exists, so open its page as soon as the
|
|
3826
|
-
// native stop command has started. Upload/finalize continues in this
|
|
3827
|
-
// webview while the page polls from `uploading` to `ready`.
|
|
3828
|
-
await openNativeUploadUrl(
|
|
3829
|
-
id,
|
|
3830
|
-
`${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`,
|
|
3831
|
-
);
|
|
3832
3905
|
try {
|
|
3833
3906
|
await Promise.race([
|
|
3834
3907
|
recorderFinalized,
|
|
@@ -3926,7 +3999,7 @@ async function startNativeFullscreenRecording(
|
|
|
3926
3999
|
return cancelPromise;
|
|
3927
4000
|
}
|
|
3928
4001
|
if (stopped) return;
|
|
3929
|
-
cancelPromise = discardTake(false);
|
|
4002
|
+
cancelPromise = discardTake(false).then(() => {});
|
|
3930
4003
|
return cancelPromise;
|
|
3931
4004
|
},
|
|
3932
4005
|
|
|
@@ -3940,11 +4013,9 @@ async function startNativeFullscreenRecording(
|
|
|
3940
4013
|
throw new Error("Recording already finished — nothing to restart");
|
|
3941
4014
|
}
|
|
3942
4015
|
// ScreenCaptureKit is driven from Rust, so the retake re-acquires
|
|
3943
|
-
// capture natively without needing a user gesture
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
audioStream: null,
|
|
3947
|
-
}));
|
|
4016
|
+
// capture natively without needing a user gesture — only the pending
|
|
4017
|
+
// transcription teardown is handed over.
|
|
4018
|
+
discardPromise = discardTake(true);
|
|
3948
4019
|
return discardPromise;
|
|
3949
4020
|
},
|
|
3950
4021
|
};
|
|
@@ -4015,6 +4086,11 @@ async function startNativeFullscreenRecording(
|
|
|
4015
4086
|
}),
|
|
4016
4087
|
listen("clips:toolbar-ready", () => {
|
|
4017
4088
|
emit("clips:toolbar-enabled", !stopped).catch(() => {});
|
|
4089
|
+
emitRecorderSession(
|
|
4090
|
+
localOnly ? null : params.serverUrl,
|
|
4091
|
+
id || null,
|
|
4092
|
+
localOnly,
|
|
4093
|
+
);
|
|
4018
4094
|
emitState();
|
|
4019
4095
|
}),
|
|
4020
4096
|
]);
|
|
@@ -4023,6 +4099,11 @@ async function startNativeFullscreenRecording(
|
|
|
4023
4099
|
startSegmentRotator();
|
|
4024
4100
|
emit("clips:toolbar-sync").catch(() => {});
|
|
4025
4101
|
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
4102
|
+
emitRecorderSession(
|
|
4103
|
+
localOnly ? null : params.serverUrl,
|
|
4104
|
+
id || null,
|
|
4105
|
+
localOnly,
|
|
4106
|
+
);
|
|
4026
4107
|
emitState();
|
|
4027
4108
|
|
|
4028
4109
|
if (!localOnly) {
|
|
@@ -4204,7 +4285,11 @@ export function resolveRestartHandoff(
|
|
|
4204
4285
|
stopStream(audioStream);
|
|
4205
4286
|
throw new Error(RESTART_CAPTURE_ENDED_MESSAGE);
|
|
4206
4287
|
}
|
|
4207
|
-
return {
|
|
4288
|
+
return {
|
|
4289
|
+
displayStream,
|
|
4290
|
+
audioStream,
|
|
4291
|
+
transcriptionTornDown: params.pendingTranscriptionTeardown,
|
|
4292
|
+
};
|
|
4208
4293
|
}
|
|
4209
4294
|
|
|
4210
4295
|
async function startRecordingInner(
|
|
@@ -4690,6 +4775,7 @@ async function startRecordingInner(
|
|
|
4690
4775
|
emit("clips:toolbar-enabled", startedAt > 0 && !stopped).catch(
|
|
4691
4776
|
() => {},
|
|
4692
4777
|
);
|
|
4778
|
+
emitRecorderSession(null, null, true);
|
|
4693
4779
|
emitState(pausedAt != null);
|
|
4694
4780
|
}),
|
|
4695
4781
|
]);
|
|
@@ -4702,6 +4788,7 @@ async function startRecordingInner(
|
|
|
4702
4788
|
startedAt = Date.now();
|
|
4703
4789
|
tickHandle = setInterval(() => emitState(pausedAt != null), 500);
|
|
4704
4790
|
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
4791
|
+
emitRecorderSession(null, null, true);
|
|
4705
4792
|
emitState(false);
|
|
4706
4793
|
|
|
4707
4794
|
const detachCombinedStream = () => {
|
|
@@ -5124,6 +5211,7 @@ async function startRecordingInner(
|
|
|
5124
5211
|
emit("clips:toolbar-enabled", startedAt > 0 && !stopped).catch(
|
|
5125
5212
|
() => {},
|
|
5126
5213
|
);
|
|
5214
|
+
emitRecorderSession(params.serverUrl, id, false);
|
|
5127
5215
|
emitState(pausedAt != null);
|
|
5128
5216
|
}),
|
|
5129
5217
|
]);
|
|
@@ -5140,6 +5228,7 @@ async function startRecordingInner(
|
|
|
5140
5228
|
// Now that MediaRecorder is actually ticking, flip the toolbar's
|
|
5141
5229
|
// Stop / Pause buttons to enabled so the user can drive the recorder.
|
|
5142
5230
|
emit("clips:toolbar-enabled", true).catch(() => {});
|
|
5231
|
+
emitRecorderSession(params.serverUrl, id, false);
|
|
5143
5232
|
// Seed the initial recorder-state so the time / paused styling match
|
|
5144
5233
|
// MediaRecorder's real state (before the first 500ms tick).
|
|
5145
5234
|
emitState(false);
|
|
@@ -5149,6 +5238,10 @@ async function startRecordingInner(
|
|
|
5149
5238
|
// delaying capture, so the first ~1s the user expected to record was lost
|
|
5150
5239
|
// (and the recording felt cut at the end). It's a separate capture from the
|
|
5151
5240
|
// recorded audio tracks, so starting it slightly late is safe.
|
|
5241
|
+
//
|
|
5242
|
+
// The engine is process-global: a restart's handed-off teardown must land
|
|
5243
|
+
// before this start, or it attaches to the dying capture.
|
|
5244
|
+
if (canTranscribeLocally) await restartHandoff.transcriptionTornDown;
|
|
5152
5245
|
transcriptionCapture = canTranscribeLocally
|
|
5153
5246
|
? await startTranscriptionCapture(
|
|
5154
5247
|
{
|
|
@@ -5243,11 +5336,6 @@ async function startRecordingInner(
|
|
|
5243
5336
|
// fixed even if launching the browser takes a moment. Open the existing
|
|
5244
5337
|
// recording row now and let its page poll while upload/finalize continues.
|
|
5245
5338
|
//
|
|
5246
|
-
// This must claim the native upload-open slot before launching. The
|
|
5247
|
-
// Finalizing overlay receives the completion event later and uses the
|
|
5248
|
-
// same claim; without it, browser recordings opened once here and then a
|
|
5249
|
-
// second time when finalization completed.
|
|
5250
|
-
await openNativeUploadUrl(id, absoluteViewUrl);
|
|
5251
5339
|
const recorderStopTimedOut = await Promise.race([
|
|
5252
5340
|
recorderStopped.then(() => false),
|
|
5253
5341
|
wait(MEDIA_RECORDER_STOP_TIMEOUT_MS).then(() => true),
|
|
@@ -5613,11 +5701,25 @@ async function startRecordingInner(
|
|
|
5613
5701
|
console.warn("[clips-recorder] local backup cleanup failed:", err);
|
|
5614
5702
|
});
|
|
5615
5703
|
// The transcription engine is process-global, so this stop must land
|
|
5616
|
-
// before the replacement session starts it again.
|
|
5617
|
-
|
|
5704
|
+
// before the replacement session starts it again. Hand the pending
|
|
5705
|
+
// teardown to the retake — it awaits it right before its own
|
|
5706
|
+
// transcription start, hiding the flush under the new countdown instead
|
|
5707
|
+
// of delaying it here. Gated on `keepCaptureStreams`, not `handsOff`:
|
|
5708
|
+
// a synthetic-capture restart hands over no streams but still restarts.
|
|
5709
|
+
const handedTranscriptionTeardown = keepCaptureStreams
|
|
5710
|
+
? (transcriptionTornDown ?? null)
|
|
5711
|
+
: null;
|
|
5618
5712
|
return handsOff
|
|
5619
|
-
? {
|
|
5620
|
-
|
|
5713
|
+
? {
|
|
5714
|
+
displayStream,
|
|
5715
|
+
audioStream,
|
|
5716
|
+
transcriptionTornDown: handedTranscriptionTeardown,
|
|
5717
|
+
}
|
|
5718
|
+
: {
|
|
5719
|
+
displayStream: null,
|
|
5720
|
+
audioStream: null,
|
|
5721
|
+
transcriptionTornDown: handedTranscriptionTeardown,
|
|
5722
|
+
};
|
|
5621
5723
|
};
|
|
5622
5724
|
|
|
5623
5725
|
const handle: RecorderHandle = {
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
export interface RewindRecordingStartPhases<TPrepared, TStarted> {
|
|
2
2
|
prepare(): Promise<TPrepared>;
|
|
3
3
|
countdown(): Promise<void>;
|
|
4
|
+
/**
|
|
5
|
+
* Tear down a countdown that is still running when `prepare` fails under
|
|
6
|
+
* it. Must make the pending `countdown()` promise settle — it is awaited
|
|
7
|
+
* afterwards so the teardown finishes before the prepare failure surfaces.
|
|
8
|
+
*/
|
|
9
|
+
cancelCountdown(): void;
|
|
4
10
|
activate(prepared: TPrepared): Promise<TStarted>;
|
|
5
11
|
onActivated?(): void;
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* Show the countdown immediately and run setup work underneath it, so the
|
|
16
|
+
* user never waits on a blank pre-countdown stall. Activation still waits for
|
|
17
|
+
* both: zero is a media boundary only after preparation has succeeded, and a
|
|
18
|
+
* prepare that outlasts the countdown extends the wait rather than starting
|
|
19
|
+
* capture early.
|
|
11
20
|
*/
|
|
12
21
|
export async function prepareRewindRecordingStart<TPrepared, TStarted>(
|
|
13
22
|
phases: RewindRecordingStartPhases<TPrepared, TStarted>,
|
|
14
23
|
): Promise<TStarted> {
|
|
15
|
-
const
|
|
16
|
-
|
|
24
|
+
const preparedPromise = phases.prepare();
|
|
25
|
+
const countdownPromise = phases.countdown();
|
|
26
|
+
// Observe the countdown early: a cancel while prepare is still settling
|
|
27
|
+
// below must not fire as an unhandled rejection. It is re-awaited with real
|
|
28
|
+
// handling once prepare's outcome is known.
|
|
29
|
+
void countdownPromise.catch(() => {});
|
|
30
|
+
let prepared: TPrepared;
|
|
31
|
+
try {
|
|
32
|
+
prepared = await preparedPromise;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
phases.cancelCountdown();
|
|
35
|
+
// Wait for the countdown teardown so no orphaned window outlives the
|
|
36
|
+
// failure, but surface the prepare error, not the induced cancel.
|
|
37
|
+
await countdownPromise.catch(() => {});
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
await countdownPromise;
|
|
17
41
|
const started = await phases.activate(prepared);
|
|
18
42
|
phases.onActivated?.();
|
|
19
43
|
return started;
|
|
@@ -14,10 +14,10 @@ import { MeetingNotification } from "./overlays/meeting-notification";
|
|
|
14
14
|
import { MeetingNub } from "./overlays/meeting-nub";
|
|
15
15
|
import { MonitorPicker } from "./overlays/monitor-picker";
|
|
16
16
|
import { Preparing } from "./overlays/preparing";
|
|
17
|
-
import { RecordingPill } from "./overlays/
|
|
17
|
+
import { RecordingPill } from "./overlays/record-pill";
|
|
18
|
+
import { MeetingPill } from "./overlays/recording-pill";
|
|
18
19
|
import { RegionGuideEditor, RegionGuides } from "./overlays/region-guides";
|
|
19
20
|
import { RegionRecordBorder } from "./overlays/region-record-border";
|
|
20
|
-
import { Toolbar } from "./overlays/toolbar";
|
|
21
21
|
|
|
22
22
|
// Imports styles.css itself, into a lower cascade layer — see tailwind.css.
|
|
23
23
|
import "./tailwind.css";
|
|
@@ -49,7 +49,7 @@ function pickRoute(route: string): React.ReactElement {
|
|
|
49
49
|
case "countdown":
|
|
50
50
|
return <Countdown />;
|
|
51
51
|
case "toolbar":
|
|
52
|
-
return <
|
|
52
|
+
return <RecordingPill />;
|
|
53
53
|
case "bubble":
|
|
54
54
|
return <Bubble />;
|
|
55
55
|
case "finalizing":
|
|
@@ -63,7 +63,7 @@ function pickRoute(route: string): React.ReactElement {
|
|
|
63
63
|
case "flow-bar":
|
|
64
64
|
return <FlowBar />;
|
|
65
65
|
case "recording-pill":
|
|
66
|
-
return <
|
|
66
|
+
return <MeetingPill />;
|
|
67
67
|
case "region-guides":
|
|
68
68
|
return <RegionGuides />;
|
|
69
69
|
case "region-guides-editor":
|