@@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
88import { Effect , Option } from "effect" ;
99import { FatalError } from "workflow" ;
1010import { runPromise } from "@/lib/server" ;
11+ import { convertRemoteVideoToMp4Buffer } from "@/lib/video-convert" ;
1112
1213interface 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-
3229async 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 ( / < A d a p t a t i o n S e t ( [ ^ > ] * ) > ( [ \s \S ] * ?) < \/ A d a p t a t i o n S e t > / g) ,
102- ] ;
103-
104- for ( const asMatch of adaptationSets ) {
105- const attrs = asMatch [ 1 ] ?? "" ;
106- const content = asMatch [ 2 ] ?? "" ;
107- const contentType = attrs . match ( / c o n t e n t T y p e = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
108-
109- if ( contentType !== "video" ) continue ;
110-
111- const representations = [
112- ...content . matchAll (
113- / < R e p r e s e n t a t i o n ( [ ^ > ] * ) > ( [ \s \S ] * ?) < \/ R e p r e s e n t a t i o n > / 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 ( / b a n d w i d t h = " ( \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- / < S e g m e n t T e m p l a t e ( [ ^ > ] * ) > ( [ \s \S ] * ?) < \/ S e g m e n t T e m p l a t e > / ,
136- ) ;
137- if ( ! templateMatch ) continue ;
138-
139- const templateAttrs = templateMatch [ 1 ] ?? "" ;
140- const templateContent = templateMatch [ 2 ] ?? "" ;
141-
142- const initFilename = templateAttrs . match ( / i n i t i a l i z a t i o n = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
143- const mediaTemplate = templateAttrs . match ( / m e d i a = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
144- const startNumber = parseInt (
145- templateAttrs . match ( / s t a r t N u m b e r = " ( \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 ( / B A N D W I D T H = ( \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