diff --git a/src/api.js b/src/api.js index bebcc46..476cedf 100644 --- a/src/api.js +++ b/src/api.js @@ -246,6 +246,37 @@ export function deleteSellHistory(id) { return apiDelete(`/api/portfolio/sell-history/${id}`); } +// ── AI 음악 생성 API ────────────────────────────────────────────────────────── +// POST /api/music/generate body: { genre, moods, instruments, duration_sec, bpm, key, scale, prompt } +// → { task_id: string } +export function generateMusic(payload) { + return apiPost('/api/music/generate', payload); +} + +// GET /api/music/status/:task_id +// → { status: "queued"|"processing"|"succeeded"|"failed", progress: 0~100, message, audio_url?, error? } +export function getMusicStatus(taskId) { + return apiGet(`/api/music/status/${encodeURIComponent(taskId)}`); +} + +// GET /api/music/library +// → { tracks: [{ id, title, genre, moods, instruments, duration_id, bpm, key, scale, audio_url, created_at }] } +export function getMusicLibrary() { + return apiGet('/api/music/library'); +} + +// POST /api/music/library body: track object +// → saved track with id +export function saveMusicTrack(data) { + return apiPost('/api/music/library', data); +} + +// DELETE /api/music/library/:id +// → { ok: true } +export function deleteMusicTrack(id) { + return apiDelete(`/api/music/library/${id}`); +} + // ── 로또 고도화 API ──────────────────────────────────────────────────────────── // GET /api/lotto/stats/performance diff --git a/src/pages/effect-lab/EffectLab.jsx b/src/pages/effect-lab/EffectLab.jsx index 2ee715c..cab3bce 100644 --- a/src/pages/effect-lab/EffectLab.jsx +++ b/src/pages/effect-lab/EffectLab.jsx @@ -25,6 +25,17 @@ const LAB_ITEMS = [ icon: '📅', status: 'live', }, + { + id: 'music', + path: '/lab/music', + title: 'Sonic Forge', + category: 'AI · 음악 제작', + desc: 'AI가 장르·분위기·악기를 조합해 완성된 트랙을 만들어줍니다. 유튜브 수익화를 위한 음악 제작 스튜디오.', + tags: ['AI 음악', '생성', 'YouTube'], + accent: '#f5a623', + icon: '🎵', + status: 'wip', + }, ]; const STATUS_LABEL = { diff --git a/src/pages/music/MusicStudio.css b/src/pages/music/MusicStudio.css new file mode 100644 index 0000000..2f2a036 --- /dev/null +++ b/src/pages/music/MusicStudio.css @@ -0,0 +1,1714 @@ +/* ═══════════════════════════════════════════════════ + Sonic Forge — AI Music Studio + Aesthetic: Industrial Recording Studio + Amber VU Meter + Fonts: Bebas Neue (display) · Syne (body) · Courier Prime (mono) +═══════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Syne:wght@400;500;600;700&family=Courier+Prime:ital@0;1&display=swap'); + +/* ── CSS tokens ──────────────────────────────────────── */ +.ms { + --ms-bg: #0c0b09; + --ms-surface: #151310; + --ms-surface2: #1e1a14; + --ms-line: rgba(245, 166, 35, 0.12); + --ms-line-2: rgba(245, 166, 35, 0.06); + --ms-text: #ede8e0; + --ms-muted: rgba(237, 232, 224, 0.42); + --ms-dim: rgba(237, 232, 224, 0.22); + --ms-accent: #f5a623; + --ms-ff-disp: 'Bebas Neue', Impact, sans-serif; + --ms-ff-body: 'Syne', system-ui, sans-serif; + --ms-ff-mono: 'Courier Prime', 'Courier New', monospace; + + display: grid; + gap: 40px; + color: var(--ms-text); + font-family: var(--ms-ff-body); + transition: --ms-accent 0.4s ease; +} + +/* ═══════════════════════════════════════════════════ + HEADER +═══════════════════════════════════════════════════ */ +.ms-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); + gap: 32px; + align-items: center; + padding-bottom: 32px; + border-bottom: 1px solid var(--ms-line); +} + +.ms-header__kicker { + font-family: var(--ms-ff-mono); + font-size: 10px; + letter-spacing: 0.32em; + color: var(--ms-accent); + margin: 0 0 12px; + text-transform: uppercase; +} + +.ms-header__title { + font-family: var(--ms-ff-disp); + font-size: clamp(56px, 8vw, 96px); + line-height: 0.88; + margin: 0 0 18px; + letter-spacing: 0.02em; + color: var(--ms-text); +} + +.ms-header__title em { + font-style: normal; + color: var(--ms-accent); + display: block; +} + +.ms-header__desc { + font-family: var(--ms-ff-body); + font-size: 14px; + line-height: 1.8; + color: var(--ms-muted); + margin: 0; + font-weight: 400; +} + +.ms-header__right { + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 28px; /* status 라벨 공간 */ +} + +/* ═══════════════════════════════════════════════════ + WAVEFORM CANVAS +═══════════════════════════════════════════════════ */ +.ms-waveform-canvas { + width: 100%; + height: 100%; + display: block; + flex: 1; + min-height: 70px; +} + +/* ═══════════════════════════════════════════════════ + SONIC RADAR (헤더 비주얼) +═══════════════════════════════════════════════════ */ +.ms-radar { + position: relative; + width: 160px; + height: 160px; + flex-shrink: 0; +} + +/* SVG 오버레이 */ +.ms-radar__svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +/* 가이드 링 */ +.ms-radar__ring { + fill: none; + stroke: var(--radar-accent, var(--ms-accent)); + stroke-width: 1; +} + +.ms-radar__ring--outer { + stroke-opacity: 0.14; + stroke-dasharray: 5 4; + transform-origin: 80px 80px; + animation: radar-spin 14s linear infinite; +} + +.ms-radar.is-active .ms-radar__ring--outer { + stroke-opacity: 0.3; + animation-duration: 4s; +} + +.ms-radar__ring--mid { + stroke-opacity: 0.07; + stroke-dasharray: 2 8; + transform-origin: 80px 80px; + animation: radar-spin-rev 22s linear infinite; +} + +.ms-radar.is-active .ms-radar__ring--mid { + stroke-opacity: 0.15; +} + +.ms-radar__ring--inner { + stroke-opacity: 0.22; +} + +.ms-radar.is-active .ms-radar__ring--inner { + stroke-opacity: 0.45; +} + +@keyframes radar-spin { to { transform: rotate(360deg); } } +@keyframes radar-spin-rev { to { transform: rotate(-360deg); } } + +/* 크로스헤어 틱 */ +.ms-radar__tick { + stroke: var(--radar-accent, var(--ms-accent)); + stroke-opacity: 0.28; + stroke-width: 1; + stroke-linecap: round; +} + +.ms-radar__tick--dim { + stroke-opacity: 0.1; +} + +/* 스윕 라인 */ +.ms-radar__sweep { + stroke: var(--radar-accent, var(--ms-accent)); + stroke-width: 1.5; + stroke-opacity: 0; + stroke-linecap: round; + transform-origin: 80px 80px; + filter: drop-shadow(0 0 4px var(--radar-accent, var(--ms-accent))); + transition: stroke-opacity 0.4s ease; +} + +.ms-radar.is-active .ms-radar__sweep { + stroke-opacity: 0.75; + animation: radar-spin 2.4s linear infinite; +} + +/* 센터 글로우 링 */ +.ms-radar__center-ring { + fill: none; + stroke: var(--radar-accent, var(--ms-accent)); + stroke-opacity: 0.1; + stroke-width: 1; + animation: radar-center-ring 3s ease-in-out infinite; +} + +.ms-radar.is-active .ms-radar__center-ring { + animation-duration: 0.7s; +} + +@keyframes radar-center-ring { + 0%, 100% { stroke-opacity: 0.07; r: 14; } + 50% { stroke-opacity: 0.28; r: 18; } +} + +/* 방사형 바 — 피벗 (회전 기준) */ +.ms-radar__pivot { + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + transform: rotate(var(--angle)); + transform-origin: center center; + pointer-events: none; +} + +/* 방사형 바 — 실제 막대 */ +.ms-radar__bar { + position: absolute; + left: -1px; + bottom: 26px; /* innerR — 바 시작 위치 */ + width: 2px; + height: 2px; + background: linear-gradient( + to top, + rgba(245, 166, 35, 0.08), + var(--radar-accent, #f5a623) + ); + border-radius: 1px 1px 0 0; + transform-origin: bottom center; + animation: radar-bar-idle 2s ease-in-out var(--delay) infinite alternate; +} + +.ms-radar.is-active .ms-radar__bar { + animation: radar-bar-active 0.38s ease-in-out var(--delay) infinite alternate; + box-shadow: 0 0 4px rgba(245, 166, 35, 0.5); +} + +@keyframes radar-bar-idle { + from { height: 2px; opacity: 0.12; } + to { height: calc(18px * var(--rnd)); opacity: 0.52; } +} + +@keyframes radar-bar-active { + from { height: calc(7px * var(--rnd)); opacity: 0.6; } + to { height: calc(26px * var(--rnd)); opacity: 1; } +} + +/* 센터 도트 */ +.ms-radar__center { + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + transform: translate(-50%, -50%); + background: var(--radar-accent, var(--ms-accent)); + box-shadow: 0 0 10px var(--radar-accent, var(--ms-accent)); + animation: radar-center-dot 2.5s ease-in-out infinite; +} + +.ms-radar.is-active .ms-radar__center { + box-shadow: 0 0 22px var(--radar-accent, var(--ms-accent)), + 0 0 44px rgba(245, 166, 35, 0.3); + animation-duration: 0.5s; +} + +@keyframes radar-center-dot { + 0%, 100% { transform: translate(-50%, -50%) scale(1); } + 50% { transform: translate(-50%, -50%) scale(0.6); } +} + +/* 상태 레이블 */ +.ms-radar__status { + position: absolute; + bottom: -22px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + font-family: var(--ms-ff-mono); + font-size: 9px; + letter-spacing: 0.22em; + color: var(--ms-dim); + text-transform: uppercase; + white-space: nowrap; + transition: color 0.4s ease; +} + +.ms-radar.is-active .ms-radar__status { + color: var(--radar-accent, var(--ms-accent)); +} + +.ms-radar__status-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + animation: radar-status-blink 2.2s ease-in-out infinite; +} + +.ms-radar.is-active .ms-radar__status-dot { + animation-duration: 0.45s; +} + +@keyframes radar-status-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} + +/* ═══════════════════════════════════════════════════ + LAYOUT +═══════════════════════════════════════════════════ */ +.ms-layout { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 32px; + align-items: start; +} + +@media (max-width: 960px) { + .ms-layout { + grid-template-columns: 1fr; + } + .ms-header { + grid-template-columns: 1fr; + } + .ms-header__right { + height: 80px; + } +} + +/* ═══════════════════════════════════════════════════ + CONTROLS (LEFT) +═══════════════════════════════════════════════════ */ +.ms-controls { + display: grid; + gap: 28px; + padding-top: 16px; +} + +/* ── Section ──────────────────────────────────────── */ +.ms-section { + display: grid; + gap: 14px; +} + +.ms-section__head { + display: flex; + align-items: baseline; + gap: 10px; +} + +.ms-section__step { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-accent); + letter-spacing: 0.1em; + flex-shrink: 0; +} + +.ms-section__title { + font-family: var(--ms-ff-disp); + font-size: 22px; + letter-spacing: 0.06em; + margin: 0; + color: var(--ms-text); + flex-shrink: 0; +} + +.ms-section__hint { + font-size: 11px; + color: var(--ms-muted); + font-family: var(--ms-ff-mono); + letter-spacing: 0.08em; +} + +/* ── 설명 토글 버튼 ──────────────────────────────── */ +.ms-desc-toggle { + margin-left: auto; + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--ms-line); + background: transparent; + color: var(--ms-dim); + font-family: var(--ms-ff-mono); + font-size: 11px; + font-style: italic; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; + line-height: 1; +} + +.ms-desc-toggle:hover { + border-color: var(--ms-muted); + color: var(--ms-muted); +} + +.ms-desc-toggle.is-open { + border-color: var(--ms-accent); + color: var(--ms-accent); + background: rgba(245, 166, 35, 0.1); +} + +.ms-desc-toggle--sm { + width: 16px; + height: 16px; + font-size: 9px; + font-style: normal; + margin-left: 6px; +} + +/* ── 설명 접기/펼치기 래퍼 (grid-template-rows 트릭) ── */ +.ms-desc-wrap { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: grid-template-rows 0.28s ease; +} + +.ms-desc-wrap.is-open { + grid-template-rows: 1fr; +} + +.ms-desc-wrap > * { + overflow: hidden; + min-height: 0; +} + +/* ── 설명 텍스트 블록 ─────────────────────────────── */ +.ms-section__desc { + font-family: var(--ms-ff-body); + font-size: 12px; + line-height: 2; + color: var(--ms-dim); + margin: 0; + padding: 10px 14px; + border-left: 2px solid var(--ms-line); + background: var(--ms-surface); + border-radius: 0 8px 8px 0; +} + +.ms-section__desc strong { + color: var(--ms-muted); + font-weight: 600; + font-family: var(--ms-ff-mono); + font-size: 11px; + letter-spacing: 0.04em; +} + +.ms-section__desc em { + color: color-mix(in srgb, var(--ms-accent) 65%, var(--ms-muted)); + font-style: normal; + display: block; + padding-left: 8px; + border-left: 1px solid var(--ms-line); + margin-top: 2px; +} + +/* ── 파라미터 라벨 행 ─────────────────────────────── */ +.ms-param-label-row { + display: flex; + align-items: center; +} + +.ms-param-hint { + font-family: var(--ms-ff-mono); + font-size: 10px; + line-height: 1.75; + color: var(--ms-dim); + margin: 0; + padding: 6px 10px; + letter-spacing: 0.03em; + border-left: 2px solid var(--ms-line); +} + +/* ═══════════════════════════════════════════════════ + GENRE GRID +═══════════════════════════════════════════════════ */ +.ms-genre-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +@media (max-width: 640px) { + .ms-genre-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.ms-genre-card { + display: grid; + gap: 4px; + padding: 12px 10px; + border-radius: 12px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + cursor: pointer; + text-align: center; + transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease; + position: relative; + overflow: hidden; +} + +.ms-genre-card::before { + content: ''; + position: absolute; + inset: 0; + background: var(--g-color); + opacity: 0; + transition: opacity 0.25s ease; +} + +.ms-genre-card:hover::before { opacity: 0.06; } +.ms-genre-card.is-active::before { opacity: 0.14; } + +.ms-genre-card.is-active { + border-color: var(--g-color); + box-shadow: 0 0 0 1px var(--g-color), 0 4px 20px rgba(0,0,0,0.4); + transform: translateY(-1px); +} + +.ms-genre-card__icon { + font-size: 22px; + line-height: 1; + position: relative; +} + +.ms-genre-card__label { + font-family: var(--ms-ff-disp); + font-size: 14px; + letter-spacing: 0.06em; + color: var(--ms-text); + position: relative; +} + +.ms-genre-card__desc { + font-family: var(--ms-ff-mono); + font-size: 9px; + color: var(--ms-muted); + letter-spacing: 0.06em; + position: relative; + line-height: 1.4; +} + +/* ═══════════════════════════════════════════════════ + MOOD CHIPS +═══════════════════════════════════════════════════ */ +.ms-mood-rack { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ms-mood-chip { + padding: 7px 14px; + border-radius: 999px; + border: 1px solid var(--ms-line); + background: transparent; + color: var(--ms-muted); + font-family: var(--ms-ff-body); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} + +.ms-mood-chip::before { + content: ''; + position: absolute; + inset: 0; + background: var(--m-color); + opacity: 0; + transition: opacity 0.2s ease; +} + +.ms-mood-chip:hover::before { opacity: 0.08; } +.ms-mood-chip.is-active::before { opacity: 0.18; } + +.ms-mood-chip.is-active { + border-color: var(--m-color); + color: var(--ms-text); + box-shadow: 0 0 10px color-mix(in srgb, var(--m-color) 30%, transparent); +} + +.ms-mood-chip span { + position: relative; +} + +/* ═══════════════════════════════════════════════════ + INSTRUMENTS +═══════════════════════════════════════════════════ */ +.ms-instrument-rack { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.ms-instrument-chip { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 14px; + border-radius: 10px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.ms-instrument-chip.is-active { + border-color: var(--ms-accent); + background: rgba(245, 166, 35, 0.1); +} + +.ms-instrument-chip__label { + font-family: var(--ms-ff-body); + font-size: 13px; + font-weight: 500; + color: var(--ms-text); +} + +.ms-instrument-chip__freq { + font-family: var(--ms-ff-mono); + font-size: 9px; + color: var(--ms-muted); + letter-spacing: 0.06em; +} + +.ms-instrument-chip.is-active .ms-instrument-chip__freq { + color: var(--ms-accent); +} + +/* ═══════════════════════════════════════════════════ + PARAMETERS +═══════════════════════════════════════════════════ */ +.ms-param-group { + display: grid; + gap: 10px; +} + +.ms-param-label { + font-family: var(--ms-ff-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--ms-muted); +} + +.ms-param-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ms-param-value { + font-family: var(--ms-ff-mono); + font-size: 20px; + color: var(--ms-accent); + font-weight: 600; + letter-spacing: 0.04em; +} + +.ms-param-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +/* Duration rail */ +.ms-duration-rail { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.ms-duration-btn { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--ms-line); + background: transparent; + color: var(--ms-muted); + font-family: var(--ms-ff-mono); + font-size: 12px; + cursor: pointer; + transition: all 0.18s ease; + letter-spacing: 0.06em; +} + +.ms-duration-btn.is-active { + border-color: var(--ms-accent); + color: var(--ms-accent); + background: rgba(245, 166, 35, 0.08); +} + +/* BPM presets */ +.ms-bpm-presets { + display: flex; + gap: 6px; +} + +.ms-bpm-preset { + flex: 1; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + color: var(--ms-muted); + font-family: var(--ms-ff-body); + font-size: 11px; + font-weight: 500; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + transition: all 0.18s ease; +} + +.ms-bpm-preset span { + font-family: var(--ms-ff-mono); + font-size: 9px; + color: var(--ms-dim); +} + +.ms-bpm-preset.is-active { + border-color: var(--ms-accent); + color: var(--ms-text); + background: rgba(245, 166, 35, 0.08); +} + +.ms-bpm-preset.is-active span { + color: var(--ms-accent); +} + +/* BPM slider */ +.ms-bpm-slider { + width: 100%; + appearance: none; + -webkit-appearance: none; + height: 3px; + background: var(--ms-surface2); + border-radius: 999px; + outline: none; + cursor: pointer; +} + +.ms-bpm-slider::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--ms-accent); + border: 2px solid var(--ms-bg); + box-shadow: 0 0 8px var(--ms-accent); + cursor: pointer; +} + +.ms-bpm-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--ms-accent); + border: 2px solid var(--ms-bg); + cursor: pointer; +} + +/* Select */ +.ms-select-wrap { + position: relative; +} + +.ms-select-wrap::after { + content: '▾'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--ms-muted); + font-size: 11px; + pointer-events: none; +} + +.ms-select { + width: 100%; + padding: 10px 32px 10px 12px; + border-radius: 10px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + color: var(--ms-text); + font-family: var(--ms-ff-mono); + font-size: 13px; + cursor: pointer; + appearance: none; + outline: none; + transition: border-color 0.2s ease; +} + +.ms-select:focus { + border-color: var(--ms-accent); +} + +/* ═══════════════════════════════════════════════════ + PROMPT +═══════════════════════════════════════════════════ */ +.ms-prompt-wrap { + position: relative; +} + +.ms-prompt { + width: 100%; + padding: 14px; + border-radius: 14px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + color: var(--ms-text); + font-family: var(--ms-ff-body); + font-size: 14px; + line-height: 1.7; + resize: vertical; + outline: none; + transition: border-color 0.2s ease; + box-sizing: border-box; +} + +.ms-prompt::placeholder { + color: var(--ms-dim); + font-style: italic; +} + +.ms-prompt:focus { + border-color: var(--ms-accent); + box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.08); +} + +.ms-prompt__count { + position: absolute; + bottom: 10px; + right: 12px; + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); +} + +/* ═══════════════════════════════════════════════════ + STAGE (RIGHT) +═══════════════════════════════════════════════════ */ +.ms-stage { + display: grid; + gap: 20px; + position: sticky; + top: 24px; + padding-top: 16px; +} + +.ms-stage__viz { + position: relative; + height: 180px; + border-radius: 18px; + overflow: hidden; + border: 1px solid var(--ms-line); + background: var(--ms-surface); +} + +.ms-stage__viz .ms-waveform-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.ms-stage__overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.ms-stage__idle { + font-family: var(--ms-ff-mono); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--ms-dim); + text-align: center; + line-height: 2; + margin: 0; +} + +.ms-stage__ready { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.ms-stage__ready-icon { + font-size: 32px; + filter: drop-shadow(0 0 12px var(--ms-accent)); + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +.ms-stage__ready-label { + font-family: var(--ms-ff-disp); + font-size: 18px; + letter-spacing: 0.1em; + color: var(--ms-accent); + margin: 0; +} + +.ms-stage__ready-moods { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-muted); + letter-spacing: 0.1em; + margin: 0; +} + +/* ═══════════════════════════════════════════════════ + GENERATE BUTTON +═══════════════════════════════════════════════════ */ +.ms-generate-btn { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 22px; + border-radius: 18px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + cursor: not-allowed; + transition: all 0.3s ease; + overflow: hidden; +} + +.ms-generate-btn.is-ready { + cursor: pointer; + border-color: var(--ms-accent); + background: rgba(245, 166, 35, 0.06); +} + +.ms-generate-btn.is-ready:hover { + background: rgba(245, 166, 35, 0.12); + box-shadow: 0 0 32px rgba(245, 166, 35, 0.2); + transform: translateY(-2px); +} + +.ms-generate-btn.is-generating { + cursor: default; + border-color: var(--ms-accent); + background: rgba(245, 166, 35, 0.08); +} + +/* Pulsing ring */ +.ms-generate-btn__ring { + position: absolute; + inset: -1px; + border-radius: 18px; + border: 1px solid var(--ms-accent); + opacity: 0; + transition: opacity 0.3s ease; +} + +.ms-generate-btn.is-generating .ms-generate-btn__ring { + opacity: 1; + animation: ring-pulse 1.8s ease-in-out infinite; +} + +@keyframes ring-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 166, 35, 0.4); } + 50% { box-shadow: 0 0 0 12px rgba(245, 166, 35, 0); } +} + +.ms-generate-btn__core { + width: 52px; + height: 52px; + border-radius: 50%; + border: 1px solid var(--ms-line); + display: flex; + align-items: center; + justify-content: center; + color: var(--ms-muted); + transition: all 0.3s ease; +} + +.ms-generate-btn.is-ready .ms-generate-btn__core { + border-color: var(--ms-accent); + color: var(--ms-accent); + box-shadow: 0 0 16px rgba(245, 166, 35, 0.3); +} + +.ms-generate-btn__spinner { + display: block; + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid rgba(245, 166, 35, 0.2); + border-top-color: var(--ms-accent); + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.ms-generate-btn__label { + font-family: var(--ms-ff-disp); + font-size: 20px; + letter-spacing: 0.1em; + color: var(--ms-muted); + transition: color 0.3s ease; +} + +.ms-generate-btn.is-ready .ms-generate-btn__label { + color: var(--ms-text); +} + +/* ═══════════════════════════════════════════════════ + PROGRESS +═══════════════════════════════════════════════════ */ +.ms-progress { + display: grid; + gap: 8px; +} + +.ms-progress__bar { + height: 3px; + background: var(--ms-surface2); + border-radius: 999px; + overflow: hidden; +} + +.ms-progress__fill { + height: 100%; + background: linear-gradient(90deg, var(--ms-accent), #e85c3a); + border-radius: 999px; + transition: width 0.5s ease; + box-shadow: 0 0 10px var(--ms-accent); +} + +.ms-progress__meta { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ms-progress__msg { + font-family: var(--ms-ff-mono); + font-size: 11px; + color: var(--ms-muted); + letter-spacing: 0.06em; + animation: blink-text 1s ease infinite alternate; +} + +@keyframes blink-text { + from { opacity: 0.6; } + to { opacity: 1; } +} + +.ms-progress__pct { + font-family: var(--ms-ff-mono); + font-size: 13px; + color: var(--ms-accent); + font-weight: 600; +} + +/* ═══════════════════════════════════════════════════ + SPEC CHIPS +═══════════════════════════════════════════════════ */ +.ms-stage__spec { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ms-spec-chip { + display: flex; + gap: 6px; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); +} + +.ms-spec-chip__label { + font-family: var(--ms-ff-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--ms-dim); +} + +.ms-spec-chip__val { + font-family: var(--ms-ff-mono); + font-size: 11px; + color: var(--ms-accent); + font-weight: 600; +} + +/* ═══════════════════════════════════════════════════ + RESULT CARD +═══════════════════════════════════════════════════ */ +.ms-result { + border: 1px solid var(--result-accent, var(--ms-accent)); + border-radius: 20px; + padding: 20px; + background: var(--ms-surface); + display: grid; + gap: 16px; + box-shadow: 0 0 40px rgba(245, 166, 35, 0.08); + animation: result-in 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes result-in { + from { opacity: 0; transform: translateY(16px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.ms-result__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ms-result__badge { + font-family: var(--ms-ff-mono); + font-size: 10px; + letter-spacing: 0.14em; + color: #97c9aa; + border: 1px solid rgba(151, 201, 170, 0.3); + border-radius: 999px; + padding: 3px 10px; + text-transform: uppercase; +} + +.ms-result__time { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); +} + +.ms-result__title-row { + display: flex; + align-items: center; + gap: 14px; +} + +.ms-result__icon { + font-size: 28px; + filter: drop-shadow(0 0 10px var(--result-accent, var(--ms-accent))); +} + +.ms-result__title { + font-family: var(--ms-ff-disp); + font-size: 18px; + letter-spacing: 0.05em; + margin: 0 0 4px; + color: var(--ms-text); +} + +.ms-result__meta { + font-family: var(--ms-ff-mono); + font-size: 11px; + color: var(--ms-muted); + margin: 0; + letter-spacing: 0.08em; +} + +/* ── Player ──────────────────────────────────────── */ +.ms-player { + display: flex; + align-items: center; + gap: 14px; +} + +.ms-player__play { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--result-accent, var(--ms-accent)); + background: transparent; + color: var(--result-accent, var(--ms-accent)); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.ms-player__play.is-playing { + background: rgba(245, 166, 35, 0.15); + box-shadow: 0 0 14px rgba(245, 166, 35, 0.3); +} + +.ms-player__timeline { + flex: 1; + display: grid; + gap: 5px; +} + +.ms-player__bar { + height: 4px; + background: var(--ms-surface2); + border-radius: 999px; + position: relative; + cursor: pointer; +} + +.ms-player__fill { + height: 100%; + background: var(--result-accent, var(--ms-accent)); + border-radius: 999px; + transition: width 1s linear; +} + +.ms-player__thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--result-accent, var(--ms-accent)); + box-shadow: 0 0 6px var(--result-accent, var(--ms-accent)); + transition: left 1s linear; +} + +.ms-player__times { + display: flex; + justify-content: space-between; + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); +} + +/* Result tags */ +.ms-result__tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.ms-result__tag { + font-family: var(--ms-ff-mono); + font-size: 10px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--ms-line); + color: var(--ms-muted); + letter-spacing: 0.06em; + text-transform: capitalize; +} + +/* Result actions */ +.ms-result__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.ms-result__yt-hint { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); + margin: 0; + letter-spacing: 0.04em; + border-top: 1px solid var(--ms-line); + padding-top: 12px; +} + +/* ═══════════════════════════════════════════════════ + BUTTONS +═══════════════════════════════════════════════════ */ +.ms-btn { + padding: 9px 18px; + border-radius: 10px; + font-family: var(--ms-ff-body); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.ms-btn--ghost { + border-color: var(--ms-line); + background: transparent; + color: var(--ms-muted); +} + +.ms-btn--ghost:hover { + border-color: var(--ms-accent); + color: var(--ms-text); +} + +.ms-btn--outline { + border-color: var(--ms-accent); + background: transparent; + color: var(--ms-accent); +} + +.ms-btn--outline:hover { + background: rgba(245, 166, 35, 0.1); +} + +.ms-btn--primary { + border-color: var(--ms-accent); + background: var(--ms-accent); + color: #0c0b09; + font-weight: 700; +} + +.ms-btn--primary:hover { + background: #fbb740; + box-shadow: 0 4px 20px rgba(245, 166, 35, 0.35); + transform: translateY(-1px); +} + +/* ═══════════════════════════════════════════════════ + STAGE FOOTER +═══════════════════════════════════════════════════ */ +.ms-stage__footer { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--ms-line-2); + background: var(--ms-surface); +} + +.ms-stage__footer-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ms-accent); + flex-shrink: 0; + margin-top: 4px; + opacity: 0.7; +} + +.ms-stage__footer-text { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); + line-height: 1.7; + letter-spacing: 0.04em; + margin: 0; +} + +/* ═══════════════════════════════════════════════════ + TAB NAV (Phase 2) +═══════════════════════════════════════════════════ */ +.ms-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--ms-line); + padding-bottom: 0; + margin-bottom: -40px; /* collapse gap above layout */ +} + +.ms-tab { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 20px; + border-radius: 12px 12px 0 0; + border: 1px solid transparent; + border-bottom: none; + background: transparent; + color: var(--ms-muted); + font-family: var(--ms-ff-body); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + bottom: -1px; +} + +.ms-tab:hover { + color: var(--ms-text); + background: var(--ms-surface); +} + +.ms-tab.is-active { + border-color: var(--ms-line); + background: var(--ms-surface); + color: var(--ms-accent); +} + +.ms-tab__icon { + font-size: 14px; + line-height: 1; +} + +.ms-tab__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--ms-accent); + color: #0c0b09; + font-size: 10px; + font-weight: 700; + font-family: var(--ms-ff-mono); +} + +/* ═══════════════════════════════════════════════════ + AUDIO PLAYER (Phase 2) +═══════════════════════════════════════════════════ */ +.ms-audio-player { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; +} + +.ms-volume { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + color: var(--ms-muted); +} + +.ms-volume__slider { + width: 64px; + appearance: none; + -webkit-appearance: none; + height: 3px; + background: var(--ms-surface2); + border-radius: 999px; + outline: none; + cursor: pointer; +} + +.ms-volume__slider::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--player-accent, var(--ms-accent)); + cursor: pointer; +} + +.ms-volume__slider::-moz-range-thumb { + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--player-accent, var(--ms-accent)); + border: none; + cursor: pointer; +} + +/* ═══════════════════════════════════════════════════ + ICON BUTTONS (Phase 2) +═══════════════════════════════════════════════════ */ +.ms-btn--icon { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid var(--ms-line); + background: transparent; + color: var(--ms-muted); + font-size: 12px; + cursor: pointer; + transition: all 0.18s ease; + flex-shrink: 0; +} + +.ms-btn--icon:hover, +.ms-btn--icon.is-active { + border-color: var(--ms-accent); + color: var(--ms-accent); + background: rgba(245, 166, 35, 0.08); +} + +.ms-btn--icon.ms-btn--danger:hover { + border-color: #e85c3a; + color: #e85c3a; + background: rgba(232, 92, 58, 0.08); +} + +/* ═══════════════════════════════════════════════════ + ERROR (Phase 2) +═══════════════════════════════════════════════════ */ +.ms-gen-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-radius: 12px; + border: 1px solid rgba(232, 92, 58, 0.4); + background: rgba(232, 92, 58, 0.07); + font-family: var(--ms-ff-mono); + font-size: 12px; + color: #e85c3a; + letter-spacing: 0.04em; +} + +/* ═══════════════════════════════════════════════════ + LIBRARY (Phase 2) +═══════════════════════════════════════════════════ */ +.ms-library { + display: grid; + gap: 20px; +} + +.ms-library--empty { + padding: 64px 24px; + text-align: center; + border: 1px dashed var(--ms-line); + border-radius: 20px; + background: var(--ms-surface); +} + +.ms-library__empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.4; +} + +.ms-library__empty-text { + font-family: var(--ms-ff-disp); + font-size: 22px; + letter-spacing: 0.06em; + color: var(--ms-muted); + margin: 0 0 8px; +} + +.ms-library__empty-hint { + font-family: var(--ms-ff-mono); + font-size: 11px; + color: var(--ms-dim); + margin: 0; + letter-spacing: 0.04em; + line-height: 1.8; +} + +.ms-library__header { + display: flex; + align-items: center; + gap: 12px; +} + +.ms-library__title { + font-family: var(--ms-ff-disp); + font-size: 26px; + letter-spacing: 0.07em; + margin: 0; + color: var(--ms-text); + flex: 1; +} + +.ms-library__count { + font-family: var(--ms-ff-mono); + font-size: 11px; + color: var(--ms-muted); + border: 1px solid var(--ms-line); + border-radius: 999px; + padding: 3px 10px; + letter-spacing: 0.06em; +} + +.ms-library__refresh { + font-size: 12px; + padding: 6px 12px; +} + +.ms-library__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +/* ── Library Card ─────────────────────────────────── */ +.ms-lib-card { + padding: 16px; + border-radius: 16px; + border: 1px solid var(--ms-line); + background: var(--ms-surface); + display: grid; + gap: 12px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.ms-lib-card.is-playing { + border-color: var(--lib-accent, var(--ms-accent)); + box-shadow: 0 0 20px rgba(245, 166, 35, 0.1); +} + +.ms-lib-card:hover { + border-color: color-mix(in srgb, var(--lib-accent, var(--ms-accent)) 60%, transparent); +} + +.ms-lib-card__top { + display: flex; + align-items: center; + gap: 10px; +} + +.ms-lib-card__icon { + font-size: 22px; + flex-shrink: 0; + filter: drop-shadow(0 0 6px var(--lib-accent, var(--ms-accent))); +} + +.ms-lib-card__info { + flex: 1; + min-width: 0; +} + +.ms-lib-card__title { + font-family: var(--ms-ff-disp); + font-size: 15px; + letter-spacing: 0.04em; + color: var(--ms-text); + margin: 0 0 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ms-lib-card__meta { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-muted); + margin: 0; + letter-spacing: 0.06em; +} + +.ms-lib-card__controls { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.ms-lib-card__tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.ms-lib-card__date { + font-family: var(--ms-ff-mono); + font-size: 10px; + color: var(--ms-dim); + margin: 0; + letter-spacing: 0.04em; +} + +/* ═══════════════════════════════════════════════════ + MOBILE +═══════════════════════════════════════════════════ */ +@media (max-width: 640px) { + .ms-header__title { + font-size: clamp(44px, 14vw, 70px); + } + + .ms-stage { + position: static; + } + + .ms-result__actions { + flex-direction: column; + } + + .ms-btn { + text-align: center; + } + + .ms-bpm-presets { + flex-wrap: wrap; + } +} + +/* ═══════════════════════════════════════════════════ + REDUCED MOTION +═══════════════════════════════════════════════════ */ +@media (prefers-reduced-motion: reduce) { + .ms-stage__ready-icon, + .ms-vu__bar, + .ms-generate-btn__ring, + .ms-progress__msg { + animation: none !important; + } +} diff --git a/src/pages/music/MusicStudio.jsx b/src/pages/music/MusicStudio.jsx new file mode 100644 index 0000000..e15952b --- /dev/null +++ b/src/pages/music/MusicStudio.jsx @@ -0,0 +1,1141 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + deleteMusicTrack, + generateMusic, + getMusicLibrary, + getMusicStatus, +} from '../../api'; +import './MusicStudio.css'; + +/* ───────────────────────────────────────────── + 데이터 상수 +───────────────────────────────────────────── */ +const GENRES = [ + { id: 'lofi', label: 'Lo-Fi', icon: '📻', color: '#f5a623', desc: 'Warm · Nostalgic' }, + { id: 'electronic', label: 'Electronic', icon: '⚡', color: '#60a5fa', desc: 'Pulse · Synth' }, + { id: 'jazz', label: 'Jazz', icon: '🎷', color: '#c084fc', desc: 'Smooth · Soul' }, + { id: 'classical', label: 'Classical', icon: '🎻', color: '#f9b6b1', desc: 'Orchestral · Grand' }, + { id: 'ambient', label: 'Ambient', icon: '🌊', color: '#4aad8b', desc: 'Space · Float' }, + { id: 'hiphop', label: 'Hip-Hop', icon: '🎤', color: '#f472b6', desc: 'Beat · Urban' }, + { id: 'rock', label: 'Rock', icon: '🎸', color: '#e85c3a', desc: 'Raw · Drive' }, + { id: 'cinematic', label: 'Cinematic', icon: '🎬', color: '#fbbf24', desc: 'Epic · Sweeping' }, +]; + +const MOODS = [ + { id: 'energetic', label: 'Energetic', color: '#e85c3a' }, + { id: 'chill', label: 'Chill', color: '#60a5fa' }, + { id: 'dark', label: 'Dark', color: '#9333ea' }, + { id: 'uplifting', label: 'Uplifting', color: '#f5a623' }, + { id: 'romantic', label: 'Romantic', color: '#f472b6' }, + { id: 'epic', label: 'Epic', color: '#fbbf24' }, + { id: 'melancholic', label: 'Melancholic', color: '#4aad8b' }, +]; + +const INSTRUMENTS = [ + { id: 'piano', label: 'Piano', freq: '261Hz' }, + { id: 'guitar', label: 'Guitar', freq: '82Hz' }, + { id: 'drums', label: 'Drums', freq: '60Hz' }, + { id: 'synth', label: 'Synth', freq: '440Hz' }, + { id: 'bass', label: 'Bass', freq: '41Hz' }, + { id: 'strings', label: 'Strings', freq: '196Hz' }, + { id: 'brass', label: 'Brass', freq: '146Hz' }, + { id: 'flute', label: 'Flute', freq: '523Hz' }, + { id: 'violin', label: 'Violin', freq: '659Hz' }, + { id: 'choir', label: 'Choir', freq: '330Hz' }, +]; + +const DURATIONS = [ + { id: '30s', label: '0:30', sec: 30 }, + { id: '60s', label: '1:00', sec: 60 }, + { id: '90s', label: '1:30', sec: 90 }, + { id: '2m', label: '2:00', sec: 120 }, + { id: '3m', label: '3:00', sec: 180 }, + { id: '5m', label: '5:00', sec: 300 }, +]; + +const BPM_PRESETS = [ + { label: 'Slow', bpm: 70 }, + { label: 'Mid', bpm: 100 }, + { label: 'Fast', bpm: 130 }, + { label: 'EDM', bpm: 160 }, +]; + +const KEYS = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; +const SCALES = ['Major','Minor','Dorian','Phrygian','Lydian','Mixolydian']; + +/* 시뮬레이션 폴백용 단계 메시지 */ +const SIM_STEPS = [ + { msg: 'Analyzing musical patterns…', pct: 16 }, + { msg: 'Building harmonic structure…', pct: 32 }, + { msg: 'Rendering instruments…', pct: 52 }, + { msg: 'Mixing and mastering…', pct: 72 }, + { msg: 'Applying final polish…', pct: 90 }, + { msg: 'Track ready!', pct: 100 }, +]; + +/* ───────────────────────────────────────────── + 유틸 +───────────────────────────────────────────── */ +const pad = (n) => String(Math.floor(n)).padStart(2, '0'); +const fmtTime = (s) => `${pad(s / 60)}:${pad(s % 60)}`; + +/* ───────────────────────────────────────────── + Waveform Canvas +───────────────────────────────────────────── */ +const WaveformCanvas = ({ isGenerating, accentColor }) => { + const canvasRef = useRef(null); + const rafRef = useRef(null); + const phaseRef = useRef(0); + const intensityRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let dpr = window.devicePixelRatio || 1; + + const resize = () => { + dpr = window.devicePixelRatio || 1; + canvas.width = canvas.offsetWidth * dpr; + canvas.height = canvas.offsetHeight * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + + const draw = () => { + const w = canvas.offsetWidth, h = canvas.offsetHeight; + ctx.clearRect(0, 0, w, h); + const target = isGenerating ? 1.0 : 0.25; + intensityRef.current += (target - intensityRef.current) * 0.04; + const intensity = intensityRef.current; + const layers = [ + { amp: h * 0.38 * intensity, freq: 1.6, speed: 0.022, alpha: 0.9, lw: 2 }, + { amp: h * 0.22 * intensity, freq: 2.8, speed: 0.034, alpha: 0.5, lw: 1.2 }, + { amp: h * 0.12 * intensity, freq: 4.5, speed: 0.055, alpha: 0.3, lw: 0.8 }, + ]; + layers.forEach(({ amp, freq, speed, alpha, lw }, li) => { + ctx.beginPath(); + ctx.strokeStyle = accentColor; + ctx.globalAlpha = alpha; + ctx.lineWidth = lw; + ctx.shadowBlur = isGenerating ? 16 : 6; + ctx.shadowColor = accentColor; + for (let x = 0; x <= w; x += 2) { + const t = (x / w) * Math.PI * 2 * freq + phaseRef.current * (1 + li * speed * 10); + const h2 = Math.sin(t * 2.1 + li) * 0.35; + const h3 = Math.sin(t * 0.45 + li * 0.7) * 0.18; + const y = h / 2 + (Math.sin(t) + h2 + h3) * amp; + x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + }); + ctx.globalAlpha = 1; + ctx.shadowBlur = 0; + phaseRef.current += isGenerating ? 0.055 : 0.018; + rafRef.current = requestAnimationFrame(draw); + }; + + draw(); + return () => { + ro.disconnect(); + cancelAnimationFrame(rafRef.current); + }; + }, [isGenerating, accentColor]); + + return ; +}; + +/* ───────────────────────────────────────────── + Sonic Radar (헤더 비주얼 — 모듈 로드 시 1회 계산) +───────────────────────────────────────────── */ +const RADAR_N = 48; +const RADAR_INNER = 26; // 중심~바 시작 거리(SVG unit) +const RADAR_DATA = Array.from({ length: RADAR_N }, (_, i) => ({ + angle: `${((i / RADAR_N) * 360).toFixed(2)}deg`, + delay: `${((i / RADAR_N) * 1.8).toFixed(2)}s`, + rnd: `${(0.22 + Math.random() * 0.78).toFixed(2)}`, +})); + +const SonicRadar = ({ isGenerating, accentColor }) => ( +