nest with ffmpeg 고찰

모기·2025년 6월 23일

Gemini에게 이 글을 바칩니다...

아직 문제가 완벽하게 해결된 것은 아니지만
드디어 프로젝트가 궤도에 오르기 시작했다는 느낌을 받게 됐다.

우리 프로젝트의 사실상 기술적 챌린지의 핵심인
'영상 쌓기'
스마트폰에서 이를 실행시키는 것이 첫 번째 목표였다.
우리는 react / react-native를 사용하고 있고, 따라서 해당 기술을 react-native에서 구현할 수 있어야 했다.

라이브러리를 샅샅이 뒤지다가 ffmpeg라는 라이브러리를 발견했다.
다른 라이브러리에는 아쉬운 점이 많거나 유료였기 때문에 사용할 수 없었고
이 ffmpeg 관련 라이브러리로 선택했다.

하지만

ffmpeg는 더 이상 리액트 네이티브를 지원하지 않는다고 했다.
이래저래 삽질을 더 하다가 미래가 없다는 것을 깨닫고
다른 방법으로 구현해야겠다는 생각이 들었다.

우리가 애초에 계획한 것은 웹에서 만든 것을 앱에서 포팅하는 것이었기 때문에
나는 프론트와 백으로 구현할 수 있는지 알아보았다.
react와 node(nest)를 사용했는데, 마침 node로도 이 ffmpeg를 사용할 수 있다는
희 소 식을 듣게 되었다.

사실 nest도 처음이고, ffmpeg라는 기술도 처음이기 때문에
거의 99%를 AI에게 의지했다.
그래도 1%는 기여했다.
중간에 AI가 갈피를 못잡길래 여기저기 디버깅 코드를 찍었고, AI가 문제점을 캐치했다.

살짝 이상한 결과가 나오긴 했지만,

영상 출처: 팀장님 여행

참고로 두 영상은 5초, 6초짜리다.

어떻게든 가까이 갈 수 있다는 것을 깨달았으니 이제 시작이라고 생각한다.

우당탕 코드를 완성했지만 그래도 코드에 대한 이해도 필요할 것이고,
추가로 nest코드를 읽다보니 Spring boot와 상당히 코드가 유사하다는 느낌도 받았다.
그래서 nest도 고찰해볼까 한다.

사실 프론트 코드는 그냥 변수에 File을 담아서 백으로 보내는 것 밖에 없기 때문에
딱히 살펴볼 필요가 없다고 생각한다.

근데 이제 살펴볼 것은

Node(nest)

이 구조와 코드들이다.
만들 때는 눈치 못챘는데 만들고나서 찬찬히 살펴보다보니 확실히 알겠다.
nest구조는 Spring과 상당히 유사하다.

그래서 유사점에 대해서 AI에게 정리를 부탁했다.

물론 스프링은 같은 계층끼리 묶지만, nest는 기능끼리 묶는다는 차이가 있어보인다.
@Injectable()
export class VideosService {
  private readonly logger = new Logger(VideosService.name);

