Skip to content

Commit 5770427

Browse files
committed
Add playback source probing and integrate into player
1 parent f7081a2 commit 5770427

File tree

8 files changed

+501
-259
lines changed

8 files changed

+501
-259
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
canPlayRawContentType,
4+
detectCrossOriginSupport,
5+
resolvePlaybackSource,
6+
} from "@/app/s/[videoId]/_components/playback-source";
7+
8+
function createResponse(
9+
url: string,
10+
init: {
11+
status: number;
12+
headers?: Record<string, string>;
13+
redirected?: boolean;
14+
},
15+
): Response {
16+
const response = new Response(null, {
17+
status: init.status,
18+
headers: init.headers,
19+
});
20+
21+
Object.defineProperty(response, "url", {
22+
value: url,
23+
configurable: true,
24+
});
25+
Object.defineProperty(response, "redirected", {
26+
value: init.redirected ?? false,
27+
configurable: true,
28+
});
29+
30+
return response;
31+
}
32+
33+
describe("detectCrossOriginSupport", () => {
34+
it("disables cross-origin for S3 and R2 URLs", () => {
35+
expect(
36+
detectCrossOriginSupport(
37+
"https://cap-assets.r2.cloudflarestorage.com/video.mp4",
38+
),
39+
).toBe(false);
40+
expect(
41+
detectCrossOriginSupport(
42+
"https://bucket.s3.eu-west-2.amazonaws.com/video.mp4",
43+
),
44+
).toBe(false);
45+
expect(detectCrossOriginSupport("/api/playlist?videoType=mp4")).toBe(true);
46+
});
47+
});
48+
49+
describe("canPlayRawContentType", () => {
50+
it("treats mp4 raw uploads as playable without probing browser support", () => {
51+
expect(
52+
canPlayRawContentType("video/mp4", "https://cap.so/raw-upload.mp4"),
53+
).toBe(true);
54+
});
55+
56+
it("checks browser support for webm raw uploads", () => {
57+
expect(
58+
canPlayRawContentType(
59+
"video/webm;codecs=vp9,opus",
60+
"https://cap.so/raw-upload.webm",
61+
() => ({
62+
canPlayType: vi.fn().mockReturnValue("probably"),
63+
}),
64+
),
65+
).toBe(true);
66+
expect(
67+
canPlayRawContentType(
68+
"video/webm;codecs=vp9,opus",
69+
"https://cap.so/raw-upload.webm",
70+
() => ({
71+
canPlayType: vi.fn().mockReturnValue(""),
72+
}),
73+
),
74+
).toBe(false);
75+
});
76+
});
77+
78+
describe("resolvePlaybackSource", () => {
79+
it("returns the MP4 source immediately when it is available", async () => {
80+
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValueOnce(
81+
createResponse("https://bucket.s3.amazonaws.com/result.mp4", {
82+
status: 206,
83+
redirected: true,
84+
}),
85+
);
86+
87+
const result = await resolvePlaybackSource({
88+
videoSrc: "/api/playlist?videoType=mp4",
89+
rawFallbackSrc: "/api/playlist?videoType=raw-preview",
90+
enableCrossOrigin: true,
91+
fetchImpl,
92+
now: () => 123,
93+
});
94+
95+
expect(fetchImpl).toHaveBeenCalledTimes(1);
96+
expect(fetchImpl).toHaveBeenCalledWith(
97+
"/api/playlist?videoType=mp4&_t=123",
98+
{
99+
headers: { range: "bytes=0-0" },
100+
},
101+
);
102+
expect(result).toEqual({
103+
url: "https://bucket.s3.amazonaws.com/result.mp4",
104+
type: "mp4",
105+
supportsCrossOrigin: false,
106+
});
107+
});
108+
109+
it("falls back to the raw preview when the MP4 probe fails", async () => {
110+
const fetchImpl = vi
111+
.fn<typeof fetch>()
112+
.mockResolvedValueOnce(
113+
createResponse("/api/playlist?videoType=mp4&_t=200", { status: 404 }),
114+
)
115+
.mockResolvedValueOnce(
116+
createResponse("https://cap.so/raw-upload.mp4", {
117+
status: 206,
118+
headers: { "content-type": "video/mp4" },
119+
redirected: true,
120+
}),
121+
);
122+
123+
const result = await resolvePlaybackSource({
124+
videoSrc: "/api/playlist?videoType=mp4",
125+
rawFallbackSrc: "/api/playlist?videoType=raw-preview",
126+
enableCrossOrigin: true,
127+
fetchImpl,
128+
now: () => 200,
129+
});
130+
131+
expect(fetchImpl).toHaveBeenNthCalledWith(
132+
1,
133+
"/api/playlist?videoType=mp4&_t=200",
134+
{
135+
headers: { range: "bytes=0-0" },
136+
},
137+
);
138+
expect(fetchImpl).toHaveBeenNthCalledWith(
139+
2,
140+
"/api/playlist?videoType=raw-preview&_t=200",
141+
{
142+
headers: { range: "bytes=0-0" },
143+
},
144+
);
145+
expect(result).toEqual({
146+
url: "https://cap.so/raw-upload.mp4",
147+
type: "raw",
148+
supportsCrossOrigin: true,
149+
});
150+
});
151+
152+
it("rejects raw webm previews when the browser cannot play them", async () => {
153+
const fetchImpl = vi
154+
.fn<typeof fetch>()
155+
.mockResolvedValueOnce(
156+
createResponse("/api/playlist?videoType=mp4&_t=300", { status: 404 }),
157+
)
158+
.mockResolvedValueOnce(
159+
createResponse("https://cap.so/raw-upload.webm", {
160+
status: 206,
161+
headers: { "content-type": "video/webm;codecs=vp9,opus" },
162+
redirected: true,
163+
}),
164+
);
165+
166+
const result = await resolvePlaybackSource({
167+
videoSrc: "/api/playlist?videoType=mp4",
168+
rawFallbackSrc: "/api/playlist?videoType=raw-preview",
169+
fetchImpl,
170+
now: () => 300,
171+
createVideoElement: () => ({
172+
canPlayType: vi.fn().mockReturnValue(""),
173+
}),
174+
});
175+
176+
expect(result).toBeNull();
177+
});
178+
179+
it("falls back after MP4 network errors and returns null when no source works", async () => {
180+
const fetchImpl = vi
181+
.fn<typeof fetch>()
182+
.mockRejectedValueOnce(new Error("network"))
183+
.mockResolvedValueOnce(
184+
createResponse("/api/playlist?videoType=raw-preview&_t=400", {
185+
status: 404,
186+
}),
187+
);
188+
189+
const result = await resolvePlaybackSource({
190+
videoSrc: "/api/playlist?videoType=mp4",
191+
rawFallbackSrc: "/api/playlist?videoType=raw-preview",
192+
fetchImpl,
193+
now: () => 400,
194+
});
195+
196+
expect(result).toBeNull();
197+
});
198+
});

