Skip to content

Commit 221778a

Browse files
authored
Merge pull request #332 from TTORANG/dev
[#170] 배포
2 parents 788f407 + 1aeba04 commit 221778a

3 files changed

Lines changed: 140 additions & 45 deletions

File tree

src/services/conversion/videoTranscode.service.js

Lines changed: 119 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
193273
const 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
*/
254323
const 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"),

src/services/shareLink.service.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export const processCreateShareLink = async (projectId, shareData) => {
9393

9494
const existingLink = await findExistingLink(projectId, scope, videoId);
9595

96-
const baseUrl = process.env.SERVER_URL || process.env.LOCAL_URL;
96+
const baseUrl = process.env.FRONTEND_URL || process.env.LOCAL_URL;
9797

9898
if (existingLink) {
9999
return {

src/services/video.service.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,24 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) {
205205
if (!file || !file.buffer || !file.mimetype) {
206206
throw new InvalidVideoChunkError({ reason: "chunk 파일이 필요합니다." });
207207
}
208-
if (!ALLOWED_VIDEO_MIME.has(file.mimetype)) {
209-
throw new InvalidVideoChunkError({ contentType: file.mimetype });
208+
209+
const resolveVideoMime = (incomingFile) => {
210+
if (ALLOWED_VIDEO_MIME.has(incomingFile.mimetype)) {
211+
return incomingFile.mimetype;
212+
}
213+
214+
const originalName = String(incomingFile.originalname || "").toLowerCase();
215+
if (originalName.endsWith(".mp4")) return "video/mp4";
216+
if (originalName.endsWith(".webm")) return "video/webm";
217+
return null;
218+
};
219+
220+
const resolvedMime = resolveVideoMime(file);
221+
if (!resolvedMime) {
222+
throw new InvalidVideoChunkError({
223+
contentType: file.mimetype,
224+
fileName: file.originalname || null,
225+
});
210226
}
211227

212228
const video = await findVideoForChunkUpload(vid);
@@ -216,7 +232,7 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) {
216232
throw new InvalidVideoStatusError({ videoId: String(vid), status: video.status });
217233
}
218234

219-
const ext = file.mimetype === "video/mp4" ? "mp4" : "webm";
235+
const ext = resolvedMime === "video/mp4" ? "mp4" : "webm";
220236

221237
// mp4 + webm 혼합 업로드 방지
222238
if (video.container && video.container !== ext) {
@@ -241,7 +257,7 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) {
241257
const uploaded = await uploadBufferToGCS({
242258
objectKey,
243259
buffer: file.buffer,
244-
contentType: file.mimetype,
260+
contentType: resolvedMime,
245261
});
246262

247263
const sha256 = crypto.createHash("sha256").update(file.buffer).digest("hex");

0 commit comments

Comments
 (0)