-
Notifications
You must be signed in to change notification settings - Fork 1
[#170] 배포 #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[#170] 배포 #332
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,8 +70,8 @@ export const videoTranscode = async (jobOrId) => { | |
| const workDir = tmpPath(`video-work-${jobId}`); | ||
| const chunksDir = path.join(workDir, "chunks"); | ||
|
|
||
| // GCS 원본 청크 확장자 | ||
| const mergedExt = video.container === "webm" ? "webm" : "mp4"; | ||
| // 업로드 컨테이너별 병합 경로 선택 (webm/mp4) | ||
| const mergedExt = video.container === "mp4" ? "mp4" : "webm"; | ||
|
|
||
| //FFmpeg 인코딩 호환성을 위해 병합 파일은 .mp4로 고정 | ||
| const mergedPath = path.join(workDir, `merged.mp4`); | ||
|
|
@@ -186,9 +186,89 @@ const downloadChunks = async (chunks, destDir, ext) => { | |
| ); | ||
| }; | ||
|
|
||
| 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; | ||
| }; | ||
|
Comment on lines
+189
to
+266
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| /** | ||
| * 청크 병합 + 중간 mp4 생성 | ||
| * - webm: raw append 고정 (MediaRecorder timeslice 안정성) | ||
| * - mp4: concat demuxer 우선, 실패 시 raw append 폴백 | ||
| */ | ||
| const mergeChunks = async (chunksDir, outputPath, chunks, ext) => { | ||
| const orderedChunks = [...chunks].sort((a, b) => a.chunkIndex - b.chunkIndex); | ||
|
|
@@ -210,59 +290,56 @@ const mergeChunks = async (chunksDir, outputPath, chunks, ext) => { | |
| } | ||
| } | ||
|
|
||
| const assembledInputPath = path.join(chunksDir, `assembled.${ext}`); | ||
| for (let i = 0; i < orderedChunks.length; i += 1) { | ||
| const c = orderedChunks[i]; | ||
| const chunkPath = path.join(chunksDir, `chunk_${String(c.chunkIndex).padStart(5, "0")}.${ext}`); | ||
| 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"); | ||
|
Comment on lines
+293
to
+313
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }; | ||
|
|
||
| /** | ||
| * 단일 품질(720p) HLS 변환 | ||
| * - aresample 옵션을 추가하여 오디오 패킷 누락 시 싱크 오류 방지 | ||
| * 중간 mp4를 실제 서비스 재생용 HLS(master.m3u8 + segment.ts)로 변환합니다. | ||
| * 왜 필요하나: | ||
| * - 720p 고정(scale+pad)으로 화면 크기를 일정하게 맞춰 디바이스별 재생 차이를 줄입니다. | ||
| * - GOP(키프레임 간격)을 고정해 HLS 세그먼트 경계를 안정화합니다. | ||
| * - independent_segments로 각 세그먼트 시작을 독립 재생 가능하게 만듭니다. | ||
| */ | ||
| const encodeToHLS = async (inputPath, hlsDir) => { | ||
| await runCmd(FFMPEG, [ | ||
| "-i", | ||
| inputPath, | ||
| "-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", | ||
|
Comment on lines
+327
to
343
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "aac", | ||
| "-b:a", | ||
|
|
@@ -277,6 +354,8 @@ const encodeToHLS = async (inputPath, hlsDir) => { | |
| "10", | ||
| "-hls_list_size", | ||
| "0", | ||
| "-hls_flags", | ||
| "independent_segments", | ||
|
Comment on lines
+357
to
+358
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "-hls_segment_filename", | ||
| path.join(hlsDir, "segment_%03d.ts"), | ||
| path.join(hlsDir, "master.m3u8"), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,7 +93,7 @@ export const processCreateShareLink = async (projectId, shareData) => { | |
|
|
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| if (existingLink) { | ||
| return { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -205,8 +205,24 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) { | |
| if (!file || !file.buffer || !file.mimetype) { | ||
| throw new InvalidVideoChunkError({ reason: "chunk 파일이 필요합니다." }); | ||
| } | ||
| if (!ALLOWED_VIDEO_MIME.has(file.mimetype)) { | ||
| throw new InvalidVideoChunkError({ contentType: file.mimetype }); | ||
|
|
||
| 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; | ||
| }; | ||
|
Comment on lines
+209
to
+218
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| const resolvedMime = resolveVideoMime(file); | ||
| if (!resolvedMime) { | ||
| throw new InvalidVideoChunkError({ | ||
| contentType: file.mimetype, | ||
| fileName: file.originalname || null, | ||
| }); | ||
|
Comment on lines
+221
to
+225
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| const video = await findVideoForChunkUpload(vid); | ||
|
|
@@ -216,7 +232,7 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) { | |
| throw new InvalidVideoStatusError({ videoId: String(vid), status: video.status }); | ||
| } | ||
|
|
||
| const ext = file.mimetype === "video/mp4" ? "mp4" : "webm"; | ||
| const ext = resolvedMime === "video/mp4" ? "mp4" : "webm"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| // mp4 + webm 혼합 업로드 방지 | ||
| if (video.container && video.container !== ext) { | ||
|
|
@@ -241,7 +257,7 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) { | |
| const uploaded = await uploadBufferToGCS({ | ||
| objectKey, | ||
| buffer: file.buffer, | ||
| contentType: file.mimetype, | ||
| contentType: resolvedMime, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }); | ||
|
|
||
| const sha256 = crypto.createHash("sha256").update(file.buffer).digest("hex"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 변경은
mergedExt변수의 로직을video.container가mp4인 경우mp4로, 그렇지 않으면webm으로 설정하도록 수정합니다. 이는ALLOWED_VIDEO_MIME에 정의된 두 가지 허용된 컨테이너 유형을 고려할 때 올바른 논리적 반전입니다. 이전 로직은webm을 우선시했지만, 이제mp4를 우선시하여 컨테이너 유형에 따라 올바른 확장자를 선택합니다.