Skip to content

Commit aeb8946

Browse files
committed
feat(workflows): import Loom streaming URLs as MP4 via ffmpeg
Made-with: Cursor
1 parent 071e818 commit aeb8946

1 file changed

Lines changed: 11 additions & 248 deletions

File tree

apps/web/workflows/import-loom-video.ts

Lines changed: 11 additions & 248 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
88
import { Effect, Option } from "effect";
99
import { FatalError } from "workflow";
1010
import { runPromise } from "@/lib/server";
11+
import { convertRemoteVideoToMp4Buffer } from "@/lib/video-convert";
1112

1213
interface ImportLoomPayload {
1314
videoId: string;
@@ -25,10 +26,6 @@ function isStreamingUrl(url: string): boolean {
2526
return path.endsWith(".m3u8") || path.endsWith(".mpd");
2627
}
2728

28-
function isMpdUrl(url: string): boolean {
29-
return (url.split("?")[0] ?? "").toLowerCase().endsWith(".mpd");
30-
}
31-
3229
async function fetchLoomCdnUrl(
3330
videoId: string,
3431
endpoint: string,
@@ -92,251 +89,17 @@ async function fetchFreshLoomDownloadUrl(loomVideoId: string): Promise<string> {
9289
);
9390
}
9491

95-
function parseMpdVideoSegments(
96-
mpdXml: string,
97-
baseUrl: string,
98-
queryParams: string,
99-
): { initUrl: string; mediaUrls: string[] } | null {
100-
const adaptationSets = [
101-
...mpdXml.matchAll(/<AdaptationSet([^>]*)>([\s\S]*?)<\/AdaptationSet>/g),
102-
];
103-
104-
for (const asMatch of adaptationSets) {
105-
const attrs = asMatch[1] ?? "";
106-
const content = asMatch[2] ?? "";
107-
const contentType = attrs.match(/contentType="([^"]+)"/)?.[1];
108-
109-
if (contentType !== "video") continue;
110-
111-
const representations = [
112-
...content.matchAll(
113-
/<Representation([^>]*)>([\s\S]*?)<\/Representation>/g,
114-
),
115-
];
116-
let bestBandwidth = 0;
117-
let bestRepContent = "";
118-
119-
for (const repMatch of representations) {
120-
const repAttrs = repMatch[1] ?? "";
121-
const repContent = repMatch[2] ?? "";
122-
const bandwidth = parseInt(
123-
repAttrs.match(/bandwidth="(\d+)"/)?.[1] ?? "0",
124-
10,
125-
);
126-
if (bandwidth > bestBandwidth) {
127-
bestBandwidth = bandwidth;
128-
bestRepContent = repContent;
129-
}
130-
}
131-
132-
if (!bestRepContent) continue;
133-
134-
const templateMatch = bestRepContent.match(
135-
/<SegmentTemplate([^>]*)>([\s\S]*?)<\/SegmentTemplate>/,
136-
);
137-
if (!templateMatch) continue;
138-
139-
const templateAttrs = templateMatch[1] ?? "";
140-
const templateContent = templateMatch[2] ?? "";
141-
142-
const initFilename = templateAttrs.match(/initialization="([^"]+)"/)?.[1];
143-
const mediaTemplate = templateAttrs.match(/media="([^"]+)"/)?.[1];
144-
const startNumber = parseInt(
145-
templateAttrs.match(/startNumber="(\d+)"/)?.[1] ?? "0",
146-
10,
147-
);
148-
149-
if (!initFilename || !mediaTemplate) continue;
150-
151-
const sElements = [...templateContent.matchAll(/<S\s([^/]*?)\/>/g)];
152-
let segmentCount = 0;
153-
154-
for (const sEl of sElements) {
155-
const r = parseInt(sEl[1]?.match(/r="(\d+)"/)?.[1] ?? "0", 10);
156-
segmentCount += 1 + r;
157-
}
158-
159-
const initUrl = `${baseUrl}${initFilename}${queryParams}`;
160-
const mediaUrls: string[] = [];
161-
for (let i = startNumber; i < startNumber + segmentCount; i++) {
162-
const filename = mediaTemplate.replace("$Number$", String(i));
163-
mediaUrls.push(`${baseUrl}${filename}${queryParams}`);
164-
}
165-
166-
return { initUrl, mediaUrls };
167-
}
168-
169-
return null;
170-
}
171-
172-
function parseHlsMediaPlaylist(
173-
content: string,
174-
baseUrl: string,
175-
queryParams: string,
176-
): string[] {
177-
return content
178-
.split("\n")
179-
.map((l) => l.trim())
180-
.filter((l) => l && !l.startsWith("#"))
181-
.map((l) => (l.startsWith("http") ? l : `${baseUrl}${l}${queryParams}`));
182-
}
183-
184-
async function downloadSegmentsToBuffer(urls: string[]): Promise<Buffer> {
185-
const chunks: Buffer[] = [];
186-
for (const url of urls) {
187-
const response = await fetch(url);
188-
if (!response.ok) continue;
189-
chunks.push(Buffer.from(await response.arrayBuffer()));
190-
}
191-
return Buffer.concat(chunks);
192-
}
193-
194-
async function tryMp4Candidates(
195-
resourceBaseUrl: string,
196-
queryParams: string,
197-
loomVideoId: string,
198-
): Promise<Buffer | null> {
199-
const mp4Candidates = [
200-
`${resourceBaseUrl}${loomVideoId}.mp4${queryParams}`,
201-
`${resourceBaseUrl}output.mp4${queryParams}`,
202-
];
203-
204-
for (const mp4Url of mp4Candidates) {
92+
async function downloadVideoContent(downloadUrl: string): Promise<Buffer> {
93+
if (isStreamingUrl(downloadUrl)) {
20594
try {
206-
const headRes = await fetch(mp4Url, { method: "HEAD" });
207-
if (!headRes.ok) continue;
208-
209-
const response = await fetch(mp4Url);
210-
if (!response.ok) continue;
211-
212-
const buffer = Buffer.from(await response.arrayBuffer());
213-
if (buffer.length >= MINIMUM_VIDEO_SIZE) return buffer;
214-
} catch {}
215-
}
216-
217-
return null;
218-
}
219-
220-
async function downloadFromStreamingUrl(
221-
streamingUrl: string,
222-
loomVideoId: string,
223-
): Promise<Buffer> {
224-
const parsedUrl = new URL(streamingUrl);
225-
const queryParams = parsedUrl.search;
226-
const pathUpToSlash = parsedUrl.pathname.substring(
227-
0,
228-
parsedUrl.pathname.lastIndexOf("/") + 1,
229-
);
230-
const streamingBaseUrl = `${parsedUrl.origin}${pathUpToSlash}`;
231-
232-
let resourceBaseUrl = streamingBaseUrl;
233-
if (pathUpToSlash.endsWith("/hls/")) {
234-
resourceBaseUrl = `${parsedUrl.origin}${pathUpToSlash.slice(0, -4)}`;
235-
}
236-
237-
const mp4Buffer = await tryMp4Candidates(
238-
resourceBaseUrl,
239-
queryParams,
240-
loomVideoId,
241-
);
242-
if (mp4Buffer) return mp4Buffer;
243-
244-
if (isMpdUrl(streamingUrl)) {
245-
const mpdResponse = await fetch(streamingUrl);
246-
if (!mpdResponse.ok) {
247-
throw new FatalError("Failed to fetch video manifest from Loom");
248-
}
249-
250-
const mpdXml = await mpdResponse.text();
251-
const segments = parseMpdVideoSegments(
252-
mpdXml,
253-
streamingBaseUrl,
254-
queryParams,
255-
);
256-
257-
if (!segments || segments.mediaUrls.length === 0) {
258-
throw new FatalError("Could not parse video segments from Loom manifest");
259-
}
260-
261-
return await downloadSegmentsToBuffer([
262-
segments.initUrl,
263-
...segments.mediaUrls,
264-
]);
265-
}
266-
267-
const masterResponse = await fetch(streamingUrl);
268-
if (!masterResponse.ok) {
269-
throw new FatalError("Failed to fetch HLS playlist from Loom");
270-
}
271-
272-
const masterContent = await masterResponse.text();
273-
const masterLines = masterContent.split("\n").map((l) => l.trim());
274-
275-
const isMediaPlaylist = masterLines.some(
276-
(l) => l.startsWith("#EXTINF:") || l.startsWith("#EXT-X-TARGETDURATION:"),
277-
);
278-
279-
let segmentUrls: string[];
280-
281-
if (isMediaPlaylist) {
282-
segmentUrls = parseHlsMediaPlaylist(
283-
masterContent,
284-
streamingBaseUrl,
285-
queryParams,
286-
);
287-
} else {
288-
let bestBandwidth = 0;
289-
let bestVariantUrl: string | null = null;
290-
291-
for (let i = 0; i < masterLines.length; i++) {
292-
const line = masterLines[i];
293-
if (line?.startsWith("#EXT-X-STREAM-INF:")) {
294-
const bwMatch = line.match(/BANDWIDTH=(\d+)/);
295-
const bandwidth = parseInt(bwMatch?.[1] ?? "0", 10);
296-
const nextLine = masterLines[i + 1]?.trim();
297-
if (
298-
nextLine &&
299-
!nextLine.startsWith("#") &&
300-
bandwidth > bestBandwidth
301-
) {
302-
bestBandwidth = bandwidth;
303-
bestVariantUrl = nextLine.startsWith("http")
304-
? nextLine
305-
: `${streamingBaseUrl}${nextLine}${queryParams}`;
306-
}
307-
}
308-
}
309-
310-
if (!bestVariantUrl) {
311-
throw new FatalError("No video variants found in HLS playlist");
312-
}
313-
314-
const variantResponse = await fetch(bestVariantUrl);
315-
if (!variantResponse.ok) {
316-
throw new FatalError("Failed to fetch HLS variant playlist from Loom");
95+
return await convertRemoteVideoToMp4Buffer(downloadUrl);
96+
} catch (error) {
97+
throw new FatalError(
98+
error instanceof Error
99+
? error.message
100+
: "Failed to convert Loom video to MP4",
101+
);
317102
}
318-
319-
const variantContent = await variantResponse.text();
320-
segmentUrls = parseHlsMediaPlaylist(
321-
variantContent,
322-
streamingBaseUrl,
323-
queryParams,
324-
);
325-
}
326-
327-
if (segmentUrls.length === 0) {
328-
throw new FatalError("No video segments found in HLS playlist");
329-
}
330-
331-
return await downloadSegmentsToBuffer(segmentUrls);
332-
}
333-
334-
async function downloadVideoContent(
335-
downloadUrl: string,
336-
loomVideoId: string,
337-
): Promise<Buffer> {
338-
if (isStreamingUrl(downloadUrl)) {
339-
return await downloadFromStreamingUrl(downloadUrl, loomVideoId);
340103
}
341104

342105
const loomResponse = await fetch(downloadUrl);
@@ -417,7 +180,7 @@ async function downloadLoomToS3(payload: ImportLoomPayload): Promise<void> {
417180
});
418181
}).pipe(runPromise);
419182

420-
const videoBuffer = await downloadVideoContent(freshDownloadUrl, loomVideoId);
183+
const videoBuffer = await downloadVideoContent(freshDownloadUrl);
421184

422185
if (videoBuffer.length < MINIMUM_VIDEO_SIZE) {
423186
throw new FatalError(

0 commit comments

Comments
 (0)