Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
6db535a
wip: Web recorder
richiemcilroy Oct 31, 2025
316f535
Merge branch 'main' into new-web-recorder
richiemcilroy Nov 6, 2025
6031aaf
feat: picture in picture camera
richiemcilroy Nov 6, 2025
1c83f8f
feat: clear errors in web recorder files
richiemcilroy Nov 6, 2025
033ea90
feat: web-backend Videos update
richiemcilroy Nov 6, 2025
bb2f923
fix: picture in picture camera switch
richiemcilroy Nov 6, 2025
f698b3b
feat: open preferred option (e.g. Window or Display)
richiemcilroy Nov 6, 2025
46fe2ef
feat: Settings dialog + auto select last device
richiemcilroy Nov 6, 2025
205e47e
feat: various styling bits for the web recorder
richiemcilroy Nov 6, 2025
00d511f
feat: web recorder cleanup
richiemcilroy Nov 6, 2025
2d01b70
fmt
richiemcilroy Nov 6, 2025
faaca08
feat: component cleanup / division
richiemcilroy Nov 6, 2025
fe680cf
feat: In progress recording bar
richiemcilroy Nov 7, 2025
8143eab
feat: Instant Mode for web recorder
richiemcilroy Nov 7, 2025
33d2ec3
feat: Browser compatibility
richiemcilroy Nov 7, 2025
abce7c2
feat: PiP gesture fix
richiemcilroy Nov 7, 2025
e8a5a76
feat: Pause button + start/stop sounds
richiemcilroy Nov 7, 2025
52f4bf2
feat: Improved web recorder resilience + stream management
richiemcilroy Nov 7, 2025
b29b9c0
revert: remove desktop changes from PR
richiemcilroy Nov 7, 2025
19f7267
revert: remove remaining desktop/src changes from PR
richiemcilroy Nov 7, 2025
c6dd107
revert: remove changes outside apps/web, web-backend, and web-domain
richiemcilroy Nov 7, 2025
336195c
revert: remove unwanted changes from web recorder PR
richiemcilroy Nov 7, 2025
4148b2b
revert: remove DeleteOrg files and changelog entry
richiemcilroy Nov 7, 2025
e92b598
Revert "revert: remove DeleteOrg files and changelog entry"
richiemcilroy Nov 7, 2025
a4240ed
feat: Chunk uploading
richiemcilroy Nov 7, 2025
31a5d2d
feat: Restart recording + segment progress
richiemcilroy Nov 8, 2025
5cf5fa2
fmt
richiemcilroy Nov 8, 2025
3abe570
add env workspace
richiemcilroy Nov 8, 2025
6fd1188
feat: use webMP4 for web recordings
richiemcilroy Nov 8, 2025
816b494
fmt
richiemcilroy Nov 8, 2025
912fbc3
feat: Popover progress indicator
richiemcilroy Nov 8, 2025
8cf485c
feat: Web recorder free plan limits
richiemcilroy Nov 8, 2025
044e583
revert: undo PR changes to MobileTab and spaces page
richiemcilroy Nov 8, 2025
425755a
coderabbit bits
richiemcilroy Nov 8, 2025
e5139c7
coderabbit suggestions
richiemcilroy Nov 8, 2025
1aa82d1
Filter out devices with empty deviceId
richiemcilroy Nov 8, 2025
0bb8cca
Add support for aborting multipart uploads
richiemcilroy Nov 8, 2025
bf63dd9
Add retry logic for S3 video result deletion
richiemcilroy Nov 8, 2025
a880d45
Add revalidatePath calls after video result deletion
richiemcilroy Nov 8, 2025
2b279b2
Refactor WebRecorderDialog to new directory structure
richiemcilroy Nov 8, 2025
928c78e
Refactor bucketId handling in video upload actions
richiemcilroy Nov 8, 2025
52d03d3
Update useEffect dependencies in CameraPreviewWindow
richiemcilroy Nov 8, 2025
5e79d71
Add cleanup for recording timer on unmount
richiemcilroy Nov 8, 2025
17a60b8
Fix timer interval reset in useRecordingTimer
richiemcilroy Nov 8, 2025
116ca00
format
richiemcilroy Nov 8, 2025
d35180c
Add @radix-ui/react-dialog and update video queries
richiemcilroy Nov 8, 2025
cabc314
Update page.tsx
richiemcilroy Nov 8, 2025
74f7d53
Add effectiveCreatedAt to video props
richiemcilroy Nov 8, 2025
68b83f1
Move S3 result file deletion outside DB transaction
richiemcilroy Nov 8, 2025
443497b
Remove console.log from recording upload
richiemcilroy Nov 8, 2025
c8cdab5
Disable status pill interactions when component is disabled
richiemcilroy Nov 8, 2025
d67f5ed
Refactor video deletion to use useEffectMutation
richiemcilroy Nov 8, 2025
6b84461
Refactor video upload and abort handling
richiemcilroy Nov 8, 2025
1aa06a3
Fix stale closure in cleanup effect for recorder
richiemcilroy Nov 8, 2025
69b32bc
format
richiemcilroy Nov 8, 2025
d2d4dd3
Add provideOptionalAuth to upload API routes
richiemcilroy Nov 8, 2025
91f9acd
Show error toast for unsupported browsers in dialog
richiemcilroy Nov 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions apps/web/actions/video/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ import { s3Buckets, videos, videoUploads } from "@cap/database/schema";
import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
import { dub, userIsPro } from "@cap/utils";
import { AwsCredentials, S3Buckets } from "@cap/web-backend";
import { type Folder, type Organisation, Video } from "@cap/web-domain";
import {
type Folder,
type Organisation,
S3Bucket,
Video,
} from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect, Option } from "effect";
import { revalidatePath } from "next/cache";
import { runPromise } from "@/lib/server";

