Skip to content

Commit e2c9672

Browse files
committed
🐛 [Fix]: 녹화 로직 개선
1 parent 5e4d168 commit e2c9672

File tree

8 files changed

+289
-67
lines changed

8 files changed

+289
-67
lines changed

.github/workflows/record-deploy.yml

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ on:
33
push:
44
branches:
55
- develop
6+
- Fix/370
67
paths:
78
- 'apps/record/**'
89
jobs:

apps/api/src/common/responses/exceptions/errorStatus.ts

+3
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,7 @@ export class ErrorStatus {
3131
//Bookmark
3232
static readonly BOOKMARK_NOT_FOUND = new ErrorStatus(404, 'BROADCAST_4000', '북마크 정보가 존재하지 않습니다.');
3333
static readonly BOOKMARK_LIMIT_EXCEEDED = new ErrorStatus(400, 'BOOKMARK_4001', '이미 북마크가 5개 존재합니다.');
34+
35+
//Record
36+
static readonly RECORD_NOT_FOUND = new ErrorStatus(404, 'RECORD_4000', '녹화 정보가 존재하지 않습니다.');
3437
}

apps/api/src/record/record.service.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,21 @@ export class RecordService {
4747
},
4848
});
4949

50-
if (!attendance) new CustomException(ErrorStatus.ATTENDANCE_NOT_FOUND);
51-
50+
if (!attendance) throw new CustomException(ErrorStatus.ATTENDANCE_NOT_FOUND);
51+
const record = await this.recordRepository
52+
.createQueryBuilder('record')
53+
.where('attendanceId = :attendanceId', { attendanceId: attendance.id })
54+
.andWhere('video IS NULL')
55+
.orderBy('record.id', 'ASC')
56+
.getOne();
57+
if (!record) throw new CustomException(ErrorStatus.RECORD_NOT_FOUND);
58+
console.log('record:', record);
59+
record.video = video;
5260
await this.recordRepository
5361
.createQueryBuilder('record')
5462
.update(Record)
5563
.set({ video })
56-
.where('attendanceId = :attendanceId AND video IS NULL', { attendanceId: attendance.id })
64+
.where('id = :id', { id: record.id })
5765
.execute();
5866
}
5967
}

apps/record/src/exchage.ts

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { spawn } from 'child_process';
2+
import fs from 'fs';
3+
import { uploadObjectFromDir } from './object';
4+
import path from 'path';
5+
6+
export const convertWebMToHLS = async (inputPath: string, recordsDirPath: string, roomId: string): Promise<void> => {
7+
return new Promise((resolve, reject) => {
8+
const outputDir = `${recordsDirPath}/${roomId}`;
9+
if (!fs.existsSync(outputDir)) {
10+
fs.mkdirSync(outputDir, { recursive: true });
11+
}
12+
console.log('Converting WebM to HLS...');
13+
const args = [
14+
'-i',
15+
inputPath, // Input WebM file
16+
'-codec:v',
17+
'libx264', // Convert video to H.264
18+
'-preset',
19+
'veryfast', // Faster encoding
20+
'-crf',
21+
'25', // Quality level (lower is better)
22+
'-codec:a',
23+
'aac', // Convert audio to AAC
24+
'-b:a',
25+
'128k', // Audio bitrate
26+
'-ac',
27+
'1', // Stereo audio
28+
'-hls_time',
29+
'10', // Duration of each HLS segment (in seconds)
30+
'-hls_list_size',
31+
'0', // Keep all HLS segments in the playlist
32+
'-hls_segment_filename',
33+
`${outputDir}/segment_%03d.ts`, // Segment files
34+
`${outputDir}/video.m3u8`, // HLS playlist file
35+
];
36+
37+
console.log('Starting FFmpeg with arguments:', args.join(' '));
38+
39+
const ffmpeg = spawn('ffmpeg', args);
40+
41+
ffmpeg.stdout.on('data', data => console.log(`[FFmpeg stdout]: ${data}`));
42+
ffmpeg.stderr.on('data', data => console.error(`[FFmpeg stderr]: ${data}`));
43+
44+
ffmpeg.on('close', async code => {
45+
if (code === 0) {
46+
try {
47+
await deleteFile(inputPath);
48+
await uploadRecord(roomId, recordsDirPath);
49+
console.log('HLS conversion completed successfully.');
50+
resolve(); // 성공적으로 종료
51+
} catch (error) {
52+
reject(error); // 에러 발생 시 reject 호출
53+
}
54+
} else {
55+
const error = new Error(`FFmpeg exited with code ${code}`);
56+
console.error(error.message);
57+
reject(error); // FFmpeg 에러 발생 시 reject 호출
58+
}
59+
});
60+
61+
ffmpeg.on('error', error => {
62+
console.error('Failed to start FFmpeg process:', error);
63+
reject(error); // 프로세스 시작 실패 시 reject 호출
64+
});
65+
});
66+
};
67+
68+
const uploadRecord = async (roomId: string, recordsDirPath: string) => {
69+
await uploadObjectFromDir(roomId, recordsDirPath);
70+
const roomDirPath = `${recordsDirPath}/${roomId}`;
71+
if (fs.existsSync(roomDirPath)) {
72+
await deleteAllFiles(roomDirPath);
73+
console.log(`All files in ${roomDirPath} deleted successfully.`);
74+
}
75+
};
76+
77+
async function deleteAllFiles(directoryPath: string): Promise<void> {
78+
try {
79+
const files = await fs.promises.readdir(directoryPath, { withFileTypes: true });
80+
for (const file of files) {
81+
const fullPath = path.join(directoryPath, file.name);
82+
if (file.isDirectory()) {
83+
await deleteAllFiles(fullPath); // 재귀적으로 디렉토리 삭제
84+
await fs.promises.rmdir(fullPath); // 빈 디렉토리 삭제
85+
} else {
86+
await fs.promises.unlink(fullPath); // 파일 삭제
87+
}
88+
}
89+
} catch (error) {
90+
console.error(`Error deleting files in directory: ${directoryPath}`, error);
91+
throw error;
92+
}
93+
}
94+
95+
async function deleteFile(filePath: string): Promise<void> {
96+
try {
97+
await fs.promises.unlink(filePath);
98+
console.log(`File deleted successfully: ${filePath}`);
99+
} catch (error) {
100+
console.error(`Error deleting file: ${filePath}`, error);
101+
}
102+
}

