@@ -70,8 +70,8 @@ export const videoTranscode = async (jobOrId) => {
7070 const workDir = tmpPath ( `video-work-${ jobId } ` ) ;
7171 const chunksDir = path . join ( workDir , "chunks" ) ;
7272
73- // GCS 원본 청크 확장자
74- const mergedExt = video . container === "webm " ? "webm " : "mp4 " ;
73+ // 업로드 컨테이너별 병합 경로 선택 (webm/mp4)
74+ const mergedExt = video . container === "mp4 " ? "mp4 " : "webm " ;
7575
7676 //FFmpeg 인코딩 호환성을 위해 병합 파일은 .mp4로 고정
7777 const mergedPath = path . join ( workDir , `merged.mp4` ) ;
@@ -186,9 +186,89 @@ const downloadChunks = async (chunks, destDir, ext) => {
186186 ) ;
187187} ;
188188
189+ const buildOrderedChunkPaths = ( chunksDir , orderedChunks , ext ) =>
190+ orderedChunks . map ( ( chunk ) =>
191+ path . join ( chunksDir , `chunk_${ String ( chunk . chunkIndex ) . padStart ( 5 , "0" ) } .${ ext } ` )
192+ ) ;
193+
189194/**
190- * 청크를 바이트 단위로 순서대로 재조립한 뒤 ffmpeg로 재인코딩
191- * - 프론트에서 완성 Blob을 raw split해 업로드한 경우를 처리
195+ * 청크로 만든 입력(webm/mp4, concat list 포함)을 "중간 mp4"로 통일합니다.
196+ * 왜 필요하나:
197+ * - 브라우저/청크마다 타임스탬프가 흔들릴 수 있어서(+genpts, make_zero) 먼저 정규화해야
198+ * 뒤 HLS 변환에서 길이/싱크가 안정적입니다.
199+ * - 코덱을 libx264+aac으로 고정해 플레이어 호환성을 높입니다.
200+ * - aresample=async로 오디오 타임라인을 보정해 소리 싱크 깨짐을 줄입니다.
201+ */
202+ const transcodeToIntermediateMp4 = async ( inputPath , outputPath , chunksDir , mode = "raw" ) => {
203+ const inputArgs =
204+ mode === "concat"
205+ ? [ "-f" , "concat" , "-safe" , "0" , "-i" , inputPath ]
206+ : [ "-i" , inputPath ] ;
207+
208+ await runCmd (
209+ FFMPEG ,
210+ [
211+ "-fflags" ,
212+ "+genpts" ,
213+ "-avoid_negative_ts" ,
214+ "make_zero" ,
215+ ...inputArgs ,
216+ "-c:v" ,
217+ "libx264" ,
218+ "-preset" ,
219+ "veryfast" ,
220+ "-crf" ,
221+ "21" ,
222+ "-pix_fmt" ,
223+ "yuv420p" ,
224+ "-c:a" ,
225+ "aac" ,
226+ "-b:a" ,
227+ "128k" ,
228+ "-ar" ,
229+ "48000" ,
230+ "-ac" ,
231+ "2" ,
232+ "-af" ,
233+ "aresample=async=1:first_pts=0" ,
234+ "-movflags" ,
235+ "+faststart" ,
236+ "-max_muxing_queue_size" ,
237+ "1024" ,
238+ "-y" ,
239+ outputPath ,
240+ ] ,
241+ { cwd : chunksDir }
242+ ) ;
243+ } ;
244+
245+ const mergeByConcatDemuxer = async ( chunkPaths , outputPath , chunksDir ) => {
246+ const concatListPath = path . join ( chunksDir , "concat.txt" ) ;
247+ const concatContent = chunkPaths
248+ . map ( ( chunkPath ) => `file '${ path . basename ( chunkPath ) . replaceAll ( "'" , "'\\''" ) } '` )
249+ . join ( "\n" ) ;
250+ await fs . writeFile ( concatListPath , `${ concatContent } \n` , "utf-8" ) ;
251+ await transcodeToIntermediateMp4 ( concatListPath , outputPath , chunksDir , "concat" ) ;
252+ } ;
253+
254+ const mergeByRawAppend = async ( orderedChunks , chunksDir , ext ) => {
255+ const assembledInputPath = path . join ( chunksDir , `assembled.${ ext } ` ) ;
256+ const chunkPaths = buildOrderedChunkPaths ( chunksDir , orderedChunks , ext ) ;
257+
258+ for ( let i = 0 ; i < chunkPaths . length ; i += 1 ) {
259+ await pipeline (
260+ createReadStream ( chunkPaths [ i ] ) ,
261+ createWriteStream ( assembledInputPath , { flags : i === 0 ? "w" : "a" } )
262+ ) ;
263+ }
264+
265+ return assembledInputPath ;
266+ } ;
267+
268+ /**
269+ * 청크 병합 + 중간 mp4 생성
270+ * - webm: raw append 고정 (MediaRecorder timeslice 안정성)
271+ * - mp4: concat demuxer 우선, 실패 시 raw append 폴백
192272 */
193273const mergeChunks = async ( chunksDir , outputPath , chunks , ext ) => {
194274 const orderedChunks = [ ...chunks ] . sort ( ( a , b ) => a . chunkIndex - b . chunkIndex ) ;
@@ -210,59 +290,56 @@ const mergeChunks = async (chunksDir, outputPath, chunks, ext) => {
210290 }
211291 }
212292
213- const assembledInputPath = path . join ( chunksDir , `assembled.${ ext } ` ) ;
214- for ( let i = 0 ; i < orderedChunks . length ; i += 1 ) {
215- const c = orderedChunks [ i ] ;
216- const chunkPath = path . join ( chunksDir , `chunk_${ String ( c . chunkIndex ) . padStart ( 5 , "0" ) } .${ ext } ` ) ;
293+ if ( ext === "webm" ) {
294+ const assembledInputPath = await mergeByRawAppend ( orderedChunks , chunksDir , ext ) ;
295+ await transcodeToIntermediateMp4 ( assembledInputPath , outputPath , chunksDir ) ;
296+ console . log ( "[VideoTranscode] webm chunk merge succeeded with raw append" ) ;
297+ return ;
298+ }
217299
218- await pipeline (
219- createReadStream ( chunkPath ) ,
220- createWriteStream ( assembledInputPath , { flags : i === 0 ? "w" : "a" } )
221- ) ;
300+ const chunkPaths = buildOrderedChunkPaths ( chunksDir , orderedChunks , ext ) ;
301+ try {
302+ await mergeByConcatDemuxer ( chunkPaths , outputPath , chunksDir ) ;
303+ console . log ( "[VideoTranscode] mp4 chunk merge succeeded with concat demuxer" ) ;
304+ return ;
305+ } catch ( concatError ) {
306+ const concatMessage =
307+ concatError ?. message ?. split ( "\n" ) . slice ( 0 , 6 ) . join ( "\n" ) || String ( concatError ) ;
308+ console . warn ( `[VideoTranscode] mp4 concat merge failed, fallback to raw append\n${ concatMessage } ` ) ;
222309 }
223310
224- await runCmd (
225- FFMPEG ,
226- [
227- "-fflags" ,
228- "+genpts" ,
229- "-i" ,
230- assembledInputPath ,
231- "-c:v" ,
232- "libx264" ,
233- "-preset" ,
234- "ultrafast" ,
235- "-pix_fmt" ,
236- "yuv420p" ,
237- "-c:a" ,
238- "aac" , // 오디오 재인코딩
239- "-b:a" ,
240- "128k" ,
241- "-movflags" ,
242- "faststart" ,
243- "-y" ,
244- outputPath ,
245- ] ,
246- { cwd : chunksDir }
247- ) ;
311+ const assembledInputPath = await mergeByRawAppend ( orderedChunks , chunksDir , ext ) ;
312+ await transcodeToIntermediateMp4 ( assembledInputPath , outputPath , chunksDir ) ;
313+ console . log ( "[VideoTranscode] mp4 chunk merge succeeded with raw append fallback" ) ;
248314} ;
249315
250316/**
251- * 단일 품질(720p) HLS 변환
252- * - aresample 옵션을 추가하여 오디오 패킷 누락 시 싱크 오류 방지
317+ * 중간 mp4를 실제 서비스 재생용 HLS(master.m3u8 + segment.ts)로 변환합니다.
318+ * 왜 필요하나:
319+ * - 720p 고정(scale+pad)으로 화면 크기를 일정하게 맞춰 디바이스별 재생 차이를 줄입니다.
320+ * - GOP(키프레임 간격)을 고정해 HLS 세그먼트 경계를 안정화합니다.
321+ * - independent_segments로 각 세그먼트 시작을 독립 재생 가능하게 만듭니다.
253322 */
254323const encodeToHLS = async ( inputPath , hlsDir ) => {
255324 await runCmd ( FFMPEG , [
256325 "-i" ,
257326 inputPath ,
327+ "-vf" ,
328+ "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2" ,
258329 "-c:v" ,
259330 "libx264" ,
260331 "-preset" ,
261332 "veryfast" ,
262333 "-crf" ,
263334 "23" ,
264- "-s" ,
265- "1280x720" ,
335+ "-pix_fmt" ,
336+ "yuv420p" ,
337+ "-g" ,
338+ "60" ,
339+ "-keyint_min" ,
340+ "60" ,
341+ "-sc_threshold" ,
342+ "0" ,
266343 "-c:a" ,
267344 "aac" ,
268345 "-b:a" ,
@@ -277,6 +354,8 @@ const encodeToHLS = async (inputPath, hlsDir) => {
277354 "10" ,
278355 "-hls_list_size" ,
279356 "0" ,
357+ "-hls_flags" ,
358+ "independent_segments" ,
280359 "-hls_segment_filename" ,
281360 path . join ( hlsDir , "segment_%03d.ts" ) ,
282361 path . join ( hlsDir , "master.m3u8" ) ,
0 commit comments