Skip to content

Commit bb1c5ec

Browse files
New web based recorder (Instant Mode) (#1363)
* wip: Web recorder * feat: picture in picture camera * feat: clear errors in web recorder files * feat: web-backend Videos update * fix: picture in picture camera switch * feat: open preferred option (e.g. Window or Display) * feat: Settings dialog + auto select last device * feat: various styling bits for the web recorder * feat: web recorder cleanup * fmt * feat: component cleanup / division * feat: In progress recording bar * feat: Instant Mode for web recorder * feat: Browser compatibility * feat: PiP gesture fix * feat: Pause button + start/stop sounds * feat: Improved web recorder resilience + stream management * revert: remove desktop changes from PR * revert: remove remaining desktop/src changes from PR * revert: remove changes outside apps/web, web-backend, and web-domain * revert: remove unwanted changes from web recorder PR * revert: remove DeleteOrg files and changelog entry * Revert "revert: remove DeleteOrg files and changelog entry" This reverts commit 4148b2b. * feat: Chunk uploading * feat: Restart recording + segment progress * fmt * add env workspace * feat: use webMP4 for web recordings * fmt * feat: Popover progress indicator * feat: Web recorder free plan limits * revert: undo PR changes to MobileTab and spaces page * coderabbit bits * coderabbit suggestions * Filter out devices with empty deviceId * Add support for aborting multipart uploads * Add retry logic for S3 video result deletion * Add revalidatePath calls after video result deletion * Refactor WebRecorderDialog to new directory structure * Refactor bucketId handling in video upload actions * Update useEffect dependencies in CameraPreviewWindow * Add cleanup for recording timer on unmount * Fix timer interval reset in useRecordingTimer * format * Add @radix-ui/react-dialog and update video queries * Update page.tsx * Add effectiveCreatedAt to video props * Move S3 result file deletion outside DB transaction * Remove console.log from recording upload * Disable status pill interactions when component is disabled * Refactor video deletion to use useEffectMutation * Refactor video upload and abort handling * Fix stale closure in cleanup effect for recorder * format * Add provideOptionalAuth to upload API routes * Show error toast for unsupported browsers in dialog
1 parent a0e007c commit bb1c5ec

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+5964
-257
lines changed

apps/web/actions/video/upload.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@ import { s3Buckets, videos, videoUploads } from "@cap/database/schema";
1111
import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
1212
import { dub, userIsPro } from "@cap/utils";
1313
import { AwsCredentials, S3Buckets } from "@cap/web-backend";
14-
import { type Folder, type Organisation, Video } from "@cap/web-domain";
14+
import {
15+
type Folder,
16+
type Organisation,
17+
S3Bucket,
18+
Video,
19+
} from "@cap/web-domain";
1520
import { eq } from "drizzle-orm";
1621
import { Effect, Option } from "effect";
1722
import { revalidatePath } from "next/cache";
1823
import { runPromise } from "@/lib/server";
1924

25+
const MAX_S3_DELETE_ATTEMPTS = 3;
26+
const S3_DELETE_RETRY_BACKOFF_MS = 250;
27+
2028
async function getVideoUploadPresignedUrl({
2129
fileKey,
2230
duration,
@@ -203,7 +211,7 @@ export async function createVideoAndGetUploadUrl({
203211
} - ${formattedDate}`,
204212
ownerId: user.id,
205213
orgId,
206-
source: { type: "desktopMP4" as const },
214+
source: { type: "webMP4" as const },
207215
isScreenshot,
208216
bucket: customBucket?.id,
209217
public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
@@ -255,3 +263,131 @@ export async function createVideoAndGetUploadUrl({
255263
);
256264
}
257265
}
266+
267+
export async function deleteVideoResultFile({
268+
videoId,
269+
}: {
270+
videoId: Video.VideoId;
271+
}) {
272+
const user = await getCurrentUser();
273+
274+
if (!user) throw new Error("Unauthorized");
275+
276+
const [video] = await db()
277+
.select({
278+
id: videos.id,
279+
ownerId: videos.ownerId,
280+
bucketId: videos.bucket,
281+
})
282+
.from(videos)
283+
.where(eq(videos.id, videoId));
284+
285+
if (!video) throw new Error("Video not found");
286+
if (video.ownerId !== user.id) throw new Error("Forbidden");
287+
288+
const bucketIdOption = Option.fromNullable(video.bucketId).pipe(
289+
Option.map((id) => S3Bucket.S3BucketId.make(id)),
290+
);
291+
const fileKey = `${video.ownerId}/${video.id}/result.mp4`;
292+
const logContext = {
293+
videoId: video.id,
294+
ownerId: video.ownerId,
295+
bucketId: video.bucketId ?? null,
296+
fileKey,
297+
};
298+
299+
try {
300+
await db().transaction(async (tx) => {
301+
await tx.delete(videoUploads).where(eq(videoUploads.videoId, videoId));
302+
});
303+
} catch (error) {
304+
console.error("video.result.delete.transaction_failure", {
305+
...logContext,
306+
error: serializeError(error),
307+
});
308+
throw error;
309+
}
310+
311+
try {
312+
await deleteResultObjectWithRetry({
313+
bucketIdOption,
314+
fileKey,
315+
logContext,
316+
});
317+
} catch (error) {
318+
console.error("video.result.delete.s3_failure", {
319+
...logContext,
320+
error: serializeError(error),
321+
});
322+
throw error;
323+
}
324+
325+
revalidatePath(`/s/${videoId}`);
326+
revalidatePath("/dashboard/caps");
327+
revalidatePath("/dashboard/folder");
328+
revalidatePath("/dashboard/spaces");
329+
330+
return { success: true };
331+
}
332+
333+
async function deleteResultObjectWithRetry({
334+
bucketIdOption,
335+
fileKey,
336+
logContext,
337+
}: {
338+
bucketIdOption: Option.Option<S3Bucket.S3BucketId>;
339+
fileKey: string;
340+
logContext: {
341+
videoId: Video.VideoId;
342+
ownerId: string;
343+
bucketId: string | null;
344+
fileKey: string;
345+
};
346+
}) {
347+
let attempt = 0;
348+
let lastError: unknown;
349+
while (attempt < MAX_S3_DELETE_ATTEMPTS) {
350+
attempt += 1;
351+
try {
352+
await Effect.gen(function* () {
353+
const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption);
354+
yield* bucket.deleteObject(fileKey);
355+
}).pipe(runPromise);
356+
return;
357+
} catch (error) {
358+
lastError = error;
359+
console.error("video.result.delete.s3_failure", {
360+
...logContext,
361+
attempt,
362+
maxAttempts: MAX_S3_DELETE_ATTEMPTS,
363+
error: serializeError(error),
364+
});
365+
366+
if (attempt < MAX_S3_DELETE_ATTEMPTS) {
367+
await sleep(S3_DELETE_RETRY_BACKOFF_MS * attempt);
368+
}
369+
}
370+
}
371+
372+
throw lastError instanceof Error
373+
? lastError
374+
: new Error("Failed to delete video result from S3");
375+
}
376+
377+
function serializeError(error: unknown) {
378+
if (error instanceof Error) {
379+
return {
380+
name: error.name,
381+
message: error.message,
382+
stack: error.stack,
383+
};
384+
}
385+
386+
return { name: "UnknownError", message: String(error) };
387+
}
388+
389+
function sleep(durationMs: number) {
390+
return new Promise<void>((resolve) => {
391+
setTimeout(resolve, durationMs);
392+
});
393+
}

apps/web/app/(org)/dashboard/caps/Caps.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
SelectedCapsBar,
1818
UploadCapButton,
1919
UploadPlaceholderCard,
20+
WebRecorderDialog,
2021
} from "./components";
2122
import { CapCard } from "./components/CapCard/CapCard";
2223
import { CapPagination } from "./components/CapPagination";
@@ -240,6 +241,7 @@ export const Caps = ({
240241
New Folder
241242
</Button>
242243
<UploadCapButton size="sm" />
244+
<WebRecorderDialog />
243245
</div>
244246
{folders.length > 0 && (
245247
<>

apps/web/app/(org)/dashboard/caps/components/EmptyCapState.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
44
import { useRive } from "@rive-app/react-canvas";
55
import { useTheme } from "../../Contexts";
66
import { UploadCapButton } from "./UploadCapButton";
7+
import { WebRecorderDialog } from "./web-recorder-dialog/web-recorder-dialog";
78

89
interface EmptyCapStateProps {
910
userName?: string;
@@ -30,7 +31,7 @@ export const EmptyCapState: React.FC<EmptyCapStateProps> = ({ userName }) => {
3031
Craft your narrative with Cap - get projects done quicker.
3132
</p>
3233
</div>
33-
<div className="flex gap-3 justify-center items-center mt-4">
34+
<div className="flex flex-wrap gap-3 justify-center items-center mt-4">
3435
<Button
3536
href="/download"
3637
className="flex relative gap-2 justify-center items-center"
@@ -40,6 +41,8 @@ export const EmptyCapState: React.FC<EmptyCapStateProps> = ({ userName }) => {
4041
Download Cap
4142
</Button>
4243
<p className="text-sm text-gray-10">or</p>
44+
<WebRecorderDialog />
45+
<p className="text-sm text-gray-10">or</p>
4346
<UploadCapButton />
4447
</div>
4548
</div>

apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "@/app/(org)/dashboard/caps/UploadingContext";
1919
import { UpgradeModal } from "@/components/UpgradeModal";
2020
import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest";
21+
import { sendProgressUpdate } from "./sendProgressUpdate";
2122

2223
export const UploadCapButton = ({
2324
size = "md",
@@ -517,29 +518,3 @@ async function legacyUploadCap(
517518
setUploadStatus(undefined);
518519
return false;
519520
}
520-
521-
const sendProgressUpdate = async (
522-
videoId: string,
523-
uploaded: number,
524-
total: number,
525-
) => {
526-
try {
527-
const response = await fetch("/api/desktop/video/progress", {
528-
method: "POST",
529-
headers: {
530-
"Content-Type": "application/json",
531-
},
532-
body: JSON.stringify({
533-
videoId,
534-
uploaded,
535-
total,
536-
updatedAt: new Date().toISOString(),
537-
}),
538-
});
539-
540-
if (!response.ok)
541-
console.error("Failed to send progress update:", response.status);
542-
} catch (err) {
543-
console.error("Error sending progress update:", err);
544-
}
545-
};

apps/web/app/(org)/dashboard/caps/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from "./NewFolderDialog";
55
export * from "./SelectedCapsBar";
66
export * from "./UploadCapButton";
77
export * from "./UploadPlaceholderCard";
8+
export * from "./web-recorder-dialog/web-recorder-dialog";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { EffectRuntime } from "@/lib/EffectRuntime";
2+
import { withRpc } from "@/lib/Rpcs";
3+
import type { VideoId } from "./web-recorder-dialog/web-recorder-types";
4+
5+
export const sendProgressUpdate = async (
6+
videoId: VideoId,
7+
uploaded: number,
8+
total: number,
9+
) => {
10+
try {
11+
await EffectRuntime.runPromise(
12+
withRpc((rpc) =>
13+
rpc.VideoUploadProgressUpdate({
14+
videoId,
15+
uploaded,
16+
total,
17+
updatedAt: new Date(),
18+
}),
19+
),
20+
);
21+
} catch (error) {
22+
console.error("Failed to send progress update:", error);
23+
}
24+
};

0 commit comments

Comments
 (0)