- GeminiScriptService: 트렌딩 제목/시나리오 생성 - VeoVideoService: Veo 3.1 영상 클립 생성 - TtsService: Google TTS 나레이션 생성 - BgmService: 분위기별 BGM 선택 - VideoAssemblyService: FFmpeg 영상 합성 - VideoGenerationJob: 백그라운드 처리 - Veo3Controller: API 엔드포인트 - React 프론트엔드 (5단계 위저드) - GoogleCloudService.getAccessToken() public 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
183 lines
5.8 KiB
PHP
183 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Video;
|
|
|
|
use App\Services\GoogleCloudService;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
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";
|
|
|
|
$response = Http::withToken($token)
|
|
->timeout(60)
|
|
->post($url, [
|
|
'instances' => [
|
|
[
|
|
'prompt' => $prompt,
|
|
],
|
|
],
|
|
'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($prompt, 0, 100),
|
|
]);
|
|
|
|
return ['operationName' => $operationName];
|
|
} catch (\Exception $e) {
|
|
Log::error('VeoVideoService: 영상 생성 예외', ['error' => $e->getMessage()]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 비동기 작업 상태 확인
|
|
*
|
|
* @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 {
|
|
$url = "https://{$this->location}-aiplatform.googleapis.com/v1/{$operationName}";
|
|
|
|
$response = Http::withToken($token)->timeout(30)->get($url);
|
|
|
|
if (! $response->successful()) {
|
|
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'];
|
|
}
|
|
|
|
// 영상 데이터 추출
|
|
$predictions = $data['response']['predictions'] ?? [];
|
|
if (empty($predictions)) {
|
|
return ['done' => true, 'video' => null, 'error' => '영상 데이터 없음'];
|
|
}
|
|
|
|
$videoBase64 = $predictions[0]['bytesBase64Encoded'] ?? null;
|
|
|
|
return ['done' => true, 'video' => $videoBase64, 'error' => null];
|
|
} catch (\Exception $e) {
|
|
Log::error('VeoVideoService: 상태 확인 예외', ['error' => $e->getMessage()]);
|
|
|
|
return ['done' => false, 'video' => null, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 영상 생성 완료까지 폴링 대기
|
|
*
|
|
* @return string|null 저장된 파일 경로 또는 null
|
|
*/
|
|
public function waitAndSave(string $operationName, string $savePath, int $maxAttempts = 120): ?string
|
|
{
|
|
for ($i = 0; $i < $maxAttempts; $i++) {
|
|
sleep(10);
|
|
|
|
$result = $this->checkOperation($operationName);
|
|
|
|
if ($result['error'] && $result['done']) {
|
|
Log::error('VeoVideoService: 영상 생성 실패', ['error' => $result['error']]);
|
|
|
|
return null;
|
|
}
|
|
|
|
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 $savePath;
|
|
}
|
|
}
|
|
|
|
Log::error('VeoVideoService: 영상 생성 타임아웃', [
|
|
'operationName' => $operationName,
|
|
'attempts' => $maxAttempts,
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|