fix:영상 클립 RAI 필터링 대응 (재시도 + 건너뛰기)

- VeoVideoService.waitAndSave() 반환값을 array로 변경 (실패 원인 포함)
- 클립 생성 실패 시 프롬프트 수정 후 자동 재시도
- 재시도 실패 시 해당 장면 건너뛰고 나머지로 합성 진행
- 성공 클립이 절반 미만일 때만 전체 실패 처리
- 건너뛴 장면의 나레이션/자막 자동 필터링

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 10:01:32 +09:00
parent cff3adfd6d
commit fb69877330
2 changed files with 92 additions and 18 deletions

View File

@@ -128,6 +128,8 @@ public function handle(
$totalCost += count($scenes) * 1.20; // Veo 비용 (Fast 기준 8초당 $1.20)
// 모든 영상 클립 완료 대기
$skippedScenes = [];
foreach ($operations as $sceneNum => $operationName) {
$video->updateProgress(
VideoGeneration::STATUS_GENERATING_CLIPS,
@@ -135,18 +137,62 @@ public function handle(
"영상 클립 생성 대기 중 ({$sceneNum}/" . count($scenes) . ')'
);
$clipPath = $veo->waitAndSave(
$result = $veo->waitAndSave(
$operationName,
"{$workDir}/clip_{$sceneNum}.mp4"
);
if (! $clipPath) {
$video->markFailed("장면 {$sceneNum} 영상 생성 실패 (타임아웃)");
if ($result['path']) {
$clipPaths[$sceneNum] = $result['path'];
return;
continue;
}
$clipPaths[$sceneNum] = $clipPath;
// 실패 시: RAI 필터링이면 프롬프트 수정 후 재시도
Log::warning("VideoGenerationJob: 장면 {$sceneNum} 실패, 재시도 중", [
'error' => $result['error'],
]);
$video->updateProgress(
VideoGeneration::STATUS_GENERATING_CLIPS,
30 + (int) (($sceneNum / count($scenes)) * 40),
"장면 {$sceneNum} 재시도 중 (프롬프트 수정)..."
);
$scene = $scenes[$sceneNum - 1] ?? null;
$retryPrompt = $this->makeSafePrompt($scene['visual_prompt'] ?? '');
$retryResult = $veo->generateClip($retryPrompt, $scene['duration'] ?? 8);
if ($retryResult) {
$retryWait = $veo->waitAndSave(
$retryResult['operationName'],
"{$workDir}/clip_{$sceneNum}.mp4"
);
if ($retryWait['path']) {
$clipPaths[$sceneNum] = $retryWait['path'];
Log::info("VideoGenerationJob: 장면 {$sceneNum} 재시도 성공");
continue;
}
}
// 재시도도 실패 → 건너뛰기
$skippedScenes[] = $sceneNum;
Log::warning("VideoGenerationJob: 장면 {$sceneNum} 최종 실패, 건너뛰기", [
'error' => $result['error'],
]);
}
// 성공한 클립이 절반 미만이면 전체 실패
if (count($clipPaths) < ceil(count($scenes) / 2)) {
$video->markFailed('영상 클립 생성 실패 (성공: ' . count($clipPaths) . '/' . count($scenes) . ')');
return;
}
if (! empty($skippedScenes)) {
Log::info('VideoGenerationJob: 건너뛴 장면', ['scenes' => $skippedScenes]);
}
// 장면 순서로 정렬
@@ -154,6 +200,18 @@ public function handle(
$video->update(['clips_data' => $clipPaths]);
// 건너뛴 장면 필터링: 나레이션/자막도 성공한 클립에 맞춤
$activeSceneNums = array_keys($clipPaths);
$activeScenes = array_filter($scenes, fn ($s) => in_array($s['scene_number'], $activeSceneNums));
$activeScenes = array_values($activeScenes);
$activeNarrationPaths = [];
foreach ($activeSceneNums as $num) {
if (isset($narrationPaths[$num - 1])) {
$activeNarrationPaths[] = $narrationPaths[$num - 1];
}
}
// === Step 4: BGM 생성/선택 ===
$video->updateProgress(VideoGeneration::STATUS_GENERATING_BGM, 75, 'BGM 준비 중...');
@@ -162,7 +220,7 @@ public function handle(
// BGM 파일이 없으면 무음 BGM 생성
if (! $bgmPath) {
$totalDuration = array_sum(array_column($scenes, 'duration'));
$totalDuration = array_sum(array_column($activeScenes, 'duration'));
$bgmPath = $bgm->generateSilence($totalDuration, "{$workDir}/bgm.mp3");
}
@@ -179,13 +237,13 @@ public function handle(
return;
}
// 5-2. 나레이션 결합
// 5-2. 나레이션 결합 (성공한 장면만)
$narrationConcatPath = "{$workDir}/narration_full.mp3";
$assembly->concatNarrations($narrationPaths, $scenes, $narrationConcatPath);
$assembly->concatNarrations($activeNarrationPaths, $activeScenes, $narrationConcatPath);
// 5-3. 자막 생성
// 5-3. 자막 생성 (성공한 장면만)
$subtitlePath = "{$workDir}/subtitles.ass";
$assembly->generateAssSubtitle($scenes, $subtitlePath);
$assembly->generateAssSubtitle($activeScenes, $subtitlePath);
// 5-4. 최종 합성
$video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 90, '최종 합성 중...');
@@ -206,10 +264,14 @@ public function handle(
}
// === 완료 ===
$stepMsg = empty($skippedScenes)
? '완료'
: '완료 (장면 ' . implode(',', $skippedScenes) . ' 건너뜀)';
$video->update([
'status' => VideoGeneration::STATUS_COMPLETED,
'progress' => 100,
'current_step' => '완료',
'current_step' => $stepMsg,
'output_path' => $finalPath,
'cost_usd' => $totalCost,
]);
@@ -238,4 +300,16 @@ public function failed(\Throwable $exception): void
$video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId);
$video?->markFailed('Job 실패: ' . $exception->getMessage());
}
/**
* RAI 필터링 방지를 위해 프롬프트를 안전하게 수정
*/
private function makeSafePrompt(string $prompt): string
{
// 사람 관련 키워드를 풍경/오브젝트 중심으로 대체
$prompt = preg_replace('/\b(woman|man|girl|boy|person|people|her|his|she|he)\b/i', 'subject', $prompt);
// 안전 키워드 추가
return 'Safe for all audiences. Professional stock footage style. ' . $prompt;
}
}