Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 119 additions & 40 deletions src/services/conversion/videoTranscode.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이 변경은 mergedExt 변수의 로직을 video.containermp4인 경우 mp4로, 그렇지 않으면 webm으로 설정하도록 수정합니다. 이는 ALLOWED_VIDEO_MIME에 정의된 두 가지 허용된 컨테이너 유형을 고려할 때 올바른 논리적 반전입니다. 이전 로직은 webm을 우선시했지만, 이제 mp4를 우선시하여 컨테이너 유형에 따라 올바른 확장자를 선택합니다.


//FFmpeg 인코딩 호환성을 위해 병합 파일은 .mp4로 고정
const mergedPath = path.join(workDir, `merged.mp4`);
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

새로운 buildOrderedChunkPaths, transcodeToIntermediateMp4, mergeByConcatDemuxer, mergeByRawAppend 함수가 도입되어 비디오 청크 병합 및 중간 트랜스코딩 프로세스를 모듈화하고 개선합니다. 특히 transcodeToIntermediateMp4는 안정적인 HLS 변환을 위해 타임스탬프 정규화, 코덱 고정 및 오디오 타임라인 보정을 위한 FFmpeg 인수를 잘 정의하고 있습니다. 이는 파이프라인의 견고성을 크게 향상시킵니다.


/**
* 청크 병합 + 중간 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);
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

mergeChunks 함수가 webmmp4 컨테이너 유형에 따라 다른 병합 전략을 사용하도록 리팩토링되었습니다. mp4의 경우 concat demuxer를 먼저 시도하고 실패 시 raw append로 폴백하는 것은 매우 견고한 접근 방식입니다. 오류 발생 시 console.warn을 사용하여 폴백을 기록하는 것도 디버깅에 유용합니다.

};

/**
* 단일 품질(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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

HLS 변환에 -vf 필터(스케일 및 패딩), -pix_fmt yuv420p, 그리고 GOP(키프레임 간격) 제어를 위한 -g, -keyint_min, -sc_threshold 옵션이 추가되었습니다. 이 매개변수들은 HLS 출력의 일관된 해상도, 색상 형식 및 안정적인 세그먼트 경계를 보장하여 스트리밍 품질과 호환성을 향상시킵니다.

"aac",
"-b:a",
Expand All @@ -277,6 +354,8 @@ const encodeToHLS = async (inputPath, hlsDir) => {
"10",
"-hls_list_size",
"0",
"-hls_flags",
"independent_segments",
Comment on lines +357 to +358
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

HLS 변환에 -hls_flags independent_segments 플래그를 추가한 것은 매우 중요합니다. 이 플래그는 각 HLS 세그먼트가 독립적으로 디코딩될 수 있도록 보장하여, 특히 스트리밍 중 탐색 및 오류 복구 기능을 크게 향상시킵니다.

"-hls_segment_filename",
path.join(hlsDir, "segment_%03d.ts"),
path.join(hlsDir, "master.m3u8"),
Expand Down
2 changes: 1 addition & 1 deletion src/services/shareLink.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

baseUrlprocess.env.SERVER_URL에서 process.env.FRONTEND_URL로 변경한 것은 공유 링크가 일반적으로 프론트엔드에서 접근된다는 점을 고려할 때 올바른 수정입니다. 이는 생성된 공유 URL이 올바른 대상(프론트엔드 애플리케이션)을 가리키도록 보장합니다.


if (existingLink) {
return {
Expand Down
24 changes: 20 additions & 4 deletions src/services/video.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

resolveVideoMime 함수를 도입하여 file.mimetype이 신뢰할 수 없거나 누락된 경우 원본 파일 이름의 확장자(.mp4 또는 .webm)를 통해 MIME 유형을 추론하도록 한 것은 견고성을 크게 향상시킵니다. 이는 클라이언트에서 제공하는 MIME 유형이 항상 정확하지 않을 수 있는 시나리오에 대한 좋은 폴백 메커니즘입니다.


const resolvedMime = resolveVideoMime(file);
if (!resolvedMime) {
throw new InvalidVideoChunkError({
contentType: file.mimetype,
fileName: file.originalname || null,
});
Comment on lines +221 to +225
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

MIME 유형을 확인할 수 없을 때 InvalidVideoChunkErrorfileName을 포함하도록 개선한 것은 디버깅에 매우 유용합니다. 어떤 파일이 문제의 원인인지 빠르게 식별하는 데 도움이 됩니다.

}

const video = await findVideoForChunkUpload(vid);
Expand All @@ -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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ext 변수가 이제 file.mimetype 대신 resolvedMime을 사용하도록 변경되었습니다. 이는 resolveVideoMime 함수를 통해 MIME 유형을 보다 안정적으로 결정하는 새로운 로직과 일치합니다.


// mp4 + webm 혼합 업로드 방지
if (video.container && video.container !== ext) {
Expand All @@ -241,7 +257,7 @@ export async function uploadVideoChunk({ videoId, chunkIndex, file }) {
const uploaded = await uploadBufferToGCS({
objectKey,
buffer: file.buffer,
contentType: file.mimetype,
contentType: resolvedMime,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

uploadBufferToGCS 호출에서 contentTypefile.mimetype 대신 resolvedMime을 사용하도록 변경되었습니다. 이는 MIME 유형 감지 로직의 개선 사항을 활용하여 GCS에 업로드되는 파일의 Content-Type 메타데이터가 더 정확하도록 보장합니다.

});

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