- Veo3Controller에 show 엔드포인트 추가 (시나리오/프롬프트 상세 데이터 반환) - YouTube Shorts 제목/설명/해시태그 자동 생성 (완료된 영상) - DetailModal 컴포넌트: 탭 UI (시나리오/프롬프트 | YouTube 텍스트) - 이력 테이블 행 클릭 시 상세 모달 표시 - 복사 버튼으로 YouTube 텍스트 클립보드 복사 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
407 lines
13 KiB
PHP
407 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Video;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Jobs\VideoGenerationJob;
|
|
use App\Models\VideoGeneration;
|
|
use App\Services\GoogleCloudStorageService;
|
|
use App\Services\Video\GeminiScriptService;
|
|
use App\Services\Video\TrendingKeywordService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\View\View;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
|
|
class Veo3Controller extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly GeminiScriptService $geminiService,
|
|
private readonly GoogleCloudStorageService $gcsService,
|
|
private readonly TrendingKeywordService $trendingService
|
|
) {}
|
|
|
|
/**
|
|
* 메인 페이지
|
|
*/
|
|
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 fetchTrending(): JsonResponse
|
|
{
|
|
$keywords = Cache::remember('health_trending', 1800, function () {
|
|
$rawKeywords = $this->trendingService->fetchTrendingKeywords(20);
|
|
|
|
$healthKeywords = $this->geminiService->filterHealthTrending($rawKeywords);
|
|
|
|
// Gemini 필터링 실패 시 원본 반환
|
|
return ! empty($healthKeywords) ? $healthKeywords : $rawKeywords;
|
|
});
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'keywords' => $keywords,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 키워드 → 제목 후보 생성 (trending_context 옵션 지원)
|
|
*/
|
|
public function generateTitles(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'keyword' => 'required|string|max:100',
|
|
'trending_context' => 'nullable|array',
|
|
]);
|
|
|
|
$keyword = $request->input('keyword');
|
|
$trendingContext = $request->input('trending_context');
|
|
|
|
if ($trendingContext) {
|
|
$titles = $this->geminiService->generateTrendingHookTitles($keyword, $trendingContext);
|
|
} else {
|
|
$titles = $this->geminiService->generateTrendingTitles($keyword);
|
|
}
|
|
|
|
if (empty($titles)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '제목 생성에 실패했습니다. API 키를 확인해주세요.',
|
|
], 500);
|
|
}
|
|
|
|
// DB에 pending 레코드 생성
|
|
$video = VideoGeneration::create([
|
|
'tenant_id' => session('selected_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,
|
|
'updated_at' => $video->updated_at?->toIso8601String(),
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 완성 영상 다운로드
|
|
*/
|
|
public function download(int $id): BinaryFileResponse|RedirectResponse|JsonResponse
|
|
{
|
|
$video = VideoGeneration::findOrFail($id);
|
|
|
|
if ($video->status !== VideoGeneration::STATUS_COMPLETED || ! $video->output_path) {
|
|
return response()->json(['message' => '아직 영상이 완성되지 않았습니다.'], 404);
|
|
}
|
|
|
|
// GCS 서명URL로 리다이렉트
|
|
if ($video->gcs_path && $this->gcsService->isAvailable()) {
|
|
$signedUrl = $this->gcsService->getSignedUrl($video->gcs_path, 30);
|
|
if ($signedUrl) {
|
|
return redirect()->away($signedUrl);
|
|
}
|
|
}
|
|
|
|
// 로컬 파일 폴백
|
|
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|RedirectResponse|JsonResponse
|
|
{
|
|
$video = VideoGeneration::findOrFail($id);
|
|
|
|
// GCS 서명URL로 리다이렉트
|
|
if ($video->gcs_path && $this->gcsService->isAvailable()) {
|
|
$signedUrl = $this->gcsService->getSignedUrl($video->gcs_path, 60);
|
|
if ($signedUrl) {
|
|
return redirect()->away($signedUrl);
|
|
}
|
|
}
|
|
|
|
// 로컬 파일 폴백
|
|
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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 생성 이력 상세 (시나리오, 프롬프트, YouTube 텍스트 등)
|
|
*/
|
|
public function show(int $id): JsonResponse
|
|
{
|
|
$video = VideoGeneration::where('user_id', auth()->id())->findOrFail($id);
|
|
|
|
$scenario = $video->scenario ?? [];
|
|
$scenes = $scenario['scenes'] ?? [];
|
|
|
|
// YouTube Shorts 텍스트 생성 (완료된 영상만)
|
|
$youtubeText = null;
|
|
if ($video->status === VideoGeneration::STATUS_COMPLETED) {
|
|
$youtubeText = $this->buildYoutubeText($video, $scenario, $scenes);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => [
|
|
'id' => $video->id,
|
|
'keyword' => $video->keyword,
|
|
'title' => $video->title,
|
|
'status' => $video->status,
|
|
'progress' => $video->progress,
|
|
'current_step' => $video->current_step,
|
|
'error_message' => $video->error_message,
|
|
'cost_usd' => $video->cost_usd,
|
|
'created_at' => $video->created_at?->toIso8601String(),
|
|
'updated_at' => $video->updated_at?->toIso8601String(),
|
|
'scenario' => $scenario,
|
|
'scenes' => array_map(function ($scene) {
|
|
return [
|
|
'scene_number' => $scene['scene_number'] ?? null,
|
|
'scene_type' => $scene['scene_type'] ?? null,
|
|
'narration' => $scene['narration'] ?? '',
|
|
'visual_prompt' => $scene['visual_prompt'] ?? '',
|
|
'duration' => $scene['duration'] ?? 0,
|
|
'mood' => $scene['mood'] ?? '',
|
|
];
|
|
}, $scenes),
|
|
'clips_data' => $video->clips_data,
|
|
'youtube_text' => $youtubeText,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* YouTube Shorts 제목 + 설명 텍스트 빌드
|
|
*/
|
|
private function buildYoutubeText(VideoGeneration $video, array $scenario, array $scenes): array
|
|
{
|
|
$title = $video->title ?? '';
|
|
$keyword = $video->keyword ?? '';
|
|
|
|
// 해시태그 생성
|
|
$hashtags = ['#shorts', '#쇼츠'];
|
|
if ($keyword) {
|
|
$hashtags[] = '#' . str_replace(' ', '', $keyword);
|
|
}
|
|
// 시나리오에서 추가 태그 추출
|
|
$bgmMood = $scenario['bgm_mood'] ?? '';
|
|
if ($bgmMood) {
|
|
$hashtags[] = '#' . $bgmMood;
|
|
}
|
|
$hashtags = array_merge($hashtags, ['#건강', '#건강정보', '#헬스']);
|
|
|
|
// 설명란 텍스트
|
|
$descLines = [];
|
|
$descLines[] = $title;
|
|
$descLines[] = '';
|
|
|
|
// 핵심 내용 요약 (나레이션에서 추출)
|
|
foreach ($scenes as $scene) {
|
|
$narration = $scene['narration'] ?? '';
|
|
if ($narration && ($scene['scene_type'] ?? '') !== 'HOOK') {
|
|
$descLines[] = '- ' . mb_substr($narration, 0, 60);
|
|
}
|
|
}
|
|
|
|
$descLines[] = '';
|
|
$descLines[] = implode(' ', array_unique($hashtags));
|
|
|
|
return [
|
|
'title' => $title,
|
|
'description' => implode("\n", $descLines),
|
|
'hashtags' => array_unique($hashtags),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 생성 이력 목록
|
|
*/
|
|
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', 'updated_at']);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $videos,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 생성 이력 삭제 (복수)
|
|
*/
|
|
public function destroy(Request $request): JsonResponse
|
|
{
|
|
$request->validate([
|
|
'ids' => 'required|array|min:1',
|
|
'ids.*' => 'integer',
|
|
]);
|
|
|
|
$videos = VideoGeneration::where('user_id', auth()->id())
|
|
->whereIn('id', $request->input('ids'))
|
|
->get();
|
|
|
|
$deleted = 0;
|
|
foreach ($videos as $video) {
|
|
// GCS 파일 삭제
|
|
if ($video->gcs_path && $this->gcsService->isAvailable()) {
|
|
$this->gcsService->delete($video->gcs_path);
|
|
}
|
|
|
|
// 로컬 작업 디렉토리 삭제 (클립, 나레이션, 최종 영상 등)
|
|
$workDir = storage_path("app/video_gen/{$video->id}");
|
|
if (is_dir($workDir)) {
|
|
$files = glob("{$workDir}/*");
|
|
foreach ($files as $file) {
|
|
if (is_file($file)) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
@rmdir($workDir);
|
|
}
|
|
|
|
$video->delete();
|
|
$deleted++;
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'deleted' => $deleted,
|
|
]);
|
|
}
|
|
}
|