Skip to content

Commit d17c6fe

Browse files
committed
Merge remote-tracking branch 'origin/dev' into codex/main-from-dev
2 parents 750bf16 + cde2da3 commit d17c6fe

6 files changed

Lines changed: 213 additions & 858 deletions

File tree

โ€Žhs_err_pid172944.logโ€Ž

Lines changed: 0 additions & 281 deletions
This file was deleted.

โ€Žhs_err_pid189152.logโ€Ž

Lines changed: 0 additions & 281 deletions
This file was deleted.

โ€Žhs_err_pid192028.logโ€Ž

Lines changed: 0 additions & 281 deletions
This file was deleted.

โ€Žrailpack.jsonโ€Ž

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"packages": {
3+
"apt": ["ffmpeg"]
4+
}
5+
}

โ€Žsrc/main/java/com/swyp/picke/global/infra/media/service/FFmpegService.javaโ€Ž

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,49 @@ public class FFmpegService implements AudioProcessor {
1717
@Value("${media.ffprobe.path:ffprobe}")
1818
private String ffprobePath;
1919

20+
private String resolveExecutable(String configuredPath, String fallback) {
21+
if (configuredPath == null || configuredPath.isBlank()) {
22+
return resolveFallbackExecutable(fallback);
23+
}
24+
25+
File candidate = new File(configuredPath);
26+
if (isAbsolutePath(configuredPath) && !candidate.exists()) {
27+
log.warn("[FFmpeg] ์„ค์ •๋œ ์‹คํ–‰ ํŒŒ์ผ์„ ์ฐพ์ง€ ๋ชปํ•ด PATH ๋ช…๋ น์–ด๋กœ ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค: {}", configuredPath);
28+
return resolveFallbackExecutable(fallback);
29+
}
30+
31+
return configuredPath;
32+
}
33+
34+
private boolean isAbsolutePath(String path) {
35+
return new File(path).isAbsolute() || path.startsWith("/") || path.startsWith("\\");
36+
}
37+
38+
private String resolveFallbackExecutable(String fallback) {
39+
if (File.separatorChar != '\\') {
40+
return fallback;
41+
}
42+
43+
try {
44+
Process process = new ProcessBuilder("where.exe", fallback).start();
45+
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
46+
String resolved = reader.readLine();
47+
process.waitFor();
48+
if (resolved != null && !resolved.isBlank()) {
49+
return resolved.trim();
50+
}
51+
}
52+
} catch (Exception e) {
53+
log.debug("[FFmpeg] ๋Œ€์ฒด ์‹คํ–‰ ํŒŒ์ผ ํƒ์ƒ‰ ์‹คํŒจ: {}, ์›์ธ={}", fallback, e.getMessage());
54+
}
55+
56+
return fallback;
57+
}
58+
2059
public Integer getAudioDurationMs(File audioFile) throws Exception {
60+
String resolvedFfprobePath = resolveExecutable(ffprobePath, "ffprobe");
2161
String[] cmd = {
22-
ffprobePath, "-v", "error", "-show_entries", "format=duration",
62+
resolvedFfprobePath, "-v", "error", "-show_entries", "format=duration",
2363
"-of", "default=noprint_wrappers=1:nokey=1", audioFile.getAbsolutePath()
2464
};
2565
Process process = new ProcessBuilder(cmd).start();
@@ -31,11 +71,12 @@ public Integer getAudioDurationMs(File audioFile) throws Exception {
3171
}
3272

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

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

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

4687
public File mergeAudioFiles(List<File> audioFiles) throws Exception {
47-
if (audioFiles == null || audioFiles.isEmpty()) throw new IllegalArgumentException("๋ณ‘ํ•ฉํ•  ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.");
88+
if (audioFiles == null || audioFiles.isEmpty()) throw new IllegalArgumentException("merged audio file list is empty.");
89+
String resolvedFfmpegPath = resolveExecutable(ffmpegPath, "ffmpeg");
4890

49-
// FFmpeg์— ์ „๋‹ฌํ•  ๋ฆฌ์ŠคํŠธ ํŒŒ์ผ ์ƒ์„ฑ (UTF-8 ์ธ์ฝ”๋”ฉ)
5091
File listFile = File.createTempFile("ffmpeg_list_", ".txt");
5192
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(listFile), "UTF-8"))) {
5293
for (File file : audioFiles) {
53-
// ์œˆ๋„์šฐ์˜ 'C:\temp\file.mp3'๋ฅผ 'C:/temp/file.mp3'๋กœ ๋ณ€ํ™˜
5494
String safePath = file.getAbsolutePath().replace("\\", "/");
55-
// FFmpeg ๋ฌธ๋ฒ•์— ๋งž์ถฐ file '๊ฒฝ๋กœ' ํ˜•ํƒœ๋กœ ๊ธฐ๋ก
5695
writer.write("file '" + safePath + "'");
5796
writer.newLine();
5897
}
5998
}
6099

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

