Skip to content

Commit b1094ab

Browse files
Upload video button in dashboard (#574)
* Show upload progress placeholder * feat: Parse metadata/convert file to mp4 * feat: Add upgrade dialog to upload button * feat: Fix thumbnail generation
1 parent a974cf1 commit b1094ab

File tree

10 files changed

+929
-90
lines changed

10 files changed

+929
-90
lines changed

apps/web/actions/video/upload.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
'use server';
2+
3+
import { getCurrentUser } from "@cap/database/auth/session";
4+
import { createS3Client, getS3Bucket, getS3Config } from "@/utils/s3";
5+
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
6+
import { db } from "@cap/database";
7+
import { s3Buckets, videos } from "@cap/database/schema";
8+
import { eq } from "drizzle-orm";
9+
import { serverEnv } from "@cap/env";
10+
import { nanoId } from "@cap/database/helpers";
11+
import {
12+
CloudFrontClient,
13+
CreateInvalidationCommand,
14+
} from "@aws-sdk/client-cloudfront";
15+
16+
export async function getVideoUploadPresignedUrl({
17+
fileKey,
18+
duration,
19+
resolution,
20+
videoCodec,
21+
audioCodec,
22+
}: {
23+
fileKey: string;
24+
duration?: string;
25+
resolution?: string;
26+
videoCodec?: string;
27+
audioCodec?: string;
28+
}) {
29+
const user = await getCurrentUser();
30+
31+
if (!user) {
32+
throw new Error("Unauthorized");
33+
}
34+
35+
try {
36+
const [bucket] = await db()
37+
.select()
38+
.from(s3Buckets)
39+
.where(eq(s3Buckets.ownerId, user.id));
40+
41+
const s3Config = bucket
42+
? {
43+
endpoint: bucket.endpoint || undefined,
44+
region: bucket.region,
45+
accessKeyId: bucket.accessKeyId,
46+
secretAccessKey: bucket.secretAccessKey,
47+
}
48+
: null;
49+
50+
if (
51+
!bucket ||
52+
!s3Config ||
53+
bucket.bucketName !== serverEnv().CAP_AWS_BUCKET
54+
) {
55+
const distributionId = serverEnv().CAP_CLOUDFRONT_DISTRIBUTION_ID;
56+
if (distributionId) {
57+
58+
const cloudfront = new CloudFrontClient({
59+
region: serverEnv().CAP_AWS_REGION || "us-east-1",
60+
credentials: {
61+
accessKeyId: serverEnv().CAP_AWS_ACCESS_KEY || "",
62+
secretAccessKey: serverEnv().CAP_AWS_SECRET_KEY || "",
63+
},
64+
});
65+
66+
const pathToInvalidate = "/" + fileKey;
67+
68+
try {
69+
const invalidation = await cloudfront.send(
70+
new CreateInvalidationCommand({
71+
DistributionId: distributionId,
72+
InvalidationBatch: {
73+
CallerReference: `${Date.now()}`,
74+
Paths: {
75+
Quantity: 1,
76+
Items: [pathToInvalidate],
77+
},
78+
},
79+
})
80+
);
81+
} catch (error) {
82+
console.error("Failed to create CloudFront invalidation:", error);
83+
}
84+
}
85+
}
86+
87+
const [s3Client] = await createS3Client(s3Config);
88+
89+
const contentType = fileKey.endsWith(".aac")
90+
? "audio/aac"
91+
: fileKey.endsWith(".webm")
92+
? "audio/webm"
93+
: fileKey.endsWith(".mp4")
94+
? "video/mp4"
95+
: fileKey.endsWith(".mp3")
96+
? "audio/mpeg"
97+
: fileKey.endsWith(".m3u8")
98+
? "application/x-mpegURL"
99+
: "video/mp2t";
100+
101+
const Fields = {
102+
"Content-Type": contentType,
103+
"x-amz-meta-userid": user.id,
104+
"x-amz-meta-duration": duration ?? "",
105+
"x-amz-meta-resolution": resolution ?? "",
106+
"x-amz-meta-videocodec": videoCodec ?? "",
107+
"x-amz-meta-audiocodec": audioCodec ?? "",
108+
};
109+
110+
const bucketName = await getS3Bucket(bucket);
111+
112+
const presignedPostData = await createPresignedPost(s3Client, {
113+
Bucket: bucketName,
114+
Key: fileKey,
115+
Fields,
116+
Expires: 1800,
117+
});
118+
119+
const customEndpoint = serverEnv().CAP_AWS_ENDPOINT;
120+
if (customEndpoint && !customEndpoint.includes("amazonaws.com")) {
121+
if (serverEnv().S3_PATH_STYLE) {
122+
presignedPostData.url = `${customEndpoint}/${bucketName}`;
123+
} else {
124+
presignedPostData.url = customEndpoint;
125+
}
126+
}
127+
128+
const videoId = fileKey.split("/")[1];
129+
if (videoId) {
130+
try {
131+
await fetch(`${serverEnv().WEB_URL}/api/revalidate`, {
132+
method: "POST",
133+
headers: {
134+
"Content-Type": "application/json",
135+
},
136+
body: JSON.stringify({ videoId }),
137+
});
138+
} catch (revalidateError) {
139+
console.error("Failed to revalidate page:", revalidateError);
140+
}
141+
}
142+
143+
return { presignedPostData };
144+
} catch (error) {
145+
console.error("Error getting presigned URL:", error);
146+
throw new Error(
147+
error instanceof Error ? error.message : "Failed to get presigned URL"
148+
);
149+
}
150+
}
151+
152+
export async function createVideoAndGetUploadUrl({
153+
videoId,
154+
duration,
155+
resolution,
156+
videoCodec,
157+
audioCodec,
158+
isScreenshot = false,
159+
isUpload = false,
160+
}: {
161+
videoId?: string;
162+
duration?: number;
163+
resolution?: string;
164+
videoCodec?: string;
165+
audioCodec?: string;
166+
isScreenshot?: boolean;
167+
isUpload?: boolean;
168+
}) {
169+
const user = await getCurrentUser();
170+
171+
if (!user) {
172+
throw new Error("Unauthorized");
173+
}
174+
175+
try {
176+
const isUpgraded = user.stripeSubscriptionStatus === "active";
177+
178+
if (!isUpgraded && duration && duration > 300) {
179+
throw new Error("upgrade_required");
180+
}
181+
182+
const [bucket] = await db()
183+
.select()
184+
.from(s3Buckets)
185+
.where(eq(s3Buckets.ownerId, user.id));
186+
187+
const s3Config = await getS3Config(bucket);
188+
const bucketName = await getS3Bucket(bucket);
189+
190+
const date = new Date();
191+
const formattedDate = `${date.getDate()} ${date.toLocaleString("default", {
192+
month: "long",
193+
})} ${date.getFullYear()}`;
194+
195+
if (videoId) {
196+
const [existingVideo] = await db()
197+
.select()
198+
.from(videos)
199+
.where(eq(videos.id, videoId));
200+
201+
if (existingVideo) {
202+
const fileKey = `${user.id}/${videoId}/${isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4"}`;
203+
const { presignedPostData } = await getVideoUploadPresignedUrl({
204+
fileKey,
205+
duration: duration?.toString(),
206+
resolution,
207+
videoCodec,
208+
audioCodec,
209+
});
210+
211+
return {
212+
id: existingVideo.id,
213+
user_id: user.id,
214+
aws_region: existingVideo.awsRegion,
215+
aws_bucket: existingVideo.awsBucket,
216+
presignedPostData,
217+
};
218+
}
219+
}
220+
221+
const idToUse = videoId || nanoId();
222+
223+
const videoData = {
224+
id: idToUse,
225+
name: `Cap ${isScreenshot ? "Screenshot" : isUpload ? "Upload" : "Recording"} - ${formattedDate}`,
226+
ownerId: user.id,
227+
awsRegion: s3Config.region,
228+
awsBucket: bucketName,
229+
source: { type: "desktopMP4" as const },
230+
isScreenshot,
231+
bucket: bucket?.id,
232+
};
233+
234+
await db().insert(videos).values(videoData);
235+
236+
const fileKey = `${user.id}/${idToUse}/${isScreenshot ? "screenshot/screen-capture.jpg" : "result.mp4"}`;
237+
const { presignedPostData } = await getVideoUploadPresignedUrl({
238+
fileKey,
239+
duration: duration?.toString(),
240+
resolution,
241+
videoCodec,
242+
audioCodec,
243+
});
244+
245+
return {
246+
id: idToUse,
247+
user_id: user.id,
248+
aws_region: s3Config.region,
249+
aws_bucket: bucketName,
250+
presignedPostData,
251+
};
252+
} catch (error) {
253+
console.error("Error creating video and getting upload URL:", error);
254+
throw new Error(
255+
error instanceof Error ? error.message : "Failed to create video"
256+
);
257+
}
258+
}

apps/web/app/api/desktop/[...route]/video.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ app.get(
2020
zValidator(
2121
"query",
2222
z.object({
23-
duration: z.number().optional(),
23+
duration: z.coerce.number().optional(),
2424
recordingMode: z
2525
.union([z.literal("hls"), z.literal("desktopMP4")])
2626
.optional(),
@@ -30,23 +30,29 @@ app.get(
3030
})
3131
),
3232
async (c) => {
33-
const { duration, recordingMode, isScreenshot, videoId, name } =
34-
c.req.valid("query");
35-
const user = c.get("user");
33+
try {
34+
const { duration, recordingMode, isScreenshot, videoId, name } =
35+
c.req.valid("query");
36+
const user = c.get("user");
37+
38+
console.log("Video create request:", { duration, recordingMode, isScreenshot, videoId, userId: user.id });
39+
40+
const isUpgraded = user.stripeSubscriptionStatus === "active";
41+
42+
if (!isUpgraded && duration && duration > 300)
43+
return c.json({ error: "upgrade_required" }, { status: 403 });
3644

37-
// Check if user is on free plan and video is over 5 minutes
38-
const isUpgraded = user.stripeSubscriptionStatus === "active";
45+
const [bucket] = await db()
46+
.select()
47+
.from(s3Buckets)
48+
.where(eq(s3Buckets.ownerId, user.id));
3949

40-
if (!isUpgraded && duration && duration > 300)
41-
return c.json({ error: "upgrade_required" }, { status: 403 });
50+
console.log("User bucket:", bucket ? "found" : "not found");
4251

43-
const [bucket] = await db()
44-
.select()
45-
.from(s3Buckets)
46-
.where(eq(s3Buckets.ownerId, user.id));
52+
const s3Config = await getS3Config(bucket);
53+
const bucketName = await getS3Bucket(bucket);
4754

48-
const s3Config = await getS3Config(bucket);
49-
const bucketName = await getS3Bucket(bucket);
55+
console.log("S3 Config:", { region: s3Config.region, bucketName });
5056

5157
const date = new Date();
5258
const formattedDate = `${date.getDate()} ${date.toLocaleString("default", {
@@ -98,7 +104,6 @@ app.get(
98104
key: idToUse,
99105
});
100106

101-
// Check if this is the user's first video and send the first shareable link email
102107
try {
103108
const videoCount = await db()
104109
.select({ count: count() })
@@ -119,7 +124,6 @@ app.get(
119124
? `https://cap.link/${idToUse}`
120125
: `${serverEnv().WEB_URL}/s/${idToUse}`;
121126

122-
// Send email with 5-minute delay using Resend's scheduling feature
123127
await sendEmail({
124128
email: user.email,
125129
subject: "You created your first Cap! 🥳",
@@ -146,5 +150,9 @@ app.get(
146150
aws_region: s3Config.region,
147151
aws_bucket: bucketName,
148152
});
153+
} catch (error) {
154+
console.error("Error in video create endpoint:", error);
155+
return c.json({ error: "Internal server error" }, { status: 500 });
156+
}
149157
}
150158
);

0 commit comments

Comments
 (0)