feat:사용자 매뉴얼 영상 자동 생성 기능 구현
- 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>
This commit is contained in:
285
app/Http/Controllers/Video/TutorialVideoController.php
Normal file
285
app/Http/Controllers/Video/TutorialVideoController.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
255
app/Jobs/TutorialVideoJob.php
Normal file
255
app/Jobs/TutorialVideoJob.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\TutorialVideo;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use App\Services\Video\BgmService;
|
||||
use App\Services\Video\ScreenAnalysisService;
|
||||
use App\Services\Video\SlideAnnotationService;
|
||||
use App\Services\Video\TtsService;
|
||||
use App\Services\Video\TutorialAssemblyService;
|
||||
use App\Services\Video\VideoAssemblyService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TutorialVideoJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 900; // 15분
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
private int $tutorialVideoId;
|
||||
|
||||
public function __construct(int $tutorialVideoId)
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Models/TutorialVideo.php
Normal file
69
app/Models/TutorialVideo.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Scopes\TenantScope;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TutorialVideo extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'title',
|
||||
'status',
|
||||
'progress',
|
||||
'current_step',
|
||||
'screenshots',
|
||||
'analysis_data',
|
||||
'slides_data',
|
||||
'output_path',
|
||||
'gcs_path',
|
||||
'cost_usd',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'screenshots' => '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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 응답 파싱 (코드블록 제거 포함)
|
||||
*/
|
||||
|
||||
136
app/Services/Video/ScreenAnalysisService.php
Normal file
136
app/Services/Video/ScreenAnalysisService.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScreenAnalysisService
|
||||
{
|
||||
private GeminiScriptService $gemini;
|
||||
|
||||
public function __construct(GeminiScriptService $gemini)
|
||||
{
|
||||
$this->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
|
||||
당신은 SAM(Smart Automation Management) 시스템의 사용자 매뉴얼 작성 전문가입니다.
|
||||
|
||||
이 스크린샷은 SAM 시스템의 화면입니다. (화면 {$screenNumber}/{$totalScreens})
|
||||
|
||||
이 화면을 분석하고 사용자 튜토리얼 나레이션을 작성하세요.
|
||||
|
||||
=== 분석 요구사항 ===
|
||||
1. 화면의 주요 목적/기능을 파악
|
||||
2. 주요 UI 요소 식별 (버튼, 입력폼, 테이블, 메뉴, 탭 등)
|
||||
3. 각 UI 요소의 대략적인 위치를 화면 비율(0~1)로 표시
|
||||
4. 사용자가 이 화면에서 수행할 작업 순서를 안내하는 나레이션 작성
|
||||
|
||||
=== 나레이션 작성 규칙 ===
|
||||
- 친근한 존댓말 사용 (예: "~하실 수 있습니다", "~을 클릭하세요")
|
||||
- TTS로 읽을 것이므로 이모지/특수기호 금지
|
||||
- 순수 한글 텍스트만 작성
|
||||
- 문장은 마침표로 끝내기
|
||||
- 전체 나레이션은 50~120자 (약 5~10초 분량)
|
||||
|
||||
반드시 아래 JSON 형식으로만 응답하세요:
|
||||
{
|
||||
"screen_number": {$screenNumber},
|
||||
"title": "이 화면의 제목 (10자 이내)",
|
||||
"narration": "사용자 안내 나레이션 (50~120자)",
|
||||
"ui_elements": [
|
||||
{
|
||||
"type": "button|input|table|menu|tab|label|icon|other",
|
||||
"label": "UI 요소에 표시된 텍스트",
|
||||
"x": 0.5,
|
||||
"y": 0.3,
|
||||
"description": "이 요소의 기능 설명 (20자 이내)"
|
||||
}
|
||||
],
|
||||
"duration": 8
|
||||
}
|
||||
|
||||
ui_elements의 x, y는 화면 좌상단(0,0) ~ 우하단(1,1) 기준 비율 좌표입니다.
|
||||
duration은 이 화면을 보여줄 권장 시간(초)입니다 (5~12초).
|
||||
PROMPT;
|
||||
|
||||
$parts = [
|
||||
['text' => $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;
|
||||
}
|
||||
}
|
||||
278
app/Services/Video/SlideAnnotationService.php
Normal file
278
app/Services/Video/SlideAnnotationService.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SlideAnnotationService
|
||||
{
|
||||
private const TARGET_WIDTH = 1920;
|
||||
private const TARGET_HEIGHT = 1080;
|
||||
private const CAPTION_HEIGHT = 100;
|
||||
private const MARKER_RADIUS = 20;
|
||||
|
||||
private string $fontPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Docker에 설치된 나눔고딕 폰트
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
292
app/Services/Video/TutorialAssemblyService.php
Normal file
292
app/Services/Video/TutorialAssemblyService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Video;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TutorialAssemblyService
|
||||
{
|
||||
private VideoAssemblyService $videoAssembly;
|
||||
|
||||
public function __construct(VideoAssemblyService $videoAssembly)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
636
resources/views/video/tutorial/index.blade.php
Normal file
636
resources/views/video/tutorial/index.blade.php
Normal file
@@ -0,0 +1,636 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'SAM 매뉴얼 영상 생성기')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.step-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 2rem; height: 2rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 700;
|
||||
}
|
||||
.step-active { background: #4f46e5; color: #fff; }
|
||||
.step-done { background: #22c55e; color: #fff; }
|
||||
.step-pending { background: #e5e7eb; color: #6b7280; }
|
||||
.progress-bar { height: 0.75rem; border-radius: 9999px; transition: all 0.5s ease-out; }
|
||||
.fade-in { animation: fadeIn 0.3s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.upload-zone {
|
||||
border: 2px dashed #d1d5db; border-radius: 0.75rem; padding: 2rem;
|
||||
text-align: center; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.upload-zone:hover, .upload-zone.dragover { border-color: #818cf8; background: #eef2ff; }
|
||||
.screenshot-thumb {
|
||||
position: relative; width: 120px; height: 90px; border-radius: 0.5rem;
|
||||
overflow: hidden; border: 2px solid #e5e7eb; cursor: grab;
|
||||
}
|
||||
.screenshot-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.screenshot-thumb .remove-btn {
|
||||
position: absolute; top: 2px; right: 2px; width: 20px; height: 20px;
|
||||
background: rgba(239,68,68,0.9); color: #fff; border: none; border-radius: 9999px;
|
||||
font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.screenshot-thumb .order-badge {
|
||||
position: absolute; bottom: 2px; left: 2px; width: 20px; height: 20px;
|
||||
background: rgba(79,70,229,0.9); color: #fff; border-radius: 9999px;
|
||||
font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.analysis-card {
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem;
|
||||
padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||
}
|
||||
.ui-element-tag {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem; background: #f3f4f6; border-radius: 9999px;
|
||||
font-size: 0.75rem; color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="tutorial-root"></div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@include('partials.react-cdn')
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback, useRef } = React;
|
||||
|
||||
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
|
||||
const api = (url, options = {}) => {
|
||||
const headers = { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json', ...options.headers };
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return fetch(url, { headers, ...options }).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step Indicator
|
||||
// ============================================================
|
||||
const StepIndicator = ({ currentStep }) => {
|
||||
const steps = [
|
||||
{ num: 1, label: '업로드' },
|
||||
{ num: 2, label: 'AI 분석' },
|
||||
{ num: 3, label: '영상 생성' },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{steps.map((step, i) => (
|
||||
<React.Fragment key={step.num}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`step-badge ${currentStep > step.num ? 'step-done' : currentStep === step.num ? 'step-active' : 'step-pending'}`}>
|
||||
{currentStep > step.num ? '\u2713' : step.num}
|
||||
</div>
|
||||
<span className={`text-xs mt-1 ${currentStep >= step.num ? 'text-indigo-600 font-medium' : 'text-gray-400'}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className={`w-12 h-0.5 mt-[-12px] ${currentStep > step.num ? 'bg-green-500' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 1: Screenshot Upload
|
||||
// ============================================================
|
||||
const ScreenshotUpload = ({ onUploadComplete, loading, setLoading }) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [previews, setPreviews] = useState([]);
|
||||
const [dragover, setDragover] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleFiles = useCallback((newFiles) => {
|
||||
const imageFiles = Array.from(newFiles).filter(f => f.type.startsWith('image/'));
|
||||
const total = [...files, ...imageFiles].slice(0, 10);
|
||||
setFiles(total);
|
||||
|
||||
const newPreviews = total.map(f => URL.createObjectURL(f));
|
||||
setPreviews(prev => { prev.forEach(URL.revokeObjectURL); return newPreviews; });
|
||||
}, [files]);
|
||||
|
||||
const removeFile = (index) => {
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
setFiles(newFiles);
|
||||
setPreviews(prev => {
|
||||
URL.revokeObjectURL(prev[index]);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
files.forEach(f => formData.append('screenshots[]', f));
|
||||
const data = await api('/video/tutorial/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'X-CSRF-TOKEN': CSRF, 'Accept': 'application/json' },
|
||||
});
|
||||
onUploadComplete(data.paths);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-2xl mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">SAM 화면 스크린샷 업로드</h2>
|
||||
<p className="text-gray-500 mt-2">AI가 화면을 분석하여 사용자 매뉴얼 영상을 자동 생성합니다</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`upload-zone ${dragover ? 'dragover' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragover(true); }}
|
||||
onDragLeave={() => setDragover(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setDragover(false); handleFiles(e.dataTransfer.files); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
클릭하거나 파일을 드래그하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG, GIF (최대 10장, 각 10MB)</p>
|
||||
</div>
|
||||
|
||||
{previews.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
업로드된 스크린샷 ({previews.length}/10)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{previews.map((src, i) => (
|
||||
<div key={i} className="screenshot-thumb">
|
||||
<img src={src} alt={`Screenshot ${i+1}`} />
|
||||
<button className="remove-btn" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
||||
x
|
||||
</button>
|
||||
<div className="order-badge">{i + 1}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/></svg>
|
||||
업로드 중...
|
||||
</span>
|
||||
) : `${files.length}장 업로드 & AI 분석 시작`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 2: Analysis Review
|
||||
// ============================================================
|
||||
const AnalysisReview = ({ analysis, paths, onConfirm, onBack, loading, setLoading }) => {
|
||||
const [editedAnalysis, setEditedAnalysis] = useState(analysis);
|
||||
const [title, setTitle] = useState('SAM 사용자 매뉴얼');
|
||||
|
||||
const updateNarration = (index, newNarration) => {
|
||||
const updated = [...editedAnalysis];
|
||||
updated[index] = { ...updated[index], narration: newNarration };
|
||||
setEditedAnalysis(updated);
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
onConfirm(editedAnalysis, title);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-3xl mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">AI 분석 결과 확인</h2>
|
||||
<p className="text-gray-500 mt-2">나레이션을 편집하고 영상을 생성하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">영상 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="영상 제목을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editedAnalysis.map((screen, i) => (
|
||||
<div key={i} className="analysis-card">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="step-badge step-active">{screen.screen_number || i + 1}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{screen.title}</div>
|
||||
<div className="text-xs text-gray-400">표시 시간: {screen.duration}초</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">나레이션 (편집 가능)</label>
|
||||
<textarea
|
||||
value={screen.narration}
|
||||
onChange={(e) => updateNarration(i, e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
|
||||
/>
|
||||
<div className="text-xs text-gray-400 mt-1">{screen.narration?.length || 0}자</div>
|
||||
</div>
|
||||
|
||||
{screen.ui_elements && screen.ui_elements.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">인식된 UI 요소</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{screen.ui_elements.map((el, j) => (
|
||||
<span key={j} className="ui-element-tag">
|
||||
<span className="text-indigo-500 font-bold">{j + 1}</span>
|
||||
{el.label || el.type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-3 mt-6 justify-center">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? '생성 시작 중...' : '영상 생성 시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Step 3: Generation Progress
|
||||
// ============================================================
|
||||
const GenerationProgress = ({ tutorialId, onComplete, onReset }) => {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [polling, setPolling] = useState(true);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tutorialId || !polling) return;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await api(`/video/tutorial/status/${tutorialId}`);
|
||||
setStatus(data);
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
setPolling(false);
|
||||
if (data.status === 'completed') onComplete?.();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
intervalRef.current = setInterval(poll, 2000);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [tutorialId, polling]);
|
||||
|
||||
const isCompleted = status?.status === 'completed';
|
||||
const isFailed = status?.status === 'failed';
|
||||
const progress = status?.progress || 0;
|
||||
|
||||
return (
|
||||
<div className="fade-in max-w-lg mx-auto text-center">
|
||||
{!isCompleted && !isFailed && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<svg className="animate-spin h-8 w-8 text-indigo-600 mx-auto" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">영상 생성 중...</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{status?.current_step || '대기 중...'}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full mb-2">
|
||||
<div
|
||||
className={`progress-bar ${isCompleted ? 'bg-green-500' : isFailed ? 'bg-red-500' : 'bg-indigo-600'}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mb-6">{progress}%</div>
|
||||
|
||||
{isCompleted && (
|
||||
<div className="fade-in">
|
||||
<div className="text-green-600 text-lg font-bold mb-4">영상 생성 완료!</div>
|
||||
|
||||
<div className="mb-6 rounded-xl overflow-hidden border border-gray-200 shadow-sm">
|
||||
<video
|
||||
controls
|
||||
className="w-full"
|
||||
src={`/video/tutorial/preview/${tutorialId}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<a
|
||||
href={`/video/tutorial/download/${tutorialId}`}
|
||||
className="px-5 py-2.5 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
다운로드
|
||||
</a>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
새로 만들기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status?.cost_usd > 0 && (
|
||||
<div className="mt-3 text-xs text-gray-400">예상 비용: ${Number(status.cost_usd).toFixed(4)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<div className="fade-in">
|
||||
<div className="text-red-600 text-lg font-bold mb-2">생성 실패</div>
|
||||
<p className="text-sm text-red-500 mb-4">{status?.error_message || '알 수 없는 오류'}</p>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// History Table
|
||||
// ============================================================
|
||||
const HistoryTable = ({ refreshKey }) => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const data = await api('/video/tutorial/history');
|
||||
setItems(data.data || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchHistory(); }, [refreshKey]);
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelected(selected.length === items.length ? [] : items.map(i => i.id));
|
||||
};
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (selected.length === 0 || !confirm(`${selected.length}개 항목을 삭제하시겠습니까?`)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api('/video/tutorial/history', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ ids: selected }),
|
||||
});
|
||||
setSelected([]);
|
||||
fetchHistory();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status) => {
|
||||
const map = {
|
||||
pending: { label: '대기', cls: 'bg-gray-100 text-gray-600' },
|
||||
analyzing: { label: '분석중', cls: 'bg-blue-100 text-blue-700' },
|
||||
generating_slides: { label: '슬라이드', cls: 'bg-blue-100 text-blue-700' },
|
||||
generating_tts: { label: 'TTS', cls: 'bg-blue-100 text-blue-700' },
|
||||
generating_bgm: { label: 'BGM', cls: 'bg-blue-100 text-blue-700' },
|
||||
assembling: { label: '합성중', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
completed: { label: '완료', cls: 'bg-green-100 text-green-700' },
|
||||
failed: { label: '실패', cls: 'bg-red-100 text-red-700' },
|
||||
};
|
||||
const s = map[status] || { label: status, cls: 'bg-gray-100 text-gray-600' };
|
||||
return <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-10 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-bold text-gray-800">생성 이력</h3>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
onClick={deleteSelected}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{selected.length}개 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left w-8">
|
||||
<input type="checkbox" checked={selected.length === items.length && items.length > 0} onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left">제목</th>
|
||||
<th className="px-3 py-2.5 text-center">상태</th>
|
||||
<th className="px-3 py-2.5 text-center">진행</th>
|
||||
<th className="px-3 py-2.5 text-center">비용</th>
|
||||
<th className="px-3 py-2.5 text-center">생성일</th>
|
||||
<th className="px-3 py-2.5 text-center">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelect(item.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-800 font-medium">{item.title || `#${item.id}`}</td>
|
||||
<td className="px-3 py-2 text-center">{statusLabel(item.status)}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-500">{item.progress}%</td>
|
||||
<td className="px-3 py-2 text-center text-gray-500">${Number(item.cost_usd || 0).toFixed(3)}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-400 text-xs">
|
||||
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{item.status === 'completed' && (
|
||||
<div className="flex gap-1 justify-center">
|
||||
<a href={`/video/tutorial/preview/${item.id}`} target="_blank" className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">보기</a>
|
||||
<span className="text-gray-300">|</span>
|
||||
<a href={`/video/tutorial/download/${item.id}`} className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">저장</a>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Main App
|
||||
// ============================================================
|
||||
const TutorialApp = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paths, setPaths] = useState([]);
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const [tutorialId, setTutorialId] = useState(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleUploadComplete = async (uploadedPaths) => {
|
||||
setPaths(uploadedPaths);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api('/video/tutorial/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paths: uploadedPaths }),
|
||||
});
|
||||
setAnalysis(data.analysis);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
alert('AI 분석 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (editedAnalysis, title) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api('/video/tutorial/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paths, analysis: editedAnalysis, title }),
|
||||
});
|
||||
setTutorialId(data.id);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
alert('생성 시작 실패: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStep(1);
|
||||
setPaths([]);
|
||||
setAnalysis(null);
|
||||
setTutorialId(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-6 px-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">SAM 매뉴얼 영상 생성기</h1>
|
||||
<p className="text-gray-500 mt-1">스크린샷을 업로드하면 AI가 자동으로 튜토리얼 영상을 만들어 드립니다</p>
|
||||
</div>
|
||||
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
{step === 1 && (
|
||||
<ScreenshotUpload
|
||||
onUploadComplete={handleUploadComplete}
|
||||
loading={loading}
|
||||
setLoading={setLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && analysis && (
|
||||
<AnalysisReview
|
||||
analysis={analysis}
|
||||
paths={paths}
|
||||
onConfirm={handleGenerate}
|
||||
onBack={() => setStep(1)}
|
||||
loading={loading}
|
||||
setLoading={setLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && tutorialId && (
|
||||
<GenerationProgress
|
||||
tutorialId={tutorialId}
|
||||
onComplete={() => setRefreshKey(k => k + 1)}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<HistoryTable refreshKey={refreshKey} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('tutorial-root'));
|
||||
root.render(<TutorialApp />);
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
@@ -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 (인증 불필요 - 서명자용)
|
||||
|
||||
Reference in New Issue
Block a user