Skip to content

Commit e4fffde

Browse files
authored
Concurrent sweep: clips desktop local-export + AGENTS.md status-block wording (BuilderIO#1043)
1 parent 605be01 commit e4fffde

4 files changed

Lines changed: 204 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,20 @@ read the relevant skill before changing that area.
2424

2525
## Final Status Block
2626

27-
Every final response must end with exactly:
27+
Every final response must end with a three-line status block:
2828

2929
```md
3030
---
3131

3232
33-
🟢 Brief status
33+
🟢 Actual concise status sentence
3434
```
3535

36-
Use `🟢` when the requested coding/work unit is finished on the current branch,
37-
even if routine commit/PR/deploy/CI remains. Use `🟡` when non-routine work or a
38-
manual step is still pending. Use `🔴` only when blocked on user input.
36+
The words after the icon are a short, task-specific status written for this
37+
response; never use the placeholder text `Brief status` literally. Use `🟢`
38+
when the requested coding/work unit is finished on the current branch, even if
39+
routine commit/PR/deploy/CI remains. Use `🟡` when non-routine work or a manual
40+
step is still pending. Use `🔴` only when blocked on user input.
3941

4042
## Architecture Contract
4143

templates/clips/desktop/src/app.tsx

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "./lib/bubble-webrtc";
1818
import {
1919
discardBrowserRecordingBackup,
20+
exportBrowserRecordingBackup,
2021
listBrowserRecordingBackups,
2122
retryBrowserRecordingBackup,
2223
shouldUseNativeFullscreenRecording,
@@ -40,6 +41,7 @@ import {
4041
IconAlertTriangle,
4142
IconArrowLeft,
4243
IconCircleCheck,
44+
IconDownload,
4345
IconFolderOpen,
4446
IconPencil,
4547
IconInfoCircle,
@@ -629,6 +631,9 @@ export function App() {
629631
[],
630632
);
631633
const [retryingUploadId, setRetryingUploadId] = useState<string | null>(null);
634+
const [exportingUploadId, setExportingUploadId] = useState<string | null>(
635+
null,
636+
);
632637
const [discardingUploadId, setDiscardingUploadId] = useState<string | null>(
633638
null,
634639
);
@@ -1961,7 +1966,7 @@ export function App() {
19611966
}
19621967

19631968
async function retryPendingUpload(upload: PendingDesktopUpload) {
1964-
if (retryingUploadId || discardingUploadId) return;
1969+
if (retryingUploadId || exportingUploadId || discardingUploadId) return;
19651970
const targetServerUrl = serverUrlForPendingUpload(upload, serverUrl);
19661971
setRecError(null);
19671972
setRetryingUploadId(upload.recordingId);
@@ -1998,8 +2003,38 @@ export function App() {
19982003
}
19992004
}
20002005

2006+
async function exportPendingUpload(upload: PendingDesktopUpload) {
2007+
if (retryingUploadId || exportingUploadId || discardingUploadId) return;
2008+
setRecError(null);
2009+
2010+
if (upload.kind === "native") {
2011+
openPendingUploadFolder(upload);
2012+
return;
2013+
}
2014+
2015+
setExportingUploadId(upload.recordingId);
2016+
try {
2017+
const exportResult = await exportBrowserRecordingBackup(
2018+
upload.recordingId,
2019+
);
2020+
setLocalRecordingNotice({
2021+
folderPath: exportResult.folderPath,
2022+
files: [exportResult.file],
2023+
});
2024+
await invoke("open_local_recording_folder", {
2025+
path: exportResult.folderPath,
2026+
});
2027+
} catch (err) {
2028+
const message = err instanceof Error ? err.message : String(err);
2029+
console.error("[clips-tray] export saved upload failed:", err);
2030+
setRecError(message);
2031+
} finally {
2032+
setExportingUploadId(null);
2033+
}
2034+
}
2035+
20012036
async function discardPendingUpload(upload: PendingDesktopUpload) {
2002-
if (retryingUploadId || discardingUploadId) return;
2037+
if (retryingUploadId || exportingUploadId || discardingUploadId) return;
20032038
setRecError(null);
20042039
setDiscardingUploadId(upload.recordingId);
20052040
setPendingUploads((uploads) =>
@@ -2511,7 +2546,9 @@ export function App() {
25112546
<PendingUploadBanner
25122547
uploads={pendingUploads}
25132548
retryingUploadId={retryingUploadId}
2549+
exportingUploadId={exportingUploadId}
25142550
discardingUploadId={discardingUploadId}
2551+
onExport={exportPendingUpload}
25152552
onRetry={retryPendingUpload}
25162553
onDiscard={discardPendingUpload}
25172554
onOpenFolder={openPendingUploadFolder}
@@ -2877,14 +2914,18 @@ function PermissionRecoveryBanner({
28772914
function PendingUploadBanner({
28782915
uploads,
28792916
retryingUploadId,
2917+
exportingUploadId,
28802918
discardingUploadId,
2919+
onExport,
28812920
onRetry,
28822921
onDiscard,
28832922
onOpenFolder,
28842923
}: {
28852924
uploads: PendingDesktopUpload[];
28862925
retryingUploadId: string | null;
2926+
exportingUploadId: string | null;
28872927
discardingUploadId: string | null;
2928+
onExport: (upload: PendingDesktopUpload) => void;
28882929
onRetry: (upload: PendingDesktopUpload) => void;
28892930
onDiscard: (upload: PendingDesktopUpload) => void;
28902931
onOpenFolder: (upload: PendingDesktopUpload) => void;
@@ -2893,7 +2934,11 @@ function PendingUploadBanner({
28932934
if (!latest) return null;
28942935

28952936
const retrying = retryingUploadId === latest.recordingId;
2937+
const exporting = exportingUploadId === latest.recordingId;
28962938
const canOpenFolder = latest.kind === "native" && !!latest.folderPath;
2939+
const canExport = latest.kind === "browser";
2940+
const actionsDisabled =
2941+
!!retryingUploadId || !!exportingUploadId || !!discardingUploadId;
28972942
const savedLabel =
28982943
uploads.length === 1
28992944
? "1 Clip saved locally"
@@ -2923,18 +2968,30 @@ function PendingUploadBanner({
29232968
<button
29242969
type="button"
29252970
className="pending-upload-folder"
2926-
disabled={discardingUploadId === latest.recordingId}
2971+
disabled={actionsDisabled}
29272972
onClick={() => onOpenFolder(latest)}
29282973
aria-label="Open saved local clip folder"
29292974
title="Open saved local clip folder"
29302975
>
29312976
<IconFolderOpen size={14} stroke={2} />
29322977
</button>
29332978
) : null}
2979+
{canExport ? (
2980+
<button
2981+
type="button"
2982+
className="pending-upload-folder"
2983+
disabled={actionsDisabled}
2984+
onClick={() => onExport(latest)}
2985+
aria-label="Download saved local clip"
2986+
title="Download saved local clip"
2987+
>
2988+
<IconDownload size={14} stroke={2} />
2989+
</button>
2990+
) : null}
29342991
<button
29352992
type="button"
29362993
className="pending-upload-retry"
2937-
disabled={!!retryingUploadId || !!discardingUploadId}
2994+
disabled={actionsDisabled}
29382995
onClick={() => onRetry(latest)}
29392996
>
29402997
<IconRefresh size={14} stroke={2} />
@@ -2943,7 +3000,7 @@ function PendingUploadBanner({
29433000
<button
29443001
type="button"
29453002
className="pending-upload-discard"
2946-
disabled={!!retryingUploadId || !!discardingUploadId}
3003+
disabled={actionsDisabled}
29473004
onClick={() => onDiscard(latest)}
29483005
aria-label="Discard saved local clip"
29493006
title="Discard saved local clip"

templates/clips/desktop/src/lib/local-export.ts

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface LocalRecordingExportHandle {
3838
cancel(): Promise<void>;
3939
}
4040

41+
export interface LocalBlobExportResult {
42+
folderPath: string;
43+
folderName: string;
44+
file: LocalExportedFile;
45+
}
46+
4147
interface PreparedLocalTarget {
4248
role: LocalRecordingFileRole;
4349
stream: MediaStream;
@@ -52,7 +58,7 @@ interface PreparedLocalTarget {
5258
writeQueue: Promise<void>;
5359
}
5460

55-
const LOCAL_EXPORT_FOLDER = "Clips";
61+
export const LOCAL_EXPORT_FOLDER = "Clips";
5662

5763
function pickRecordingMimeType(): string {
5864
return (
@@ -64,7 +70,7 @@ function pickRecordingMimeType(): string {
6470
);
6571
}
6672

67-
function extensionForMimeType(mimeType: string): string {
73+
export function extensionForMimeType(mimeType: string): string {
6874
const normalized = mimeType.toLowerCase();
6975
if (normalized.includes("mp4")) return "mp4";
7076
if (normalized.includes("quicktime")) return "mov";
@@ -85,6 +91,96 @@ export function createLocalRecordingFolderName(): string {
8591
return `clip-${timestamp}-${nonce}`;
8692
}
8793

94+
export async function exportBlobChunksToLocalRecordingFile({
95+
chunks,
96+
role = "composed",
97+
mimeType,
98+
folderName,
99+
durationMs = 0,
100+
width = null,
101+
height = null,
102+
}: {
103+
chunks: Blob[];
104+
role?: LocalRecordingFileRole;
105+
mimeType: string;
106+
folderName?: string;
107+
durationMs?: number;
108+
width?: number | null;
109+
height?: number | null;
110+
}): Promise<LocalBlobExportResult> {
111+
if (chunks.length === 0) {
112+
throw new Error("No saved recording chunks are available to export");
113+
}
114+
115+
const resolvedFolderName = folderName ?? createLocalRecordingFolderName();
116+
const relativeFolderPath = `${LOCAL_EXPORT_FOLDER}/${resolvedFolderName}`;
117+
const normalizedMimeType = mimeType || "video/webm";
118+
const fileName = `${roleFileSuffix(role)}.${extensionForMimeType(
119+
normalizedMimeType,
120+
)}`;
121+
const relativePath = `${relativeFolderPath}/${fileName}`;
122+
123+
await mkdir(relativeFolderPath, {
124+
baseDir: BaseDirectory.Video,
125+
recursive: true,
126+
});
127+
128+
const folderPath = await join(await videoDir(), relativeFolderPath);
129+
const filePath = await join(folderPath, fileName);
130+
const file = await create(relativePath, {
131+
baseDir: BaseDirectory.Video,
132+
});
133+
134+
let bytes = 0;
135+
let closed = false;
136+
try {
137+
for (const chunk of chunks) {
138+
if (!chunk || chunk.size === 0) continue;
139+
const data = new Uint8Array(await chunk.arrayBuffer());
140+
if (data.byteLength === 0) continue;
141+
const written = await file.write(data);
142+
if (written !== data.byteLength) {
143+
throw new Error(
144+
`Short write for ${fileName}: wrote ${written} of ${data.byteLength} bytes`,
145+
);
146+
}
147+
bytes += written;
148+
}
149+
await file.close();
150+
closed = true;
151+
} catch (err) {
152+
if (!closed) {
153+
await file.close().catch(() => {});
154+
}
155+
await remove(relativePath, {
156+
baseDir: BaseDirectory.Video,
157+
}).catch(() => {});
158+
throw err;
159+
}
160+
161+
if (bytes === 0) {
162+
await remove(relativePath, {
163+
baseDir: BaseDirectory.Video,
164+
}).catch(() => {});
165+
throw new Error("Saved recording export was empty");
166+
}
167+
168+
return {
169+
folderPath,
170+
folderName: resolvedFolderName,
171+
file: {
172+
role,
173+
path: filePath,
174+
fileName,
175+
mimeType: normalizedMimeType,
176+
bytes,
177+
durationMs,
178+
width,
179+
height,
180+
},
181+
};
182+
}
183+
88184
function enqueueWrite(target: PreparedLocalTarget, blob: Blob) {
89185
target.writeQueue = target.writeQueue
90186
.then(async () => {

templates/clips/desktop/src/lib/recorder.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ import { open as openExternal } from "@tauri-apps/plugin-shell";
5353
import { createCameraCompositeStream } from "./camera-composite";
5454
import {
5555
createLocalRecordingFolderName,
56+
exportBlobChunksToLocalRecordingFile,
5657
prepareLocalRecordingExport,
58+
type LocalBlobExportResult,
5759
type LocalRecordingExportHandle,
5860
type LocalExportedFile,
5961
type LocalRecordingTarget,
@@ -569,6 +571,28 @@ export async function discardBrowserRecordingBackup(
569571
await deleteBrowserRecordingBackup(recordingId);
570572
}
571573

574+
export async function exportBrowserRecordingBackup(
575+
recordingId: string,
576+
): Promise<LocalBlobExportResult> {
577+
const meta = await getBrowserRecordingBackupMeta(recordingId);
578+
if (!meta) {
579+
throw new Error("Local recording backup not found");
580+
}
581+
const chunks = await getBrowserRecordingBackupChunks(recordingId);
582+
if (chunks.length === 0) {
583+
throw new Error("Local recording backup has no chunks");
584+
}
585+
586+
return exportBlobChunksToLocalRecordingFile({
587+
chunks: chunks.map((chunk) => chunk.blob),
588+
role: "composed",
589+
mimeType: meta.mimeType || chunks[0]?.mimeType || "video/webm",
590+
durationMs: meta.durationMs,
591+
width: meta.width,
592+
height: meta.height,
593+
});
594+
}
595+
572596
async function markBrowserRecordingBackupError(
573597
recordingId: string,
574598
error: string,
@@ -2749,6 +2773,16 @@ async function startNativeRecordingInner(
27492773
elapsedMs,
27502774
}).catch(() => {});
27512775
}
2776+
2777+
async function openFailedRecordingPage() {
2778+
const viewUrl = `/r/${id}?saveFailed=1`;
2779+
try {
2780+
await openExternal(`${params.serverUrl.replace(/\/+$/, "")}${viewUrl}`);
2781+
} catch (err) {
2782+
console.error("[clips-recorder] openExternal failed:", err);
2783+
}
2784+
}
2785+
27522786
const tickHandle = setInterval(() => emitState(pausedAt != null), 500);
27532787

27542788
// 5. Wire toolbar events.
@@ -2987,6 +3021,7 @@ async function startNativeRecordingInner(
29873021
invoke("hide_finalizing").catch((err) =>
29883022
console.error("[clips-recorder] hide_finalizing failed:", err),
29893023
);
3024+
await openFailedRecordingPage();
29903025
throw failed;
29913026
}
29923027

@@ -3027,9 +3062,11 @@ async function startNativeRecordingInner(
30273062
await markBrowserRecordingBackupError(id, error.message).catch(
30283063
() => {},
30293064
);
3065+
await abortRecordingUpload(params.serverUrl, id, error.message);
30303066
invoke("hide_finalizing").catch((hideErr) =>
30313067
console.error("[clips-recorder] hide_finalizing failed:", hideErr),
30323068
);
3069+
await openFailedRecordingPage();
30333070
throw error;
30343071
}
30353072
await deleteBrowserRecordingBackup(id).catch((err) => {

0 commit comments

Comments
 (0)