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', ]); } /** * 영상 미리보기 (스트리밍 또는 서명URL) * * ?url=1 파라미터: JSON으로 서명URL 반환 (CORS 회피용) * 그 외: 로컬 파일 직접 스트리밍 */ public function preview(Request $request, int $id): Response|RedirectResponse|JsonResponse|BinaryFileResponse { $tutorial = TutorialVideo::findOrFail($id); // ?url=1 → JSON으로 GCS 서명URL 반환 (video src에서 직접 사용) if ($request->query('url') && $tutorial->gcs_path && $this->gcsService->isAvailable()) { $signedUrl = $this->gcsService->getSignedUrl($tutorial->gcs_path, 60); if ($signedUrl) { return response()->json(['url' => $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 detail(int $id): JsonResponse { $tutorial = TutorialVideo::where('user_id', auth()->id()) ->findOrFail($id); return response()->json([ 'success' => true, 'data' => [ 'id' => $tutorial->id, 'title' => $tutorial->title, 'status' => $tutorial->status, 'progress' => $tutorial->progress, 'analysis_data' => $tutorial->analysis_data, 'slides_data' => $tutorial->slides_data, 'cost_usd' => $tutorial->cost_usd, 'created_at' => $tutorial->created_at?->toIso8601String(), ], ]); } /** * 이력 삭제 */ 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, ]); } }