diff --git a/app/Http/Controllers/Rd/CmSongController.php b/app/Http/Controllers/Rd/CmSongController.php index ff035123..6c5bb335 100644 --- a/app/Http/Controllers/Rd/CmSongController.php +++ b/app/Http/Controllers/Rd/CmSongController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers\Rd; use App\Http\Controllers\Controller; +use App\Models\Rd\CmSong; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Illuminate\View\View; class CmSongController extends Controller @@ -21,7 +24,7 @@ public function __construct() } /** - * CM송 제작 페이지 + * CM송 목록 */ public function index(Request $request): View|\Illuminate\Http\Response { @@ -29,7 +32,37 @@ public function index(Request $request): View|\Illuminate\Http\Response return response('', 200)->header('HX-Redirect', route('rd.cm-song.index')); } - return view('rd.cm-song.index'); + $songs = CmSong::with('user') + ->orderByDesc('created_at') + ->paginate(20); + + return view('rd.cm-song.index', compact('songs')); + } + + /** + * CM송 제작 페이지 + */ + public function create(Request $request): View|\Illuminate\Http\Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('rd.cm-song.create')); + } + + return view('rd.cm-song.create'); + } + + /** + * CM송 상세 + */ + public function show(Request $request, int $id): View|\Illuminate\Http\Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('rd.cm-song.show', $id)); + } + + $song = CmSong::with('user')->findOrFail($id); + + return view('rd.cm-song.show', compact('song')); } /** @@ -41,16 +74,25 @@ public function generateLyrics(Request $request): JsonResponse 'company_name' => 'required|string|max:100', 'industry' => 'required|string|max:200', 'mood' => 'required|string|max:50', + 'duration' => 'required|integer|min:10|max:60', ]); - $prompt = "당신은 전문 CM송 작사가입니다. 다음 정보를 바탕으로 짧고 기억에 남는 15초 분량의 라디오 CM송 가사를 작성해주세요. + $duration = $request->duration; + $lines = match (true) { + $duration <= 15 => '3~4줄', + $duration <= 30 => '6~8줄', + $duration <= 45 => '10~12줄', + default => '14~16줄', + }; + + $prompt = "당신은 전문 CM송 작사가입니다. 다음 정보를 바탕으로 기억에 남는 {$duration}초 분량의 라디오 CM송 가사를 작성해주세요. 회사명: {$request->company_name} 업종/제품: {$request->industry} 분위기: {$request->mood} 조건: -- 3~4줄로 짧게 작성 +- {$lines}로 작성 ({$duration}초 분량) - 운율을 살려서 작성 - 지시문(예: (음악 소리), (밝은 목소리로)) 없이 오직 읽을 수 있는 가사 텍스트만 출력할 것."; @@ -144,4 +186,127 @@ public function generateAudio(Request $request): JsonResponse ], 500); } } + + /** + * CM송 저장 + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'company_name' => 'required|string|max:100', + 'industry' => 'required|string|max:200', + 'mood' => 'required|string|max:50', + 'duration' => 'required|integer|min:10|max:60', + 'lyrics' => 'required|string|max:2000', + 'audio_data' => 'nullable|string', + 'audio_mime_type' => 'nullable|string', + ]); + + $user = Auth::user(); + $audioPath = null; + + // 오디오 데이터가 있으면 WAV 파일로 저장 + if ($request->audio_data) { + $mimeType = $request->audio_mime_type ?? 'audio/L16;rate=24000'; + $audioBytes = base64_decode($request->audio_data); + + if (str_contains($mimeType, 'L16') || str_contains($mimeType, 'pcm')) { + $sampleRate = 24000; + if (preg_match('/rate=(\d+)/', $mimeType, $m)) { + $sampleRate = (int) $m[1]; + } + $audioBytes = $this->pcmToWav($audioBytes, $sampleRate); + } + + $filename = 'cm-song-'.date('Ymd-His').'-'.uniqid().'.wav'; + $dir = "cm-songs/{$user->tenant_id}"; + Storage::disk('tenant')->makeDirectory($dir); + Storage::disk('tenant')->put("{$dir}/{$filename}", $audioBytes); + $audioPath = "{$dir}/{$filename}"; + } + + $song = CmSong::create([ + 'tenant_id' => $user->tenant_id, + 'user_id' => $user->id, + 'company_name' => $request->company_name, + 'industry' => $request->industry, + 'lyrics' => $request->lyrics, + 'audio_path' => $audioPath, + 'options' => [ + 'mood' => $request->mood, + 'duration' => $request->duration, + ], + ]); + + return response()->json([ + 'success' => true, + 'id' => $song->id, + 'message' => 'CM송이 저장되었습니다.', + ]); + } + + /** + * 음성 파일 다운로드 + */ + public function download(int $id) + { + $song = CmSong::findOrFail($id); + + if (! $song->audio_path || ! Storage::disk('tenant')->exists($song->audio_path)) { + abort(404, '음성 파일이 없습니다.'); + } + + $filename = "CM송_{$song->company_name}_".date('Ymd', strtotime($song->created_at)).'.wav'; + + return Storage::disk('tenant')->download($song->audio_path, $filename); + } + + /** + * CM송 삭제 + */ + public function destroy(int $id): JsonResponse + { + $song = CmSong::findOrFail($id); + + if ($song->audio_path && Storage::disk('tenant')->exists($song->audio_path)) { + Storage::disk('tenant')->delete($song->audio_path); + } + + $song->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'CM송이 삭제되었습니다.', + ]); + } + + /** + * PCM → WAV 변환 (서버사이드) + */ + private function pcmToWav(string $pcmData, int $sampleRate): string + { + $numChannels = 1; + $bitsPerSample = 16; + $byteRate = $sampleRate * $numChannels * $bitsPerSample / 8; + $blockAlign = $numChannels * $bitsPerSample / 8; + $dataSize = strlen($pcmData); + + $header = pack('A4VVA4', 'RIFF', 36 + $dataSize, 0x45564157, 'WAVEfmt '); + // 'WAVE' as little-endian is 0x45564157... actually let me write it properly + $header = 'RIFF'; + $header .= pack('V', 36 + $dataSize); + $header .= 'WAVE'; + $header .= 'fmt '; + $header .= pack('V', 16); // SubChunk1Size + $header .= pack('v', 1); // AudioFormat (PCM) + $header .= pack('v', $numChannels); + $header .= pack('V', $sampleRate); + $header .= pack('V', $byteRate); + $header .= pack('v', $blockAlign); + $header .= pack('v', $bitsPerSample); + $header .= 'data'; + $header .= pack('V', $dataSize); + + return $header.$pcmData; + } } diff --git a/app/Models/Rd/CmSong.php b/app/Models/Rd/CmSong.php new file mode 100644 index 00000000..41abc798 --- /dev/null +++ b/app/Models/Rd/CmSong.php @@ -0,0 +1,57 @@ + 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): void + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + } + + public function getMood(): string + { + return $this->getOption('mood', '-'); + } + + public function getDuration(): int + { + return $this->getOption('duration', 15); + } +} diff --git a/resources/views/rd/cm-song/create.blade.php b/resources/views/rd/cm-song/create.blade.php new file mode 100644 index 00000000..b1b8ca7f --- /dev/null +++ b/resources/views/rd/cm-song/create.blade.php @@ -0,0 +1,467 @@ +@extends('layouts.app') + +@section('title', 'AI CM송 제작') + +@section('content') + +
정보를 입력하고 버튼을 누르면
이곳에 CM송이 나타납니다.
Powered by Google Gemini AI
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/rd/cm-song/index.blade.php b/resources/views/rd/cm-song/index.blade.php index 889d92d0..94945b3e 100644 --- a/resources/views/rd/cm-song/index.blade.php +++ b/resources/views/rd/cm-song/index.blade.php @@ -1,348 +1,125 @@ @extends('layouts.app') -@section('title', 'AI CM송 제작') +@section('title', 'CM송 관리') @section('content') - -| No. | +회사명 | +업종/제품 | +분위기 | +길이 | +음성 | +생성자 | +생성일 | +관리 | +
|---|---|---|---|---|---|---|---|---|
| {{ $songs->total() - ($songs->perPage() * ($songs->currentPage() - 1)) - $loop->index }} | ++ + {{ $song->company_name }} + + | +{{ Str::limit($song->industry, 30) }} | ++ {{ $song->getMood() }} + | +{{ $song->getDuration() }}초 | ++ @if($song->audio_path) + + @else + - + @endif + | +{{ $song->user?->name ?? '-' }} | +{{ $song->created_at->format('Y-m-d H:i') }} | +
+
+ @if($song->audio_path)
+
+
+
+ @endif
+
+
+ |
+
정보를 입력하고 버튼을 누르면
이곳에 CM송이 나타납니다.
아직 생성된 CM송이 없습니다.
+ + 첫 번째 CM송을 만들어보세요 → + +{{ $song->lyrics }}
+들어보기
+음성 파일이 없습니다.
+