  async createCollage(
    video1: Express.Multer.File,
    video2: Express.Multer.File,
  ): Promise<string> {

NestJS는 DI 컨테이너를 관리한다고 한다.
그리고 여기서 Injectable() 데코레이터는 그 아래 클래스를 Provider로 등록하는 역할을 한다고 하는데, Provider로 등록되면 DI 컨테이너가 관리할 수 있다.
Injectable은 일종의 Bean역할을 하는데, 이제 주입당하는? Bean 역할인 것 같다.

@Controller('videos')
export class VideosController {
  // 👇 바로 이 생성자 부분이 의존성 주입의 핵심입니다!
  constructor(private readonly videosService: VideosService) {}

constructor를 가진 클래스는 소비자가 되고, constructor은 DI 컨테이너에서 videosService 인스턴스를 꺼낸다.

그리고 nest는 모듈을 보고

@Module({
  controllers: [VideosController], // 👈 컨트롤러 등록
  providers: [VideosService],      // 👈 서비스(Provider) 등록. 이 부분이 핵심!
})
export class VideosModule {}

어떤 클래스를 주입할지 파악한다고 한다.

그래서 매칭해보면

SpringNest
Spring ContextDI Container
InjectableBean
constructorAutowired

느낌이 아닐까 싶다.

ffmpeg

nest 코드 구조의 분석은 사실 얘를 위한 빌드업이었다.
그렇지만 또 nest 공부가 주가 될 것 같기도 하다.

우선 post가 살펴볼텐데 아래에 두 개의 뭔가가 있다.
첫 번째는 @UseInterceptors이고 두 번째는 post 요청이 왔을 때 사용하는 함수이다.

Controllers

UseInterceptors

@UseInterceptors(
    FileFieldsInterceptor(
      [
        { name: 'video1', maxCount: 1 },
        { name: 'video2', maxCount: 1 },
      ],
      {
        // 👇 여기에 storage 설정을 직접 주입합니다.
        storage: diskStorage({
          destination: (req, file, cb) => {
            const uploadPath = './uploads';
            if (!fs.existsSync(uploadPath)) {
              fs.mkdirSync(uploadPath, { recursive: true });
            }
            cb(null, uploadPath);
          },
          filename: (req, file, cb) => {
            const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
            const ext = path.extname(file.originalname);
            cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
          },
        }),
      },
    ),
  )

post 바로 아래에 있는 @는 데코레이터라고 한다.
그리고 인터셉터는 라우트 핸들러가 실행되기 전후에 특정 로직을 실행하고 싶을 때 사용한다고 한다.
여기서는 FileFieldsInterceptor를 사용한다.

FileInterceptor는 클라이언트가 보낸 파일 데이터를 서버의 핵심 로직이 처리하기 전에 중간에서 가로채 필요한 사전 작업을 수행하는 기술을 의미한다.

FileInterceptor는 업로드 처리 방식에 따라 다양한 형태를 가진다

싱글 파일 업로드

@UseInterceptors(FileInterceptor('avatar'))

여러 파일 업로드(단일 필드)

@UseInterceptors(FilesInterceptor('photos', 10))

여러 파일 업로드(여러 필드)

 @UseInterceptors(FileFieldsInterceptor([
    { name: 'image', maxCount: 1 },
    { name: 'manual', maxCount: 1 },
  ])

참고로 위의 방법들은 서버 메모리에 저장하는데, 서버 디스크에 전달하려면 storage 옵션을 두 번째 인자로 전달해야한다.

FileFieldsInterceptor(
      [ // 첫 번째 인자
        { name: 'video1', maxCount: 1 },
        { name: 'video2', maxCount: 1 },
      ], 
      { // 두 번째 인자
        // 👇 여기에 storage 설정을 직접 주입합니다.
        storage: diskStorage({
          destination: (req, file, cb) => {
            const uploadPath = './uploads';
            if (!fs.existsSync(uploadPath)) {
              fs.mkdirSync(uploadPath, { recursive: true });
            }
            cb(null, uploadPath);
          },
          filename: (req, file, cb) => {
            const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
            const ext = path.extname(file.originalname);
            cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
          },
        }),
      },
    ),

따라서 파일 업로드 방식에 따라 인터셉터 함수 이름과 첫 번째 인자 전달 방식이 바뀌고
서버 디스크 전달 여부에 따라 두 번째 인자의 형태가 달라진다.

GPT 말로는 두 번째 인자는 MulterOption 타입이라고 하며,
여기에는 파일 저장 방식(storage)/파일 필터링(fileFilter)/업로드 제한(limits)/경로 보존 여부(preservePath) 등이 들어갈 수 있다고 한다.

먼저 Multer가 무엇인지 알아보자.

파일을 해석해서 저장하는 역할을 하는 것 같은데 여기서 의문이 든다.
Multer는 파일 형식을 어떤 형태로 받을까?

우선 브라우저에서 서버로 전송하는 파일의 형태는 브라우저가 정한다.

그러면 Multer는 multipart/form-data를 받아서 핵심적인 역할 두 가지를 한다.

파일 객체는 다음과 같은 형태로 생성한다.

{
  fieldname: 'video',
  originalname: 'cat.mp4',
  encoding: '7bit',
  mimetype: 'video/mp4',
  destination: './uploads',
  filename: 'video-123456789.mp4',
  path: './uploads/video-123456789.mp4',
  size: 1048576
}

그리고 파일을 단지 저장할 뿐 파일을 해석하는 것은 아니라고 한다.
그래서 물어봤다. 어떤 형태든 서버에 저장이 가능한지

따라서 조심해야될 수도 있다.

Multer에 대한 이해는 어느 정도 이해된 것 같으니
내부 코드에 대해서 심층적으로 이해하려 한다.

storage: diskStorage({
          destination: (req, file, cb) => {
            const uploadPath = './uploads';
            if (!fs.existsSync(uploadPath)) {
              fs.mkdirSync(uploadPath, { recursive: true });
            }
            cb(null, uploadPath);
          },
          filename: (req, file, cb) => {
            const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
            const ext = path.extname(file.originalname);
            cb(null, `${file.fieldname}-${uniqueSuffix}${ext}`);
          },
        }),

먼저 diskStorage를 사용하면 디스크에 memoryStorage를 사용하면 메모리에 저장한다고 한다.

다음으로 destination에 대해서 살펴보면

딱히 더 살펴볼 것은 없는 것 같다.

fs는 서버의 파일 시스템을 다루는 모듈이다. 그래서 exist와 mkdir 함수로 파일을 올릴 폴더가 있는지 확인하고

콜백 함수로 에러과 객체를 전달한다.

마지막으로 filename에 대해 살펴보자. 파라미터는 destination과 같다.

const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);

유일한 파일이름을 생성하기 위해 다양한 값들이 적혀있다.

ext는 확장자이다.

따라서 ${file.fieldname}-${uniqueSuffix}${ext}을 통해 video-18127424-12879127.mp4 와 같은 형태로 저장된다.

이를 통해 받은 파일을 어떤 식으로 저장할지 명시해둔다.

createCollage

async createCollage(
    @UploadedFiles() files: { video1?: Express.Multer.File[]; video2?: Express.Multer.File[] },
    @Res() res: Response,
  ) {
    // post work1, post work2 로그는 직접 추가하신 것 같으니 그대로 두셔도 좋습니다.

    if (!files.video1 || !files.video2) {
      throw new HttpException('두 개의 영상 파일이 모두 필요합니다.', HttpStatus.BAD_REQUEST);
    }

    console.log('post work1: ', files.video1[0]);
    console.log('post work2: ', files.video2[0]);
    // 이제 files.video1[0] 객체에는 반드시 'path' 속성이 포함될 것입니다.
    const video1 = files.video1[0];
    const video2 = files.video2[0];
    let outputFilePath: string = '';

    try {
      outputFilePath = await this.videosService.createCollage(video1, video2);

      const stream = fs.createReadStream(outputFilePath);
      res.setHeader('Content-Type', 'video/mp4');
      stream.pipe(res);

      stream.on('end', () => {
        this.videosService.cleanupFile(outputFilePath);
      });

      stream.on('error', (err) => {
        this.logger.error('Stream error:', err);
        this.videosService.cleanupFile(outputFilePath);
        res.end();
      });
    } catch (error) {
      if (outputFilePath) {
        this.videosService.cleanupFile(outputFilePath);
      }
      throw new HttpException(
        error.message || '영상 처리 중 서버 오류 발생',
        error.status || HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

@UploadedFiles는 FileFieldsInterceptor에 의해 처리된 파일들을 files 파라미터에 주입한다고 한다.

어떻게 이게 가능한지 궁금했는데 Express.Multer.File[]에 그 방법이 나와있다.
우선 FileInterceptor는 해당하는 형식에 따라 Multer에 저장한다.
그리고 @UploadedFiles는 Express.Multer.File[]에서 꺼내쓴다.

@Res는 Express.Response 객체를 직접 다룰 때 사용하는 데코레이터라고 한다.
여기에도 나와있듯이 express는 다음과 같이 응답을 처리한다.

app.post('/api/posting', async (req, res) => {
  try{
    const { title, content } = req.body;
    const newPost = new Post({title: title, content: content});
    await newPost.save();

    res.json({ message: '게시물 저장 성공!', data: newPost });
  } catch(e) {
    console.log('post saving error: ', e);
    res.status(500).json({ message: '서버 에러' });
  }
});

이걸 nest에서 할 수 있도록 만들어준다.

아무튼 그렇게 하고 try 문에서 실제 콜라쥬를 실행한다.

outputFilePath = await this.videosService.createCollage(video1, video2);

실제 콜라쥬는 아래 service 항목을 참고하자.
콜라쥬가 끝나면 완성 영상의 경로가 반환된다.

const stream = fs.createReadStream(outputFilePath);
      res.setHeader('Content-Type', 'video/mp4');
      stream.pipe(res);

이 코드는 서버의 디스크에 저장된 비디오 파일을 클라이언트에게 스트리밍 방식으로 전송하는 역할을 한다고 한다.

패킷 전송이랑 비슷한데 어떤 차이가 있을까?
뭔가 네트워크 주차에 배웠던 것 같은데 기억이 가물가물치다.

여기서 말하는 스트리밍은 아마 스트리밍 서비스의 그 '스트리밍'일 것이다.
보통 스트리밍 서비스는 모든 데이터를 다 받지 않아도 데이터를 받은 부분에 한해서 재생이 가능하다.
그렇다면 이게 어떻게 가능한 걸까? 그리고 패킷은 왜 안되는 걸까?

스트림은 의미있는 정보를 담아서 주기 때문이라고 한다.
그럼 어떻게 의미있는 정보로 쪼갤 수 있을까?

우리가 종종 들었던 코덱의 역할인 듯하다. 그리고 확장자 또한 그런 역할을 한다.

avi는 불가능이라고 한다.

이후 error는 error 처리에 관한 것이므로 크게 볼 것이 없다.

Service

@Injectable()
export class VideosService {
  private readonly logger = new Logger(VideosService.name);

  async createCollage(
    video1: Express.Multer.File,
    video2: Express.Multer.File,
  ): Promise<string> {
    const outputPath = path.join('./processed', `collage-${Date.now()}.mp4`);
    const processedDir = path.dirname(outputPath);

    if (!fs.existsSync(processedDir)) {
      fs.mkdirSync(processedDir, { recursive: true });
    }

    // 디버깅 로그는 이제 필요하면 사용하고, 원치 않으면 지우셔도 됩니다.
    this.logger.debug(`Video 1 Path: ${video1.path}`);
    this.logger.debug(`Video 2 Path: ${video2.path}`);

    return new Promise((resolve, reject) => {
      ffmpeg()
        .input(video1.path)
        .input(video2.path)
        .complexFilter([
          // 👇 이 필터로 교체하여 높이가 다른 비디오를 자동으로 처리합니다.
          // 1. 첫 번째 비디오의 높이를 1080px로 조절 (가로세로 비율 유지) -> 결과 이름 [v0]
          // 2. 두 번째 비디오의 높이를 1080px로 조절 (가로세로 비율 유지) -> 결과 이름 [v1]
          // 3. 높이가 통일된 [v0]와 [v1]을 옆으로 붙임 -> 최종 비디오 결과 [v]
          '[0:v]scale=-2:1080[v0];[1:v]scale=-2:1080[v1];[v0][v1]hstack=inputs=2[v]',
          // 오디오는 첫 번째 비디오의 것을 사용합니다.
          '[0:a]apad[a]',
        ])
        .map('[v]')
        .map('[a]')
        .outputOptions('-c:v', 'libx264')
        .outputOptions('-preset', 'fast')
        .on('start', (commandLine) => {
          this.logger.log('Spawned FFmpeg with command: ' + commandLine);
        })
        .on('end', () => {
          this.logger.log('FFmpeg process finished successfully.');
          this.cleanupFile(video1.path);
          this.cleanupFile(video2.path);
          resolve(outputPath);
        })
        .on('error', (err, stdout, stderr) => {
          this.logger.error('FFmpeg error:', err.message);
          this.logger.error('ffmpeg stdout:\n' + stdout);
          this.logger.error('ffmpeg stderr:\n' + stderr);
          this.cleanupFile(video1.path);
          this.cleanupFile(video2.path);
          reject(
            new InternalServerErrorException(
              `비디오 처리 중 오류 발생: ${err.message}`,
            ),
          );
        })
        .save(outputPath);
    });
  }

  cleanupFile(filePath: string) {
    fs.unlink(filePath, (err) => {
      if (err) {
        this.logger.error(`Failed to delete temporary file: ${filePath}`, err);
      } else {
        this.logger.log(`Deleted temporary file: ${filePath}`);
      }
    });
  }
}

createCollage

죽죽 뛰어넘어서

return new Promise((resolve, reject) => {
      ffmpeg()
        .input(video1.path)
        .input(video2.path)
        .complexFilter([
          // 👇 이 필터로 교체하여 높이가 다른 비디오를 자동으로 처리합니다.
          // 1. 첫 번째 비디오의 높이를 1080px로 조절 (가로세로 비율 유지) -> 결과 이름 [v0]
          // 2. 두 번째 비디오의 높이를 1080px로 조절 (가로세로 비율 유지) -> 결과 이름 [v1]
          // 3. 높이가 통일된 [v0]와 [v1]을 옆으로 붙임 -> 최종 비디오 결과 [v]
          '[0:v]scale=-2:1080[v0];[1:v]scale=-2:1080[v1];[v0][v1]hstack=inputs=2[v]',
          // 오디오는 첫 번째 비디오의 것을 사용합니다.
          '[0:a]apad[a]',
        ])
        .map('[v]')
        .map('[a]')
        .outputOptions('-c:v', 'libx264')
        .outputOptions('-preset', 'fast')
        .on('start', (commandLine) => {
          this.logger.log('Spawned FFmpeg with command: ' + commandLine);
        })
        .on('end', () => {
          this.logger.log('FFmpeg process finished successfully.');
          this.cleanupFile(video1.path);
          this.cleanupFile(video2.path);
          resolve(outputPath);
        })
        .on('error', (err, stdout, stderr) => {
          this.logger.error('FFmpeg error:', err.message);
          this.logger.error('ffmpeg stdout:\n' + stdout);
          this.logger.error('ffmpeg stderr:\n' + stderr);
          this.cleanupFile(video1.path);
          this.cleanupFile(video2.path);
          reject(
            new InternalServerErrorException(
              `비디오 처리 중 오류 발생: ${err.message}`,
            ),
          );
        })
        .save(outputPath);
    });

리턴 부분을 살펴보자.
ffmpeg가 동영상을 처리하는 부분 같다.
input은 아마도 동영상 삽입과 관련돼 있고, 그 아래부터 본격적으로 편집하는 부분인 것 같다.
얘가 뭘 할 수 있는지 자세히 물어보자.

우선 Complex Filter가 뭘까?

우리가 서비스하려고 하는 것과 관련된 핵심 기능이 들어있다.

🎬 FFmpeg Complex Filter 완전 정리

-filter_complex는 FFmpeg에서 복잡한 비디오 및 오디오 필터링 작업을 수행할 수 있도록 해주는 고급 기능입니다. 이 문서에서는 핵심 개념부터 다양한 예제까지 깔끔하게 설명합니다.

✅ 1. 핵심 문법 구성

a. 📦 Filter Chain (필터 체인)

하나의 스트림에 여러 필터를 순서대로 적용할 때 사용.
각 필터는 쉼표( , )로 구분됩니다.

[입력] filter1=옵션1:옵션2, filter2=옵션A:옵션B [출력]

예시:

[0:v] scale=1280:720, rotate=PI/2 [out]

b. 🔗 Filter Graph (필터 그래프)

여러 필터 체인을 병렬로 실행할 때 사용.
각 체인은 세미콜론( ; ) 으로 구분합니다.

[입력1] chain1 [출력1]; [입력2] chain2 [출력2]; [출력1][출력2] chain3 [최종출력]

c. 🏷 Stream Specifiers & Labels (스트림 지정자와 이름표)

필터의 입력과 출력을 정확히 지정하기 위해 사용.

📌 입력 스트림 지정자:

문법의미
[0:v]첫 번째 입력 파일의 비디오 스트림
[0:a]첫 번째 입력 파일의 오디오 스트림
[1:v]두 번째 입력 파일의 비디오 스트림
[logo]이름으로 지정된 입력 스트림

📌 출력/중간 스트림 이름표:

  • [이름] 형태로 결과 스트림에 이름을 붙입니다.
  • 다음 필터의 입력으로 사용됩니다.

✅ 2. 문법 종합 예시

[0:v] scale=1280:720 [scaled_v];              # 1번 입력 영상의 해상도 조절 → [scaled_v]
[1:v] format=rgba [logo_img];                 # 2번 입력 로고에 포맷 지정 → [logo_img]
[scaled_v][logo_img] overlay=W-w-10:10 [out]  # 오버레이 → [out]

🎨 3. 실전 예제 모음

a. 📺 비디오 레이아웃

📌 Picture-in-Picture (작은 화면 삽입)

ffmpeg -i main.mp4 -i sub.mp4 -filter_complex \
"[1:v]scale=iw/4:-1[sub_v]; [0:v][sub_v]overlay=W-w-10:H-h-10" \
output.mp4

📌 2x2 분할 화면

ffmpeg -i 1.mp4 -i 2.mp4 -i 3.mp4 -i 4.mp4 -filter_complex \
"[0:v]pad=iw*2:ih*2[a]; [a][1:v]overlay=w[b]; [b][2:v]overlay=0:h[c]; [c][3:v]overlay=w:h" \
output.mp4

b. 🖼 워터마크 및 텍스트 추가

📌 이미지 워터마크

ffmpeg -i main.mp4 -i logo.png -filter_complex \
"[0:v][1:v]overlay=10:10" \
output.mp4

📌 동적 텍스트 (재생 시간 표시)

ffmpeg -i input.mp4 -vf \
"drawtext=fontfile=/System/Library/Fonts/Supplemental/Arial.ttf:text='%{pts\:hms}':x=10:y=10:fontsize=24:fontcolor=yellow" \
output.mp4

drawtext는 -vf로도 사용 가능하지만, 여러 필터와 함께 쓰려면 -filter_complex가 필요합니다.

c. 🎞 비디오 전환 효과 (Transition)

📌 크로스페이드

ffmpeg -i 1.mp4 -i 2.mp4 -filter_complex \
"xfade=transition=fade:duration=1:offset=4" \
output.mp4
  • offset=4 → 첫 번째 영상의 4초 후부터 전환 시작
  • duration=1 → 1초 동안 페이드 처리

d. 🎵 오디오 믹싱

📌 배경음악 삽입

ffmpeg -i video.mp4 -i music.mp3 -filter_complex \
"[1:a]volume=0.5[bgm]; [0:a][bgm]amix=inputs=2:duration=first" \
-map 0:v output.mp4
  • [1:a]volume=0.5[bgm] → 배경음악 볼륨 조절
  • amix → 두 오디오 스트림을 믹싱

e. 🔍 고급 효과: 세로 영상 + 흐림 배경

인스타 릴스, 유튜브 쇼츠 같은 효과

ffmpeg -i vertical_video.mp4 -filter_complex \
"[0:v]split[main][bg]; \
 [bg]scale=1920:1080,boxblur=10:1[bg_blurred]; \
 [main]scale=608:1080[main_scaled]; \
 [bg_blurred][main_scaled]overlay=(W-w)/2:(H-h)/2" \
output.mp4
  • split → 하나의 영상 스트림을 두 개로 복사
  • boxblur → 흐릿한 배경 생성
  • overlay → 중앙에 원본 영상 배치

🧠 4. 결론

  • Complex Filter는 FFmpeg의 고급 기능을 활용한 영상/오디오 처리의 중심 도구입니다.
  • 핵심 구성요소는 다음과 같습니다:
구성요소설명
, (쉼표)필터 체인 연결
; (세미콜론)필터 그래프 분리
[ ]스트림 지정자 및 이름표
filter=옵션필터 설정
overlay, scale, amix, xfade 등대표 필터
  • 생각보다 규칙은 단순합니다:
    [입력 스트림] → 필터 → [이름표] → 다음 필터…

그럼 다시 코드로 돌아와서

        .map('[v]')
        .map('[a]')
        .outputOptions('-c:v', 'libx264')
        .outputOptions('-preset', 'fast')
        .on('start', (commandLine) => {
          this.logger.log('Spawned FFmpeg with command: ' + commandLine);
        })
        .on('end', () => {
          this.logger.log('FFmpeg process finished successfully.');
          this.cleanupFile(video1.path);
          this.cleanupFile(video2.path);
          resolve(outputPath);
        })
        .on('error', (err, stdout, stderr) => {
          this.logger.error('FFmpeg error:', err.message);
          this.logger.error('ffmpeg stdout:\n' + stdout);
          this.logger.error('ffmpeg stderr:\n' + stderr);
          this.cleanupFile(video1.path);
          this.cleanupFile(video2.path);
          reject(
            new InternalServerErrorException(
              `비디오 처리 중 오류 발생: ${err.message}`,
            ),
          );
        })
        .save(outputPath);

이 부분을 살펴볼건데
.on은 안봐도 알 것 같다. 아마 시작, 끝, 에러 같은 상황에서 무엇을 할 건지 정하는 코드가 아닐까 싶다.
.save 역시 저장일거라 생각한다.

궁금한건 map과 outputOptions였는데,
map은 의외로 단순했다. 위에서 만들어진 v와 a로 최종 영상을 만들겠다는 의미이다.

outputOptions은 출력 옵션(저장할 때 설정들)과 관련된 것들이고, 아래와 같은 것들이 있다고 한다.

1. 화질 및 파일 크기 제어

옵션설명fluent-ffmpeg 사용 예시권장 / 팁
-crf(강력 추천) 목표 품질을 설정합니다. 숫자가 낮을수록 고화질/고용량입니다..outputOptions('-crf', '23')libx264 코덱 기준, 18~28 사이가 일반적입니다.
23은 품질과 용량의 좋은 균형점입니다.
-b:v목표 평균 비트레이트를 설정합니다. (M: Mbps, k: kbps).outputOptions('-b:v', '5M')파일 크기를 예측해야 하는 스트리밍 환경에 유용합니다.
-minrate
-maxrate
-bufsize
비트레이트의 최소/최대치를 제한합니다..outputOptions(['-b:v 2M', '-maxrate 2.5M'])스트리밍 버퍼링 방지용입니다.

2. 인코딩 속도 제어

옵션설명fluent-ffmpeg 사용 예시권장 / 팁
-preset인코딩 속도와 압축 효율의 균형을 맞춥니다..outputOptions('-preset', 'fast')ultrafast ~ veryslow 범위. 서버 환경에서는 fast 또는 veryfast가 합리적입니다.

3. 호환성 제어

옵션설명fluent-ffmpeg 사용 예시권장 / 팁
-c:v비디오 코덱을 지정합니다..outputOptions('-c:v', 'libx264')libx264 (H.264)가 범용성/호환성이 가장 높습니다.
-pix_fmt픽셀 포맷을 지정합니다..outputOptions('-pix_fmt', 'yuv420p')(매우 중요) 웹 영상 호환성을 위해 yuv420p 사용을 강력히 권장합니다.
-profile:v코덱의 세부 프로파일을 설정합니다. (Baseline, Main, High).outputOptions('-profile:v', 'main')대부분의 경우 따로 설정할 필요 없습니다.

4. 오디오 제어

옵션설명fluent-ffmpeg 사용 예시권장 / 팁
-c:a오디오 코덱을 지정합니다..outputOptions('-c:a', 'aac')aac가 MP4 컨테이너의 표준 오디오 코덱입니다.
-b:a오디오 비트레이트를 지정합니다..outputOptions('-b:a', '128k')128k (일반), 192k (고음질) 정도가 많이 사용됩니다.

5. 시간 및 길이 제어

옵션설명fluent-ffmpeg 사용 예시권장 / 팁
-t최종 출력물의 길이를 지정합니다..duration(10)fluent-ffmpeg.duration() 메소드가 더 편리합니다.
-ss출력 시작 시간을 지정합니다..seekOutput('00:00:05')fluent-ffmpeg.seekOutput() 메소드를 사용합니다.
-shortest가장 짧은 입력 스트림이 끝나면 인코딩을 종료합니다..outputOptions('-shortest')길이가 다른 소스를 합칠 때 매우 유용합니다.

6. 기타 유용한 옵션

옵션설명fluent-ffmpeg 사용 예시권장 / 팁
-movflags +faststartMP4 파일의 메타데이터를 파일 앞으로 이동시킵니다..outputOptions('-movflags', '+faststart')(웹 영상 필수) 다운로드와 동시에 재생(스트리밍)이 가능하게 합니다.
-metadata제목, 아티스트, 코멘트 등 메타데이터를 추가합니다..outputOptions('-metadata', 'title=My Video')key=value 형식으로 원하는 메타데이터를 추가할 수 있습니다.
-f출력 포맷을 강제로 지정합니다..toFormat('mp4')fluent-ffmpeg.toFormat() 사용이 더 편리합니다.
profile
안녕

0개의 댓글