feat:YouTube Shorts AI 자동 생성 시스템 구현 (Veo 3.1 + Gemini)

- 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>
This commit is contained in:
김보곤
2026-02-15 08:46:28 +09:00
parent 9714dedd04
commit 6ab93aedd2
12 changed files with 2199 additions and 1 deletions

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Http\Controllers\Video;
use App\Http\Controllers\Controller;
use App\Jobs\VideoGenerationJob;
use App\Models\VideoGeneration;
use App\Services\Video\GeminiScriptService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class Veo3Controller extends Controller
{
public function __construct(
private readonly GeminiScriptService $geminiService
) {}
/**
* 메인 페이지
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('video.veo3.index'));
}
return view('video.veo3.index');
}
/**
* 키워드 → 제목 후보 생성
*/
public function generateTitles(Request $request): JsonResponse
{
$request->validate([
'keyword' => 'required|string|max:100',
]);
$keyword = $request->input('keyword');
$titles = $this->geminiService->generateTrendingTitles($keyword);
if (empty($titles)) {
return response()->json([
'success' => false,
'message' => '제목 생성에 실패했습니다. API 키를 확인해주세요.',
], 500);
}
// DB에 pending 레코드 생성
$video = VideoGeneration::create([
'tenant_id' => auth()->user()->tenant_id,
'user_id' => auth()->id(),
'keyword' => $keyword,
'status' => VideoGeneration::STATUS_TITLES_GENERATED,
]);
return response()->json([
'success' => true,
'video_id' => $video->id,
'titles' => $titles,
]);
}
/**
* 시나리오 생성 (제목 선택 후)
*/
public function generateScenario(Request $request): JsonResponse
{
$request->validate([
'video_id' => 'required|integer',
'title' => 'required|string|max:500',
]);
$video = VideoGeneration::findOrFail($request->input('video_id'));
$title = $request->input('title');
$scenario = $this->geminiService->generateScenario($title, $video->keyword);
if (empty($scenario) || empty($scenario['scenes'])) {
return response()->json([
'success' => false,
'message' => '시나리오 생성에 실패했습니다.',
], 500);
}
$video->update([
'title' => $title,
'scenario' => $scenario,
'status' => VideoGeneration::STATUS_SCENARIO_READY,
]);
return response()->json([
'success' => true,
'video_id' => $video->id,
'scenario' => $scenario,
]);
}
/**
* 영상 생성 시작 (Job 디스패치)
*/
public function generate(Request $request): JsonResponse
{
$request->validate([
'video_id' => 'required|integer',
'scenario' => 'nullable|array',
]);
$video = VideoGeneration::findOrFail($request->input('video_id'));
// 이미 생성 중이면 거부
if (in_array($video->status, [
VideoGeneration::STATUS_GENERATING_TTS,
VideoGeneration::STATUS_GENERATING_CLIPS,
VideoGeneration::STATUS_GENERATING_BGM,
VideoGeneration::STATUS_ASSEMBLING,
])) {
return response()->json([
'success' => false,
'message' => '이미 영상을 생성 중입니다.',
], 409);
}
$customScenario = $request->input('scenario');
if ($customScenario) {
$video->update(['scenario' => $customScenario]);
}
$video->updateProgress(VideoGeneration::STATUS_PENDING, 0, '대기 중...');
VideoGenerationJob::dispatch($video->id, $video->title, $customScenario ?? $video->scenario);
return response()->json([
'success' => true,
'video_id' => $video->id,
'message' => '영상 생성이 시작되었습니다.',
]);
}
/**
* 진행 상태 폴링
*/
public function status(int $id): JsonResponse
{
$video = VideoGeneration::findOrFail($id);
return response()->json([
'id' => $video->id,
'status' => $video->status,
'progress' => $video->progress,
'current_step' => $video->current_step,
'error_message' => $video->error_message,
'output_path' => $video->output_path ? true : false,
'cost_usd' => $video->cost_usd,
]);
}
/**
* 완성 영상 다운로드
*/
public function download(int $id): BinaryFileResponse|JsonResponse
{
$video = VideoGeneration::findOrFail($id);
if ($video->status !== VideoGeneration::STATUS_COMPLETED || ! $video->output_path) {
return response()->json(['message' => '아직 영상이 완성되지 않았습니다.'], 404);
}
if (! file_exists($video->output_path)) {
return response()->json(['message' => '영상 파일을 찾을 수 없습니다.'], 404);
}
$filename = "shorts_{$video->keyword}_{$video->id}.mp4";
$filename = preg_replace('/[^a-zA-Z0-9가-힣_\-.]/', '_', $filename);
return response()->download($video->output_path, $filename, [
'Content-Type' => 'video/mp4',
]);
}
/**
* 영상 미리보기 (스트리밍)
*/
public function preview(int $id): Response|JsonResponse
{
$video = VideoGeneration::findOrFail($id);
if (! $video->output_path || ! file_exists($video->output_path)) {
return response()->json(['message' => '영상 파일을 찾을 수 없습니다.'], 404);
}
$path = $video->output_path;
$size = filesize($path);
return response()->file($path, [
'Content-Type' => 'video/mp4',
'Content-Length' => $size,
'Accept-Ranges' => 'bytes',
]);
}
/**
* 생성 이력 목록
*/
public function history(Request $request): JsonResponse
{
$videos = VideoGeneration::where('user_id', auth()->id())
->orderByDesc('created_at')
->limit(50)
->get(['id', 'keyword', 'title', 'status', 'progress', 'cost_usd', 'created_at']);
return response()->json([
'success' => true,
'data' => $videos,
]);
}
}

View File

