- TTS try-catch 블록 누락 수정 (JS 구문 오류 해결) - audioReady display:flex 명시적 설정 (hidden 제거 후 레이아웃 보장)
507 lines
22 KiB
PHP
507 lines
22 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'AI 나레이션 제작')
|
|
|
|
@section('content')
|
|
<!-- 페이지 헤더 -->
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
|
<i class="ri-music-2-line text-indigo-600"></i>
|
|
AI 나레이션 제작
|
|
</h1>
|
|
<a href="{{ route('rd.cm-song.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
|
|
<i class="ri-arrow-left-line mr-1"></i> 나레이션 목록
|
|
</a>
|
|
</div>
|
|
|
|
<!-- 메인 콘텐츠 -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- 입력 섹션 -->
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
|
|
<i class="ri-edit-line text-indigo-500"></i>
|
|
나레이션 정보 입력
|
|
</h2>
|
|
|
|
<div class="space-y-5">
|
|
<!-- 회사명 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
|
|
<i class="ri-building-line text-gray-400"></i>
|
|
회사명 / 브랜드명
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="companyName"
|
|
value="코드브릿지엑스"
|
|
placeholder="예: 코드브릿지엑스"
|
|
class="w-full px-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 업종/제품 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
|
|
<i class="ri-briefcase-line text-gray-400"></i>
|
|
업종 / 주요 제품
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="industry"
|
|
value="ERP 솔루션, 스마트 공장 자동화"
|
|
placeholder="예: ERP 솔루션, 스마트 공장 자동화"
|
|
class="w-full px-4 py-3 rounded-lg border border-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 분위기 선택 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
|
|
<i class="ri-emotion-happy-line text-gray-400"></i>
|
|
원하는 분위기
|
|
</label>
|
|
<div class="grid grid-cols-2 gap-3" id="moodSelector">
|
|
<button type="button" data-mood="신나는" class="mood-btn selected py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-indigo-200 bg-indigo-50 text-indigo-700">
|
|
신나는
|
|
</button>
|
|
<button type="button" data-mood="신뢰감 있는" class="mood-btn py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-gray-100 bg-white text-gray-600 hover:border-gray-200">
|
|
신뢰감 있는
|
|
</button>
|
|
<button type="button" data-mood="감성적인" class="mood-btn py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-gray-100 bg-white text-gray-600 hover:border-gray-200">
|
|
감성적인
|
|
</button>
|
|
<button type="button" data-mood="유머러스한" class="mood-btn py-2.5 px-4 rounded-lg text-sm font-medium transition border-2 border-gray-100 bg-white text-gray-600 hover:border-gray-200">
|
|
유머러스한
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 나레이션 길이 슬라이더 -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1.5 flex items-center gap-1.5">
|
|
<i class="ri-time-line text-gray-400"></i>
|
|
나레이션 길이
|
|
</label>
|
|
<div class="flex items-center gap-4">
|
|
<input
|
|
type="range"
|
|
id="durationSlider"
|
|
min="10"
|
|
max="60"
|
|
step="5"
|
|
value="15"
|
|
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
|
/>
|
|
<span id="durationDisplay" class="px-3 py-1 bg-indigo-50 text-indigo-700 text-sm font-semibold rounded-lg whitespace-nowrap" style="min-width: 52px; text-align: center;">15초</span>
|
|
</div>
|
|
<div class="flex justify-between text-xs text-gray-400 mt-1 px-0.5">
|
|
<span>10초</span>
|
|
<span>30초</span>
|
|
<span>60초</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 생성 버튼 -->
|
|
<button
|
|
type="button"
|
|
id="generateBtn"
|
|
class="w-full py-3.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg font-medium flex items-center justify-center gap-2 transition shadow-sm hover:shadow-md"
|
|
>
|
|
<i class="ri-sparkling-line"></i>
|
|
나레이션 제작
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 결과 섹션 -->
|
|
<div id="resultPanel" class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex flex-col">
|
|
<h2 class="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
|
|
<i class="ri-music-2-line text-indigo-500"></i>
|
|
생성된 나레이션
|
|
</h2>
|
|
|
|
<div class="flex-1 flex flex-col justify-center" id="resultArea">
|
|
<!-- 초기 상태 -->
|
|
<div id="emptyState" class="text-center text-gray-400 py-12">
|
|
<i class="ri-music-2-line text-5xl mb-3 block opacity-20"></i>
|
|
<p class="text-sm">정보를 입력하고 버튼을 누르면<br>이곳에 나레이션이 나타납니다.</p>
|
|
</div>
|
|
|
|
<!-- 로딩 상태 -->
|
|
<div id="loadingState" class="text-center text-indigo-400 py-12 hidden">
|
|
<i class="ri-loader-4-line text-5xl mb-3 block animate-spin"></i>
|
|
<p class="text-sm" id="loadingText">AI가 나레이션을 작성하고 있어요...</p>
|
|
</div>
|
|
|
|
<!-- 결과 -->
|
|
<div id="resultState" class="space-y-6 hidden">
|
|
<!-- 가사 -->
|
|
<div class="bg-gray-50 p-6 rounded-xl border border-gray-100">
|
|
<p id="lyricsText" class="text-lg leading-relaxed text-gray-800 whitespace-pre-line font-medium text-center"></p>
|
|
</div>
|
|
|
|
<!-- 오디오 플레이어 -->
|
|
<div id="audioSection">
|
|
<div class="flex flex-col items-center gap-4">
|
|
<audio id="audioPlayer" class="hidden"></audio>
|
|
|
|
<!-- 음성 로딩 중 -->
|
|
<div id="audioLoading" class="flex flex-col items-center gap-2">
|
|
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center">
|
|
<i class="ri-loader-4-line text-2xl text-gray-400 animate-spin"></i>
|
|
</div>
|
|
<p class="text-sm text-gray-400 font-medium">음성 생성 중...</p>
|
|
</div>
|
|
|
|
<!-- 재생 버튼 (음성 준비 완료 후) -->
|
|
<div id="audioReady" class="hidden" style="flex-direction: column; align-items: center; gap: 1rem;">
|
|
<button
|
|
type="button"
|
|
id="playBtn"
|
|
class="w-16 h-16 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full flex items-center justify-center shadow-lg hover:shadow-xl transition hover:scale-105"
|
|
>
|
|
<i id="playIcon" class="ri-play-fill text-3xl ml-0.5"></i>
|
|
</button>
|
|
<p id="playLabel" class="text-sm text-gray-500 font-medium">들어보기</p>
|
|
|
|
<!-- 다운로드 버튼 -->
|
|
<button
|
|
type="button"
|
|
id="downloadBtn"
|
|
class="flex items-center gap-2 px-5 py-2.5 border-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 rounded-xl text-sm font-medium transition"
|
|
>
|
|
<i class="ri-download-line"></i>
|
|
음성 파일 다운로드
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 음성 생성 실패 -->
|
|
<div id="audioError" class="hidden text-center">
|
|
<p class="text-sm text-red-400">음성 생성에 실패했습니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 저장 / 다시 만들기 -->
|
|
<div class="flex items-center justify-center gap-4 pt-2">
|
|
<button
|
|
type="button"
|
|
id="saveBtn"
|
|
class="flex items-center gap-1.5 px-5 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition"
|
|
>
|
|
<i class="ri-save-line"></i> 저장하기
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="retryBtn"
|
|
class="flex items-center gap-1.5 px-5 py-2 bg-white hover:bg-gray-50 text-gray-600 border rounded-lg text-sm font-medium transition"
|
|
>
|
|
<i class="ri-refresh-line"></i> 다시 만들기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-center text-xs text-gray-400 mt-6">Powered by Google Gemini AI</p>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const companyInput = document.getElementById('companyName');
|
|
const industryInput = document.getElementById('industry');
|
|
const generateBtn = document.getElementById('generateBtn');
|
|
const moodSelector = document.getElementById('moodSelector');
|
|
const durationSlider = document.getElementById('durationSlider');
|
|
const durationDisplay = document.getElementById('durationDisplay');
|
|
const resultPanel = document.getElementById('resultPanel');
|
|
const emptyState = document.getElementById('emptyState');
|
|
const loadingState = document.getElementById('loadingState');
|
|
const loadingText = document.getElementById('loadingText');
|
|
const resultState = document.getElementById('resultState');
|
|
const lyricsText = document.getElementById('lyricsText');
|
|
const audioSection = document.getElementById('audioSection');
|
|
const audioPlayer = document.getElementById('audioPlayer');
|
|
const playBtn = document.getElementById('playBtn');
|
|
const playIcon = document.getElementById('playIcon');
|
|
const playLabel = document.getElementById('playLabel');
|
|
const audioLoading = document.getElementById('audioLoading');
|
|
const audioReady = document.getElementById('audioReady');
|
|
const audioError = document.getElementById('audioError');
|
|
const downloadBtn = document.getElementById('downloadBtn');
|
|
const saveBtn = document.getElementById('saveBtn');
|
|
const retryBtn = document.getElementById('retryBtn');
|
|
|
|
let selectedMood = '신나는';
|
|
let isPlaying = false;
|
|
let currentAudioData = null;
|
|
let currentAudioMimeType = null;
|
|
let currentLyrics = null;
|
|
let currentAudioBlob = null;
|
|
|
|
// 슬라이더 업데이트
|
|
durationSlider.addEventListener('input', function () {
|
|
durationDisplay.textContent = this.value + '초';
|
|
});
|
|
|
|
// 분위기 선택
|
|
moodSelector.addEventListener('click', function (e) {
|
|
const btn = e.target.closest('.mood-btn');
|
|
if (!btn) return;
|
|
selectedMood = btn.dataset.mood;
|
|
document.querySelectorAll('.mood-btn').forEach(b => {
|
|
b.classList.remove('selected', 'border-indigo-200', 'bg-indigo-50', 'text-indigo-700');
|
|
b.classList.add('border-gray-100', 'bg-white', 'text-gray-600');
|
|
});
|
|
btn.classList.add('selected', 'border-indigo-200', 'bg-indigo-50', 'text-indigo-700');
|
|
btn.classList.remove('border-gray-100', 'bg-white', 'text-gray-600');
|
|
});
|
|
|
|
// PCM -> WAV 변환
|
|
function pcmToWav(pcmData, sampleRate) {
|
|
const numChannels = 1;
|
|
const bitsPerSample = 16;
|
|
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
|
const blockAlign = (numChannels * bitsPerSample) / 8;
|
|
const dataSize = pcmData.length;
|
|
const buffer = new ArrayBuffer(44 + dataSize);
|
|
const view = new DataView(buffer);
|
|
|
|
function writeString(offset, string) {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
}
|
|
|
|
writeString(0, 'RIFF');
|
|
view.setUint32(4, 36 + dataSize, true);
|
|
writeString(8, 'WAVE');
|
|
writeString(12, 'fmt ');
|
|
view.setUint32(16, 16, true);
|
|
view.setUint16(20, 1, true);
|
|
view.setUint16(22, numChannels, true);
|
|
view.setUint32(24, sampleRate, true);
|
|
view.setUint32(28, byteRate, true);
|
|
view.setUint16(32, blockAlign, true);
|
|
view.setUint16(34, bitsPerSample, true);
|
|
writeString(36, 'data');
|
|
view.setUint32(40, dataSize, true);
|
|
|
|
new Uint8Array(buffer, 44).set(pcmData);
|
|
return new Blob([buffer], { type: 'audio/wav' });
|
|
}
|
|
|
|
function base64ToUint8Array(base64) {
|
|
const binaryString = window.atob(base64);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function showState(state) {
|
|
emptyState.classList.add('hidden');
|
|
loadingState.classList.add('hidden');
|
|
resultState.classList.add('hidden');
|
|
if (state === 'empty') emptyState.classList.remove('hidden');
|
|
if (state === 'loading') loadingState.classList.remove('hidden');
|
|
if (state === 'result') resultState.classList.remove('hidden');
|
|
}
|
|
|
|
// 나레이션 생성
|
|
async function generate() {
|
|
const companyName = companyInput.value.trim();
|
|
const industry = industryInput.value.trim();
|
|
const duration = parseInt(durationSlider.value);
|
|
|
|
if (!companyName || !industry) {
|
|
alert('회사명과 업종을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
generateBtn.disabled = true;
|
|
generateBtn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 나레이션 제작 중...';
|
|
showState('loading');
|
|
loadingText.textContent = 'AI가 나레이션을 작성하고 있어요...';
|
|
audioLoading.classList.remove('hidden');
|
|
audioReady.classList.add('hidden');
|
|
audioReady.style.display = '';
|
|
audioError.classList.add('hidden');
|
|
currentAudioData = null;
|
|
currentAudioMimeType = null;
|
|
currentAudioBlob = null;
|
|
|
|
// 결과 패널로 스크롤
|
|
resultPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
try {
|
|
// 1. 가사 생성
|
|
const lyricsRes = await fetch('{{ route("rd.cm-song.generate-lyrics") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
},
|
|
body: JSON.stringify({
|
|
company_name: companyName,
|
|
industry: industry,
|
|
mood: selectedMood,
|
|
duration: duration,
|
|
}),
|
|
});
|
|
|
|
const lyricsData = await lyricsRes.json();
|
|
if (!lyricsData.success) {
|
|
throw new Error(lyricsData.error || '나레이션 생성 실패');
|
|
}
|
|
|
|
currentLyrics = lyricsData.lyrics;
|
|
lyricsText.textContent = lyricsData.lyrics;
|
|
showState('result');
|
|
|
|
// 2. TTS 음성 생성
|
|
try {
|
|
const audioRes = await fetch('{{ route("rd.cm-song.generate-audio") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
},
|
|
body: JSON.stringify({ lyrics: lyricsData.lyrics }),
|
|
});
|
|
|
|
const audioData = await audioRes.json();
|
|
if (audioData.success && audioData.audio_data) {
|
|
currentAudioData = audioData.audio_data;
|
|
currentAudioMimeType = audioData.mime_type || 'audio/L16;rate=24000';
|
|
|
|
const mimeType = currentAudioMimeType;
|
|
let audioUrl;
|
|
|
|
if (mimeType.includes('L16') || mimeType.includes('pcm')) {
|
|
const rateMatch = mimeType.match(/rate=(\d+)/);
|
|
const rate = rateMatch ? parseInt(rateMatch[1]) : 24000;
|
|
const pcmBytes = base64ToUint8Array(audioData.audio_data);
|
|
currentAudioBlob = pcmToWav(pcmBytes, rate);
|
|
audioUrl = URL.createObjectURL(currentAudioBlob);
|
|
} else {
|
|
const rawBytes = base64ToUint8Array(audioData.audio_data);
|
|
currentAudioBlob = new Blob([rawBytes], { type: mimeType });
|
|
audioUrl = URL.createObjectURL(currentAudioBlob);
|
|
}
|
|
|
|
audioPlayer.src = audioUrl;
|
|
audioLoading.classList.add('hidden');
|
|
audioReady.classList.remove('hidden');
|
|
audioReady.style.display = 'flex';
|
|
} else {
|
|
audioLoading.classList.add('hidden');
|
|
audioError.classList.remove('hidden');
|
|
}
|
|
} catch (audioErr) {
|
|
console.error('음성 생성 오류:', audioErr);
|
|
audioLoading.classList.add('hidden');
|
|
audioError.classList.remove('hidden');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('나레이션 생성 오류:', error);
|
|
alert('생성 중 오류가 발생했습니다: ' + error.message);
|
|
showState('empty');
|
|
} finally {
|
|
generateBtn.disabled = false;
|
|
generateBtn.innerHTML = '<i class="ri-sparkling-line"></i> 나레이션 제작';
|
|
}
|
|
}
|
|
|
|
// 다운로드
|
|
downloadBtn.addEventListener('click', function () {
|
|
if (!currentAudioBlob) return;
|
|
const companyName = companyInput.value.trim() || '나레이션';
|
|
const url = URL.createObjectURL(currentAudioBlob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = '나레이션_' + companyName + '_' + new Date().toISOString().slice(0,10) + '.wav';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// 저장
|
|
saveBtn.addEventListener('click', async function () {
|
|
if (!currentLyrics) return;
|
|
|
|
saveBtn.disabled = true;
|
|
saveBtn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 저장 중...';
|
|
|
|
try {
|
|
const res = await fetch('{{ route("rd.cm-song.store") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
},
|
|
body: JSON.stringify({
|
|
company_name: companyInput.value.trim(),
|
|
industry: industryInput.value.trim(),
|
|
mood: selectedMood,
|
|
duration: parseInt(durationSlider.value),
|
|
lyrics: currentLyrics,
|
|
audio_data: currentAudioData,
|
|
audio_mime_type: currentAudioMimeType,
|
|
}),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
alert('나레이션이 저장되었습니다.');
|
|
window.location.href = '{{ route("rd.cm-song.index") }}';
|
|
} else {
|
|
throw new Error(data.error || '저장 실패');
|
|
}
|
|
} catch (error) {
|
|
alert('저장 중 오류: ' + error.message);
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = '<i class="ri-save-line"></i> 저장하기';
|
|
}
|
|
});
|
|
|
|
// 재생/정지
|
|
playBtn.addEventListener('click', function () {
|
|
if (isPlaying) {
|
|
audioPlayer.pause();
|
|
} else {
|
|
audioPlayer.play();
|
|
}
|
|
});
|
|
|
|
audioPlayer.addEventListener('play', function () {
|
|
isPlaying = true;
|
|
playIcon.className = 'ri-pause-fill text-3xl';
|
|
playIcon.classList.remove('ml-0.5');
|
|
playLabel.textContent = '재생 중...';
|
|
});
|
|
|
|
audioPlayer.addEventListener('pause', function () {
|
|
isPlaying = false;
|
|
playIcon.className = 'ri-play-fill text-3xl ml-0.5';
|
|
playLabel.textContent = '들어보기';
|
|
});
|
|
|
|
audioPlayer.addEventListener('ended', function () {
|
|
isPlaying = false;
|
|
playIcon.className = 'ri-play-fill text-3xl ml-0.5';
|
|
playLabel.textContent = '들어보기';
|
|
});
|
|
|
|
generateBtn.addEventListener('click', generate);
|
|
retryBtn.addEventListener('click', generate);
|
|
});
|
|
</script>
|
|
@endpush
|