From 272df315015bbb7462ffaeb17f570d70498a774d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 14:37:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[rd]=20CM=EC=86=A1=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=8D=94,=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C,=20=EC=A0=80=EC=9E=A5/=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10~60초 5초 간격 길이 선택 슬라이더 - 음성 파일 WAV 다운로드 - 생성 결과 DB 저장 + 목록/상세/삭제 관리 - CmSong 모델 + tenant 스토리지 연동 --- app/Http/Controllers/Rd/CmSongController.php | 173 ++++++- app/Models/Rd/CmSong.php | 57 +++ resources/views/rd/cm-song/create.blade.php | 467 +++++++++++++++++++ resources/views/rd/cm-song/index.blade.php | 427 ++++------------- resources/views/rd/cm-song/show.blade.php | 142 ++++++ routes/web.php | 5 + 6 files changed, 942 insertions(+), 329 deletions(-) create mode 100644 app/Models/Rd/CmSong.php create mode 100644 resources/views/rd/cm-song/create.blade.php create mode 100644 resources/views/rd/cm-song/show.blade.php 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') + +
+

+ + AI CM송 제작 +

+ + CM송 목록 + +
+ + +
+ +
+

+ + CM송 정보 입력 +

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + + +
+
+ + +
+ +
+ + 15초 +
+
+ 10초 + 30초 + 60초 +
+
+ + + +
+
+ + +
+

+ + 생성된 CM송 +

+ +
+ +
+ +

정보를 입력하고 버튼을 누르면
이곳에 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')

- AI CM송 제작 + CM송 관리

- - R&D 대시보드 - +
- -
- -
-

- - CM송 정보 입력 -

- -
- -
- - -
- - -
- - -
- - -
- -
- - - - -
-
- - - -
+ +
+ @if($songs->count() > 0) +
+ + + + + + + + + + + + + + + + @foreach($songs as $song) + + + + + + + + + + + + @endforeach + +
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송이 나타납니다.

-
- - - - - - -
+ @if($songs->hasPages()) +
+ {{ $songs->links() }}
+ @endif + @else +
+ +

아직 생성된 CM송이 없습니다.

+ + 첫 번째 CM송을 만들어보세요 → + +
+ @endif
@endsection @push('scripts') @endpush diff --git a/resources/views/rd/cm-song/show.blade.php b/resources/views/rd/cm-song/show.blade.php new file mode 100644 index 00000000..4dce6b6e --- /dev/null +++ b/resources/views/rd/cm-song/show.blade.php @@ -0,0 +1,142 @@ +@extends('layouts.app') + +@section('title', 'CM송 상세 - ' . $song->company_name) + +@section('content') + +
+

+ + CM송 상세 +

+ +
+ +
+ +
+

+ + CM송 정보 +

+ +
+
+ 회사명 + {{ $song->company_name }} +
+
+ 업종/제품 + {{ $song->industry }} +
+
+ 분위기 + {{ $song->getMood() }} +
+
+ 길이 + {{ $song->getDuration() }}초 +
+
+ 생성자 + {{ $song->user?->name ?? '-' }} +
+
+ 생성일 + {{ $song->created_at->format('Y-m-d H:i:s') }} +
+
+ + @if($song->audio_path) + + @endif +
+ + +
+

+ + 가사 +

+ +
+

{{ $song->lyrics }}

+
+ + @if($song->audio_path) +
+ + +

들어보기

+
+ @else +
+

음성 파일이 없습니다.

+
+ @endif +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/routes/web.php b/routes/web.php index 79dc67f1..f5cbee9b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -384,6 +384,11 @@ // CM송 제작 Route::prefix('cm-song')->name('cm-song.')->group(function () { Route::get('/', [CmSongController::class, 'index'])->name('index'); + Route::get('/create', [CmSongController::class, 'create'])->name('create'); + Route::post('/', [CmSongController::class, 'store'])->name('store'); + Route::get('/{id}', [CmSongController::class, 'show'])->name('show'); + Route::delete('/{id}', [CmSongController::class, 'destroy'])->name('destroy'); + Route::get('/{id}/download', [CmSongController::class, 'download'])->name('download'); Route::post('/generate-lyrics', [CmSongController::class, 'generateLyrics'])->name('generate-lyrics'); Route::post('/generate-audio', [CmSongController::class, 'generateAudio'])->name('generate-audio'); });