63-
// ๋ณ‘ํ•ฉ ๋ช…๋ น์–ด ๊ตฌ์„ฑ
64102
String[] cmd = {
65-
ffmpegPath, "-y", "-f", "concat", "-safe", "0",
103+
resolvedFfmpegPath, "-y", "-f", "concat", "-safe", "0",
66104
"-i", listFile.getAbsolutePath(),
67105
"-acodec", "libmp3lame", "-q:a", "2",
68106
output.getAbsolutePath()
@@ -74,24 +112,22 @@ public File mergeAudioFiles(List<File> audioFiles) throws Exception {
74112
}
75113

76114
private void executeCommand(String[] cmd) throws Exception {
77-
log.info("[FFmpeg] ๋ช…๋ น์–ด ์‹คํ–‰: {}", String.join(" ", cmd));
115+
log.info("[FFmpeg] ๋ช…๋ น ์‹คํ–‰: {}", String.join(" ", cmd));
78116
ProcessBuilder pb = new ProcessBuilder(cmd);
79-
pb.redirectErrorStream(true); // ์—๋Ÿฌ์™€ ์ถœ๋ ฅ์„ ํ•ฉ์นจ
117+
pb.redirectErrorStream(true);
80118
Process process = pb.start();
81119

82-
// ์‹ค์‹œ๊ฐ„ ๋กœ๊ทธ ์ถœ๋ ฅ (์—๋Ÿฌ ์›์ธ ํŒŒ์•…์šฉ)
83120
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), "UTF-8"))) {
84121
String line;
85122
while ((line = reader.readLine()) != null) {
86-
log.info("[FFmpeg Output] {}", line); // ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ๋กœ๊ทธ์—์„œ ๋ฐ”๋กœ ํ™•์ธ ๊ฐ€๋Šฅ
123+
log.info("[FFmpeg ์ถœ๋ ฅ] {}", line);
87124
}
88125
}
89126

