Files
sam-manage/app/Services/Video/VeoVideoService.php
2026-02-25 11:45:01 +09:00

243 lines
8.5 KiB
PHP

<?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";
// 한국인 여성 등장인물 프롬프트 프리픽스 추가
$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).'초)'];
}
}