@@ -0,0 +1,241 @@
<?php
namespace App\Jobs;
use App\Models\VideoGeneration;
use App\Services\Video\BgmService;
use App\Services\Video\GeminiScriptService;
use App\Services\Video\TtsService;
use App\Services\Video\VeoVideoService;
use App\Services\Video\VideoAssemblyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class VideoGenerationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 1800; // 30분
public int $tries = 1;
private int $videoGenerationId;
private ?string $selectedTitle;
private ?array $customScenario;
public function __construct(int $videoGenerationId, ?string $selectedTitle = null, ?array $customScenario = null)
{
$this->videoGenerationId = $videoGenerationId;
$this->selectedTitle = $selectedTitle;
$this->customScenario = $customScenario;
}
public function handle(
GeminiScriptService $gemini,
VeoVideoService $veo,
TtsService $tts,
BgmService $bgm,
VideoAssemblyService $assembly
): void {
$video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId);
if (! $video) {
Log::error('VideoGenerationJob: 레코드를 찾을 수 없음', ['id' => $this->videoGenerationId]);
return;
}
$workDir = storage_path("app/video_gen/{$video->id}");
if (! is_dir($workDir)) {
mkdir($workDir, 0755, true);
}
$totalCost = 0.0;
try {
// === Step 1: 시나리오 생성 ===
$video->updateProgress(VideoGeneration::STATUS_SCENARIO_READY, 5, '시나리오 생성 중...');
if ($this->customScenario) {
$scenario = $this->customScenario;
} else {
$title = $this->selectedTitle ?? $video->title;
$scenario = $gemini->generateScenario($title, $video->keyword);
if (empty($scenario) || empty($scenario['scenes'])) {
$video->markFailed('시나리오 생성 실패');
return;
}
}
$video->update([
'scenario' => $scenario,
'title' => $scenario['title'] ?? $video->title,
]);
$scenes = $scenario['scenes'] ?? [];
$totalCost += 0.001; // Gemini 비용
// === Step 2: 나레이션 생성 ===
$video->updateProgress(VideoGeneration::STATUS_GENERATING_TTS, 15, '나레이션 생성 중...');
$narrationPaths = $tts->synthesizeScenes($scenes, $workDir);
if (empty($narrationPaths)) {
$video->markFailed('나레이션 생성 실패');
return;
}
$totalCost += 0.01; // TTS 비용
// === Step 3: 영상 클립 생성 ===
$video->updateProgress(VideoGeneration::STATUS_GENERATING_CLIPS, 20, '영상 클립 생성 요청 중...');
$clipPaths = [];
$operations = [];
// 모든 장면의 영상 생성 요청 (비동기)
foreach ($scenes as $scene) {
$sceneNum = $scene['scene_number'];
$prompt = $scene['visual_prompt'] ?? '';
$duration = $scene['duration'] ?? 8;
$video->updateProgress(
VideoGeneration::STATUS_GENERATING_CLIPS,
20 + (int) (($sceneNum / count($scenes)) * 10),
"영상 클립 생성 요청 중 ({$sceneNum}/" . count($scenes) . ')'
);
$result = $veo->generateClip($prompt, $duration);
if (! $result) {
$video->markFailed("장면 {$sceneNum} 영상 생성 요청 실패");
return;
}
$operations[$sceneNum] = $result['operationName'];
}
$totalCost += count($scenes) * 1.20; // Veo 비용 (Fast 기준 8초당 $1.20)
// 모든 영상 클립 완료 대기
foreach ($operations as $sceneNum => $operationName) {
$video->updateProgress(
VideoGeneration::STATUS_GENERATING_CLIPS,
30 + (int) (($sceneNum / count($scenes)) * 40),
"영상 클립 생성 대기 중 ({$sceneNum}/" . count($scenes) . ')'
);
$clipPath = $veo->waitAndSave(
$operationName,
"{$workDir}/clip_{$sceneNum}.mp4"
);
if (! $clipPath) {
$video->markFailed("장면 {$sceneNum} 영상 생성 실패 (타임아웃)");
return;
}
$clipPaths[$sceneNum] = $clipPath;
}
// 장면 순서로 정렬
ksort($clipPaths);
$video->update(['clips_data' => $clipPaths]);
// === Step 4: BGM 생성/선택 ===
$video->updateProgress(VideoGeneration::STATUS_GENERATING_BGM, 75, 'BGM 준비 중...');
$bgmMood = $scenario['bgm_mood'] ?? 'upbeat';
$bgmPath = $bgm->select($bgmMood, "{$workDir}/bgm.mp3");
// BGM 파일이 없으면 무음 BGM 생성
if (! $bgmPath) {
$totalDuration = array_sum(array_column($scenes, 'duration'));
$bgmPath = $bgm->generateSilence($totalDuration, "{$workDir}/bgm.mp3");
}
// === Step 5: 최종 합성 ===
$video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 80, '영상 합성 중...');
// 5-1. 클립 결합
$concatPath = "{$workDir}/concat.mp4";
$concatResult = $assembly->concatClips(array_values($clipPaths), $concatPath);
if (! $concatResult) {
$video->markFailed('영상 클립 결합 실패');
return;
}
// 5-2. 나레이션 결합
$narrationConcatPath = "{$workDir}/narration_full.mp3";
$assembly->concatNarrations($narrationPaths, $scenes, $narrationConcatPath);
// 5-3. 자막 생성
$subtitlePath = "{$workDir}/subtitles.ass";
$assembly->generateAssSubtitle($scenes, $subtitlePath);
// 5-4. 최종 합성
$video->updateProgress(VideoGeneration::STATUS_ASSEMBLING, 90, '최종 합성 중...');
$finalPath = "{$workDir}/final_{$video->id}.mp4";
$result = $assembly->assemble(
$concatPath,
file_exists($narrationConcatPath) ? $narrationConcatPath : null,
$bgmPath,
$subtitlePath,
$finalPath
);
if (! $result) {
$video->markFailed('최종 영상 합성 실패');
return;
}
// === 완료 ===
$video->update([
'status' => VideoGeneration::STATUS_COMPLETED,
'progress' => 100,
'current_step' => '완료',
'output_path' => $finalPath,
'cost_usd' => $totalCost,
]);
// 중간 파일 정리
$assembly->cleanup($workDir);
Log::info('VideoGenerationJob: 영상 생성 완료', [
'id' => $video->id,
'output' => $finalPath,
'cost' => $totalCost,
]);
} catch (\Exception $e) {
Log::error('VideoGenerationJob: 예외 발생', [
'id' => $video->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$video->markFailed($e->getMessage());
}
}
public function failed(\Throwable $exception): void
{
$video = VideoGeneration::withoutGlobalScopes()->find($this->videoGenerationId);
$video?->markFailed('Job 실패: ' . $exception->getMessage());
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Model;
class VideoGeneration extends Model
{
protected $fillable = [
'tenant_id',
'user_id',
'keyword',
'title',
'scenario',
'status',
'progress',
'current_step',
'clips_data',
'output_path',
'cost_usd',
'error_message',
];
protected $casts = [
'scenario' => 'array',
'clips_data' => 'array',
'progress' => 'integer',
'cost_usd' => 'decimal:4',
];
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
public function user()
{
return $this->belongsTo(\App\Models\User::class);
}
/**
* 상태 상수
*/
const STATUS_PENDING = 'pending';
const STATUS_TITLES_GENERATED = 'titles_generated';
const STATUS_SCENARIO_READY = 'scenario_ready';
const STATUS_GENERATING_TTS = 'generating_tts';
const STATUS_GENERATING_CLIPS = 'generating_clips';
const STATUS_GENERATING_BGM = 'generating_bgm';
const STATUS_ASSEMBLING = 'assembling';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
/**
* 진행 상태 업데이트 헬퍼
*/
public function updateProgress(string $status, int $progress, string $step): void
{
$this->update([
'status' => $status,
'progress' => $progress,
'current_step' => $step,
]);
}
/**
* 실패 처리
*/
public function markFailed(string $errorMessage): void
{
$this->update([
'status' => self::STATUS_FAILED,
'error_message' => $errorMessage,
]);
}
}

View File

@@ -37,7 +37,7 @@ private function loadServiceAccount(): void
/**
* OAuth 토큰 발급
*/
private function getAccessToken(): ?string
public function getAccessToken(): ?string
{
// 캐시된 토큰이 유효하면 재사용
if ($this->accessToken && $this->tokenExpiry && time() < $this->tokenExpiry - 60) {

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class BgmService
{
/**
* 분위기별 BGM 매핑 (로열티프리 BGM 파일 풀)
* storage/app/bgm/ 디렉토리에 미리 준비
*/
private array $moodMap = [
'upbeat' => ['upbeat_01.mp3', 'upbeat_02.mp3'],
'energetic' => ['energetic_01.mp3', 'energetic_02.mp3'],
'exciting' => ['exciting_01.mp3', 'exciting_02.mp3'],
'calm' => ['calm_01.mp3', 'calm_02.mp3'],
'dramatic' => ['dramatic_01.mp3', 'dramatic_02.mp3'],
'happy' => ['happy_01.mp3', 'happy_02.mp3'],
'sad' => ['sad_01.mp3', 'sad_02.mp3'],
'mysterious' => ['mysterious_01.mp3', 'mysterious_02.mp3'],
'inspiring' => ['inspiring_01.mp3', 'inspiring_02.mp3'],
];
/**
* 분위기에 맞는 BGM 선택
*
* @return string|null BGM 파일 경로
*/
public function select(string $mood, string $savePath): ?string
{
$bgmDir = storage_path('app/bgm');
// 분위기 키워드 매칭 (부분 일치 지원)
$matchedFiles = [];
$moodLower = strtolower($mood);
foreach ($this->moodMap as $key => $files) {
if (str_contains($moodLower, $key)) {
$matchedFiles = array_merge($matchedFiles, $files);
}
}
// 매칭되는 분위기가 없으면 기본값
if (empty($matchedFiles)) {
$matchedFiles = $this->moodMap['upbeat'] ?? ['default.mp3'];
}
// 랜덤 선택
$selectedFile = $matchedFiles[array_rand($matchedFiles)];
$sourcePath = "{$bgmDir}/{$selectedFile}";
// BGM 파일 존재 확인
if (! file_exists($sourcePath)) {
Log::warning('BgmService: BGM 파일 없음', [
'path' => $sourcePath,
'mood' => $mood,
]);
// BGM 디렉토리에서 아무 파일이나 선택
return $this->selectFallback($bgmDir, $savePath);
}
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
copy($sourcePath, $savePath);
Log::info('BgmService: BGM 선택 완료', [
'mood' => $mood,
'file' => $selectedFile,
]);
return $savePath;
}
/**
* 폴백: BGM 디렉토리에서 아무 MP3 선택
*/
private function selectFallback(string $bgmDir, string $savePath): ?string
{
if (! is_dir($bgmDir)) {
Log::error('BgmService: BGM 디렉토리 없음', ['dir' => $bgmDir]);
return null;
}
$files = glob("{$bgmDir}/*.mp3");
if (empty($files)) {
Log::error('BgmService: BGM 파일이 하나도 없음');
return null;
}
$selected = $files[array_rand($files)];
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
copy($selected, $savePath);
return $savePath;
}
/**
* 무음 BGM 생성 (FFmpeg 사용, BGM 파일이 없을 때 폴백)
*/
public function generateSilence(int $durationSec, string $savePath): ?string
{
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$cmd = sprintf(
'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t %d -c:a libmp3lame -q:a 9 %s 2>&1',
$durationSec,
escapeshellarg($savePath)
);
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
Log::error('BgmService: 무음 BGM 생성 실패', ['output' => implode("\n", $output)]);
return null;
}
return $savePath;
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiScriptService
{
private string $apiKey;
private string $model = 'gemini-2.5-flash';
public function __construct()
{
$this->apiKey = config('services.gemini.api_key', '');
}
/**
* 키워드 → 트렌딩 제목 5개 생성
*/
public function generateTrendingTitles(string $keyword): array
{
$prompt = <<<PROMPT
당신은 YouTube Shorts 전문 크리에이터입니다.
키워드: "{$keyword}"
이 키워드로 YouTube Shorts에서 조회수를 극대화할 수 있는 매력적인 제목 5개를 생성해주세요.
요구사항:
- 각 제목은 40자 이내
- 호기심을 자극하는 제목
- 한국어로 작성
- 이모지 1-2개 포함 가능
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"titles": [
{"title": "제목1", "hook": "이 제목이 효과적인 이유 한줄"},
{"title": "제목2", "hook": "이유"},
{"title": "제목3", "hook": "이유"},
{"title": "제목4", "hook": "이유"},
{"title": "제목5", "hook": "이유"}
]
}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
$parsed = $this->parseJsonResponse($result);
return $parsed['titles'] ?? [];
}
/**
* 제목 → 장면별 시나리오 생성
*/
public function generateScenario(string $title, string $keyword = ''): array
{
$prompt = <<<PROMPT
당신은 YouTube Shorts 영상 시나리오 전문 작가입니다.
영상 제목: "{$title}"
키워드: "{$keyword}"
이 제목으로 40초 분량의 YouTube Shorts 영상 시나리오를 작성해주세요.
요구사항:
- 5~6개 장면 (각 6~8초)
- 각 장면에 나레이션 텍스트 (한국어, 자막으로도 사용)
- 각 장면에 Veo 3.1 영상 생성용 프롬프트 (영어, 구체적이고 시각적)
- 총 길이 합계가 정확히 40초
- 첫 장면은 강렬한 후크 (시청 유지율 극대화)
- 마지막 장면은 CTA (좋아요/구독 유도)
visual_prompt 작성 규칙:
- 영어로 작성
- 카메라 앵글, 조명, 분위기를 구체적으로 묘사
- "cinematic", "4K", "dramatic lighting" 등 품질 키워드 포함
- 사람이 등장할 경우 외모, 표정, 동작을 구체적으로
- 9:16 세로 영상에 적합한 구도
반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 없이):
{
"title": "{$title}",
"scenes": [
{
"scene_number": 1,
"duration": 8,
"narration": "나레이션 텍스트 (한국어)",
"visual_prompt": "Detailed English prompt for Veo 3.1 video generation...",
"mood": "exciting"
}
],
"total_duration": 40,
"bgm_mood": "upbeat, energetic"
}
PROMPT;
$result = $this->callGemini($prompt);
if (! $result) {
return [];
}
return $this->parseJsonResponse($result) ?: [];
}
/**
* Gemini API 호출
*/
private function callGemini(string $prompt): ?string
{
if (empty($this->apiKey)) {
Log::error('GeminiScriptService: API 키가 설정되지 않았습니다.');
return null;
}
try {
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$this->model}:generateContent";
$response = Http::withHeaders([
'x-goog-api-key' => $this->apiKey,
])->timeout(60)->post($url, [
'contents' => [
[
'parts' => [
['text' => $prompt],
],
],
],
'generationConfig' => [
'temperature' => 0.9,
'maxOutputTokens' => 4096,
'responseMimeType' => 'application/json',
],
]);
if (! $response->successful()) {
Log::error('GeminiScriptService: API 호출 실패', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$data = $response->json();
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? null;
return $text;
} catch (\Exception $e) {
Log::error('GeminiScriptService: 예외 발생', ['error' => $e->getMessage()]);
return null;
}
}
/**
* JSON 응답 파싱 (코드블록 제거 포함)
*/
private function parseJsonResponse(string $text): ?array
{
// 코드블록 제거 (```json ... ```)
$text = preg_replace('/^```(?:json)?\s*/m', '', $text);
$text = preg_replace('/```\s*$/m', '', $text);
$text = trim($text);
$decoded = json_decode($text, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('GeminiScriptService: JSON 파싱 실패', [
'error' => json_last_error_msg(),
'text' => substr($text, 0, 500),
]);
return null;
}
return $decoded;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Services\Video;
use App\Services\GoogleCloudService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TtsService
{
private GoogleCloudService $googleCloud;
public function __construct(GoogleCloudService $googleCloud)
{
$this->googleCloud = $googleCloud;
}
/**
* 텍스트 → MP3 음성 파일 생성
*
* @return string|null 저장된 파일 경로
*/
public function synthesize(string $text, string $savePath, array $options = []): ?string
{
$token = $this->googleCloud->getAccessToken();
if (! $token) {
Log::error('TtsService: 액세스 토큰 획득 실패');
return null;
}
try {
$languageCode = $options['language_code'] ?? 'ko-KR';
$voiceName = $options['voice_name'] ?? 'ko-KR-Wavenet-A';
$speakingRate = $options['speaking_rate'] ?? 1.0;
$pitch = $options['pitch'] ?? 0.0;
$response = Http::withToken($token)
->timeout(30)
->post('https://texttospeech.googleapis.com/v1/text:synthesize', [
'input' => [
'text' => $text,
],
'voice' => [
'languageCode' => $languageCode,
'name' => $voiceName,
],
'audioConfig' => [
'audioEncoding' => 'MP3',
'speakingRate' => $speakingRate,
'pitch' => $pitch,
'sampleRateHertz' => 24000,
],
]);
if (! $response->successful()) {
Log::error('TtsService: TTS API 실패', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$data = $response->json();
$audioContent = $data['audioContent'] ?? null;
if (! $audioContent) {
Log::error('TtsService: 오디오 데이터 없음');
return null;
}
$dir = dirname($savePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($savePath, base64_decode($audioContent));
Log::info('TtsService: 음성 파일 생성 완료', [
'path' => $savePath,
'text_length' => mb_strlen($text),
]);
return $savePath;
} catch (\Exception $e) {
Log::error('TtsService: 예외 발생', ['error' => $e->getMessage()]);
return null;
}
}
/**
* 장면별 일괄 나레이션 생성
*
* @param array $scenes [{narration, scene_number}, ...]
* @return array [scene_number => file_path, ...]
*/
public function synthesizeScenes(array $scenes, string $baseDir): array
{
$results = [];
foreach ($scenes as $scene) {
$sceneNum = $scene['scene_number'] ?? 0;
$narration = $scene['narration'] ?? '';
if (empty($narration)) {
continue;
}
$savePath = "{$baseDir}/narration_{$sceneNum}.mp3";
$result = $this->synthesize($narration, $savePath);
if ($result) {
$results[$sceneNum] = $result;
}
}
return $results;
}
}

View File

@@ -0,0 +1,182 @@
<?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;
}
}

View File

@@ -0,0 +1,384 @@
<?php
namespace App\Services\Video;
use Illuminate\Support\Facades\Log;
class VideoAssemblyService
{
/**
* 영상 클립 결합 (concat)
*
* @param array $clipPaths 클립 파일 경로 배열
* @return string|null 결합된 영상 경로
*/
public function concatClips(array $clipPaths, string $outputPath): ?string
{
if (empty($clipPaths)) {
return null;
}
// 클립이 1개면 그대로 복사
if (count($clipPaths) === 1) {
copy($clipPaths[0], $outputPath);
return $outputPath;
}
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
// concat용 파일 리스트 생성
$listFile = "{$dir}/concat_list.txt";
$listContent = '';
foreach ($clipPaths as $path) {
$listContent .= "file " . escapeshellarg($path) . "\n";
}
file_put_contents($listFile, $listContent);
// 모든 클립을 동일 형식으로 재인코딩 후 concat
$scaledPaths = [];
foreach ($clipPaths as $i => $path) {
$scaledPath = "{$dir}/scaled_{$i}.mp4";
$scaleCmd = sprintf(
'ffmpeg -y -i %s -vf "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1" -r 30 -c:v libx264 -preset fast -crf 23 -an %s 2>&1',
escapeshellarg($path),
escapeshellarg($scaledPath)
);
exec($scaleCmd, $output, $returnCode);
if ($returnCode !== 0) {
Log::error('VideoAssemblyService: 클립 스케일링 실패', [
'clip' => $path,
'output' => implode("\n", $output),
]);
return null;
}
$scaledPaths[] = $scaledPath;
}
// 스케일된 클립 리스트
$scaledListFile = "{$dir}/scaled_list.txt";
$scaledListContent = '';
foreach ($scaledPaths as $path) {
$scaledListContent .= "file " . escapeshellarg($path) . "\n";
}
file_put_contents($scaledListFile, $scaledListContent);
$cmd = sprintf(
'ffmpeg -y -f concat -safe 0 -i %s -c copy %s 2>&1',
escapeshellarg($scaledListFile),
escapeshellarg($outputPath)
);
exec($cmd, $output, $returnCode);
// 임시 파일 정리
@unlink($listFile);
@unlink($scaledListFile);
foreach ($scaledPaths as $path) {
@unlink($path);
}
if ($returnCode !== 0) {
Log::error('VideoAssemblyService: 클립 결합 실패', [
'output' => implode("\n", $output),
]);
return null;
}
return $outputPath;
}
/**
* 나레이션 오디오 파일들을 하나로 합치기
*
* @param array $audioPaths [scene_number => file_path]
* @param array $scenes 시나리오 장면 정보 (duration 사용)
*/
public function concatNarrations(array $audioPaths, array $scenes, string $outputPath): ?string
{
if (empty($audioPaths)) {
return null;
}
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
// 각 나레이션에 패딩(무음)을 추가해서 장면 길이에 맞춤
$paddedPaths = [];
foreach ($scenes as $scene) {
$sceneNum = $scene['scene_number'];
$duration = $scene['duration'] ?? 8;
if (isset($audioPaths[$sceneNum])) {
$paddedPath = "{$dir}/narration_padded_{$sceneNum}.mp3";
// 나레이션을 장면 길이에 맞춰 패딩
$cmd = sprintf(
'ffmpeg -y -i %s -af "apad=whole_dur=%d" -c:a libmp3lame -q:a 2 %s 2>&1',
escapeshellarg($audioPaths[$sceneNum]),
$duration,
escapeshellarg($paddedPath)
);
exec($cmd, $output, $returnCode);
if ($returnCode === 0) {
$paddedPaths[] = $paddedPath;
} else {
$paddedPaths[] = $audioPaths[$sceneNum];
}
}
}
if (empty($paddedPaths)) {
return null;
}
// 나레이션 결합
if (count($paddedPaths) === 1) {
copy($paddedPaths[0], $outputPath);
} else {
$listFile = "{$dir}/narration_list.txt";
$listContent = '';
foreach ($paddedPaths as $path) {
$listContent .= "file " . escapeshellarg($path) . "\n";
}
file_put_contents($listFile, $listContent);
$cmd = sprintf(
'ffmpeg -y -f concat -safe 0 -i %s -c:a libmp3lame -q:a 2 %s 2>&1',
escapeshellarg($listFile),
escapeshellarg($outputPath)
);
exec($cmd, $output, $returnCode);
@unlink($listFile);
if ($returnCode !== 0) {
Log::error('VideoAssemblyService: 나레이션 결합 실패', [
'output' => implode("\n", $output),
]);
return null;
}
}
// 임시 패딩 파일 정리
foreach ($paddedPaths as $path) {
if (str_contains($path, 'narration_padded_')) {
@unlink($path);
}
}
return $outputPath;
}
/**
* ASS 자막 파일 생성
*
* @param array $scenes [{scene_number, narration, duration}, ...]
*/
public function generateAssSubtitle(array $scenes, string $outputPath): string
{
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$ass = "[Script Info]\n";
$ass .= "ScriptType: v4.00+\n";
$ass .= "PlayResX: 1080\n";
$ass .= "PlayResY: 1920\n";
$ass .= "WrapStyle: 0\n\n";
$ass .= "[V4+ Styles]\n";
$ass .= "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n";
$ass .= "Style: Default,NanumGothic,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,40,40,200,1\n\n";
$ass .= "[Events]\n";
$ass .= "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
$currentTime = 0;
foreach ($scenes as $scene) {
$duration = $scene['duration'] ?? 8;
$narration = $scene['narration'] ?? '';
if (empty($narration)) {
$currentTime += $duration;
continue;
}
$startTime = $this->formatAssTime($currentTime);
$endTime = $this->formatAssTime($currentTime + $duration);
// 긴 텍스트는 줄바꿈
$text = $this->wrapText($narration, 18);
$text = str_replace("\n", "\\N", $text);
$ass .= "Dialogue: 0,{$startTime},{$endTime},Default,,0,0,0,,{$text}\n";
$currentTime += $duration;
}
file_put_contents($outputPath, $ass);
return $outputPath;
}
/**
* 최종 합성: 영상 + 나레이션 + BGM + 자막
*/
public function assemble(
string $videoPath,
?string $narrationPath,
?string $bgmPath,
string $subtitlePath,
string $outputPath
): ?string {
$dir = dirname($outputPath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$inputs = ['-i ' . escapeshellarg($videoPath)];
$filterParts = [];
$mapParts = ['-map 0:v'];
$audioIndex = 1;
// 나레이션 추가
if ($narrationPath && file_exists($narrationPath)) {
$inputs[] = '-i ' . escapeshellarg($narrationPath);
$filterParts[] = "[{$audioIndex}:a]volume=1.0[nar]";
$audioIndex++;
}
// BGM 추가
if ($bgmPath && file_exists($bgmPath)) {
$inputs[] = '-i ' . escapeshellarg($bgmPath);
$filterParts[] = "[{$audioIndex}:a]volume=0.15[bgm]";
$audioIndex++;
}
// 오디오 믹싱 필터
if (count($filterParts) === 2) {
// 나레이션 + BGM
$filterComplex = implode(';', $filterParts) . ';[nar][bgm]amix=inputs=2:duration=first[a]';
$mapParts[] = '-map "[a]"';
} elseif (count($filterParts) === 1) {
// 나레이션 또는 BGM 중 하나만
$streamName = $narrationPath ? 'nar' : 'bgm';
$filterComplex = $filterParts[0];
$mapParts[] = '-map "[' . $streamName . ']"';
} else {
$filterComplex = null;
}
// 자막 비디오 필터
$vf = sprintf("subtitles=%s", escapeshellarg($subtitlePath));
// FFmpeg 명령 조립
$cmd = 'ffmpeg -y ' . implode(' ', $inputs);
if ($filterComplex) {
$cmd .= ' -filter_complex "' . $filterComplex . '"';
}
$cmd .= ' ' . implode(' ', $mapParts);
$cmd .= ' -vf ' . escapeshellarg($vf);
$cmd .= ' -c:v libx264 -preset fast -crf 23 -r 30';
$cmd .= ' -c:a aac -b:a 192k';
$cmd .= ' -shortest';
$cmd .= ' ' . escapeshellarg($outputPath);
$cmd .= ' 2>&1';
Log::info('VideoAssemblyService: 최종 합성 시작', ['cmd' => $cmd]);
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
Log::error('VideoAssemblyService: 최종 합성 실패', [
'return_code' => $returnCode,
'output' => implode("\n", array_slice($output, -20)),
]);
return null;
}
Log::info('VideoAssemblyService: 최종 합성 완료', [
'output' => $outputPath,
'size' => file_exists($outputPath) ? filesize($outputPath) : 0,
]);
return $outputPath;
}
/**
* ASS 시간 형식 (H:MM:SS.cs)
*/
private function formatAssTime(float $seconds): string
{
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
$secs = floor($seconds % 60);
$centiseconds = round(($seconds - floor($seconds)) * 100);
return sprintf('%d:%02d:%02d.%02d', $hours, $minutes, $secs, $centiseconds);
}
/**
* 긴 텍스트를 일정 글자수로 줄바꿈
*/
private function wrapText(string $text, int $maxCharsPerLine): string
{
if (mb_strlen($text) <= $maxCharsPerLine) {
return $text;
}
$lines = [];
$words = preg_split('/\s+/', $text);
$currentLine = '';
foreach ($words as $word) {
if (mb_strlen($currentLine . ' ' . $word) > $maxCharsPerLine && $currentLine !== '') {
$lines[] = trim($currentLine);
$currentLine = $word;
} else {
$currentLine .= ($currentLine ? ' ' : '') . $word;
}
}
if ($currentLine !== '') {
$lines[] = trim($currentLine);
}
return implode("\n", $lines);
}
/**
* 작업 디렉토리 정리
*/
public function cleanup(string $workDir): void
{
if (! is_dir($workDir)) {
return;
}
$files = glob("{$workDir}/*");
foreach ($files as $file) {
if (is_file($file) && ! str_contains($file, 'final_')) {
@unlink($file);
}
}
}
}

View File

@@ -50,6 +50,11 @@
'location' => env('GOOGLE_STT_LOCATION', 'asia-southeast1'),
],
'vertex_ai' => [
'project_id' => env('VERTEX_AI_PROJECT_ID', 'codebridge-chatbot'),
'location' => env('VERTEX_AI_LOCATION', 'us-central1'),
],
/*
|--------------------------------------------------------------------------
| 바로빌 API

View File

@@ -0,0 +1,619 @@
@extends('layouts.app')
@section('title', 'YouTube Shorts AI 생성기')
@push('styles')
<style>
.step-badge { @apply inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold; }
.step-active { @apply bg-indigo-600 text-white; }
.step-done { @apply bg-green-500 text-white; }
.step-pending { @apply bg-gray-200 text-gray-500; }
.scene-card { @apply bg-white border rounded-lg p-4 mb-3 shadow-sm; }
.progress-bar { @apply h-3 rounded-full transition-all duration-500 ease-out; }
.title-option { @apply border rounded-lg p-3 cursor-pointer transition-all hover:border-indigo-400 hover:bg-indigo-50; }
.title-option.selected { @apply border-indigo-600 bg-indigo-50 ring-2 ring-indigo-200; }
.fade-in { animation: fadeIn 0.3s ease-in; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>
@endpush
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="veo3-root"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
@verbatim
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content;
const api = (url, options = {}) => {
return fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF,
'Accept': 'application/json',
...options.headers,
},
...options,
}).then(async (res) => {
const data = await res.json();
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
return data;
});
};
// ============================================================
// Step Indicator
// ============================================================
const StepIndicator = ({ currentStep }) => {
const steps = [
{ num: 1, label: '키워드 입력' },
{ num: 2, label: '제목 선택' },
{ num: 3, label: '시나리오 확인' },
{ num: 4, label: '영상 생성' },
{ num: 5, label: '완성' },
];
return (
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step, i) => (
<React.Fragment key={step.num}>
<div className="flex flex-col items-center">
<div className={`step-badge ${currentStep > step.num ? 'step-done' : currentStep === step.num ? 'step-active' : 'step-pending'}`}>
{currentStep > step.num ? '✓' : step.num}
</div>
<span className={`text-xs mt-1 ${currentStep >= step.num ? 'text-indigo-600 font-medium' : 'text-gray-400'}`}>
{step.label}
</span>
</div>
{i < steps.length - 1 && (
<div className={`w-12 h-0.5 mt-[-12px] ${currentStep > step.num ? 'bg-green-500' : 'bg-gray-200'}`} />
)}
</React.Fragment>
))}
</div>
);
};
// ============================================================
// Step 1: Keyword Input
// ============================================================
const KeywordInput = ({ onSubmit, loading }) => {
const [keyword, setKeyword] = useState('');
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const handleSubmit = (e) => {
e.preventDefault();
if (keyword.trim()) onSubmit(keyword.trim());
};
return (
<div className="fade-in max-w-lg mx-auto">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">키워드를 입력하세요</h2>
<p className="text-gray-500 mt-2">AI가 트렌딩 YouTube Shorts 제목을 생성합니다</p>
</div>
<form onSubmit={handleSubmit} className="flex gap-3">
<input
ref={inputRef}
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="예: 다이어트, 주식투자, 여행..."
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
maxLength={100}
disabled={loading}
/>
<button
type="submit"
disabled={!keyword.trim() || loading}
className="bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/></svg>
생성 ...
</span>
) : '제목 생성'}
</button>
</form>
</div>
);
};
// ============================================================
// Step 2: Title Selection
// ============================================================
const TitleSelection = ({ titles, onSelect, onBack, loading }) => {
const [selectedIdx, setSelectedIdx] = useState(null);
return (
<div className="fade-in max-w-lg mx-auto">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">제목을 선택하세요</h2>
<p className="text-gray-500 mt-2">클릭하여 마음에 드는 제목을 선택합니다</p>
</div>
<div className="space-y-3 mb-6">
{titles.map((item, i) => (
<div
key={i}
className={`title-option ${selectedIdx === i ? 'selected' : ''}`}
onClick={() => setSelectedIdx(i)}
>
<div className="font-medium text-gray-800">{item.title}</div>
<div className="text-sm text-gray-500 mt-1">{item.hook}</div>
</div>
))}
</div>
<div className="flex gap-3">
<button
onClick={onBack}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50"
>
뒤로
</button>
<button
onClick={() => selectedIdx !== null && onSelect(titles[selectedIdx].title)}
disabled={selectedIdx === null || loading}
className="flex-1 bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? '시나리오 생성 중...' : '이 제목으로 시나리오 생성'}
</button>
</div>
</div>
);
};
// ============================================================
// Step 3: Scenario Preview & Edit
// ============================================================
const ScenarioPreview = ({ scenario, onGenerate, onBack, loading }) => {
const [scenes, setScenes] = useState(scenario.scenes || []);
const [bgmMood, setBgmMood] = useState(scenario.bgm_mood || 'upbeat');
const updateScene = (idx, field, value) => {
const updated = [...scenes];
updated[idx] = { ...updated[idx], [field]: value };
setScenes(updated);
};
const totalDuration = scenes.reduce((sum, s) => sum + (s.duration || 0), 0);
const handleGenerate = () => {
onGenerate({ ...scenario, scenes, bgm_mood: bgmMood, total_duration: totalDuration });
};
return (
<div className="fade-in max-w-2xl mx-auto">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-800">시나리오 미리보기</h2>
<p className="text-gray-500 mt-2">
"{scenario.title}" | {totalDuration} | {scenes.length} 장면
</p>
</div>
<div className="space-y-3 mb-6">
{scenes.map((scene, i) => (
<div key={i} className="scene-card">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-indigo-600">
장면 {scene.scene_number} ({scene.duration})
</span>
<span className="text-xs px-2 py-1 bg-gray-100 rounded-full text-gray-500">
{scene.mood}
</span>
</div>
<div className="mb-2">
<label className="text-xs text-gray-500 font-medium">나레이션 (자막)</label>
<textarea
value={scene.narration}
onChange={(e) => updateScene(i, 'narration', e.target.value)}
className="w-full border border-gray-200 rounded p-2 text-sm mt-1 resize-none"
rows={2}
/>
</div>
<div>
<label className="text-xs text-gray-500 font-medium">영상 프롬프트 (영어)</label>
<textarea
value={scene.visual_prompt}
onChange={(e) => updateScene(i, 'visual_prompt', e.target.value)}
className="w-full border border-gray-200 rounded p-2 text-sm mt-1 resize-none font-mono"
rows={3}
/>
</div>
</div>
))}
</div>
<div className="mb-6 p-3 bg-gray-50 rounded-lg">
<label className="text-sm font-medium text-gray-600">BGM 분위기</label>
<input
type="text"
value={bgmMood}
onChange={(e) => setBgmMood(e.target.value)}
className="w-full border border-gray-200 rounded p-2 text-sm mt-1"
placeholder="upbeat, energetic, calm..."
/>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-700">
예상 비용: ~$7.72 (Veo 3.1 Fast 기준) | 소요 시간: 10~20
</div>
<div className="flex gap-3">
<button
onClick={onBack}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50"
>
뒤로
</button>
<button
onClick={handleGenerate}
disabled={loading}
className="flex-1 bg-green-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? '요청 중...' : '영상 생성 시작'}
</button>
</div>
</div>
);
};
// ============================================================
// Step 4: Progress
// ============================================================
const GenerationProgress = ({ videoId }) => {
const [status, setStatus] = useState(null);
const intervalRef = useRef(null);
useEffect(() => {
const poll = async () => {
try {
const data = await api(`/video/veo3/status/${videoId}`);
setStatus(data);
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(intervalRef.current);
}
} catch (err) {
console.error('Poll error:', err);
}
};
poll();
intervalRef.current = setInterval(poll, 5000);
return () => clearInterval(intervalRef.current);
}, [videoId]);
if (!status) {
return (
<div className="text-center py-12">
<svg className="animate-spin h-10 w-10 mx-auto text-indigo-600" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
<p className="mt-3 text-gray-500">상태 확인 ...</p>
</div>
);
}
const progressColor = status.status === 'failed' ? 'bg-red-500' : status.progress === 100 ? 'bg-green-500' : 'bg-indigo-600';
return (
<div className="fade-in max-w-lg mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-800">
{status.status === 'completed' ? '영상 생성 완료!' : status.status === 'failed' ? '생성 실패' : '영상 생성 중...'}
</h2>
</div>
{/* Progress Bar */}
<div className="mb-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600">{status.current_step || '대기 중'}</span>
<span className="font-bold text-indigo-600">{status.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div className={`progress-bar ${progressColor}`} style={{ width: `${status.progress}%` }}></div>
</div>
</div>
{/* Error */}
{status.status === 'failed' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-700 text-sm">{status.error_message || '알 수 없는 오류가 발생했습니다.'}</p>
</div>
)}
{/* Cost */}
{status.cost_usd > 0 && (
<div className="text-center text-sm text-gray-500 mb-4">
예상 비용: ${parseFloat(status.cost_usd).toFixed(4)}
</div>
)}
{/* Status not completed yet */}
{!['completed', 'failed'].includes(status.status) && (
<div className="text-center text-sm text-gray-400">
5초마다 자동으로 상태를 확인합니다
</div>
)}
</div>
);
};
// ============================================================
// Step 5: Completed
// ============================================================
const CompletedView = ({ videoId }) => {
return (
<div className="fade-in max-w-lg mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-800">YouTube Shorts 영상 완성!</h2>
<p className="text-gray-500 mt-2">영상을 미리보기하고 다운로드하세요</p>
</div>
{/* Video Preview */}
<div className="mb-6 bg-black rounded-lg overflow-hidden" style={{ maxHeight: '500px' }}>
<video
src={`/video/veo3/preview/${videoId}`}
controls
className="w-full mx-auto"
style={{ maxHeight: '500px' }}
/>
</div>
<div className="flex gap-3 justify-center">
<a
href={`/video/veo3/download/${videoId}`}
className="bg-indigo-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-indigo-700 transition-colors inline-flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
MP4 다운로드
</a>
</div>
</div>
);
};
// ============================================================
// History Table
// ============================================================
const HistoryTable = ({ onSelect }) => {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api('/video/veo3/history')
.then(data => setHistory(data.data || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="text-center py-4 text-gray-400">이력 로딩 ...</div>;
if (history.length === 0) return null;
const statusLabels = {
pending: '대기',
titles_generated: '제목 생성됨',
scenario_ready: '시나리오 준비',
generating_tts: 'TTS 생성',
generating_clips: '영상 생성',
generating_bgm: 'BGM 생성',
assembling: '합성 중',
completed: '완료',
failed: '실패',
};
const statusColors = {
completed: 'bg-green-100 text-green-700',
failed: 'bg-red-100 text-red-700',
};
return (
<div className="mt-12">
<h3 className="text-lg font-bold text-gray-700 mb-4">생성 이력</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left py-2 px-3 font-medium text-gray-500">날짜</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">키워드</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">제목</th>
<th className="text-left py-2 px-3 font-medium text-gray-500">상태</th>
<th className="text-right py-2 px-3 font-medium text-gray-500">비용</th>
<th className="text-center py-2 px-3 font-medium text-gray-500">액션</th>
</tr>
</thead>
<tbody>
{history.map((item) => (
<tr key={item.id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3 text-gray-600">{new Date(item.created_at).toLocaleDateString('ko-KR')}</td>
<td className="py-2 px-3 font-medium">{item.keyword}</td>
<td className="py-2 px-3 text-gray-600 truncate max-w-[200px]">{item.title || '-'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[item.status] || 'bg-blue-100 text-blue-700'}`}>
{statusLabels[item.status] || item.status}
</span>
</td>
<td className="py-2 px-3 text-right text-gray-600">
{item.cost_usd > 0 ? `$${parseFloat(item.cost_usd).toFixed(2)}` : '-'}
</td>
<td className="py-2 px-3 text-center">
{item.status === 'completed' && (
<a href={`/video/veo3/download/${item.id}`} className="text-indigo-600 hover:underline text-xs">
다운로드
</a>
)}
{['generating_tts','generating_clips','generating_bgm','assembling'].includes(item.status) && (
<button onClick={() => onSelect(item.id)} className="text-blue-600 hover:underline text-xs">
진행 확인
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
// ============================================================
// Main App
// ============================================================
const App = () => {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [videoId, setVideoId] = useState(null);
const [titles, setTitles] = useState([]);
const [scenario, setScenario] = useState(null);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
// Step 1: Generate Titles
const handleKeywordSubmit = async (kw) => {
setLoading(true);
setError('');
setKeyword(kw);
try {
const data = await api('/video/veo3/titles', {
method: 'POST',
body: JSON.stringify({ keyword: kw }),
});
setVideoId(data.video_id);
setTitles(data.titles);
setStep(2);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Step 2: Select Title → Generate Scenario
const handleTitleSelect = async (title) => {
setLoading(true);
setError('');
try {
const data = await api('/video/veo3/scenario', {
method: 'POST',
body: JSON.stringify({ video_id: videoId, title }),
});
setScenario(data.scenario);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Step 3: Start Generation
const handleGenerate = async (editedScenario) => {
setLoading(true);
setError('');
try {
await api('/video/veo3/generate', {
method: 'POST',
body: JSON.stringify({ video_id: videoId, scenario: editedScenario }),
});
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// History item click → jump to progress
const handleHistorySelect = (id) => {
setVideoId(id);
setStep(4);
};
// Determine actual display step from polling status
const ProgressWithCompletion = ({ videoId }) => {
const [done, setDone] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
const check = async () => {
try {
const data = await api(`/video/veo3/status/${videoId}`);
if (data.status === 'completed') setDone(true);
if (data.status === 'failed') setFailed(true);
} catch (err) {}
};
check();
const iv = setInterval(check, 5000);
return () => clearInterval(iv);
}, [videoId]);
if (done) return <CompletedView videoId={videoId} />;
return <GenerationProgress videoId={videoId} />;
};
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">YouTube Shorts AI 생성기</h1>
<p className="text-gray-500 mt-2">키워드 하나로 AI가 YouTube Shorts 영상을 자동 생성합니다</p>
<p className="text-xs text-gray-400 mt-1">Gemini 2.5 Flash + Veo 3.1 + Google TTS</p>
</div>
{/* Step Indicator */}
<StepIndicator currentStep={step} />
{/* Error */}
{error && (
<div className="max-w-lg mx-auto mb-6 bg-red-50 border border-red-200 rounded-lg p-3 text-red-700 text-sm">
{error}
<button onClick={() => setError('')} className="ml-2 text-red-500 hover:text-red-700"></button>
</div>
)}
{/* Steps */}
{step === 1 && <KeywordInput onSubmit={handleKeywordSubmit} loading={loading} />}
{step === 2 && <TitleSelection titles={titles} onSelect={handleTitleSelect} onBack={() => setStep(1)} loading={loading} />}
{step === 3 && scenario && <ScenarioPreview scenario={scenario} onGenerate={handleGenerate} onBack={() => setStep(2)} loading={loading} />}
{step >= 4 && videoId && <ProgressWithCompletion videoId={videoId} />}
{/* New Generation Button (after completion) */}
{step >= 4 && (
<div className="text-center mt-8">
<button
onClick={() => { setStep(1); setVideoId(null); setTitles([]); setScenario(null); setError(''); }}
className="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
>
+ 영상 만들기
</button>
</div>
)}
{/* History */}
<HistoryTable onSelect={handleHistorySelect} />
</div>
);
};
ReactDOM.createRoot(document.getElementById('veo3-root')).render(<App />);
</script>
@endverbatim
@endpush

View File

@@ -1457,6 +1457,22 @@
});
});
/*
|--------------------------------------------------------------------------
| YouTube Shorts AI Generator (Veo 3.1)
|--------------------------------------------------------------------------
*/
Route::prefix('video/veo3')->name('video.veo3.')->middleware('auth')->group(function () {
Route::get('/', [\App\Http\Controllers\Video\Veo3Controller::class, 'index'])->name('index');
Route::post('/titles', [\App\Http\Controllers\Video\Veo3Controller::class, 'generateTitles'])->name('titles');
Route::post('/scenario', [\App\Http\Controllers\Video\Veo3Controller::class, 'generateScenario'])->name('scenario');
Route::post('/generate', [\App\Http\Controllers\Video\Veo3Controller::class, 'generate'])->name('generate');
Route::get('/status/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'status'])->name('status');
Route::get('/download/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'download'])->name('download');
Route::get('/preview/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'preview'])->name('preview');
Route::get('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'history'])->name('history');
});
/*
|--------------------------------------------------------------------------
| SAM E-Sign Public Routes (인증 불필요 - 서명자용)