apps/web/__tests__/unit/upload-progress-playback.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ describe("shouldDeferPlaybackSource", () => {
1515
lastUpdated: new Date(),
1616
progress: 10,
1717
},
18+
])("returns true for active upload state %#", (uploadProgress) => {
19+
expect(shouldDeferPlaybackSource(uploadProgress as never)).toBe(true);
20+
});
21+
22+
it.each([
23+
null,
1824
{
1925
status: "processing",
2026
lastUpdated: new Date(),
@@ -26,12 +32,6 @@ describe("shouldDeferPlaybackSource", () => {
2632
lastUpdated: new Date(),
2733
progress: 90,
2834
},
29-
])("returns true for active upload state %#", (uploadProgress) => {
30-
expect(shouldDeferPlaybackSource(uploadProgress as never)).toBe(true);
31-
});
32-
33-
it.each([
34-
null,
3535
{
3636
status: "error",
3737
lastUpdated: new Date(),

apps/web/app/api/playlist/route.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import * as Db from "@cap/database/schema";
12
import { serverEnv } from "@cap/env";
2-
import { provideOptionalAuth, S3Buckets, Videos } from "@cap/web-backend";
3+
import {
4+
Database,
5+
provideOptionalAuth,
6+
S3Buckets,
7+
Videos,
8+
} from "@cap/web-backend";
39
import { Video } from "@cap/web-domain";
410
import {
511
HttpApi,
@@ -9,6 +15,7 @@ import {
915
HttpApiGroup,
1016
HttpServerResponse,
1117
} from "@effect/platform";
18+
import { eq } from "drizzle-orm";
1219
import { Effect, Layer, Option, Schema } from "effect";
1320
import { apiToHandler } from "@/lib/server";
1421
import { CACHE_CONTROL_HEADERS } from "@/utils/helpers";
@@ -21,7 +28,7 @@ export const dynamic = "force-dynamic";
2128

2229
const GetPlaylistParams = Schema.Struct({
2330
videoId: Video.VideoId,
24-
videoType: Schema.Literal("video", "audio", "master", "mp4"),
31+
videoType: Schema.Literal("video", "audio", "master", "mp4", "raw-preview"),
2532
thumbnail: Schema.OptionFromUndefinedOr(Schema.String),
2633
fileType: Schema.OptionFromUndefinedOr(Schema.String),
2734
});
@@ -85,6 +92,24 @@ const getPlaylistResponse = (
8592
const isMp4Source =
8693
video.source.type === "desktopMP4" || video.source.type === "webMP4";
8794

95+
if (urlParams.videoType === "raw-preview") {
96+
const db = yield* Database;
97+
const [uploadRecord] = yield* db.use((db) =>
98+
db
99+
.select({ rawFileKey: Db.videoUploads.rawFileKey })
100+
.from(Db.videoUploads)
101+
.where(eq(Db.videoUploads.videoId, urlParams.videoId)),
102+
);
103+
104+
if (!uploadRecord?.rawFileKey) {
105+
return yield* Effect.fail(new HttpApiError.NotFound());
106+
}
107+
108+
return yield* s3
109+
.getSignedObjectUrl(uploadRecord.rawFileKey)
110+
.pipe(Effect.map(HttpServerResponse.redirect));
111+
}
112+
88113
if (Option.isNone(customBucket)) {
89114
let redirect = `${video.ownerId}/${video.id}/combined-source/stream.m3u8`;
90115

@@ -170,34 +195,19 @@ const getPlaylistResponse = (
170195
.pipe(Effect.map(HttpServerResponse.redirect));
171196
}
172197

173-
let prefix;
174-
switch (urlParams.videoType) {
175-
case "video":
176-
prefix = videoPrefix;
177-
break;
178-
case "audio":
179-
prefix = audioPrefix;
180-
break;
181-
case "master":
182-
prefix = null;
183-
break;
184-
}
185-
186-
if (prefix === null) {
198+
if (urlParams.videoType === "master") {
187199
const [videoSegment, audioSegment] = yield* Effect.all([
188200
s3.listObjects({ prefix: videoPrefix, maxKeys: 1 }),
189201
s3.listObjects({ prefix: audioPrefix, maxKeys: 1 }),
190202
]);
191203

192-
let audioMetadata;
193204
const videoMetadata = yield* s3.headObject(
194205
videoSegment.Contents?.[0]?.Key ?? "",
195206
);
196-
if (audioSegment?.KeyCount && audioSegment?.KeyCount > 0) {
197-
audioMetadata = yield* s3.headObject(
198-
audioSegment.Contents?.[0]?.Key ?? "",
199-
);
200-
}
207+
const audioMetadata =
208+
audioSegment?.KeyCount && audioSegment.KeyCount > 0
209+
? yield* s3.headObject(audioSegment.Contents?.[0]?.Key ?? "")
210+
: undefined;
201211

202212
const generatedPlaylist = generateMasterPlaylist(
203213
videoMetadata?.Metadata?.resolution ?? "",
@@ -213,6 +223,17 @@ const getPlaylistResponse = (
213223
});
214224
}
215225

226+
const prefix =
227+
urlParams.videoType === "video"
228+
? videoPrefix
229+
: urlParams.videoType === "audio"
230+
? audioPrefix
231+
: undefined;
232+
233+
if (!prefix) {
234+
return yield* Effect.fail(new HttpApiError.NotFound());
235+
}
236+
216237
const objects = yield* s3.listObjects({
217238
prefix,
218239
maxKeys: urlParams.thumbnail ? 1 : undefined,

apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424

2525
declare global {
2626
interface Window {
27-
MSStream: any;
27+
MSStream: unknown;
2828
}
2929
}
3030

@@ -54,7 +54,14 @@ export const EmbedVideo = forwardRef<
5454
}
5555
>(
5656
(
57-
{ data, user, comments, chapters = [], ownerName, autoplay = false },
57+
{
58+
data,
59+
user: _user,
60+
comments: _comments,
61+
chapters = [],
62+
ownerName,
63+
autoplay: _autoplay = false,
64+
},
5865
ref,
5966
) => {
6067
const videoRef = useRef<HTMLVideoElement>(null);
@@ -128,6 +135,10 @@ export const EmbedVideo = forwardRef<
128135
const isMp4Source =
129136
data.source.type === "desktopMP4" || data.source.type === "webMP4";
130137
let videoSrc: string;
138+
const rawFallbackSrc =
139+
data.source.type === "webMP4"
140+
? `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=raw-preview`
141+
: undefined;
131142
let enableCrossOrigin = false;
132143

133144
if (isMp4Source) {
@@ -179,6 +190,7 @@ export const EmbedVideo = forwardRef<
179190
videoId={data.id}
180191
mediaPlayerClassName="w-full h-full"
181192
videoSrc={videoSrc}
193+
rawFallbackSrc={rawFallbackSrc}
182194
chaptersSrc={chaptersUrl || ""}
183195
captionsSrc={subtitleUrl || ""}
184196
videoRef={videoRef}

0 commit comments

Comments
 (0)