2026-02-15 08:46:28 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Video;
|
|
|
|
|
|
|
|
|
|
use App\Services\GoogleCloudService;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
|
|
|
|
|
class VeoVideoService
|
|
|
|
|
{
|
|
|
|
|
private GoogleCloudService $googleCloud;
|
|
|
|
|
|
|
|
|
|
private string $projectId;
|
|
|
|
|
|
|
|
|
|
private string $location;
|
|
|
|
|
|
|
|
|
|
public function __construct(GoogleCloudService $googleCloud)
|
|
|
|
|
{
|
|
|
|
|
$this->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";
|
|
|
|
|
|
2026-02-15 10:50:51 +09:00
|
|
|
// 한국인 여성 등장인물 프롬프트 프리픽스 추가
|
|
|
|
|
$characterPrefix = 'Featuring a young Korean woman in her 20s with natural black hair. ';
|
2026-02-25 11:45:01 +09:00
|
|
|
$fullPrompt = $characterPrefix.$prompt;
|
2026-02-15 10:50:51 +09:00
|
|
|
|
2026-02-15 08:46:28 +09:00
|
|
|
$response = Http::withToken($token)
|
|
|
|
|
->timeout(60)
|
|
|
|
|
->post($url, [
|
|
|
|
|
'instances' => [
|
|
|
|
|
[
|
2026-02-15 10:50:51 +09:00
|
|
|
'prompt' => $fullPrompt,
|
2026-02-15 08:46:28 +09:00
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'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,
|
2026-02-15 10:50:51 +09:00
|
|
|
'prompt' => substr($fullPrompt, 0, 150),
|
2026-02-15 08:46:28 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return ['operationName' => $operationName];
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::error('VeoVideoService: 영상 생성 예외', ['error' => $e->getMessage()]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-15 09:44:23 +09:00
|
|
|
* 비동기 작업 상태 확인 (fetchPredictOperation 엔드포인트 사용)
|
2026-02-15 08:46:28 +09:00
|
|
|
*
|
|
|
|
|
* @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 {
|
2026-02-15 09:44:23 +09:00
|
|
|
// 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";
|
2026-02-15 08:46:28 +09:00
|
|
|
|
2026-02-15 09:44:23 +09:00
|
|
|
$response = Http::withToken($token)->timeout(30)->post($url, [
|
|
|
|
|
'operationName' => $operationName,
|
|
|
|
|
]);
|
2026-02-15 08:46:28 +09:00
|
|
|
|
|
|
|
|
if (! $response->successful()) {
|
2026-02-15 09:44:23 +09:00
|
|
|
Log::warning('VeoVideoService: 상태 확인 HTTP 오류', [
|
|
|
|
|
'status' => $response->status(),
|
|
|
|
|
'body' => substr($response->body(), 0, 500),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-25 11:45:01 +09:00
|
|
|
return ['done' => false, 'video' => null, 'error' => 'HTTP '.$response->status()];
|
2026-02-15 08:46:28 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 09:44:23 +09:00
|
|
|
// 영상 데이터 추출 (response.videos[].bytesBase64Encoded)
|
|
|
|
|
$videos = $data['response']['videos'] ?? [];
|
|
|
|
|
if (empty($videos)) {
|
|
|
|
|
return ['done' => true, 'video' => null, 'error' => '영상 데이터 없음 (RAI 필터링 가능)'];
|
2026-02-15 08:46:28 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 09:44:23 +09:00
|
|
|
$videoBase64 = $videos[0]['bytesBase64Encoded'] ?? null;
|
2026-02-15 08:46:28 +09:00
|
|
|
|
2026-02-15 09:44:23 +09:00
|
|
|
return ['done' => true, 'video' => $videoBase64, 'error' => $videoBase64 ? null : 'Base64 데이터 없음'];
|
2026-02-15 08:46:28 +09:00
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
Log::error('VeoVideoService: 상태 확인 예외', ['error' => $e->getMessage()]);
|
|
|
|
|
|
|
|
|
|
return ['done' => false, 'video' => null, 'error' => $e->getMessage()];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 09:44:23 +09:00
|
|
|
/**
|
|
|
|
|
* 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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 08:46:28 +09:00
|
|
|
/**
|
|
|
|
|
* 영상 생성 완료까지 폴링 대기
|
|
|
|
|
*
|
2026-02-15 10:01:32 +09:00
|
|
|
* @return array ['path' => string|null, 'error' => string|null]
|
2026-02-15 08:46:28 +09:00
|
|
|
*/
|
2026-02-15 10:01:32 +09:00
|
|
|
public function waitAndSave(string $operationName, string $savePath, int $maxAttempts = 120): array
|
2026-02-15 08:46:28 +09:00
|
|
|
{
|
2026-02-15 09:44:23 +09:00
|
|
|
$consecutiveErrors = 0;
|
|
|
|
|
|
2026-02-15 08:46:28 +09:00
|
|
|
for ($i = 0; $i < $maxAttempts; $i++) {
|
|
|
|
|
sleep(10);
|
|
|
|
|
|
|
|
|
|
$result = $this->checkOperation($operationName);
|
|
|
|
|
|
2026-02-15 10:01:32 +09:00
|
|
|
// 완료 + 에러 → 생성 실패 (원인 반환)
|
2026-02-15 08:46:28 +09:00
|
|
|
if ($result['error'] && $result['done']) {
|
|
|
|
|
Log::error('VeoVideoService: 영상 생성 실패', ['error' => $result['error']]);
|
|
|
|
|
|
2026-02-15 10:01:32 +09:00
|
|
|
return ['path' => null, 'error' => $result['error']];
|
2026-02-15 08:46:28 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 09:44:23 +09:00
|
|
|
// 미완료 + 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,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-25 11:45:01 +09:00
|
|
|
return ['path' => null, 'error' => '연속 폴링 실패: '.$result['error']];
|
2026-02-15 09:44:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 에러 없으면 카운터 초기화
|
|
|
|
|
$consecutiveErrors = 0;
|
|
|
|
|
|
|
|
|
|
// 완료 + 영상 데이터 있음 → 저장
|
2026-02-15 08:46:28 +09:00
|
|
|
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),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-15 10:01:32 +09:00
|
|
|
return ['path' => $savePath, 'error' => null];
|
2026-02-15 08:46:28 +09:00
|
|
|
}
|
2026-02-15 09:44:23 +09:00
|
|
|
|
|
|
|
|
// 진행 중 로그 (30초 간격)
|
|
|
|
|
if (($i + 1) % 3 === 0) {
|
|
|
|
|
Log::info('VeoVideoService: 영상 생성 대기 중', [
|
|
|
|
|
'attempt' => $i + 1,
|
2026-02-25 11:45:01 +09:00
|
|
|
'elapsed' => ($i + 1) * 10 .'초',
|
2026-02-15 09:44:23 +09:00
|
|
|
]);
|
|
|
|
|
}
|
2026-02-15 08:46:28 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Log::error('VeoVideoService: 영상 생성 타임아웃', [
|
|
|
|
|
'operationName' => $operationName,
|
|
|
|
|
'attempts' => $maxAttempts,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-25 11:45:01 +09:00
|
|
|
return ['path' => null, 'error' => '타임아웃 ('.($maxAttempts * 10).'초)'];
|
2026-02-15 08:46:28 +09:00
|
|
|
}
|
|
|
|
|
}
|