Conversation
Summary of ChangesHello @wnsgh7368, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 Pull Request는 비디오 처리 파이프라인의 견고성과 안정성을 대폭 향상시키는 데 중점을 둡니다. 특히, 다양한 형식의 비디오 청크를 병합하고 HLS로 변환하는 과정에서 발생할 수 있는 문제를 해결하기 위해 새로운 중간 변환 단계를 도입하고 인코딩 파라미터를 최적화했습니다. 또한, 비디오 청크 업로드 시 MIME 타입 감지 로직을 개선하고, 공유 링크가 올바른 프론트엔드 URL을 사용하도록 환경 변수를 조정했습니다. 이러한 변경사항들은 전반적인 비디오 처리 워크플로우의 신뢰성을 높이고 사용자 경험을 개선하는 데 기여합니다. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
| const buildOrderedChunkPaths = (chunksDir, orderedChunks, ext) => | ||
| orderedChunks.map((chunk) => | ||
| path.join(chunksDir, `chunk_${String(chunk.chunkIndex).padStart(5, "0")}.${ext}`) | ||
| ); | ||
|
|
||
| /** | ||
| * 청크를 바이트 단위로 순서대로 재조립한 뒤 ffmpeg로 재인코딩 | ||
| * - 프론트에서 완성 Blob을 raw split해 업로드한 경우를 처리 | ||
| * 청크로 만든 입력(webm/mp4, concat list 포함)을 "중간 mp4"로 통일합니다. | ||
| * 왜 필요하나: | ||
| * - 브라우저/청크마다 타임스탬프가 흔들릴 수 있어서(+genpts, make_zero) 먼저 정규화해야 | ||
| * 뒤 HLS 변환에서 길이/싱크가 안정적입니다. | ||
| * - 코덱을 libx264+aac으로 고정해 플레이어 호환성을 높입니다. | ||
| * - aresample=async로 오디오 타임라인을 보정해 소리 싱크 깨짐을 줄입니다. | ||
| */ | ||
| const transcodeToIntermediateMp4 = async (inputPath, outputPath, chunksDir, mode = "raw") => { | ||
| const inputArgs = | ||
| mode === "concat" | ||
| ? ["-f", "concat", "-safe", "0", "-i", inputPath] | ||
| : ["-i", inputPath]; | ||
|
|
||
| await runCmd( | ||
| FFMPEG, | ||
| [ | ||
| "-fflags", | ||
| "+genpts", | ||
| "-avoid_negative_ts", | ||
| "make_zero", | ||
| ...inputArgs, | ||
| "-c:v", | ||
| "libx264", | ||
| "-preset", | ||
| "veryfast", | ||
| "-crf", | ||
| "21", | ||
| "-pix_fmt", | ||
| "yuv420p", | ||
| "-c:a", | ||
| "aac", | ||
| "-b:a", | ||
| "128k", | ||
| "-ar", | ||
| "48000", | ||
| "-ac", | ||
| "2", | ||
| "-af", | ||
| "aresample=async=1:first_pts=0", | ||
| "-movflags", | ||
| "+faststart", | ||
| "-max_muxing_queue_size", | ||
| "1024", | ||
| "-y", | ||
| outputPath, | ||
| ], | ||
| { cwd: chunksDir } | ||
| ); | ||
| }; | ||
|
|
||
| const mergeByConcatDemuxer = async (chunkPaths, outputPath, chunksDir) => { | ||
| const concatListPath = path.join(chunksDir, "concat.txt"); | ||
| const concatContent = chunkPaths | ||
| .map((chunkPath) => `file '${path.basename(chunkPath).replaceAll("'", "'\\''")}'`) | ||
| .join("\n"); | ||
| await fs.writeFile(concatListPath, `${concatContent}\n`, "utf-8"); | ||
| await transcodeToIntermediateMp4(concatListPath, outputPath, chunksDir, "concat"); | ||
| }; | ||
|
|
||
| const mergeByRawAppend = async (orderedChunks, chunksDir, ext) => { | ||
| const assembledInputPath = path.join(chunksDir, `assembled.${ext}`); | ||
| const chunkPaths = buildOrderedChunkPaths(chunksDir, orderedChunks, ext); | ||
|
|
||
| for (let i = 0; i < chunkPaths.length; i += 1) { | ||
| await pipeline( | ||
| createReadStream(chunkPaths[i]), | ||
| createWriteStream(assembledInputPath, { flags: i === 0 ? "w" : "a" }) | ||
| ); | ||
| } | ||
|
|
||
| return assembledInputPath; | ||
| }; |
There was a problem hiding this comment.
| if (ext === "webm") { | ||
| const assembledInputPath = await mergeByRawAppend(orderedChunks, chunksDir, ext); | ||
| await transcodeToIntermediateMp4(assembledInputPath, outputPath, chunksDir); | ||
| console.log("[VideoTranscode] webm chunk merge succeeded with raw append"); | ||
| return; | ||
| } | ||
|
|
||
| await pipeline( | ||
| createReadStream(chunkPath), | ||
| createWriteStream(assembledInputPath, { flags: i === 0 ? "w" : "a" }) | ||
| ); | ||
| const chunkPaths = buildOrderedChunkPaths(chunksDir, orderedChunks, ext); | ||
| try { | ||
| await mergeByConcatDemuxer(chunkPaths, outputPath, chunksDir); | ||
| console.log("[VideoTranscode] mp4 chunk merge succeeded with concat demuxer"); | ||
| return; | ||
| } catch (concatError) { | ||
| const concatMessage = | ||
| concatError?.message?.split("\n").slice(0, 6).join("\n") || String(concatError); | ||
| console.warn(`[VideoTranscode] mp4 concat merge failed, fallback to raw append\n${concatMessage}`); | ||
| } | ||
|
|
||
| await runCmd( | ||
| FFMPEG, | ||
| [ | ||
| "-fflags", | ||
| "+genpts", | ||
| "-i", | ||
| assembledInputPath, | ||
| "-c:v", | ||
| "libx264", | ||
| "-preset", | ||
| "ultrafast", | ||
| "-pix_fmt", | ||
| "yuv420p", | ||
| "-c:a", | ||
| "aac", // 오디오 재인코딩 | ||
| "-b:a", | ||
| "128k", | ||
| "-movflags", | ||
| "faststart", | ||
| "-y", | ||
| outputPath, | ||
| ], | ||
| { cwd: chunksDir } | ||
| ); | ||
| const assembledInputPath = await mergeByRawAppend(orderedChunks, chunksDir, ext); | ||
| await transcodeToIntermediateMp4(assembledInputPath, outputPath, chunksDir); | ||
| console.log("[VideoTranscode] mp4 chunk merge succeeded with raw append fallback"); |
| // GCS 원본 청크 확장자 | ||
| const mergedExt = video.container === "webm" ? "webm" : "mp4"; | ||
| // 업로드 컨테이너별 병합 경로 선택 (webm/mp4) | ||
| const mergedExt = video.container === "mp4" ? "mp4" : "webm"; |
| "-vf", | ||
| "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2", | ||
| "-c:v", | ||
| "libx264", | ||
| "-preset", | ||
| "veryfast", | ||
| "-crf", | ||
| "23", | ||
| "-s", | ||
| "1280x720", | ||
| "-pix_fmt", | ||
| "yuv420p", | ||
| "-g", | ||
| "60", | ||
| "-keyint_min", | ||
| "60", | ||
| "-sc_threshold", | ||
| "0", | ||
| "-c:a", |
| "-hls_flags", | ||
| "independent_segments", |
| const existingLink = await findExistingLink(projectId, scope, videoId); | ||
|
|
||
| const baseUrl = process.env.SERVER_URL || process.env.LOCAL_URL; | ||
| const baseUrl = process.env.FRONTEND_URL || process.env.LOCAL_URL; |
| const resolveVideoMime = (incomingFile) => { | ||
| if (ALLOWED_VIDEO_MIME.has(incomingFile.mimetype)) { | ||
| return incomingFile.mimetype; | ||
| } | ||
|
|
||
| const originalName = String(incomingFile.originalname || "").toLowerCase(); | ||
| if (originalName.endsWith(".mp4")) return "video/mp4"; | ||
| if (originalName.endsWith(".webm")) return "video/webm"; | ||
| return null; | ||
| }; |
| if (!resolvedMime) { | ||
| throw new InvalidVideoChunkError({ | ||
| contentType: file.mimetype, | ||
| fileName: file.originalname || null, | ||
| }); |
| } | ||
|
|
||
| const ext = file.mimetype === "video/mp4" ? "mp4" : "webm"; | ||
| const ext = resolvedMime === "video/mp4" ? "mp4" : "webm"; |
| objectKey, | ||
| buffer: file.buffer, | ||
| contentType: file.mimetype, | ||
| contentType: resolvedMime, |
🔗 관련 이슈