90127
int exitCode = process.waitFor();
91-
92128
if (exitCode != 0) {
93-
log.error("[FFmpeg] ํ”„๋กœ์„ธ์Šค ์˜ค๋ฅ˜ ๋ฐœ์ƒ! Exit Code: {}", exitCode);
129+
log.error("[FFmpeg] ํ”„๋กœ์„ธ์Šค ์˜ค๋ฅ˜ ๋ฐœ์ƒ. Exit Code: {}", exitCode);
94130
throw new RuntimeException("FFmpeg ํ”„๋กœ์„ธ์Šค ์˜ค๋ฅ˜ ๋ฐœ์ƒ. Exit Code: " + exitCode);
95131
}
96132
}
97-
}
133+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package com.swyp.picke.domain.scenario.service;
2+
3+
import com.swyp.picke.domain.battle.entity.Battle;
4+
import com.swyp.picke.domain.battle.enums.BattleCreatorType;
5+
import com.swyp.picke.domain.battle.enums.BattleStatus;
6+
import com.swyp.picke.domain.battle.repository.BattleRepository;
7+
import com.swyp.picke.domain.scenario.entity.Scenario;
8+
import com.swyp.picke.domain.scenario.entity.ScenarioNode;
9+
import com.swyp.picke.domain.scenario.entity.Script;
10+
import com.swyp.picke.domain.scenario.enums.AudioPathType;
11+
import com.swyp.picke.domain.scenario.enums.CreatorType;
12+
import com.swyp.picke.domain.scenario.enums.ScenarioStatus;
13+
import com.swyp.picke.domain.scenario.enums.SpeakerType;
14+
import com.swyp.picke.domain.scenario.repository.ScenarioRepository;
15+
import com.swyp.picke.global.infra.media.service.FFmpegService;
16+
import com.swyp.picke.global.infra.s3.service.S3UploadService;
17+
import com.swyp.picke.global.infra.tts.service.TtsService;
18+
import org.junit.jupiter.api.AfterEach;
19+
import org.junit.jupiter.api.BeforeEach;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.extension.ExtendWith;
22+
import org.mockito.Mock;
23+
import org.mockito.junit.jupiter.MockitoExtension;
24+
import org.springframework.test.util.ReflectionTestUtils;
25+
26+
import java.io.File;
27+
import java.time.LocalDate;
28+
import java.util.ArrayList;
29+
import java.util.List;
30+
import java.util.Map;
31+
import java.util.Optional;
32+
import java.util.concurrent.atomic.AtomicInteger;
33+
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
import static org.mockito.ArgumentMatchers.any;
36+
import static org.mockito.ArgumentMatchers.anyString;
37+
import static org.mockito.ArgumentMatchers.eq;
38+
import static org.mockito.Mockito.times;
39+
import static org.mockito.Mockito.verify;
40+
import static org.mockito.Mockito.when;
41+
42+
@ExtendWith(MockitoExtension.class)
43+
class ScenarioAudioPipelineServiceTest {
44+
45+
@Mock
46+
private ScenarioRepository scenarioRepository;
47+
48+
@Mock
49+
private TtsService ttsService;
50+
51+
@Mock
52+
private S3UploadService s3UploadService;
53+
54+
@Mock
55+
private BattleRepository battleRepository;
56+
57+
private FFmpegService ffmpegService;
58+
private ScenarioAudioPipelineService scenarioAudioPipelineService;
59+
private final List<File> uploadedFiles = new ArrayList<>();
60+
61+
@BeforeEach
62+
void setUp() {
63+
ffmpegService = new FFmpegService();
64+
ReflectionTestUtils.setField(ffmpegService, "ffmpegPath", "/usr/bin/ffmpeg");
65+
ReflectionTestUtils.setField(ffmpegService, "ffprobePath", "/usr/bin/ffprobe");
66+
67+
scenarioAudioPipelineService = new ScenarioAudioPipelineService(
68+
scenarioRepository,
69+
ttsService,
70+
ffmpegService,
71+
s3UploadService,
72+
battleRepository
73+
);
74+
}
75+
76+
@AfterEach
77+
void tearDown() {
78+
for (File file : uploadedFiles) {
79+
if (file != null && file.exists()) {
80+
file.delete();
81+
}
82+
}
83+
}
84+
85+
@Test
86+
void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() throws Exception {
87+
Battle battle = Battle.builder()
88+
.title("scenario battle")
89+
.summary("summary")
90+
.description("description")
91+
.targetDate(LocalDate.now())
92+
.audioDuration(0)
93+
.status(BattleStatus.PENDING)
94+
.creatorType(BattleCreatorType.ADMIN)
95+
.build();
96+
97+
Scenario scenario = Scenario.builder()
98+
.battle(battle)
99+
.isInteractive(false)
100+
.status(ScenarioStatus.PENDING)
101+
.creatorType(CreatorType.ADMIN)
102+
.build();
103+
104+
ScenarioNode node = ScenarioNode.builder()
105+
.nodeName("START")
106+
.isStartNode(true)
107+
.audioDuration(0)
108+
.build();
109+
110+
Script firstScript = Script.builder()
111+
.speakerType(SpeakerType.NARRATOR)
112+
.speakerName("Narrator")
113+
.text("First line")
114+
.build();
115+
Script secondScript = Script.builder()
116+
.speakerType(SpeakerType.NARRATOR)
117+
.speakerName("Narrator")
118+
.text("Second line")
119+
.build();
120+
121+
node.addScript(firstScript);
122+
node.addScript(secondScript);
123+
scenario.addNode(node);
124+
scenario.replaceVoiceSettings(Map.of(SpeakerType.NARRATOR, "voice-narrator"));
125+
126+
when(scenarioRepository.findById(1L)).thenReturn(Optional.of(scenario));
127+
when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator")))
128+
.thenReturn(ffmpegService.createSilenceFile(1000));
129+
when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator")))
130+
.thenReturn(ffmpegService.createSilenceFile(1000));
131+
132+
AtomicInteger chunkIndex = new AtomicInteger();
133+
when(s3UploadService.uploadFile(anyString(), any(File.class))).thenAnswer(invocation -> {
134+
File file = invocation.getArgument(1, File.class);
135+
uploadedFiles.add(file);
136+
137+
String key = invocation.getArgument(0, String.class);
138+
if (key.contains("/chunks/")) {
139+
return "s3://scenario/chunks/" + chunkIndex.incrementAndGet() + ".mp3";
140+
}
141+
return "s3://scenario/merged/common.mp3";
142+
});
143+
144+
scenarioAudioPipelineService.generateAndMergeAudioAsync(1L);
145+
146+
assertThat(scenario.getStatus()).isEqualTo(ScenarioStatus.PUBLISHED);
147+
assertThat(firstScript.getAudioUrl()).isEqualTo("s3://scenario/chunks/1.mp3");
148+
assertThat(secondScript.getAudioUrl()).isEqualTo("s3://scenario/chunks/2.mp3");
149+
assertThat(scenario.getAudios()).containsEntry(AudioPathType.COMMON, "s3://scenario/merged/common.mp3");
150+
assertThat(battle.getAudioDuration()).isNotNull().isGreaterThan(0);
151+
152+
verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator"));
153+
verify(s3UploadService, times(3)).uploadFile(anyString(), any(File.class));
154+
verify(battleRepository).save(battle);
155+
verify(scenarioRepository).saveAndFlush(scenario);
156+
}
157+
}

0 commit comments

Comments
ย (0)