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
281 changes: 0 additions & 281 deletions hs_err_pid172944.log

This file was deleted.

281 changes: 0 additions & 281 deletions hs_err_pid189152.log

This file was deleted.

281 changes: 0 additions & 281 deletions hs_err_pid192028.log

This file was deleted.

7 changes: 7 additions & 0 deletions railpack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://schema.railpack.com",
"buildAptPackages": ["ffmpeg"],
"deploy": {
"aptPackages": ["ffmpeg"]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,49 @@ public class FFmpegService implements AudioProcessor {
@Value("${media.ffprobe.path:ffprobe}")
private String ffprobePath;

private String resolveExecutable(String configuredPath, String fallback) {
if (configuredPath == null || configuredPath.isBlank()) {
return resolveFallbackExecutable(fallback);
}

File candidate = new File(configuredPath);
if (isAbsolutePath(configuredPath) && !candidate.exists()) {
log.warn("[FFmpeg] 설정된 실행 파일을 찾지 못해 PATH 명령어로 대체합니다: {}", configuredPath);
return resolveFallbackExecutable(fallback);
}

return configuredPath;
}

private boolean isAbsolutePath(String path) {
return new File(path).isAbsolute() || path.startsWith("/") || path.startsWith("\\");
}

private String resolveFallbackExecutable(String fallback) {
if (File.separatorChar != '\\') {
return fallback;
}

try {
Process process = new ProcessBuilder("where.exe", fallback).start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String resolved = reader.readLine();
process.waitFor();
if (resolved != null && !resolved.isBlank()) {
return resolved.trim();
}
}
} catch (Exception e) {
log.debug("[FFmpeg] 대체 실행 파일 탐색 실패: {}, 원인={}", fallback, e.getMessage());
}

return fallback;
}

public Integer getAudioDurationMs(File audioFile) throws Exception {
String resolvedFfprobePath = resolveExecutable(ffprobePath, "ffprobe");
String[] cmd = {
ffprobePath, "-v", "error", "-show_entries", "format=duration",
resolvedFfprobePath, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", audioFile.getAbsolutePath()
};
Process process = new ProcessBuilder(cmd).start();
Expand All @@ -31,11 +71,12 @@ public Integer getAudioDurationMs(File audioFile) throws Exception {
}

public File createSilenceFile(int durationMs) throws Exception {
String resolvedFfmpegPath = resolveExecutable(ffmpegPath, "ffmpeg");
File output = File.createTempFile("silence_", ".mp3");
double seconds = durationMs / 1000.0;

String[] cmd = {
ffmpegPath, "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
resolvedFfmpegPath, "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", String.valueOf(seconds), "-acodec", "libmp3lame", output.getAbsolutePath()
};

Expand All @@ -44,25 +85,22 @@ public File createSilenceFile(int durationMs) throws Exception {
}

public File mergeAudioFiles(List<File> audioFiles) throws Exception {
if (audioFiles == null || audioFiles.isEmpty()) throw new IllegalArgumentException("병합할 파일이 없습니다.");
if (audioFiles == null || audioFiles.isEmpty()) throw new IllegalArgumentException("merged audio file list is empty.");
String resolvedFfmpegPath = resolveExecutable(ffmpegPath, "ffmpeg");

// FFmpeg에 전달할 리스트 파일 생성 (UTF-8 인코딩)
File listFile = File.createTempFile("ffmpeg_list_", ".txt");
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(listFile), "UTF-8"))) {
for (File file : audioFiles) {
// 윈도우의 'C:\temp\file.mp3'를 'C:/temp/file.mp3'로 변환
String safePath = file.getAbsolutePath().replace("\\", "/");
// FFmpeg 문법에 맞춰 file '경로' 형태로 기록
writer.write("file '" + safePath + "'");
writer.newLine();
}
}

File output = File.createTempFile("merged_full_", ".mp3");

// 병합 명령어 구성
String[] cmd = {
ffmpegPath, "-y", "-f", "concat", "-safe", "0",
resolvedFfmpegPath, "-y", "-f", "concat", "-safe", "0",
"-i", listFile.getAbsolutePath(),
"-acodec", "libmp3lame", "-q:a", "2",
output.getAbsolutePath()
Expand All @@ -74,24 +112,22 @@ public File mergeAudioFiles(List<File> audioFiles) throws Exception {
}

private void executeCommand(String[] cmd) throws Exception {
log.info("[FFmpeg] 명령어 실행: {}", String.join(" ", cmd));
log.info("[FFmpeg] 명령 실행: {}", String.join(" ", cmd));
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true); // 에러와 출력을 합침
pb.redirectErrorStream(true);
Process process = pb.start();

// 실시간 로그 출력 (에러 원인 파악용)
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"))) {
String line;
while ((line = reader.readLine()) != null) {
log.info("[FFmpeg Output] {}", line); // 문제 발생 시 로그에서 바로 확인 가능
log.info("[FFmpeg 출력] {}", line);
}
}

int exitCode = process.waitFor();

if (exitCode != 0) {
log.error("[FFmpeg] 프로세스 오류 발생! Exit Code: {}", exitCode);
log.error("[FFmpeg] 프로세스 오류 발생. Exit Code: {}", exitCode);
throw new RuntimeException("FFmpeg 프로세스 오류 발생. Exit Code: " + exitCode);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package com.swyp.picke.domain.scenario.service;

import com.swyp.picke.domain.battle.entity.Battle;
import com.swyp.picke.domain.battle.enums.BattleCreatorType;
import com.swyp.picke.domain.battle.enums.BattleStatus;
import com.swyp.picke.domain.battle.repository.BattleRepository;
import com.swyp.picke.domain.scenario.entity.Scenario;
import com.swyp.picke.domain.scenario.entity.ScenarioNode;
import com.swyp.picke.domain.scenario.entity.Script;
import com.swyp.picke.domain.scenario.enums.AudioPathType;
import com.swyp.picke.domain.scenario.enums.CreatorType;
import com.swyp.picke.domain.scenario.enums.ScenarioStatus;
import com.swyp.picke.domain.scenario.enums.SpeakerType;
import com.swyp.picke.domain.scenario.repository.ScenarioRepository;
import com.swyp.picke.global.infra.media.service.FFmpegService;
import com.swyp.picke.global.infra.s3.service.S3UploadService;
import com.swyp.picke.global.infra.tts.service.TtsService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

import java.io.File;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class ScenarioAudioPipelineServiceTest {

@Mock
private ScenarioRepository scenarioRepository;

@Mock
private TtsService ttsService;

@Mock
private S3UploadService s3UploadService;

@Mock
private BattleRepository battleRepository;

private FFmpegService ffmpegService;
private ScenarioAudioPipelineService scenarioAudioPipelineService;
private final List<File> uploadedFiles = new ArrayList<>();

@BeforeEach
void setUp() {
ffmpegService = new FFmpegService();
ReflectionTestUtils.setField(ffmpegService, "ffmpegPath", "/usr/bin/ffmpeg");
ReflectionTestUtils.setField(ffmpegService, "ffprobePath", "/usr/bin/ffprobe");

scenarioAudioPipelineService = new ScenarioAudioPipelineService(
scenarioRepository,
ttsService,
ffmpegService,
s3UploadService,
battleRepository
);
}

@AfterEach
void tearDown() {
for (File file : uploadedFiles) {
if (file != null && file.exists()) {
file.delete();
}
}
}

@Test
void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() throws Exception {
Battle battle = Battle.builder()
.title("scenario battle")
.summary("summary")
.description("description")
.targetDate(LocalDate.now())
.audioDuration(0)
.status(BattleStatus.PENDING)
.creatorType(BattleCreatorType.ADMIN)
.build();

Scenario scenario = Scenario.builder()
.battle(battle)
.isInteractive(false)
.status(ScenarioStatus.PENDING)
.creatorType(CreatorType.ADMIN)
.build();

ScenarioNode node = ScenarioNode.builder()
.nodeName("START")
.isStartNode(true)
.audioDuration(0)
.build();

Script firstScript = Script.builder()
.speakerType(SpeakerType.NARRATOR)
.speakerName("Narrator")
.text("First line")
.build();
Script secondScript = Script.builder()
.speakerType(SpeakerType.NARRATOR)
.speakerName("Narrator")
.text("Second line")
.build();

node.addScript(firstScript);
node.addScript(secondScript);
scenario.addNode(node);
scenario.replaceVoiceSettings(Map.of(SpeakerType.NARRATOR, "voice-narrator"));

when(scenarioRepository.findById(1L)).thenReturn(Optional.of(scenario));
when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator")))
.thenReturn(ffmpegService.createSilenceFile(1000));
when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator")))
.thenReturn(ffmpegService.createSilenceFile(1000));

AtomicInteger chunkIndex = new AtomicInteger();
when(s3UploadService.uploadFile(anyString(), any(File.class))).thenAnswer(invocation -> {
File file = invocation.getArgument(1, File.class);
uploadedFiles.add(file);

String key = invocation.getArgument(0, String.class);
if (key.contains("/chunks/")) {
return "s3://scenario/chunks/" + chunkIndex.incrementAndGet() + ".mp3";
}
return "s3://scenario/merged/common.mp3";
});

scenarioAudioPipelineService.generateAndMergeAudioAsync(1L);

assertThat(scenario.getStatus()).isEqualTo(ScenarioStatus.PUBLISHED);
assertThat(firstScript.getAudioUrl()).isEqualTo("s3://scenario/chunks/1.mp3");
assertThat(secondScript.getAudioUrl()).isEqualTo("s3://scenario/chunks/2.mp3");
assertThat(scenario.getAudios()).containsEntry(AudioPathType.COMMON, "s3://scenario/merged/common.mp3");
assertThat(battle.getAudioDuration()).isNotNull().isGreaterThan(0);

verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator"));
verify(s3UploadService, times(3)).uploadFile(anyString(), any(File.class));
verify(battleRepository).save(battle);
verify(scenarioRepository).saveAndFlush(scenario);
}
}
Loading