apps/record/src/ffmpeg.ts

+87-60
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { spawn } from 'child_process';
22
import fs from 'fs';
33
import path from 'path';
4-
import { uploadObjectFromDir } from './object';
4+
import { addVideoToQueue } from './queue';
55

66
export const createFfmpegProcess = (
77
videoPort: number,
@@ -11,23 +11,26 @@ export const createFfmpegProcess = (
1111
audioPort?: number,
1212
) => {
1313
if (type === 'record') {
14-
fs.mkdirSync(`${assetsDirPath}/records/${roomId}`, { recursive: true });
14+
fs.mkdirSync(`${assetsDirPath}/videos/${roomId}`, { recursive: true });
1515
}
16+
const randomStr = crypto.randomUUID();
1617
const sdpString = audioPort ? createRecordSdpText(videoPort, audioPort) : createThumbnailSdpText(videoPort);
17-
const args = type === 'thumbnail' ? thumbnailArgs(assetsDirPath, roomId) : recordArgs(assetsDirPath, roomId);
18+
const args =
19+
type === 'thumbnail' ? thumbnailArgs(assetsDirPath, roomId) : recordArgs(assetsDirPath, roomId, randomStr);
1820
const ffmpegProcess = spawn('ffmpeg', args);
1921

2022
ffmpegProcess.stdin.write(sdpString);
2123
ffmpegProcess.stdin.end();
2224

23-
handleFfmpegProcess(ffmpegProcess, type, roomId, assetsDirPath);
25+
handleFfmpegProcess(ffmpegProcess, type, roomId, assetsDirPath, randomStr);
2426
};
2527

2628
const handleFfmpegProcess = (
2729
process: ReturnType<typeof spawn>,
2830
type: string,
2931
roomId: string,
3032
assetsDirPath: string,
33+
uuid: string,
3134
) => {
3235
if (process.stderr) {
3336
process.stderr.setEncoding('utf-8');
@@ -40,7 +43,7 @@ const handleFfmpegProcess = (
4043
process.on('error', error => console.error(`[FFmpeg ${type}] error:`, error));
4144
process.on('close', async code => {
4245
if (type === 'record') {
43-
await stopRecord(assetsDirPath, roomId);
46+
await stopRecord(assetsDirPath, roomId, uuid);
4447
} else {
4548
await stopMakeThumbnail(assetsDirPath, roomId);
4649
}
@@ -98,49 +101,67 @@ const thumbnailArgs = (dirPath: string, roomId: string) => {
98101
return commandArgs;
99102
};
100103

101-
const recordArgs = (dirPath: string, roomId: string) => {
104+
const recordArgs = (dirPath: string, roomId: string, randomStr: string) => {
102105
const commandArgs = [
103106
'-loglevel',
104107
'info', // 로그 활성화
105108
'-protocol_whitelist',
106109
'pipe,udp,rtp', // 허용할 프로토콜 정의
107110
'-f',
108-
'sdp', // SDP 입력 포맷
111+
'sdp', // 입력 포맷
109112
'-i',
110113
'pipe:0', // SDP를 파이프로 전달
111-
'-c:v',
112-
'libx264', // 비디오 코덱
113-
'-preset',
114-
'superfast',
115-
'-profile:v',
116-
'high', // H.264 High 프로필
117-
'-level:v',
118-
'4.1', // H.264 레벨 설정 (4.1)
119-
'-crf',
120-
'23', // 비디오 품질 설정
121-
'-c:a',
122-
'libmp3lame', // 오디오 코덱
123-
'-b:a',
124-
'128k', // 오디오 비트레이트
125-
'-ar',
126-
'48000', // 오디오 샘플링 레이트
127-
'-af',
128-
'aresample=async=1', // 오디오 샘플 동기화
129-
'-ac',
130-
'2',
131-
'-f',
132-
'hls', // HLS 출력 포맷
133-
'-hls_time',
134-
'15', // 각 세그먼트 길이 (초)
135-
'-hls_list_size',
136-
'0', // 유지할 세그먼트 개수
137-
'-hls_segment_filename',
138-
`${dirPath}/records/${roomId}/segment_%03d.ts`, // HLS 세그먼트 파일 이름
139-
`${dirPath}/records/${roomId}/video.m3u8`, // HLS 플레이리스트 파일 이름
114+
'-c',
115+
'copy', // 코덱 재인코딩 없이 원본 저장
116+
'-y', // 파일 덮어쓰기 허용
117+
`${dirPath}/videos/${roomId}/${randomStr}.webm`,
140118
];
141119
return commandArgs;
142120
};
143121

122+
// const recordArgs = (dirPath: string, roomId: string) => {
123+
// const commandArgs = [
124+
// '-loglevel',
125+
// 'info', // 로그 활성화
126+
// '-protocol_whitelist',
127+
// 'pipe,udp,rtp', // 허용할 프로토콜 정의
128+
// '-f',
129+
// 'sdp', // SDP 입력 포맷
130+
// '-i',
131+
// 'pipe:0', // SDP를 파이프로 전달
132+
// '-c:v',
133+
// 'libx264', // 비디오 코덱
134+
// '-preset',
135+
// 'superfast',
136+
// '-profile:v',
137+
// 'high', // H.264 High 프로필
138+
// '-level:v',
139+
// '4.1', // H.264 레벨 설정 (4.1)
140+
// '-crf',
141+
// '23', // 비디오 품질 설정
142+
// '-c:a',
143+
// 'libmp3lame', // 오디오 코덱
144+
// '-b:a',
145+
// '128k', // 오디오 비트레이트
146+
// '-ar',
147+
// '48000', // 오디오 샘플링 레이트
148+
// '-af',
149+
// 'aresample=async=1', // 오디오 샘플 동기화
150+
// '-ac',
151+
// '2',
152+
// '-f',
153+
// 'hls', // HLS 출력 포맷
154+
// '-hls_time',
155+
// '15', // 각 세그먼트 길이 (초)
156+
// '-hls_list_size',
157+
// '0', // 유지할 세그먼트 개수
158+
// '-hls_segment_filename',
159+
// `${dirPath}/records/${roomId}/segment_%03d.ts`, // HLS 세그먼트 파일 이름
160+
// `${dirPath}/records/${roomId}/video.m3u8`, // HLS 플레이리스트 파일 이름
161+
// ];
162+
// return commandArgs;
163+
// };
164+
144165
async function stopMakeThumbnail(assetsDirPath: string, roomId: string) {
145166
const thumbnailPath = path.join(assetsDirPath, 'thumbnails', `${roomId}.jpg`);
146167
fs.unlink(thumbnailPath, err => {
@@ -150,29 +171,35 @@ async function stopMakeThumbnail(assetsDirPath: string, roomId: string) {
150171
});
151172
}
152173

153-
async function stopRecord(assetsDirPath: string, roomId: string) {
154-
const roomDirPath = path.join(assetsDirPath, 'records', roomId);
155-
await uploadObjectFromDir(roomId, assetsDirPath);
156-
if (fs.existsSync(roomDirPath)) {
157-
await deleteAllFiles(roomDirPath);
158-
console.log(`All files in ${roomDirPath} deleted successfully.`);
159-
}
174+
async function stopRecord(assetsDirPath: string, roomId: string, uuid: string) {
175+
const video = {
176+
roomId,
177+
randomStr: uuid,
178+
title: 'title',
179+
};
180+
addVideoToQueue(video);
181+
// const roomDirPath = path.join(assetsDirPath, 'records', roomId, `${uuid}.webm`);
182+
// await uploadObjectFromDir(roomId, assetsDirPath);
183+
// if (fs.existsSync(roomDirPath)) {
184+
// await deleteAllFiles(roomDirPath);
185+
// console.log(`All files in ${roomDirPath} deleted successfully.`);
186+
// }
160187
}
161188

162-
async function deleteAllFiles(directoryPath: string): Promise<void> {
163-
try {
164-
const files = await fs.promises.readdir(directoryPath, { withFileTypes: true });
165-
for (const file of files) {
166-
const fullPath = path.join(directoryPath, file.name);
167-
if (file.isDirectory()) {
168-
await deleteAllFiles(fullPath); // 재귀적으로 디렉토리 삭제
169-
await fs.promises.rmdir(fullPath); // 빈 디렉토리 삭제
170-
} else {
171-
await fs.promises.unlink(fullPath); // 파일 삭제
172-
}
173-
}
174-
} catch (error) {
175-
console.error(`Error deleting files in directory: ${directoryPath}`, error);
176-
throw error;
177-
}
178-
}
189+
// async function deleteAllFiles(directoryPath: string): Promise<void> {
190+
// try {
191+
// const files = await fs.promises.readdir(directoryPath, { withFileTypes: true });
192+
// for (const file of files) {
193+
// const fullPath = path.join(directoryPath, file.name);
194+
// if (file.isDirectory()) {
195+
// await deleteAllFiles(fullPath); // 재귀적으로 디렉토리 삭제
196+
// await fs.promises.rmdir(fullPath); // 빈 디렉토리 삭제
197+
// } else {
198+
// await fs.promises.unlink(fullPath); // 파일 삭제
199+
// }
200+
// }
201+
// } catch (error) {
202+
// console.error(`Error deleting files in directory: ${directoryPath}`, error);
203+
// throw error;
204+
// }
205+
// }

apps/record/src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const assetsDirPath = path.join(__dirname, '../assets');
1313
const thumbnailsDirPath = path.join(__dirname, '../assets/thumbnails');
1414
const recordsDirPath = path.join(__dirname, '../assets/records');
1515
const defaultThumbnailPath = path.join(__dirname, '../assets/default-thumbnail.jpg');
16+
const videosDirPath = path.join(__dirname, '../assets/videos');
1617

1718
app.use(express.json());
1819
app.use(cors());
@@ -30,6 +31,10 @@ if (!fs.existsSync(recordsDirPath)) {
3031
fs.mkdirSync(recordsDirPath, { recursive: true });
3132
}
3233

34+
if (!fs.existsSync(videosDirPath)) {
35+
fs.mkdirSync(videosDirPath, { recursive: true });
36+
}
37+
3338
app.post('/thumbnail', (req, res) => {
3439
const { roomId } = req.body;
3540
try {

apps/record/src/object.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ const s3Client = new S3Client({
2727
},
2828
});
2929

30-
export const uploadObjectFromDir = async (roomId: string, dirPath: string) => {
31-
const folderPath = `${dirPath}/records/${roomId}`;
30+
export const uploadObjectFromDir = async (roomId: string, recordsDirPath: string) => {
31+
const folderPath = `${recordsDirPath}/${roomId}`;
3232
const files = fs.readdirSync(folderPath);
3333
const endTime = `${formatDate(new Date())}.${formatTime(new Date())}`;
3434
const video = `${CDN_URL}/records/${roomId}/${endTime}/video.m3u8`;
35-
35+
await axios.patch(`${API_SERVER_URL}/v1/records`, { roomId, video });
3636
for (const file of files) {
3737
const filePath = path.join(folderPath, file);
3838
const fileStream = fs.createReadStream(filePath);
@@ -46,7 +46,6 @@ export const uploadObjectFromDir = async (roomId: string, dirPath: string) => {
4646
ACL: 'public-read',
4747
});
4848
await s3Client.send(command);
49-
await axios.patch(`${API_SERVER_URL}/v1/records`, { roomId, video });
5049
} catch (error) {
5150
console.error('Error uploading file:', error);
5251
throw error;

0 commit comments

Comments
 (0)