googleCloud = $googleCloud; $this->projectId = config('services.vertex_ai.project_id', 'codebridge-chatbot'); $this->location = config('services.vertex_ai.location', 'us-central1'); } /** * 프롬프트 → 영상 클립 생성 요청 (비동기) * * @return array|null ['operationName' => '...'] or null */ public function generateClip(string $prompt, int $duration = 8): ?array { $token = $this->googleCloud->getAccessToken(); if (! $token) { Log::error('VeoVideoService: 액세스 토큰 획득 실패'); return null; } try { $url = "https://{$this->location}-aiplatform.googleapis.com/v1/projects/{$this->projectId}/locations/{$this->location}/publishers/google/models/veo-3.1-generate-preview:predictLongRunning"; // 한국인 여성 등장인물 프롬프트 프리픽스 추가 $characterPrefix = 'Featuring a young Korean woman in her 20s with natural black hair. '; $fullPrompt = $characterPrefix.$prompt; $response = Http::withToken($token) ->timeout(60) ->post($url, [ 'instances' => [ [ 'prompt' => $fullPrompt, ], ], 'parameters' => [ 'aspectRatio' => '9:16', 'duration' => "{$duration}s", 'resolution' => '720p', 'personGeneration' => 'allow_adult', 'generateAudio' => false, 'sampleCount' => 1, ], ]); if (! $response->successful()) { Log::error('VeoVideoService: 영상 생성 요청 실패', [ 'status' => $response->status(), 'body' => $response->body(), ]); return null; } $data = $response->json(); $operationName = $data['name'] ?? null; if (! $operationName) { Log::error('VeoVideoService: Operation name 없음', ['response' => $data]); return null; } Log::info('VeoVideoService: 영상 생성 요청 성공', [ 'operationName' => $operationName, 'prompt' => substr($fullPrompt, 0, 150), ]); return ['operationName' => $operationName]; } catch (\Exception $e) { Log::error('VeoVideoService: 영상 생성 예외', ['error' => $e->getMessage()]); return null; } } /** * 비동기 작업 상태 확인 (fetchPredictOperation 엔드포인트 사용) * * @return array ['done' => bool, 'video' => base64|null, 'error' => string|null] */ public function checkOperation(string $operationName): array { $token = $this->googleCloud->getAccessToken(); if (! $token) { return ['done' => false, 'video' => null, 'error' => '토큰 획득 실패']; } try { // Veo 전용: fetchPredictOperation POST 엔드포인트 사용 $model = $this->getModelFromOperation($operationName); $url = "https://{$this->location}-aiplatform.googleapis.com/v1/projects/{$this->projectId}/locations/{$this->location}/publishers/google/models/{$model}:fetchPredictOperation"; $response = Http::withToken($token)->timeout(30)->post($url, [ 'operationName' => $operationName, ]); if (! $response->successful()) { Log::warning('VeoVideoService: 상태 확인 HTTP 오류', [ 'status' => $response->status(), 'body' => substr($response->body(), 0, 500), ]); return ['done' => false, 'video' => null, 'error' => 'HTTP '.$response->status()]; } $data = $response->json(); if (! ($data['done'] ?? false)) { return ['done' => false, 'video' => null, 'error' => null]; } if (isset($data['error'])) { return ['done' => true, 'video' => null, 'error' => $data['error']['message'] ?? 'Unknown error']; } // 영상 데이터 추출 (response.videos[].bytesBase64Encoded) $videos = $data['response']['videos'] ?? []; if (empty($videos)) { return ['done' => true, 'video' => null, 'error' => '영상 데이터 없음 (RAI 필터링 가능)']; } $videoBase64 = $videos[0]['bytesBase64Encoded'] ?? null; return ['done' => true, 'video' => $videoBase64, 'error' => $videoBase64 ? null : 'Base64 데이터 없음']; } catch (\Exception $e) { Log::error('VeoVideoService: 상태 확인 예외', ['error' => $e->getMessage()]); return ['done' => false, 'video' => null, 'error' => $e->getMessage()]; } } /** * Operation 이름에서 모델명 추출 */ private function getModelFromOperation(string $operationName): string { // "projects/.../models/veo-3.1-generate-preview/operations/..." → "veo-3.1-generate-preview" if (preg_match('/models\/([^\/]+)\/operations/', $operationName, $matches)) { return $matches[1]; } return 'veo-3.1-generate-preview'; } /** * 영상 생성 완료까지 폴링 대기 * * @return array ['path' => string|null, 'error' => string|null] */ public function waitAndSave(string $operationName, string $savePath, int $maxAttempts = 120): array { $consecutiveErrors = 0; for ($i = 0; $i < $maxAttempts; $i++) { sleep(10); $result = $this->checkOperation($operationName); // 완료 + 에러 → 생성 실패 (원인 반환) if ($result['error'] && $result['done']) { Log::error('VeoVideoService: 영상 생성 실패', ['error' => $result['error']]); return ['path' => null, 'error' => $result['error']]; } // 미완료 + HTTP 에러 → 연속 에러 카운트 if ($result['error'] && ! $result['done']) { $consecutiveErrors++; Log::warning('VeoVideoService: 폴링 오류', [ 'attempt' => $i + 1, 'error' => $result['error'], 'consecutiveErrors' => $consecutiveErrors, ]); if ($consecutiveErrors >= 5) { Log::error('VeoVideoService: 연속 5회 폴링 실패로 중단', [ 'operationName' => $operationName, ]); return ['path' => null, 'error' => '연속 폴링 실패: '.$result['error']]; } continue; } // 에러 없으면 카운터 초기화 $consecutiveErrors = 0; // 완료 + 영상 데이터 있음 → 저장 if ($result['done'] && $result['video']) { $videoData = base64_decode($result['video']); $dir = dirname($savePath); if (! is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents($savePath, $videoData); Log::info('VeoVideoService: 영상 저장 완료', [ 'path' => $savePath, 'size' => strlen($videoData), ]); return ['path' => $savePath, 'error' => null]; } // 진행 중 로그 (30초 간격) if (($i + 1) % 3 === 0) { Log::info('VeoVideoService: 영상 생성 대기 중', [ 'attempt' => $i + 1, 'elapsed' => ($i + 1) * 10 .'초', ]); } } Log::error('VeoVideoService: 영상 생성 타임아웃', [ 'operationName' => $operationName, 'attempts' => $maxAttempts, ]); return ['path' => null, 'error' => '타임아웃 ('.($maxAttempts * 10).'초)']; } }