From 768bc30a6de720b8532e02110948b09da79dedd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sun, 15 Feb 2026 15:56:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=A7=A4?= =?UTF-8?q?=EB=89=B4=EC=96=BC=20=EC=98=81=EC=83=81=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TutorialVideo 모델 (상태 관리, TenantScope) - GeminiScriptService에 callGeminiWithParts() 멀티모달 지원 추가 - ScreenAnalysisService: Gemini Vision 스크린샷 AI 분석 - SlideAnnotationService: PHP GD 이미지 어노테이션 (마커, 캡션) - TutorialAssemblyService: FFmpeg 이미지→영상 합성 (crossfade) - TutorialVideoJob: 분석→슬라이드→TTS→BGM→합성 파이프라인 - TutorialVideoController: 업로드/분석/생성/상태/다운로드/이력 API - React-in-Blade UI: 3단계 (업로드→분석확인→생성모니터링) + 이력 Co-Authored-By: Claude Opus 4.6 --- .../Video/TutorialVideoController.php | 285 ++++++++ app/Jobs/TutorialVideoJob.php | 255 +++++++ app/Models/TutorialVideo.php | 69 ++ app/Services/Video/GeminiScriptService.php | 35 +- app/Services/Video/ScreenAnalysisService.php | 136 ++++ app/Services/Video/SlideAnnotationService.php | 278 ++++++++ .../Video/TutorialAssemblyService.php | 292 ++++++++ .../views/video/tutorial/index.blade.php | 636 ++++++++++++++++++ routes/web.php | 17 + 9 files changed, 1992 insertions(+), 11 deletions(-) create mode 100644 app/Http/Controllers/Video/TutorialVideoController.php create mode 100644 app/Jobs/TutorialVideoJob.php create mode 100644 app/Models/TutorialVideo.php create mode 100644 app/Services/Video/ScreenAnalysisService.php create mode 100644 app/Services/Video/SlideAnnotationService.php create mode 100644 app/Services/Video/TutorialAssemblyService.php create mode 100644 resources/views/video/tutorial/index.blade.php diff --git a/app/Http/Controllers/Video/TutorialVideoController.php b/app/Http/Controllers/Video/TutorialVideoController.php new file mode 100644 index 00000000..a07beef5 --- /dev/null +++ b/app/Http/Controllers/Video/TutorialVideoController.php @@ -0,0 +1,285 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('video.tutorial.index')); + } + + return view('video.tutorial.index'); + } + + /** + * 스크린샷 업로드 (임시 저장) + */ + public function upload(Request $request): JsonResponse + { + $request->validate([ + 'screenshots' => 'required|array|min:1|max:10', + 'screenshots.*' => 'required|image|max:10240', // 최대 10MB + ]); + + $uploadDir = storage_path('app/tutorial_uploads/' . auth()->id() . '/' . time()); + if (! is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + $paths = []; + foreach ($request->file('screenshots') as $i => $file) { + $filename = sprintf('screenshot_%02d.%s', $i + 1, $file->getClientOriginalExtension()); + $file->move($uploadDir, $filename); + $paths[] = $uploadDir . '/' . $filename; + } + + return response()->json([ + 'success' => true, + 'paths' => $paths, + 'count' => count($paths), + ]); + } + + /** + * AI 스크린샷 분석 + */ + public function analyze(Request $request): JsonResponse + { + $request->validate([ + 'paths' => 'required|array|min:1', + 'paths.*' => 'required|string', + ]); + + $paths = $request->input('paths'); + + // 파일 존재 확인 + foreach ($paths as $path) { + if (! file_exists($path)) { + return response()->json([ + 'success' => false, + 'message' => '업로드된 파일을 찾을 수 없습니다: ' . basename($path), + ], 400); + } + } + + $analysisData = $this->screenAnalysisService->analyzeScreenshots($paths); + + if (empty($analysisData)) { + return response()->json([ + 'success' => false, + 'message' => '스크린샷 분석에 실패했습니다. AI 설정을 확인해주세요.', + ], 500); + } + + return response()->json([ + 'success' => true, + 'analysis' => $analysisData, + ]); + } + + /** + * 영상 생성 시작 (Job dispatch) + */ + public function generate(Request $request): JsonResponse + { + $request->validate([ + 'paths' => 'required|array|min:1', + 'analysis' => 'required|array', + 'title' => 'nullable|string|max:500', + ]); + + $paths = $request->input('paths'); + $analysis = $request->input('analysis'); + $title = $request->input('title', 'SAM 사용자 매뉴얼'); + + // DB 레코드 생성 + $tutorial = TutorialVideo::create([ + 'tenant_id' => session('selected_tenant_id'), + 'user_id' => auth()->id(), + 'title' => $title, + 'status' => TutorialVideo::STATUS_PENDING, + 'screenshots' => $paths, + 'analysis_data' => $analysis, + ]); + + // Job dispatch + TutorialVideoJob::dispatch($tutorial->id); + + return response()->json([ + 'success' => true, + 'id' => $tutorial->id, + 'message' => '영상 생성이 시작되었습니다.', + ]); + } + + /** + * 진행 상태 폴링 + */ + public function status(int $id): JsonResponse + { + $tutorial = TutorialVideo::findOrFail($id); + + return response()->json([ + 'id' => $tutorial->id, + 'status' => $tutorial->status, + 'progress' => $tutorial->progress, + 'current_step' => $tutorial->current_step, + 'error_message' => $tutorial->error_message, + 'output_path' => $tutorial->output_path ? true : false, + 'cost_usd' => $tutorial->cost_usd, + 'updated_at' => $tutorial->updated_at?->toIso8601String(), + 'created_at' => $tutorial->created_at?->toIso8601String(), + ]); + } + + /** + * 완성 영상 다운로드 + */ + public function download(int $id): BinaryFileResponse|RedirectResponse|JsonResponse + { + $tutorial = TutorialVideo::findOrFail($id); + + if ($tutorial->status !== TutorialVideo::STATUS_COMPLETED || ! $tutorial->output_path) { + return response()->json(['message' => '아직 영상이 완성되지 않았습니다.'], 404); + } + + // GCS 서명URL + if ($tutorial->gcs_path && $this->gcsService->isAvailable()) { + $signedUrl = $this->gcsService->getSignedUrl($tutorial->gcs_path, 30); + if ($signedUrl) { + return redirect()->away($signedUrl); + } + } + + if (! file_exists($tutorial->output_path)) { + return response()->json(['message' => '영상 파일을 찾을 수 없습니다.'], 404); + } + + $filename = 'tutorial_' . ($tutorial->title ? preg_replace('/[^a-zA-Z0-9가-힣_\-.]/', '_', $tutorial->title) : $tutorial->id) . '.mp4'; + + return response()->download($tutorial->output_path, $filename, [ + 'Content-Type' => 'video/mp4', + ]); + } + + /** + * 영상 미리보기 (스트리밍) + */ + public function preview(int $id): Response|RedirectResponse|JsonResponse + { + $tutorial = TutorialVideo::findOrFail($id); + + if ($tutorial->gcs_path && $this->gcsService->isAvailable()) { + $signedUrl = $this->gcsService->getSignedUrl($tutorial->gcs_path, 60); + if ($signedUrl) { + return redirect()->away($signedUrl); + } + } + + if (! $tutorial->output_path || ! file_exists($tutorial->output_path)) { + return response()->json(['message' => '영상 파일을 찾을 수 없습니다.'], 404); + } + + return response()->file($tutorial->output_path, [ + 'Content-Type' => 'video/mp4', + 'Content-Length' => filesize($tutorial->output_path), + 'Accept-Ranges' => 'bytes', + ]); + } + + /** + * 생성 이력 목록 + */ + public function history(): JsonResponse + { + $tutorials = TutorialVideo::where('user_id', auth()->id()) + ->orderByDesc('created_at') + ->limit(50) + ->get(['id', 'title', 'status', 'progress', 'cost_usd', 'created_at', 'updated_at']); + + return response()->json([ + 'success' => true, + 'data' => $tutorials, + ]); + } + + /** + * 이력 삭제 + */ + public function destroy(Request $request): JsonResponse + { + $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer', + ]); + + $tutorials = TutorialVideo::where('user_id', auth()->id()) + ->whereIn('id', $request->input('ids')) + ->get(); + + $deleted = 0; + foreach ($tutorials as $tutorial) { + // GCS 파일 삭제 + if ($tutorial->gcs_path && $this->gcsService->isAvailable()) { + $this->gcsService->delete($tutorial->gcs_path); + } + + // 로컬 작업 디렉토리 삭제 + $workDir = storage_path("app/tutorial_gen/{$tutorial->id}"); + if (is_dir($workDir)) { + $files = glob("{$workDir}/*"); + foreach ($files as $file) { + if (is_file($file)) { + @unlink($file); + } + } + @rmdir($workDir); + } + + // 업로드된 원본 삭제 + $screenshots = $tutorial->screenshots ?? []; + foreach ($screenshots as $path) { + if (file_exists($path)) { + @unlink($path); + } + } + // 업로드 디렉토리도 정리 + if (! empty($screenshots) && isset($screenshots[0])) { + $uploadDir = dirname($screenshots[0]); + if (is_dir($uploadDir)) { + @rmdir($uploadDir); + } + } + + $tutorial->delete(); + $deleted++; + } + + return response()->json([ + 'success' => true, + 'deleted' => $deleted, + ]); + } +} diff --git a/app/Jobs/TutorialVideoJob.php b/app/Jobs/TutorialVideoJob.php new file mode 100644 index 00000000..3e709ce9 --- /dev/null +++ b/app/Jobs/TutorialVideoJob.php @@ -0,0 +1,255 @@ +tutorialVideoId = $tutorialVideoId; + } + + public function handle( + ScreenAnalysisService $screenAnalysis, + SlideAnnotationService $slideAnnotation, + TtsService $tts, + BgmService $bgm, + VideoAssemblyService $videoAssembly, + TutorialAssemblyService $tutorialAssembly, + GoogleCloudStorageService $gcs + ): void { + $tutorial = TutorialVideo::withoutGlobalScopes()->find($this->tutorialVideoId); + + if (! $tutorial) { + Log::error('TutorialVideoJob: 레코드를 찾을 수 없음', ['id' => $this->tutorialVideoId]); + return; + } + + $workDir = storage_path("app/tutorial_gen/{$tutorial->id}"); + if (! is_dir($workDir)) { + mkdir($workDir, 0755, true); + } + + $totalCost = 0.0; + + try { + // === Step 1: AI 스크린샷 분석 (10%) === + $tutorial->updateProgress(TutorialVideo::STATUS_ANALYZING, 5, 'AI 스크린샷 분석 중...'); + + $analysisData = $tutorial->analysis_data; + + if (empty($analysisData)) { + $screenshots = $tutorial->screenshots ?? []; + + if (empty($screenshots)) { + $tutorial->markFailed('업로드된 스크린샷이 없습니다'); + return; + } + + $analysisData = $screenAnalysis->analyzeScreenshots($screenshots); + + if (empty($analysisData)) { + $tutorial->markFailed('스크린샷 분석에 실패했습니다'); + return; + } + + $tutorial->update(['analysis_data' => $analysisData]); + $totalCost += 0.01; // Gemini Vision + } + + $tutorial->updateProgress(TutorialVideo::STATUS_ANALYZING, 10, 'AI 분석 완료'); + + // === Step 2: 어노테이션 슬라이드 생성 (30%) === + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, 15, '슬라이드 생성 중...'); + + $slidePaths = []; + $durations = []; + $scenes = []; + $screenshots = $tutorial->screenshots ?? []; + + foreach ($analysisData as $i => $screen) { + $screenNum = $screen['screen_number'] ?? ($i + 1); + $imagePath = $screenshots[$i] ?? null; + + if (! $imagePath || ! file_exists($imagePath)) { + Log::warning("TutorialVideoJob: 스크린샷 없음 - index {$i}"); + continue; + } + + $outputSlide = "{$workDir}/slide_{$screenNum}.png"; + + $result = $slideAnnotation->annotateSlide( + $imagePath, + $screen['ui_elements'] ?? [], + $screenNum, + $screen['narration'] ?? "화면 {$screenNum}", + $outputSlide + ); + + if ($result) { + $slidePaths[] = $result; + $duration = $screen['duration'] ?? 8; + $durations[] = $duration; + $scenes[] = [ + 'scene_number' => $screenNum, + 'narration' => $screen['narration'] ?? '', + 'duration' => $duration, + ]; + } + + $progress = 15 + (int) (($i + 1) / count($analysisData) * 15); + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, $progress, "슬라이드 {$screenNum} 생성 완료"); + } + + if (empty($slidePaths)) { + $tutorial->markFailed('슬라이드 생성에 실패했습니다'); + return; + } + + $tutorial->update(['slides_data' => [ + 'slide_paths' => $slidePaths, + 'durations' => $durations, + 'scenes' => $scenes, + ]]); + + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_SLIDES, 30, '슬라이드 생성 완료'); + + // === Step 3: TTS 나레이션 생성 (50%) === + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_TTS, 35, '나레이션 생성 중...'); + + $narrationPaths = $tts->synthesizeScenes($scenes, $workDir); + $totalCost += 0.01; // TTS + + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_TTS, 50, '나레이션 생성 완료'); + + // 실제 나레이션 길이 측정 + duration 보정 + $narrationDurations = []; + foreach ($narrationPaths as $sceneNum => $path) { + $actualDuration = $videoAssembly->getAudioDuration($path); + if ($actualDuration > 0) { + $narrationDurations[$sceneNum] = $actualDuration; + + // 나레이션이 슬라이드보다 길면 duration 보정 + foreach ($durations as $di => &$dur) { + if (($scenes[$di]['scene_number'] ?? 0) === $sceneNum) { + if ($actualDuration > $dur) { + $dur = ceil($actualDuration) + 1; + $scenes[$di]['duration'] = $dur; + } + } + } + unset($dur); + } + } + + // 나레이션 합치기 + $concatNarrationPath = "{$workDir}/narration_full.mp3"; + $narrationPath = $videoAssembly->concatNarrations($narrationPaths, $scenes, $concatNarrationPath); + + // === Step 4: BGM 생성 (65%) === + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_BGM, 55, 'BGM 생성 중...'); + + $totalDuration = array_sum($durations); + $bgmPath = "{$workDir}/bgm.mp3"; + + // 튜토리얼용 차분한 BGM + $bgmResult = $bgm->generateWithLyria('calm, instructional, soft background', $totalDuration + 5, $bgmPath); + + if (! $bgmResult) { + $bgmResult = $bgm->select('calm', $bgmPath); + } + if (! $bgmResult) { + $bgmResult = $bgm->generateAmbient('calm', $totalDuration + 5, $bgmPath); + } + + $totalCost += 0.06; // Lyria + + $tutorial->updateProgress(TutorialVideo::STATUS_GENERATING_BGM, 65, 'BGM 생성 완료'); + + // === Step 5: 최종 합성 (80%) === + $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 70, '영상 합성 중...'); + + // ASS 자막 생성 + $subtitlePath = "{$workDir}/subtitle.ass"; + $videoAssembly->generateAssSubtitle($scenes, $subtitlePath, $narrationDurations); + + // 최종 MP4 합성 + $finalOutputPath = "{$workDir}/final_tutorial.mp4"; + $result = $tutorialAssembly->assembleFromImages( + $slidePaths, + $durations, + $narrationPath, + $bgmResult, + $subtitlePath, + $finalOutputPath + ); + + if (! $result || ! file_exists($finalOutputPath)) { + $tutorial->markFailed('영상 합성에 실패했습니다'); + return; + } + + $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 80, '영상 합성 완료'); + + // === Step 6: GCS 업로드 (95%) === + $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 85, 'GCS 업로드 중...'); + + $gcsPath = null; + if ($gcs->isAvailable()) { + $objectName = "tutorials/{$tutorial->tenant_id}/{$tutorial->id}/tutorial_" . date('Ymd_His') . '.mp4'; + $gcsPath = $gcs->upload($finalOutputPath, $objectName); + } + + $tutorial->updateProgress(TutorialVideo::STATUS_ASSEMBLING, 95, '업로드 완료'); + + // === Step 7: 완료 (100%) === + $tutorial->update([ + 'status' => TutorialVideo::STATUS_COMPLETED, + 'progress' => 100, + 'current_step' => '완료', + 'output_path' => $finalOutputPath, + 'gcs_path' => $gcsPath, + 'cost_usd' => $totalCost, + ]); + + Log::info('TutorialVideoJob: 완료', [ + 'id' => $tutorial->id, + 'output' => $finalOutputPath, + 'gcs' => $gcsPath, + 'cost' => $totalCost, + ]); + + } catch (\Exception $e) { + Log::error('TutorialVideoJob: 예외 발생', [ + 'id' => $tutorial->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + $tutorial->markFailed($e->getMessage()); + } + } +} diff --git a/app/Models/TutorialVideo.php b/app/Models/TutorialVideo.php new file mode 100644 index 00000000..5fa879d3 --- /dev/null +++ b/app/Models/TutorialVideo.php @@ -0,0 +1,69 @@ + 'array', + 'analysis_data' => 'array', + 'slides_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_ANALYZING = 'analyzing'; + const STATUS_GENERATING_SLIDES = 'generating_slides'; + const STATUS_GENERATING_TTS = 'generating_tts'; + 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, + ]); + } +} diff --git a/app/Services/Video/GeminiScriptService.php b/app/Services/Video/GeminiScriptService.php index 42732805..93a3db94 100644 --- a/app/Services/Video/GeminiScriptService.php +++ b/app/Services/Video/GeminiScriptService.php @@ -331,9 +331,19 @@ public function generateScenario(string $title, string $keyword = ''): array } /** - * Gemini API 호출 (AiConfig 기반 - API Key / Vertex AI 자동 분기) + * Gemini API 호출 (텍스트 전용 - 기존 호환) */ private function callGemini(string $prompt): ?string + { + return $this->callGeminiWithParts([['text' => $prompt]]); + } + + /** + * Gemini API 호출 (멀티모달 지원 - 텍스트 + 이미지) + * + * @param array $parts [['text' => '...'], ['inlineData' => ['mimeType' => '...', 'data' => '...']]] + */ + public function callGeminiWithParts(array $parts, float $temperature = 0.9, int $maxTokens = 4096): ?string { if (! $this->config) { Log::error('GeminiScriptService: 활성화된 Gemini 설정이 없습니다. (시스템 > AI 설정 확인)'); @@ -346,20 +356,17 @@ private function callGemini(string $prompt): ?string $body = [ 'contents' => [ [ - 'parts' => [ - ['text' => $prompt], - ], + 'parts' => $parts, ], ], 'generationConfig' => [ - 'temperature' => 0.9, - 'maxOutputTokens' => 4096, + 'temperature' => $temperature, + 'maxOutputTokens' => $maxTokens, 'responseMimeType' => 'application/json', ], ]; if ($this->config->isVertexAi()) { - // Vertex AI 방식 (서비스 계정 OAuth) $accessToken = $this->googleCloud->getAccessToken(); if (! $accessToken) { Log::error('GeminiScriptService: Vertex AI 액세스 토큰 획득 실패'); @@ -371,19 +378,17 @@ private function callGemini(string $prompt): ?string $region = $this->config->getRegion(); $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; - // Vertex AI에서는 role 필수 $body['contents'][0]['role'] = 'user'; $response = Http::withToken($accessToken) - ->timeout(60) + ->timeout(120) ->post($url, $body); } else { - // API Key 방식 $apiKey = $this->config->api_key; $baseUrl = $this->config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; - $response = Http::timeout(60)->post($url, $body); + $response = Http::timeout(120)->post($url, $body); } if (! $response->successful()) { @@ -407,6 +412,14 @@ private function callGemini(string $prompt): ?string } } + /** + * JSON 응답 파싱 (public - 외부 서비스에서도 사용) + */ + public function parseJson(string $text): ?array + { + return $this->parseJsonResponse($text); + } + /** * JSON 응답 파싱 (코드블록 제거 포함) */ diff --git a/app/Services/Video/ScreenAnalysisService.php b/app/Services/Video/ScreenAnalysisService.php new file mode 100644 index 00000000..7d5b29ca --- /dev/null +++ b/app/Services/Video/ScreenAnalysisService.php @@ -0,0 +1,136 @@ +gemini = $gemini; + } + + /** + * 스크린샷 배열을 Gemini Vision으로 분석 + * + * @param array $imagePaths 스크린샷 파일 경로 배열 + * @return array 분석 결과 배열 + */ + public function analyzeScreenshots(array $imagePaths): array + { + $results = []; + + foreach ($imagePaths as $index => $imagePath) { + $screenNumber = $index + 1; + + Log::info("ScreenAnalysis: 스크린샷 {$screenNumber}/" . count($imagePaths) . " 분석 시작", [ + 'path' => $imagePath, + ]); + + $result = $this->analyzeSingleScreen($imagePath, $screenNumber, count($imagePaths)); + + if ($result) { + $results[] = $result; + } else { + Log::warning("ScreenAnalysis: 스크린샷 {$screenNumber} 분석 실패, 기본값 사용"); + $results[] = [ + 'screen_number' => $screenNumber, + 'title' => "화면 {$screenNumber}", + 'narration' => "이 화면에서는 주요 기능을 확인할 수 있습니다.", + 'ui_elements' => [], + 'duration' => 8, + ]; + } + } + + return $results; + } + + /** + * 단일 스크린샷 분석 + */ + private function analyzeSingleScreen(string $imagePath, int $screenNumber, int $totalScreens): ?array + { + if (! file_exists($imagePath)) { + Log::error("ScreenAnalysis: 파일 없음 - {$imagePath}"); + return null; + } + + $imageData = base64_encode(file_get_contents($imagePath)); + $mimeType = mime_content_type($imagePath) ?: 'image/png'; + + $prompt = << $prompt], + [ + 'inlineData' => [ + 'mimeType' => $mimeType, + 'data' => $imageData, + ], + ], + ]; + + $result = $this->gemini->callGeminiWithParts($parts, 0.3, 2048); + + if (! $result) { + return null; + } + + $parsed = $this->gemini->parseJson($result); + + if (! $parsed || ! isset($parsed['screen_number'])) { + Log::warning('ScreenAnalysis: JSON 파싱 실패 또는 형식 불일치', [ + 'result' => substr($result, 0, 300), + ]); + return null; + } + + // screen_number 강제 보정 + $parsed['screen_number'] = $screenNumber; + + return $parsed; + } +} diff --git a/app/Services/Video/SlideAnnotationService.php b/app/Services/Video/SlideAnnotationService.php new file mode 100644 index 00000000..9ff57908 --- /dev/null +++ b/app/Services/Video/SlideAnnotationService.php @@ -0,0 +1,278 @@ +fontPath = '/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf'; + if (! file_exists($this->fontPath)) { + $this->fontPath = '/usr/share/fonts/truetype/nanum/NanumGothic.ttf'; + } + } + + /** + * 스크린샷 위에 시각적 어노테이션 추가 + * + * @param string $imagePath 원본 스크린샷 경로 + * @param array $uiElements UI 요소 배열 [{type, label, x, y, description}] + * @param int $stepNumber 현재 스텝 번호 + * @param string $caption 하단 캡션 텍스트 + * @param string $outputPath 출력 파일 경로 + * @return string|null 성공 시 출력 경로 + */ + public function annotateSlide( + string $imagePath, + array $uiElements, + int $stepNumber, + string $caption, + string $outputPath + ): ?string { + if (! file_exists($imagePath)) { + Log::error("SlideAnnotation: 원본 이미지 없음 - {$imagePath}"); + return null; + } + + try { + $dir = dirname($outputPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + // 원본 이미지 로드 + $source = $this->loadImage($imagePath); + if (! $source) { + return null; + } + + $srcW = imagesx($source); + $srcH = imagesy($source); + + // 16:9 캔버스 생성 + $canvas = imagecreatetruecolor(self::TARGET_WIDTH, self::TARGET_HEIGHT); + + // 배경 검정 + $black = imagecolorallocate($canvas, 0, 0, 0); + imagefill($canvas, 0, 0, $black); + + // 캡션 영역 제외한 영역에 이미지 리사이즈+센터링 + $availH = self::TARGET_HEIGHT - self::CAPTION_HEIGHT; + $scale = min(self::TARGET_WIDTH / $srcW, $availH / $srcH); + $newW = (int) ($srcW * $scale); + $newH = (int) ($srcH * $scale); + $offsetX = (int) ((self::TARGET_WIDTH - $newW) / 2); + $offsetY = (int) (($availH - $newH) / 2); + + imagecopyresampled($canvas, $source, $offsetX, $offsetY, 0, 0, $newW, $newH, $srcW, $srcH); + imagedestroy($source); + + // 반투명 오버레이 (약간 dim) + $overlay = imagecolorallocatealpha($canvas, 0, 0, 0, 100); // 약간 어둡게 + imagefilledrectangle($canvas, $offsetX, $offsetY, $offsetX + $newW, $offsetY + $newH, $overlay); + + // UI 요소 마커 그리기 + $this->drawMarkers($canvas, $uiElements, $offsetX, $offsetY, $newW, $newH); + + // 하단 캡션 바 + $this->drawCaptionBar($canvas, $caption, $stepNumber); + + // 상단 스텝 인디케이터 + $this->drawStepBadge($canvas, $stepNumber); + + // PNG로 저장 + imagepng($canvas, $outputPath, 6); + imagedestroy($canvas); + + Log::info("SlideAnnotation: 슬라이드 생성 완료", ['output' => $outputPath]); + + return $outputPath; + } catch (\Exception $e) { + Log::error("SlideAnnotation: 예외 발생", ['error' => $e->getMessage()]); + return null; + } + } + + /** + * UI 요소 위치에 빨간 번호 마커 그리기 + */ + private function drawMarkers(\GdImage $canvas, array $uiElements, int $offsetX, int $offsetY, int $imgW, int $imgH): void + { + $red = imagecolorallocate($canvas, 239, 68, 68); + $white = imagecolorallocate($canvas, 255, 255, 255); + $highlightBg = imagecolorallocatealpha($canvas, 239, 68, 68, 100); + + foreach ($uiElements as $i => $element) { + $x = $element['x'] ?? 0.5; + $y = $element['y'] ?? 0.5; + + // 비율 좌표 → 실제 픽셀 좌표 + $px = $offsetX + (int) ($x * $imgW); + $py = $offsetY + (int) ($y * $imgH); + + // 하이라이트 영역 (요소 주변 밝게) + $hlSize = 60; + imagefilledrectangle( + $canvas, + max(0, $px - $hlSize), + max(0, $py - $hlSize), + min(self::TARGET_WIDTH, $px + $hlSize), + min(self::TARGET_HEIGHT - self::CAPTION_HEIGHT, $py + $hlSize), + $highlightBg + ); + + // 빨간 원형 배지 + $r = self::MARKER_RADIUS; + imagefilledellipse($canvas, $px, $py, $r * 2, $r * 2, $red); + + // 흰색 테두리 + imageellipse($canvas, $px, $py, $r * 2, $r * 2, $white); + + // 번호 텍스트 + $num = (string) ($i + 1); + $fontSize = 14; + + if (file_exists($this->fontPath)) { + $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $num); + $tw = $bbox[2] - $bbox[0]; + $th = $bbox[1] - $bbox[7]; + imagettftext($canvas, $fontSize, 0, $px - (int) ($tw / 2), $py + (int) ($th / 2), $white, $this->fontPath, $num); + } else { + imagestring($canvas, 5, $px - 4, $py - 7, $num, $white); + } + } + } + + /** + * 하단 캡션 바 그리기 + */ + private function drawCaptionBar(\GdImage $canvas, string $caption, int $stepNumber): void + { + $barY = self::TARGET_HEIGHT - self::CAPTION_HEIGHT; + + // 반투명 검정 배경 + $barBg = imagecolorallocatealpha($canvas, 0, 0, 0, 40); + imagefilledrectangle($canvas, 0, $barY, self::TARGET_WIDTH, self::TARGET_HEIGHT, $barBg); + + // 상단 구분선 (인디고) + $accent = imagecolorallocate($canvas, 79, 70, 229); + imagefilledrectangle($canvas, 0, $barY, self::TARGET_WIDTH, $barY + 3, $accent); + + // 캡션 텍스트 + $white = imagecolorallocate($canvas, 255, 255, 255); + $fontSize = 20; + $textY = $barY + 55; + + if (file_exists($this->fontPath)) { + // 텍스트 줄바꿈 처리 + $wrappedText = $this->wrapText($caption, 60); + $lines = explode("\n", $wrappedText); + + $lineHeight = 30; + $startY = $barY + 20 + $lineHeight; + + if (count($lines) > 1) { + $fontSize = 17; + $lineHeight = 26; + $startY = $barY + 15 + $lineHeight; + } + + foreach ($lines as $li => $line) { + $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $line); + $tw = $bbox[2] - $bbox[0]; + $tx = (int) ((self::TARGET_WIDTH - $tw) / 2); + imagettftext($canvas, $fontSize, 0, $tx, $startY + ($li * $lineHeight), $white, $this->fontPath, $line); + } + } else { + imagestring($canvas, 5, 40, $textY, $caption, $white); + } + } + + /** + * 좌상단 스텝 배지 그리기 + */ + private function drawStepBadge(\GdImage $canvas, int $stepNumber): void + { + $badgeBg = imagecolorallocate($canvas, 79, 70, 229); + $white = imagecolorallocate($canvas, 255, 255, 255); + + // 둥근 사각형 배지 (좌상단) + $bx = 30; + $by = 20; + $bw = 140; + $bh = 44; + + imagefilledrectangle($canvas, $bx, $by, $bx + $bw, $by + $bh, $badgeBg); + + $text = "STEP {$stepNumber}"; + + if (file_exists($this->fontPath)) { + $fontSize = 18; + $bbox = imagettfbbox($fontSize, 0, $this->fontPath, $text); + $tw = $bbox[2] - $bbox[0]; + $tx = $bx + (int) (($bw - $tw) / 2); + imagettftext($canvas, $fontSize, 0, $tx, $by + 32, $white, $this->fontPath, $text); + } else { + imagestring($canvas, 5, $bx + 20, $by + 12, $text, $white); + } + } + + /** + * 이미지 로드 (형식 자동 감지) + */ + private function loadImage(string $path): ?\GdImage + { + $info = getimagesize($path); + if (! $info) { + Log::error("SlideAnnotation: 이미지 정보를 읽을 수 없음 - {$path}"); + return null; + } + + return match ($info[2]) { + IMAGETYPE_PNG => imagecreatefrompng($path), + IMAGETYPE_JPEG => imagecreatefromjpeg($path), + IMAGETYPE_GIF => imagecreatefromgif($path), + IMAGETYPE_WEBP => imagecreatefromwebp($path), + default => null, + }; + } + + /** + * 텍스트 줄바꿈 + */ + private function wrapText(string $text, int $maxChars): string + { + if (mb_strlen($text) <= $maxChars) { + return $text; + } + + $words = preg_split('/(?<=\s)|(?<=\.)|(?<=,)/u', $text); + $lines = []; + $currentLine = ''; + + foreach ($words as $word) { + if (mb_strlen($currentLine . $word) > $maxChars && $currentLine !== '') { + $lines[] = trim($currentLine); + $currentLine = $word; + } else { + $currentLine .= $word; + } + } + + if ($currentLine !== '') { + $lines[] = trim($currentLine); + } + + return implode("\n", array_slice($lines, 0, 3)); + } +} diff --git a/app/Services/Video/TutorialAssemblyService.php b/app/Services/Video/TutorialAssemblyService.php new file mode 100644 index 00000000..1446433a --- /dev/null +++ b/app/Services/Video/TutorialAssemblyService.php @@ -0,0 +1,292 @@ +videoAssembly = $videoAssembly; + } + + /** + * 어노테이션 이미지들 → MP4 영상 합성 + * + * @param array $slidePaths 슬라이드 이미지 경로 배열 + * @param array $durations 각 슬라이드 표시 시간(초) 배열 + * @param string|null $narrationPath 나레이션 오디오 경로 + * @param string|null $bgmPath BGM 오디오 경로 + * @param string $subtitlePath ASS 자막 파일 경로 + * @param string $outputPath 최종 MP4 출력 경로 + * @return string|null 성공 시 출력 경로 + */ + public function assembleFromImages( + array $slidePaths, + array $durations, + ?string $narrationPath, + ?string $bgmPath, + string $subtitlePath, + string $outputPath + ): ?string { + if (empty($slidePaths)) { + Log::error('TutorialAssembly: 슬라이드가 없습니다'); + return null; + } + + $dir = dirname($outputPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + // Step 1: 이미지 시퀀스 → 무음 MP4 (crossfade 포함) + $silentVideoPath = "{$dir}/silent_video.mp4"; + $silentVideo = $this->imagesToVideo($slidePaths, $durations, $silentVideoPath); + + if (! $silentVideo) { + Log::error('TutorialAssembly: 이미지→영상 변환 실패'); + return null; + } + + // Step 2: 무음 영상 + 나레이션 + BGM + 자막 합성 + $result = $this->videoAssembly->assemble( + $silentVideo, + $narrationPath, + $bgmPath, + $subtitlePath, + $outputPath + ); + + // 임시 파일 정리 + @unlink($silentVideoPath); + + return $result; + } + + /** + * 이미지 시퀀스를 crossfade 트랜지션으로 영상 변환 + */ + private function imagesToVideo(array $slidePaths, array $durations, string $outputPath): ?string + { + $count = count($slidePaths); + + if ($count === 0) { + return null; + } + + // 이미지 1장인 경우 단순 변환 + if ($count === 1) { + $duration = $durations[0] ?? 8; + $cmd = sprintf( + 'ffmpeg -y -loop 1 -i %s -c:v libx264 -t %d -pix_fmt yuv420p -vf "scale=1920:1080" -r 30 %s 2>&1', + escapeshellarg($slidePaths[0]), + $duration, + escapeshellarg($outputPath) + ); + + exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + Log::error('TutorialAssembly: 단일 이미지 변환 실패', [ + 'output' => implode("\n", array_slice($output, -10)), + ]); + return null; + } + + return $outputPath; + } + + // 여러 장인 경우: 각 이미지를 개별 영상으로 만든 후 xfade로 결합 + $fadeDuration = 0.5; + $dir = dirname($outputPath); + $clipPaths = []; + + // Step 1: 각 이미지를 개별 클립으로 변환 + foreach ($slidePaths as $i => $slidePath) { + $duration = $durations[$i] ?? 8; + $clipPath = "{$dir}/clip_{$i}.mp4"; + + $cmd = sprintf( + 'ffmpeg -y -loop 1 -i %s -c:v libx264 -t %s -pix_fmt yuv420p -vf "scale=1920:1080" -r 30 %s 2>&1', + escapeshellarg($slidePath), + $duration, + escapeshellarg($clipPath) + ); + + exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + Log::error("TutorialAssembly: 클립 {$i} 변환 실패", [ + 'output' => implode("\n", array_slice($output, -10)), + ]); + // 실패 시 crossfade 없이 fallback + return $this->imagesToVideoSimple($slidePaths, $durations, $outputPath); + } + + $clipPaths[] = $clipPath; + } + + // Step 2: xfade 필터로 crossfade 결합 + $result = $this->xfadeConcat($clipPaths, $durations, $fadeDuration, $outputPath); + + // 임시 클립 정리 + foreach ($clipPaths as $path) { + @unlink($path); + } + + return $result; + } + + /** + * xfade 필터를 사용한 crossfade 결합 + */ + private function xfadeConcat(array $clipPaths, array $durations, float $fadeDuration, string $outputPath): ?string + { + $count = count($clipPaths); + + if ($count < 2) { + return null; + } + + // 2개인 경우 단순 xfade + if ($count === 2) { + $offset = max(0, ($durations[0] ?? 8) - $fadeDuration); + $cmd = sprintf( + 'ffmpeg -y -i %s -i %s -filter_complex "[0:v][1:v]xfade=transition=fade:duration=%s:offset=%s[v]" -map "[v]" -c:v libx264 -preset fast -crf 23 -r 30 -pix_fmt yuv420p %s 2>&1', + escapeshellarg($clipPaths[0]), + escapeshellarg($clipPaths[1]), + $fadeDuration, + $offset, + escapeshellarg($outputPath) + ); + + exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + Log::warning('TutorialAssembly: xfade 실패, concat fallback', [ + 'output' => implode("\n", array_slice($output, -10)), + ]); + return $this->simpleConcatClips($clipPaths, $outputPath); + } + + return $outputPath; + } + + // 3개 이상: 체인 xfade + $inputs = ''; + foreach ($clipPaths as $path) { + $inputs .= '-i ' . escapeshellarg($path) . ' '; + } + + $filter = ''; + $cumulativeOffset = 0; + + for ($i = 0; $i < $count - 1; $i++) { + $cumulativeOffset += ($durations[$i] ?? 8) - $fadeDuration; + + $inputA = ($i === 0) ? '[0:v]' : "[v{$i}]"; + $inputB = '[' . ($i + 1) . ':v]'; + $outputLabel = ($i === $count - 2) ? '[v]' : "[v" . ($i + 1) . "]"; + + $filter .= "{$inputA}{$inputB}xfade=transition=fade:duration={$fadeDuration}:offset={$cumulativeOffset}{$outputLabel}"; + + if ($i < $count - 2) { + $filter .= ';'; + } + } + + $cmd = sprintf( + 'ffmpeg -y %s -filter_complex "%s" -map "[v]" -c:v libx264 -preset fast -crf 23 -r 30 -pix_fmt yuv420p %s 2>&1', + $inputs, + $filter, + escapeshellarg($outputPath) + ); + + exec($cmd, $output, $returnCode); + + if ($returnCode !== 0) { + Log::warning('TutorialAssembly: 체인 xfade 실패, concat fallback', [ + 'output' => implode("\n", array_slice($output, -10)), + ]); + return $this->simpleConcatClips($clipPaths, $outputPath); + } + + return $outputPath; + } + + /** + * 단순 concat fallback (crossfade 실패 시) + */ + private function simpleConcatClips(array $clipPaths, string $outputPath): ?string + { + $dir = dirname($outputPath); + $listFile = "{$dir}/concat_tutorial.txt"; + $listContent = ''; + + foreach ($clipPaths as $path) { + $listContent .= "file " . escapeshellarg($path) . "\n"; + } + + file_put_contents($listFile, $listContent); + + $cmd = sprintf( + 'ffmpeg -y -f concat -safe 0 -i %s -c copy %s 2>&1', + escapeshellarg($listFile), + escapeshellarg($outputPath) + ); + + exec($cmd, $output, $returnCode); + @unlink($listFile); + + if ($returnCode !== 0) { + Log::error('TutorialAssembly: concat fallback도 실패', [ + 'output' => implode("\n", array_slice($output, -10)), + ]); + return null; + } + + return $outputPath; + } + + /** + * crossfade 없이 단순 이미지→영상 변환 fallback + */ + private function imagesToVideoSimple(array $slidePaths, array $durations, string $outputPath): ?string + { + $dir = dirname($outputPath); + $clipPaths = []; + + foreach ($slidePaths as $i => $slidePath) { + $duration = $durations[$i] ?? 8; + $clipPath = "{$dir}/simple_clip_{$i}.mp4"; + + $cmd = sprintf( + 'ffmpeg -y -loop 1 -i %s -c:v libx264 -t %d -pix_fmt yuv420p -vf "scale=1920:1080" -r 30 %s 2>&1', + escapeshellarg($slidePath), + $duration, + escapeshellarg($clipPath) + ); + + exec($cmd, $output, $returnCode); + + if ($returnCode === 0) { + $clipPaths[] = $clipPath; + } + } + + if (empty($clipPaths)) { + return null; + } + + $result = $this->simpleConcatClips($clipPaths, $outputPath); + + foreach ($clipPaths as $path) { + @unlink($path); + } + + return $result; + } +} diff --git a/resources/views/video/tutorial/index.blade.php b/resources/views/video/tutorial/index.blade.php new file mode 100644 index 00000000..53f9758e --- /dev/null +++ b/resources/views/video/tutorial/index.blade.php @@ -0,0 +1,636 @@ +@extends('layouts.app') + +@section('title', 'SAM 매뉴얼 영상 생성기') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') +@include('partials.react-cdn') +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 45107abe..34f5f7ec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1476,6 +1476,23 @@ Route::delete('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'destroy'])->name('destroy'); }); +/* +|-------------------------------------------------------------------------- +| Tutorial Video Generator (사용자 매뉴얼 영상 자동 생성) +|-------------------------------------------------------------------------- +*/ +Route::prefix('video/tutorial')->name('video.tutorial.')->middleware('auth')->group(function () { + Route::get('/', [\App\Http\Controllers\Video\TutorialVideoController::class, 'index'])->name('index'); + Route::post('/upload', [\App\Http\Controllers\Video\TutorialVideoController::class, 'upload'])->name('upload'); + Route::post('/analyze', [\App\Http\Controllers\Video\TutorialVideoController::class, 'analyze'])->name('analyze'); + Route::post('/generate', [\App\Http\Controllers\Video\TutorialVideoController::class, 'generate'])->name('generate'); + Route::get('/status/{id}', [\App\Http\Controllers\Video\TutorialVideoController::class, 'status'])->name('status'); + Route::get('/download/{id}', [\App\Http\Controllers\Video\TutorialVideoController::class, 'download'])->name('download'); + Route::get('/preview/{id}', [\App\Http\Controllers\Video\TutorialVideoController::class, 'preview'])->name('preview'); + Route::get('/history', [\App\Http\Controllers\Video\TutorialVideoController::class, 'history'])->name('history'); + Route::delete('/history', [\App\Http\Controllers\Video\TutorialVideoController::class, 'destroy'])->name('destroy'); +}); + /* |-------------------------------------------------------------------------- | SAM E-Sign Public Routes (인증 불필요 - 서명자용)