- 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 <noreply@anthropic.com>
286 lines
8.8 KiB
PHP
286 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Video;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Jobs\TutorialVideoJob;
|
|
use App\Models\TutorialVideo;
|
|
use App\Services\GoogleCloudStorageService;
|
|
use App\Services\Video\ScreenAnalysisService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\View\View;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
|
|
class TutorialVideoController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly GoogleCloudStorageService $gcsService,
|
|
private readonly ScreenAnalysisService $screenAnalysisService
|
|
) {}
|
|
|
|
/**
|
|
* 메인 페이지
|
|
*/
|
|
public function index(Request $request): View|Response
|
|
{
|
|
if ($request->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,
|
|
]);
|
|
}
|
|
}
|