const MAX_S3_DELETE_ATTEMPTS = 3;
const S3_DELETE_RETRY_BACKOFF_MS = 250;

async function getVideoUploadPresignedUrl({
fileKey,
duration,
Expand Down Expand Up @@ -203,7 +211,7 @@ export async function createVideoAndGetUploadUrl({
} - ${formattedDate}`,
ownerId: user.id,
orgId,
source: { type: "desktopMP4" as const },
source: { type: "webMP4" as const },
isScreenshot,
bucket: customBucket?.id,
public: serverEnv().CAP_VIDEOS_DEFAULT_PUBLIC,
Expand Down Expand Up @@ -255,3 +263,131 @@ export async function createVideoAndGetUploadUrl({
);
}
}

export async function deleteVideoResultFile({
videoId,
}: {
videoId: Video.VideoId;
}) {
const user = await getCurrentUser();

if (!user) throw new Error("Unauthorized");

const [video] = await db()
.select({
id: videos.id,
ownerId: videos.ownerId,
bucketId: videos.bucket,
})
.from(videos)
.where(eq(videos.id, videoId));

if (!video) throw new Error("Video not found");
if (video.ownerId !== user.id) throw new Error("Forbidden");

const bucketIdOption = Option.fromNullable(video.bucketId).pipe(
Option.map((id) => S3Bucket.S3BucketId.make(id)),
);
const fileKey = `${video.ownerId}/${video.id}/result.mp4`;
const logContext = {
videoId: video.id,
ownerId: video.ownerId,
bucketId: video.bucketId ?? null,
fileKey,
};

try {
await db().transaction(async (tx) => {
await tx.delete(videoUploads).where(eq(videoUploads.videoId, videoId));
});
} catch (error) {
console.error("video.result.delete.transaction_failure", {
...logContext,
error: serializeError(error),
});
throw error;
}

try {
await deleteResultObjectWithRetry({
bucketIdOption,
fileKey,
logContext,
});
} catch (error) {
console.error("video.result.delete.s3_failure", {
...logContext,
error: serializeError(error),
});
throw error;
}

revalidatePath(`/s/${videoId}`);
revalidatePath("/dashboard/caps");
revalidatePath("/dashboard/folder");
revalidatePath("/dashboard/spaces");

return { success: true };
}

async function deleteResultObjectWithRetry({
bucketIdOption,
fileKey,
logContext,
}: {
bucketIdOption: Option.Option<S3Bucket.S3BucketId>;
fileKey: string;
logContext: {
videoId: Video.VideoId;
ownerId: string;
bucketId: string | null;
fileKey: string;
};
}) {
let attempt = 0;
let lastError: unknown;
while (attempt < MAX_S3_DELETE_ATTEMPTS) {
attempt += 1;
try {
await Effect.gen(function* () {
const [bucket] = yield* S3Buckets.getBucketAccess(bucketIdOption);
yield* bucket.deleteObject(fileKey);
}).pipe(runPromise);
return;
} catch (error) {
lastError = error;
console.error("video.result.delete.s3_failure", {
...logContext,
attempt,
maxAttempts: MAX_S3_DELETE_ATTEMPTS,
error: serializeError(error),
});

if (attempt < MAX_S3_DELETE_ATTEMPTS) {
await sleep(S3_DELETE_RETRY_BACKOFF_MS * attempt);
}
}
}

throw lastError instanceof Error
? lastError
: new Error("Failed to delete video result from S3");
}

function serializeError(error: unknown) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}

return { name: "UnknownError", message: String(error) };
}

function sleep(durationMs: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
});
}
2 changes: 2 additions & 0 deletions apps/web/app/(org)/dashboard/caps/Caps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
SelectedCapsBar,
UploadCapButton,
UploadPlaceholderCard,
WebRecorderDialog,
} from "./components";
import { CapCard } from "./components/CapCard/CapCard";
import { CapPagination } from "./components/CapPagination";
Expand Down Expand Up @@ -240,6 +241,7 @@ export const Caps = ({
New Folder
</Button>
<UploadCapButton size="sm" />
<WebRecorderDialog />
</div>
{folders.length > 0 && (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRive } from "@rive-app/react-canvas";
import { useTheme } from "../../Contexts";
import { UploadCapButton } from "./UploadCapButton";
import { WebRecorderDialog } from "./web-recorder-dialog/web-recorder-dialog";

interface EmptyCapStateProps {
userName?: string;
Expand All @@ -30,7 +31,7 @@ export const EmptyCapState: React.FC<EmptyCapStateProps> = ({ userName }) => {
Craft your narrative with Cap - get projects done quicker.
</p>
</div>
<div className="flex gap-3 justify-center items-center mt-4">
<div className="flex flex-wrap gap-3 justify-center items-center mt-4">
<Button
href="/download"
className="flex relative gap-2 justify-center items-center"
Expand All @@ -40,6 +41,8 @@ export const EmptyCapState: React.FC<EmptyCapStateProps> = ({ userName }) => {
Download Cap
</Button>
<p className="text-sm text-gray-10">or</p>
<WebRecorderDialog />
<p className="text-sm text-gray-10">or</p>
<UploadCapButton />
</div>
</div>
Expand Down
27 changes: 1 addition & 26 deletions apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@/app/(org)/dashboard/caps/UploadingContext";
import { UpgradeModal } from "@/components/UpgradeModal";
import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest";
import { sendProgressUpdate } from "./sendProgressUpdate";

export const UploadCapButton = ({
size = "md",
Expand Down Expand Up @@ -517,29 +518,3 @@ async function legacyUploadCap(
setUploadStatus(undefined);
return false;
}

const sendProgressUpdate = async (
videoId: string,
uploaded: number,
total: number,
) => {
try {
const response = await fetch("/api/desktop/video/progress", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
videoId,
uploaded,
total,
updatedAt: new Date().toISOString(),
}),
});

if (!response.ok)
console.error("Failed to send progress update:", response.status);
} catch (err) {
console.error("Error sending progress update:", err);
}
};
1 change: 1 addition & 0 deletions apps/web/app/(org)/dashboard/caps/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./NewFolderDialog";
export * from "./SelectedCapsBar";
export * from "./UploadCapButton";
export * from "./UploadPlaceholderCard";
export * from "./web-recorder-dialog/web-recorder-dialog";
24 changes: 24 additions & 0 deletions apps/web/app/(org)/dashboard/caps/components/sendProgressUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EffectRuntime } from "@/lib/EffectRuntime";
import { withRpc } from "@/lib/Rpcs";
import type { VideoId } from "./web-recorder-dialog/web-recorder-types";

export const sendProgressUpdate = async (
videoId: VideoId,
uploaded: number,
total: number,
) => {
try {
await EffectRuntime.runPromise(
withRpc((rpc) =>
rpc.VideoUploadProgressUpdate({
videoId,
uploaded,
total,
updatedAt: new Date(),
}),
),
);
} catch (error) {
console.error("Failed to send progress update:", error);
}
};
Loading