11import { randomUUID } from "node:crypto" ;
22import { 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
95function 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 ( / < 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) ,
33- ] ;
34-
35- for ( const asMatch of adaptationSets ) {
36- const attrs = asMatch [ 1 ] ?? "" ;
37- const content = asMatch [ 2 ] ?? "" ;
38- const contentType = attrs . match ( / c o n t e n t T y p e = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
39-
40- if ( contentType !== "video" && contentType !== "audio" ) continue ;
41-
42- const representations = [
43- ...content . matchAll (
44- / < 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,
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 ( / b a n d w i d t h = " ( \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- / < 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 > / ,
67- ) ;
68- if ( ! templateMatch ) continue ;
69-
70- const templateAttrs = templateMatch [ 1 ] ?? "" ;
71- const templateContent = templateMatch [ 2 ] ?? "" ;
72-
73- const initFilename = templateAttrs . match ( / i n i t i a l i z a t i o n = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
74- const mediaTemplate = templateAttrs . match ( / m e d i a = " ( [ ^ " ] + ) " / ) ?. [ 1 ] ;
75- const startNumber = parseInt (
76- templateAttrs . match ( / s t a r t N u m b e r = " ( \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 ( / U R I = " ( [ ^ " ] + ) " / ) ;
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 ( / B A N D W I D T H = ( \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-
19217async 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