Files
sam-manage/app/Http/Controllers/Video/Veo3Controller.php
김보곤 ed1967405c feat:트렌드 키워드를 건강 채널용으로 필터링
건강 채널 전용 트렌딩 시스템:
- Gemini로 실시간 트렌드에서 건강 관련 키워드만 필터링
- 간접적 키워드도 건강 앵글로 리프레이밍 (예: 김치 → 장건강)
- 필터 결과 30분 캐싱 (Gemini 호출 최소화)
- 필터 실패 시 원본 키워드 폴백

제목 생성 건강 앵글 반영:
- generateTrendingHookTitles 프롬프트에 건강 채널 명시
- trending_context에 health_angle, suggested_topic 추가
- 모든 제목이 건강/웰빙 관점으로 생성되도록 가이드

UI 건강 테마 적용:
- 버튼/칩 색상: orange/indigo → green 테마
- 칩에 건강 앵글 태그 배지 표시
- 칩 클릭 시 건강 주제(suggested_topic)가 인풋에 채워짐

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:08:57 +09:00

318 lines
9.8 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',
]);
}
/**
* 생성 이력 목록
*/
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,
]);
}
}