Skip to content

Commit 071e818

Browse files
committed
feat(api): serve Loom streaming downloads as MP4 via ffmpeg
Made-with: Cursor
1 parent e6858ac commit 071e818

1 file changed

Lines changed: 13 additions & 302 deletions

File tree

  • apps/web/app/api/tools/loom-download
Lines changed: 13 additions & 302 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import { type NextRequest, NextResponse } from "next/server";
3-
4-
interface SegmentInfo {
5-
initUrl: string;
6-
mediaUrls: string[];
7-
}
3+
import { convertRemoteVideoToMp4Buffer } from "@/lib/video-convert";
84

95
function isHlsUrl(url: string): boolean {
106
return (url.split("?")[0] ?? "").toLowerCase().endsWith(".m3u8");
@@ -18,177 +14,6 @@ function isStreamingUrl(url: string): boolean {
1814
return isHlsUrl(url) || isMpdUrl(url);
1915
}
2016

21-
function parseMpdSegments(
22-
mpdXml: string,
23-
baseUrl: string,
24-
queryParams: string,
25-
): { video: SegmentInfo | null; audio: SegmentInfo | null } {
26-
const result = {
27-
video: null as SegmentInfo | null,
28-
audio: null as SegmentInfo | null,
29-
};
30-
31-
const adaptationSets = [
32-
...mpdXml.matchAll(/<AdaptationSet([^>]*)>([\s\S]*?)<\/AdaptationSet>/g),
33-
];
34-
35-
for (const asMatch of adaptationSets) {
36-
const attrs = asMatch[1] ?? "";
37-
const content = asMatch[2] ?? "";
38-
const contentType = attrs.match(/contentType="([^"]+)"/)?.[1];
39-
40-
if (contentType !== "video" && contentType !== "audio") continue;
41-
42-
const representations = [
43-
...content.matchAll(
44-
/<Representation([^>]*)>([\s\S]*?)<\/Representation>/g,
45-
),
46-
];
47-
let bestBandwidth = 0;
48-
let bestRepContent = "";
49-
50-
for (const repMatch of representations) {
51-
const repAttrs = repMatch[1] ?? "";
52-
const repContent = repMatch[2] ?? "";
53-
const bandwidth = parseInt(
54-
repAttrs.match(/bandwidth="(\d+)"/)?.[1] ?? "0",
55-
10,
56-
);
57-
if (bandwidth > bestBandwidth) {
58-
bestBandwidth = bandwidth;
59-
bestRepContent = repContent;
60-
}
61-
}
62-
63-
if (!bestRepContent) continue;
64-
65-
const templateMatch = bestRepContent.match(
66-
/<SegmentTemplate([^>]*)>([\s\S]*?)<\/SegmentTemplate>/,
67-
);
68-
if (!templateMatch) continue;
69-
70-
const templateAttrs = templateMatch[1] ?? "";
71-
const templateContent = templateMatch[2] ?? "";
72-
73-
const initFilename = templateAttrs.match(/initialization="([^"]+)"/)?.[1];
74-
const mediaTemplate = templateAttrs.match(/media="([^"]+)"/)?.[1];
75-
const startNumber = parseInt(
76-
templateAttrs.match(/startNumber="(\d+)"/)?.[1] ?? "0",
77-
10,
78-
);
79-
80-
if (!initFilename || !mediaTemplate) continue;
81-
82-
const sElements = [...templateContent.matchAll(/<S\s([^/]*?)\/>/g)];
83-
let segmentCount = 0;
84-
85-
for (const sEl of sElements) {
86-
const r = parseInt(sEl[1]?.match(/r="(\d+)"/)?.[1] ?? "0", 10);
87-
segmentCount += 1 + r;
88-
}
89-
90-
const initUrl = `${baseUrl}${initFilename}${queryParams}`;
91-
const mediaUrls: string[] = [];
92-
for (let i = startNumber; i < startNumber + segmentCount; i++) {
93-
const filename = mediaTemplate.replace("$Number$", String(i));
94-
mediaUrls.push(`${baseUrl}${filename}${queryParams}`);
95-
}
96-
97-
const info: SegmentInfo = { initUrl, mediaUrls };
98-
99-
if (contentType === "video") result.video = info;
100-
else result.audio = info;
101-
}
102-
103-
return result;
104-
}
105-
106-
async function streamSegments(
107-
segments: SegmentInfo,
108-
controller: ReadableStreamDefaultController<Uint8Array>,
109-
) {
110-
const initResponse = await fetch(segments.initUrl);
111-
if (!initResponse.ok || !initResponse.body) {
112-
throw new Error(`Failed to fetch init segment: ${initResponse.status}`);
113-
}
114-
const initReader = initResponse.body.getReader();
115-
let done = false;
116-
while (!done) {
117-
const result = await initReader.read();
118-
done = result.done;
119-
if (result.value) controller.enqueue(result.value);
120-
}
121-
122-
for (const mediaUrl of segments.mediaUrls) {
123-
const mediaResponse = await fetch(mediaUrl);
124-
if (!mediaResponse.ok || !mediaResponse.body) {
125-
throw new Error(`Failed to fetch media segment: ${mediaResponse.status}`);
126-
}
127-
const mediaReader = mediaResponse.body.getReader();
128-
done = false;
129-
while (!done) {
130-
const result = await mediaReader.read();
131-
done = result.done;
132-
if (result.value) controller.enqueue(result.value);
133-
}
134-
}
135-
}
136-
137-
function parseHlsMasterPlaylist(
138-
content: string,
139-
baseUrl: string,
140-
queryParams: string,
141-
): { bestVariantUrl: string | null; audioRenditionUrl: string | null } {
142-
const lines = content.split("\n").map((l) => l.trim());
143-
let audioRenditionUrl: string | null = null;
144-
let bestBandwidth = 0;
145-
let bestVariantUrl: string | null = null;
146-
147-
for (const line of lines) {
148-
if (line.startsWith("#EXT-X-MEDIA:") && line.includes("TYPE=AUDIO")) {
149-
const uriMatch = line.match(/URI="([^"]+)"/);
150-
if (uriMatch?.[1]) {
151-
const uri = uriMatch[1];
152-
audioRenditionUrl = uri.startsWith("http")
153-
? uri
154-
: `${baseUrl}${uri}${queryParams}`;
155-
}
156-
}
157-
}
158-
159-
for (let i = 0; i < lines.length; i++) {
160-
const line = lines[i];
161-
if (line?.startsWith("#EXT-X-STREAM-INF:")) {
162-
const bwMatch = line.match(/BANDWIDTH=(\d+)/);
163-
const bandwidth = parseInt(bwMatch?.[1] ?? "0", 10);
164-
const nextLine = lines[i + 1]?.trim();
165-
if (nextLine && !nextLine.startsWith("#") && bandwidth > bestBandwidth) {
166-
bestBandwidth = bandwidth;
167-
bestVariantUrl = nextLine.startsWith("http")
168-
? nextLine
169-
: `${baseUrl}${nextLine}${queryParams}`;
170-
}
171-
}
172-
}
173-
174-
return { bestVariantUrl, audioRenditionUrl };
175-
}
176-
177-
function parseHlsMediaPlaylist(
178-
content: string,
179-
baseUrl: string,
180-
queryParams: string,
181-
): string[] {
182-
return content
183-
.split("\n")
184-
.map((l) => l.trim())
185-
.filter((l) => l && !l.startsWith("#"))
186-
.map((l) => {
187-
if (l.startsWith("http")) return l;
188-
return `${baseUrl}${l}${queryParams}`;
189-
});
190-
}
191-
19217
async function fetchLoomCdnUrl(
19318
videoId: string,
19419
endpoint: string,
@@ -401,139 +226,25 @@ export async function GET(request: NextRequest) {
401226
);
402227
if (mp4Result) return mp4Result;
403228

404-
if (isMpdUrl(cdnUrl)) {
405-
const mpdResponse = await fetch(cdnUrl);
406-
if (!mpdResponse.ok) {
407-
return NextResponse.json(
408-
{ error: "Failed to fetch video manifest" },
409-
{ status: 502 },
410-
);
411-
}
412-
413-
const mpdXml = await mpdResponse.text();
414-
const { video } = parseMpdSegments(mpdXml, streamingBaseUrl, queryParams);
415-
416-
if (!video || video.mediaUrls.length === 0) {
417-
return NextResponse.json(
418-
{ error: "Could not parse video segments from manifest" },
419-
{ status: 502 },
420-
);
421-
}
422-
423-
const webmFilename = sanitizedName
424-
? `${sanitizedName}.webm`
425-
: `loom-video-${videoId}.webm`;
426-
427-
const stream = new ReadableStream<Uint8Array>({
428-
async start(controller) {
429-
try {
430-
await streamSegments(video, controller);
431-
controller.close();
432-
} catch {
433-
controller.close();
434-
}
435-
},
436-
});
229+
try {
230+
const mp4Buffer = await convertRemoteVideoToMp4Buffer(cdnUrl);
437231

438-
return new NextResponse(stream, {
232+
return new NextResponse(mp4Buffer, {
439233
headers: {
440-
"Content-Type": "video/webm",
441-
"Content-Disposition": `attachment; filename="${webmFilename}"`,
234+
"Content-Type": "video/mp4",
235+
"Content-Disposition": `attachment; filename="${mp4Filename}"`,
442236
"Cache-Control": "no-store",
443237
},
444238
});
445-
}
446-
447-
const masterResponse = await fetch(cdnUrl);
448-
if (!masterResponse.ok) {
449-
return NextResponse.json(
450-
{ error: "Failed to fetch HLS playlist" },
451-
{ status: 502 },
452-
);
453-
}
454-
455-
const masterContent = await masterResponse.text();
456-
const masterLines = masterContent.split("\n").map((l) => l.trim());
457-
458-
const isMediaPlaylist = masterLines.some(
459-
(l) => l.startsWith("#EXTINF:") || l.startsWith("#EXT-X-TARGETDURATION:"),
460-
);
461-
462-
let segmentUrls: string[];
463-
464-
if (isMediaPlaylist) {
465-
segmentUrls = masterLines
466-
.filter((l) => l && !l.startsWith("#"))
467-
.map((l) =>
468-
l.startsWith("http") ? l : `${streamingBaseUrl}${l}${queryParams}`,
469-
);
470-
} else {
471-
const { bestVariantUrl } = parseHlsMasterPlaylist(
472-
masterContent,
473-
streamingBaseUrl,
474-
queryParams,
475-
);
476-
477-
if (!bestVariantUrl) {
478-
return NextResponse.json(
479-
{ error: "No video variants found in HLS playlist" },
480-
{ status: 502 },
481-
);
482-
}
483-
484-
const variantResponse = await fetch(bestVariantUrl);
485-
if (!variantResponse.ok) {
486-
return NextResponse.json(
487-
{ error: "Failed to fetch HLS variant playlist" },
488-
{ status: 502 },
489-
);
490-
}
491-
492-
const variantContent = await variantResponse.text();
493-
segmentUrls = parseHlsMediaPlaylist(
494-
variantContent,
495-
streamingBaseUrl,
496-
queryParams,
497-
);
498-
}
499-
500-
if (segmentUrls.length === 0) {
239+
} catch (error) {
501240
return NextResponse.json(
502-
{ error: "No video segments found in HLS playlist" },
241+
{
242+
error:
243+
error instanceof Error
244+
? error.message
245+
: "Failed to convert streaming video",
246+
},
503247
{ status: 502 },
504248
);
505249
}
506-
507-
const tsFilename = sanitizedName
508-
? `${sanitizedName}.ts`
509-
: `loom-video-${videoId}.ts`;
510-
511-
const stream = new ReadableStream<Uint8Array>({
512-
async start(controller) {
513-
try {
514-
for (const segUrl of segmentUrls) {
515-
const segResponse = await fetch(segUrl);
516-
if (!segResponse.ok || !segResponse.body) continue;
517-
const reader = segResponse.body.getReader();
518-
let done = false;
519-
while (!done) {
520-
const result = await reader.read();
521-
done = result.done;
522-
if (result.value) controller.enqueue(result.value);
523-
}
524-
}
525-
controller.close();
526-
} catch {
527-
controller.close();
528-
}
529-
},
530-
});
531-
532-
return new NextResponse(stream, {
533-
headers: {
534-
"Content-Type": "video/mp2t",
535-
"Content-Disposition": `attachment; filename="${tsFilename}"`,
536-
"Cache-Control": "no-store",
537-
},
538-
});
539250
}

0 commit comments

Comments
 (0)