Spaces:
Sleeping
Sleeping
feat: real emergence — anti-imitation system prompt + LLM tournament selection (structural-invention judge replaces heuristic top-1)
3b19377 verified | ; | |
| /* ── tiny DOM helpers ─────────────────────────────────────── */ | |
| const $ = (sel, el = document) => el.querySelector(sel); | |
| const $$ = (sel, el = document) => Array.from(el.querySelectorAll(sel)); | |
| const _post = (url, body) => fetch(url, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }).then(async r => { | |
| if (!r.ok) { | |
| const j = await r.json().catch(() => ({ detail: r.statusText })); | |
| throw new Error(j.detail || `HTTP ${r.status}`); | |
| } | |
| return r.json(); | |
| }); | |
| const API = { | |
| meta: () => fetch('/api/meta').then(r => r.json()), | |
| tensions: () => fetch('/api/tensions').then(r => r.json()), | |
| rules: () => fetch('/api/rules').then(r => r.json()), | |
| generate: (body) => _post('/api/generate', body), | |
| productFromPrompt: (body) => _post('/api/products/from_prompt', body), | |
| tensionFromPrompt: (body) => _post('/api/tensions/from_prompt', body), | |
| }; | |
| /* ── Persona presets — bright, varied, ad-friendly ─────────── */ | |
| const PERSONA_PRESETS = { | |
| newbie: '첫 출근하는 25세 신입사원, 두근거림과 설렘', | |
| family: '35세 부모, 아이와 함께하는 따뜻한 주말 일상', | |
| student: '20세 대학생, 친구들과 도전하는 새 학기 첫 주', | |
| couple: '신혼부부, 새 집에서 함께 만드는 첫 아침', | |
| friends: '친구 4명, 오랜만에 모인 옥상 파티의 활기', | |
| solo: '40대 1인 가구, 혼자만의 평온한 저녁 루틴', | |
| travel: '28세 직장인, 첫 해외 여행 공항 게이트의 두근거림', | |
| senior: '60대 어르신, 매일 동네에서 발견하는 작은 기쁨', | |
| parent_baby: '30대 신생아 부모, 첫 아기와의 따뜻한 새벽', | |
| entrepreneur: '32세 창업가, 첫 매출의 환희와 다음 도전', | |
| hobby: '취미를 시작한 45세, 처음 만든 작품 앞의 미소', | |
| reunion: '10년 만에 재회한 동창들, 다시 만난 반가움', | |
| }; | |
| /* ── Preset prompt strings ─────────────────────────────────── */ | |
| const PROMPT_PRESETS = { | |
| watch: '아날로그 손목시계 AETHER-A · 용두, 초침, 사파이어 크리스탈, 방수, 가죽 스트랩', | |
| coffee: '수제 로스팅 원두 커피 NOVA-Bean · 원두, 그라인더, 필터, 추출수, 세라믹 잔', | |
| phone: '스마트폰 AETHER-Phone · OLED 디스플레이, 트리플 카메라, 배터리, AI 칩셋, 진동모터', | |
| sneaker: '러닝화 PULSE-Run · 미드솔 폼, 아웃솔 고무, 니트 어퍼, 레이스, TPU 뒤꿈치컵', | |
| beer: '수제 IPA 맥주 HOP-Haru · 갈색 병, 크라운 캡, 헤드 거품, 라벨, 홉', | |
| cosmetic: '스킨케어 앰플 GLOW-Han · 유리 스포이드, 갈색 자외선 병, 세럼, 한방 허브 향, 한지 박스', | |
| car: '전기차 SPIRA-EV · 바닥 배터리팩, 듀얼 마그넷 모터, 17인치 스크린, 요크 핸들, 800V 충전포트', | |
| laundry: 'O2O 프리미엄 세탁 서비스 MOON-Fold · 수거기사, 앱 실시간 알림, 비닐 커버, 향기 캡슐, 옷걸이', | |
| edu: '온라인 코딩 부트캠프 CODE-Muze · 영상 강의, 과제 피드백, 1:1 코드리뷰, 슬랙 방, 수료증', | |
| banking: '모바일 은행 앱 HANA-Flow · 홈 화면 위젯, 송금 버튼, 생체 인증, 알림 배너, 챗봇', | |
| }; | |
| /* ── State ─────────────────────────────────────────────────── */ | |
| let TENSIONS = []; | |
| let CHARTS = []; | |
| let PIPELINE_TIMER = null; | |
| let CURRENT_PRODUCT = null; // {product_id, brand, category, atoms, ...} | |
| let LAST_PRODUCT_PROMPT = null; | |
| let CURRENT_TENSION = null; // {id, name, greimas_square, emotional_payoff, description} | |
| let LAST_TENSION_KEY = null; // string — either preset id or "custom:<prompt>" | |
| let LATEST_RESULTS = null; // raw /api/generate result — for media handlers | |
| let HAS_FAL = false; // set from /api/meta | |
| const MEDIA_STATE = {}; // MEDIA_STATE[seedIdx][beatIdx] = {image_url?, video_url?, status, message} | |
| /* ── Boot ──────────────────────────────────────────────────── */ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| try { | |
| wireInputs(); | |
| } catch (e) { | |
| console.error('wireInputs failed:', e); | |
| showGlobalError(`초기화 실패: ${e.message || e}. 브라우저를 하드 리프레시하세요 (Ctrl+Shift+R).`); | |
| } | |
| Promise.all([loadMeta(), loadTensions(), loadRules()]).catch(e => { | |
| console.error('initial loads failed:', e); | |
| }); | |
| }); | |
| async function loadMeta() { | |
| try { | |
| const m = await API.meta(); | |
| const vEl = $('#version'); if (vEl) vEl.textContent = m.version; | |
| const sel = $('#backendSel'); | |
| if (!sel) return; | |
| sel.querySelectorAll('option').forEach(o => { | |
| if (o.value === 'claude' && !m.has_anthropic_key) { | |
| o.disabled = true; o.textContent += ' (no key)'; | |
| } | |
| if (o.value === 'fireworks' && !m.has_fireworks_key) { | |
| o.disabled = true; o.textContent += ' (no key)'; | |
| } | |
| if (o.value === 'hf' && !m.has_hf_token) { | |
| o.disabled = true; o.textContent += ' (no token)'; | |
| } | |
| }); | |
| sel.value = m.default_backend || 'claude'; | |
| HAS_FAL = !!m.has_fal_key; | |
| syncBackendPlaceholder(); | |
| } catch (e) { console.error('meta failed', e); } | |
| } | |
| async function loadTensions() { | |
| TENSIONS = await API.tensions(); | |
| // Auto-select first tension as default without requiring user action | |
| if (!CURRENT_TENSION && TENSIONS.length > 0) { | |
| setTension(TENSIONS[0], TENSIONS[0].id); | |
| } | |
| } | |
| async function loadRules() { | |
| const rules = await API.rules(); | |
| const rows = [ | |
| `<div class="rule-row header"> | |
| <div>rule_id</div><div>name</div><div>pattern</div><div>theoretical_anchor</div> | |
| </div>`, | |
| ]; | |
| rules.forEach(r => { | |
| rows.push(` | |
| <div class="rule-row"> | |
| <div class="rule-id">${r.rule_id}</div> | |
| <div><strong>${escapeHtml(r.name)}</strong></div> | |
| <div>${escapeHtml(r.pattern)}</div> | |
| <div class="rule-anchor">${escapeHtml(r.theoretical_anchor)}</div> | |
| </div>`); | |
| }); | |
| const tbl = $('#rulesTable'); | |
| if (tbl) tbl.innerHTML = rows.join(''); | |
| } | |
| /* ── Wire up inputs defensively — any single null never breaks the rest ─ */ | |
| function wireInputs() { | |
| safeOn('#nSeeds', 'input', e => { | |
| const out = $('#nSeedsVal'); if (out) out.textContent = e.target.value; | |
| }); | |
| safeOn('#topK', 'input', e => { | |
| const out = $('#topKVal'); if (out) out.textContent = e.target.value; | |
| }); | |
| safeOn('#backendSel', 'change', syncBackendPlaceholder); | |
| safeOn('#genBtn', 'click', runGenerate); | |
| safeOn('#productPrompt', 'input', () => { | |
| if (!$('#productPrompt')) return; | |
| if ($('#productPrompt').value.trim() !== LAST_PRODUCT_PROMPT) { | |
| setStatus('#productStatus', 'idle', 'Generate를 누르면 Kimi-K2P6가 genome을 확장합니다.'); | |
| } | |
| }); | |
| safeOn('#tensionPrompt', 'input', () => { | |
| const val = $('#tensionPrompt').value.trim(); | |
| if (!val) { | |
| setStatus('#tensionStatus', 'idle', '프리셋을 클릭하거나 텍스트를 입력하세요.'); | |
| return; | |
| } | |
| const key = 'custom:' + val; | |
| if (key !== LAST_TENSION_KEY) { | |
| setStatus('#tensionStatus', 'idle', 'Generate 시 자유 입력을 Greimas 사각형으로 확장합니다.'); | |
| // Clear current tension preview until expanded | |
| if (CURRENT_TENSION && LAST_TENSION_KEY && LAST_TENSION_KEY.startsWith('custom:')) { | |
| hideGreimasPreview(); | |
| } | |
| } | |
| }); | |
| $$('.product-presets .preset-chip-inline').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const key = btn.dataset.preset; | |
| const val = PROMPT_PRESETS[key]; | |
| const ta = $('#productPrompt'); | |
| if (val && ta) { | |
| ta.value = val; | |
| ta.dispatchEvent(new Event('input')); | |
| ta.focus(); | |
| } | |
| }); | |
| }); | |
| $$('.tension-presets .preset-chip-inline').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const id = btn.dataset.tension; | |
| const t = TENSIONS.find(x => x.id === id); | |
| if (!t) return; | |
| // Clear the textarea so the preset takes priority | |
| const ta = $('#tensionPrompt'); if (ta) ta.value = ''; | |
| setTension(t, id); | |
| }); | |
| }); | |
| $$('.persona-presets .preset-chip-inline').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const key = btn.dataset.persona; | |
| const val = PERSONA_PRESETS[key]; | |
| const ta = $('#personaIn'); | |
| if (val && ta) { ta.value = val; ta.focus(); } | |
| }); | |
| }); | |
| } | |
| function safeOn(selector, ev, handler) { | |
| const el = $(selector); | |
| if (!el) { console.warn('element missing:', selector); return; } | |
| el.addEventListener(ev, handler); | |
| } | |
| function syncBackendPlaceholder() { | |
| const v = $('#backendSel') ? $('#backendSel').value : 'claude'; | |
| const placeholders = { | |
| claude: 'claude-sonnet-4-6', | |
| fireworks: 'accounts/fireworks/models/kimi-k2p6', | |
| hf: 'Qwen/Qwen2.5-72B-Instruct', | |
| none: '(not used)', | |
| }; | |
| if ($('#modelIn')) $('#modelIn').placeholder = placeholders[v] || ''; | |
| } | |
| /* ── Tension handling ─────────────────────────────────────── */ | |
| function setTension(t, key) { | |
| CURRENT_TENSION = t; | |
| LAST_TENSION_KEY = key; | |
| renderGreimasPreview(t); | |
| setStatus('#tensionStatus', 'ready', | |
| `✓ <strong>${escapeHtml(t.name)}</strong> · ${escapeHtml(t.emotional_payoff || '')}`); | |
| } | |
| function renderGreimasPreview(t) { | |
| if (!t || !t.greimas_square) return; | |
| const sq = t.greimas_square; | |
| const preview = $('#tensionPreview'); | |
| if (preview) preview.hidden = false; | |
| if ($('[data-pos="s1"]')) $('[data-pos="s1"]').textContent = sq.S1; | |
| if ($('[data-pos="s2"]')) $('[data-pos="s2"]').textContent = sq.S2; | |
| if ($('[data-pos="ns1"]')) $('[data-pos="ns1"]').textContent = sq.not_S1; | |
| if ($('[data-pos="ns2"]')) $('[data-pos="ns2"]').textContent = sq.not_S2; | |
| const payoff = $('#tensionPayoff'); | |
| if (payoff) { | |
| payoff.innerHTML = | |
| `${escapeHtml(t.description || '')}<br/><strong>payoff:</strong> ${escapeHtml(t.emotional_payoff || '')}`; | |
| } | |
| } | |
| function hideGreimasPreview() { | |
| const preview = $('#tensionPreview'); | |
| if (preview) preview.hidden = true; | |
| } | |
| async function ensureTensionReady() { | |
| const customText = $('#tensionPrompt') ? $('#tensionPrompt').value.trim() : ''; | |
| // Priority: if user typed custom text, use it (overrides any preset) | |
| if (customText) { | |
| const key = 'custom:' + customText; | |
| if (CURRENT_TENSION && LAST_TENSION_KEY === key) return CURRENT_TENSION; | |
| const backend = $('#backendSel').value === 'none' ? 'fireworks' : $('#backendSel').value; | |
| setStatus('#tensionStatus', 'loading', '🧬 Kimi-K2P6가 Greimas 사각형으로 확장 중… (10~20초)'); | |
| try { | |
| const t = await API.tensionFromPrompt({ prompt: customText, backend }); | |
| setTension(t, key); | |
| return t; | |
| } catch (e) { | |
| setStatus('#tensionStatus', 'error', `❌ ${escapeHtml(e.message || String(e))}`); | |
| throw e; | |
| } | |
| } | |
| if (CURRENT_TENSION) return CURRENT_TENSION; | |
| throw new Error('긴장 상황을 선택하거나 입력하세요.'); | |
| } | |
| /* ── Product handling ─────────────────────────────────────── */ | |
| async function ensureProductReady() { | |
| const text = $('#productPrompt') ? $('#productPrompt').value.trim() : ''; | |
| if (!text) throw new Error('제품 또는 서비스 설명을 입력하세요.'); | |
| if (CURRENT_PRODUCT && LAST_PRODUCT_PROMPT === text) return CURRENT_PRODUCT; | |
| const backend = $('#backendSel').value === 'none' ? 'fireworks' : $('#backendSel').value; | |
| setStatus('#productStatus', 'loading', '🧬 Kimi-K2P6가 genome을 확장 중… (30~60초)'); | |
| try { | |
| const product = await API.productFromPrompt({ prompt: text, backend }); | |
| CURRENT_PRODUCT = product; | |
| LAST_PRODUCT_PROMPT = text; | |
| const atoms = (product.atoms || []).map(a => a.name).join(' · '); | |
| setStatus('#productStatus', 'ready', | |
| `✓ <strong>${escapeHtml(product.brand)}</strong> · ${escapeHtml(product.category)} · ${product.atom_count} atoms` + | |
| (atoms ? `<br/><span style="color: var(--text-subtle); font-size: 11px;">${escapeHtml(atoms)}</span>` : '') | |
| ); | |
| return product; | |
| } catch (e) { | |
| setStatus('#productStatus', 'error', `❌ ${escapeHtml(e.message || String(e))}`); | |
| throw e; | |
| } | |
| } | |
| /* ── Generate ─────────────────────────────────────────────── */ | |
| async function runGenerate() { | |
| // ── Synchronous visual feedback FIRST (before any await). | |
| // Without this, user sees NO change for a few ms after click, | |
| // so they think the button is broken. | |
| setRunning(true); | |
| const err = $('#errBox'); if (err) { err.hidden = true; err.textContent = ''; } | |
| const empty = $('#emptyState'); if (empty) empty.style.display = 'none'; | |
| animatePipeline(); | |
| // Also immediately reflect prep state in the status dots | |
| if (!$('#productPrompt').value.trim()) { | |
| // Nothing to expand — will fail validation below, but show prep status anyway | |
| setStatus('#productStatus', 'loading', '⏳ 입력 확인 중…'); | |
| } else if (!CURRENT_PRODUCT || LAST_PRODUCT_PROMPT !== $('#productPrompt').value.trim()) { | |
| setStatus('#productStatus', 'loading', '🧬 genome 확장 준비 중…'); | |
| } | |
| if ($('#tensionPrompt').value.trim() && | |
| (LAST_TENSION_KEY !== 'custom:' + $('#tensionPrompt').value.trim())) { | |
| setStatus('#tensionStatus', 'loading', '🧬 Greimas 사각형 준비 중…'); | |
| } | |
| let tension, product; | |
| try { | |
| tension = await ensureTensionReady(); | |
| } catch (e) { | |
| resetPipeline(); | |
| showGlobalError(`❌ ${e.message || e}`); | |
| setRunning(false); | |
| if ($('#summaryBar') && $('#summaryBar').hidden && empty) empty.style.display = ''; | |
| return; | |
| } | |
| try { | |
| product = await ensureProductReady(); | |
| } catch (e) { | |
| resetPipeline(); | |
| showGlobalError(`❌ ${e.message || e}`); | |
| setRunning(false); | |
| if ($('#summaryBar') && $('#summaryBar').hidden && empty) empty.style.display = ''; | |
| return; | |
| } | |
| const durInput = $$('input[name="duration"]').find(r => r.checked); | |
| const body = { | |
| product_id: product.product_id, | |
| tension_id: tension.id, | |
| persona: ($('#personaIn') && $('#personaIn').value.trim()) || '일반 소비자', | |
| duration: durInput ? parseInt(durInput.value, 10) : 15, | |
| n_seeds: $('#nSeeds') ? parseInt($('#nSeeds').value, 10) : 5, | |
| top_k: $('#topK') ? parseInt($('#topK').value, 10) : 3, | |
| backend: $('#backendSel').value, | |
| model: ($('#modelIn') && $('#modelIn').value.trim()) || null, | |
| rng_seed: $('#rngSeed') ? parseInt($('#rngSeed').value || '42', 10) : 42, | |
| }; | |
| try { | |
| const r = await API.generate(body); | |
| completePipeline(); | |
| renderResults(r); | |
| } catch (e) { | |
| resetPipeline(); | |
| const msg = e && e.message ? e.message : String(e); | |
| showGlobalError(`❌ ${msg}`); | |
| if ($('#summaryBar') && $('#summaryBar').hidden && empty) empty.style.display = ''; | |
| console.error(e); | |
| } finally { | |
| setRunning(false); | |
| } | |
| } | |
| function showGlobalError(msg) { | |
| const box = $('#errBox'); | |
| if (box) { box.hidden = false; box.textContent = msg; } | |
| else { alert(msg); } | |
| } | |
| function setStatus(sel, kind, html) { | |
| const box = $(sel); | |
| if (!box) return; | |
| const dot = box.querySelector('.status-dot'); | |
| const text = box.querySelector('.status-text'); | |
| if (dot) dot.className = `status-dot status-${kind}`; | |
| if (text) text.innerHTML = html; | |
| } | |
| function setRunning(running) { | |
| const btn = $('#genBtn'); if (!btn) return; | |
| btn.disabled = running; | |
| const spin = btn.querySelector('.btn-spinner'); | |
| const lbl = btn.querySelector('.btn-label'); | |
| if (spin) spin.hidden = !running; | |
| if (lbl) lbl.textContent = running ? '생성 중…' : '⚡ Generate'; | |
| } | |
| /* ── Pipeline animation ───────────────────────────────────── */ | |
| function animatePipeline() { | |
| resetPipeline(); | |
| const stages = ['corpus', 'encoding', 'incubation', 'emergence', 'filtering']; | |
| let i = 0; | |
| const tick = () => { | |
| if (i > 0) { | |
| const prev = document.querySelector(`.stage[data-stage="${stages[i-1]}"]`); | |
| if (prev) { prev.classList.remove('active'); prev.classList.add('done'); } | |
| } | |
| if (i < stages.length) { | |
| const cur = document.querySelector(`.stage[data-stage="${stages[i]}"]`); | |
| if (cur) cur.classList.add('active'); | |
| i++; | |
| PIPELINE_TIMER = setTimeout(tick, 1200); | |
| } | |
| }; | |
| tick(); | |
| } | |
| function completePipeline() { | |
| if (PIPELINE_TIMER) { clearTimeout(PIPELINE_TIMER); PIPELINE_TIMER = null; } | |
| $$('.stage').forEach(s => { s.classList.remove('active'); s.classList.add('done'); }); | |
| } | |
| function resetPipeline() { | |
| if (PIPELINE_TIMER) { clearTimeout(PIPELINE_TIMER); PIPELINE_TIMER = null; } | |
| $$('.stage').forEach(s => s.classList.remove('active', 'done')); | |
| } | |
| /* ── Render results ───────────────────────────────────────── */ | |
| function renderResults(data) { | |
| LATEST_RESULTS = data; | |
| // Reset media state for a fresh run | |
| Object.keys(MEDIA_STATE).forEach(k => { delete MEDIA_STATE[k]; }); | |
| const bar = $('#summaryBar'); if (bar) bar.hidden = false; | |
| if ($('#sumCount')) $('#sumCount').textContent = data.summary.count; | |
| if ($('#sumAvg')) $('#sumAvg').textContent = (data.summary.avg_final || 0).toFixed(3); | |
| if ($('#sumTop')) $('#sumTop').textContent = (data.summary.top_final || 0).toFixed(3); | |
| if ($('#sumBackend')) $('#sumBackend').textContent = | |
| data.summary.backend + | |
| (data.summary.model ? ' · ' + data.summary.model.split('/').pop() : ''); | |
| CHARTS.forEach(c => { try { c.destroy(); } catch(_){} }); | |
| CHARTS = []; | |
| const box = $('#seedsBox'); if (!box) return; | |
| box.innerHTML = ''; | |
| data.seeds.forEach((s, idx) => { | |
| box.appendChild(renderSeedCard(s, idx + 1)); | |
| }); | |
| // Kick off the fal.ai auto-chain for the top 3 seeds' first beat | |
| if (HAS_FAL) { | |
| autoMediaChain(data.seeds); | |
| } else { | |
| showFalMissingBanner(); | |
| } | |
| } | |
| function showFalMissingBanner() { | |
| // soft notice at top of seeds list | |
| const box = $('#seedsBox'); if (!box) return; | |
| const notice = document.createElement('div'); | |
| notice.className = 'err'; | |
| notice.style.marginBottom = '12px'; | |
| notice.textContent = '⚠️ FAL_KEY 미설정 — 이미지/비디오 생성이 비활성됩니다 (Settings → Secrets → FAL_KEY).'; | |
| box.prepend(notice); | |
| } | |
| function renderSeedCard(s, rank) { | |
| const seed = s.seed; | |
| const score = s.score; | |
| const final = score.final; | |
| const color = final >= 0.5 ? '#10b981' : final >= 0.3 ? '#f59e0b' : '#ef4444'; | |
| const R = 34, CIRC = 2 * Math.PI * R; | |
| const offset = CIRC * (1 - Math.max(0, Math.min(1, final))); | |
| const card = document.createElement('div'); | |
| card.className = 'seed-card'; | |
| card.innerHTML = ` | |
| <div class="seed-head"> | |
| <div class="seed-rank-block"> | |
| <span class="seed-rank-label">Rank</span> | |
| <div class="seed-rank">#${rank}</div> | |
| </div> | |
| <div class="seed-info"> | |
| <div class="seed-id">${escapeHtml(seed.seed_id)}</div> | |
| <div class="seed-rules"> | |
| ${seed.rules_applied.map(r => `<span class="rule-pill">${escapeHtml(r)}</span>`).join('')} | |
| </div> | |
| </div> | |
| <div class="final-gauge" title="final score"> | |
| <svg viewBox="0 0 80 80"> | |
| <circle class="final-gauge-track" cx="40" cy="40" r="${R}"/> | |
| <circle class="final-gauge-fill" cx="40" cy="40" r="${R}" | |
| stroke="${color}" | |
| stroke-dasharray="${CIRC}" | |
| stroke-dashoffset="${offset}"/> | |
| </svg> | |
| <div class="final-gauge-text"> | |
| <div> | |
| <div class="final-gauge-value">${final.toFixed(2)}</div> | |
| <div class="final-gauge-label">final</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ${seed.scene_summary ? ` | |
| <div class="scene-summary"> | |
| <span class="scene-summary-badge">📜 한 줄 요약</span> | |
| <span class="scene-summary-text">${escapeHtml(seed.scene_summary)}</span> | |
| </div>` : ''} | |
| ${seed.wow_anchor ? `<div class="seed-anchor">${escapeHtml(seed.wow_anchor)}</div>` : ''} | |
| ${renderTournament(seed.tournament)} | |
| ${renderConstraints(seed.creative_constraints)} | |
| <div class="seed-section-title">Concept</div> | |
| <div class="seed-concept">${escapeHtml(seed.concept)}</div> | |
| <div class="seed-section-title">Pixar Story Spine · ${seed.duration}s · ${seed.beats.length} beats</div> | |
| <div class="timeline"> | |
| <div class="timeline-bar">${renderBeatBar(seed.beats, seed.duration)}</div> | |
| <div class="timeline-scale">${renderTicks(seed.duration)}</div> | |
| </div> | |
| <div class="seed-section-title">Beats · 이미지/비디오</div> | |
| <div class="beats-detail"> | |
| ${seed.beats.map((b, bi) => ` | |
| <div class="beat-row"> | |
| <div class="beat-row-time">${b.time_range[0]}–${b.time_range[1]}s</div> | |
| <div class="beat-row-name">${escapeHtml(b.beat)}</div> | |
| <div class="beat-row-content">${escapeHtml(b.content || '—')}</div> | |
| <div class="beat-media" data-media="${rank - 1}-${bi}"></div> | |
| </div> | |
| `).join('')} | |
| </div> | |
| <div data-stitch-host="${rank - 1}"></div> | |
| <div class="aether-provenance" title="AETHER 5-생성자가 내부에서 서로를 상생·상극으로 조율하여 이 하나의 장면을 합성했습니다."> | |
| ⚙️ AETHER 5-생성자 메타인지: 木(씨앗) → 火(증폭) → 土(지반) → 金(편집) → 水(통합) | |
| </div> | |
| ${renderAetherCritique(seed.aether_critique)} | |
| ${renderRawDetails(seed)} | |
| <div class="scores-section"> | |
| <div class="radar-wrap"><canvas></canvas></div> | |
| <div class="scores-table"> | |
| ${scoreItem('Novelty', score.novelty)} | |
| ${scoreItem('Utility', score.utility)} | |
| ${scoreItem('Affect', score.affect)} | |
| ${scoreItem('Humor/Pathos', score.humor_or_pathos)} | |
| ${scoreItem('Surprise', score.surprise)} | |
| ${scoreItem('Risk', score.risk, { risk: true })} | |
| ${scoreItem('Gating', (score.gating_passed || 0) + '/5', { raw: true })} | |
| ${scoreItem('Final', score.final, { bold: true })} | |
| </div> | |
| </div> | |
| `; | |
| const canvas = $('canvas', card); | |
| const chart = new Chart(canvas.getContext('2d'), { | |
| type: 'radar', | |
| data: { | |
| labels: ['Novelty', 'Utility', 'Affect', 'Humor/Pathos', 'Surprise'], | |
| datasets: [{ | |
| data: [ | |
| score.novelty, score.utility, score.affect, | |
| score.humor_or_pathos, score.surprise, | |
| ], | |
| backgroundColor: 'rgba(79, 70, 229, 0.18)', | |
| borderColor: '#4f46e5', | |
| borderWidth: 2, | |
| pointBackgroundColor: '#4f46e5', | |
| pointBorderColor: '#ffffff', | |
| pointRadius: 3, | |
| }], | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| r: { | |
| suggestedMin: 0, suggestedMax: 1, | |
| ticks: { display: false, stepSize: 0.25 }, | |
| grid: { color: '#e6e9f0' }, | |
| angleLines: { color: '#e6e9f0' }, | |
| pointLabels: { font: { size: 10, weight: '600' }, color: '#475569' }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| CHARTS.push(chart); | |
| // Initialize media slots AFTER DOM insertion — queued as a microtask so | |
| // the card is already in the tree when we query data-media attributes. | |
| queueMicrotask(() => { | |
| seed.beats.forEach((_, bi) => renderMediaSlot(rank - 1, bi)); | |
| renderStitchSection(rank - 1); | |
| }); | |
| return card; | |
| } | |
| function renderTournament(t) { | |
| if (!t || typeof t !== 'object') return ''; | |
| const inv = (t.structural_invention || '').trim(); | |
| const why = (t.why_winner || '').trim(); | |
| const ru = (t.runners_up || '').trim(); | |
| if (!inv && !why) return ''; | |
| return ` | |
| <div class="tournament"> | |
| <div class="tournament-head"> | |
| <span class="tournament-badge">🏆 토너먼트 우승</span> | |
| <span class="tournament-label">LLM 판사가 ${escapeHtml(t.winner_index !== undefined ? '#' + (Number(t.winner_index) + 1) : '')} 후보를 구조적 창발 1위로 선정</span> | |
| </div> | |
| ${inv ? ` | |
| <div class="tournament-row"> | |
| <span class="tournament-key">구조적 발명</span> | |
| <span class="tournament-val tournament-val-invention">${escapeHtml(inv)}</span> | |
| </div>` : ''} | |
| ${why ? ` | |
| <div class="tournament-row"> | |
| <span class="tournament-key">결정적 이유</span> | |
| <span class="tournament-val">${escapeHtml(why)}</span> | |
| </div>` : ''} | |
| ${ru ? ` | |
| <div class="tournament-row"> | |
| <span class="tournament-key">차점자 약점</span> | |
| <span class="tournament-val tournament-val-muted">${escapeHtml(ru)}</span> | |
| </div>` : ''} | |
| </div> | |
| `; | |
| } | |
| function renderAetherCritique(c) { | |
| if (!c || typeof c !== 'object') return ''; | |
| const verdict = (c.verdict || 'PASS').toUpperCase(); | |
| const isRevise = verdict === 'REVISE'; | |
| const verdictBadge = isRevise | |
| ? '<span class="critique-verdict critique-verdict-revise">REVISE → 재집필 적용됨</span>' | |
| : '<span class="critique-verdict critique-verdict-pass">PASS → 추가 수정 없음</span>'; | |
| const rows = [ | |
| ['🌳 木 직관', c.wood, '木 (Wood) — KEY VISUAL이 1년 뒤에도 기억나는가? 클리셰는?'], | |
| ['🔥 火 감정', c.fire, '火 (Fire) — 첫 3초가 손을 멈추게 하는가? 추상어로 도망친 곳은?'], | |
| ['🪨 土 논리·필연', c.earth, '土 (Earth) — 다른 브랜드로 바꿔도 성립하는가?'], | |
| ['⚔️ 金 비판·절단', c.metal, '金 (Metal) — 가장 약한 BEAT + 즉시 강해지는 대안.'], | |
| ['🌊 水 통찰', c.water, '水 (Water) — 보지 못한 진실 + 톱 CF에 닿지 못한 결.'], | |
| ].filter(([, v]) => v && v.trim()); | |
| if (!rows.length && !c.revision_brief) return ''; | |
| const briefBlock = c.revision_brief && c.revision_brief.trim() | |
| ? `<div class="critique-brief"><span class="critique-brief-label">재집필 지시</span> | |
| <span class="critique-brief-text">${escapeHtml(c.revision_brief)}</span></div>` | |
| : ''; | |
| return ` | |
| <details class="critique" ${isRevise ? 'open' : ''}> | |
| <summary class="critique-summary"> | |
| 🧠 AETHER 메타인지 비평 ${verdictBadge} | |
| </summary> | |
| <div class="critique-body"> | |
| <div class="critique-grid"> | |
| ${rows.map(([k, v, tip]) => ` | |
| <div class="critique-row" title="${escapeHtml(tip)}"> | |
| <span class="critique-key">${k}</span> | |
| <span class="critique-val">${escapeHtml(v)}</span> | |
| </div>`).join('')} | |
| </div> | |
| ${briefBlock} | |
| </div> | |
| </details> | |
| `; | |
| } | |
| function renderConstraints(c) { | |
| if (!c) return ''; | |
| const rows = [ | |
| ['📦 오브제', c.object], | |
| ['🎥 카메라', c.shot], | |
| ['🔀 공감각', c.synesthesia], | |
| ['🎬 장르 전환', c.genre_twist], | |
| ['⏱ 시간 구조', c.time], | |
| ['🔊 의성어', c.onomatopoeia], | |
| ].filter(([, v]) => v); | |
| if (!rows.length) return ''; | |
| return ` | |
| <div class="constraints"> | |
| <div class="constraints-head">창발 강제 조건 <small>· Kimi-K2P6에 하드 주입된 구체 제약</small></div> | |
| <div class="constraints-grid"> | |
| ${rows.map(([k, v]) => ` | |
| <div class="constraint-chip"> | |
| <span class="constraint-key">${k}</span> | |
| <span class="constraint-val">${escapeHtml(v)}</span> | |
| </div>`).join('')} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| /* ── Media (fal.ai images + Bytedance Seedance 2.0 video) ──────────────── */ | |
| const STITCH_STATE = {}; // legacy; not used by auto chain anymore | |
| const FINAL_VIDEO_STATE = {}; // FINAL_VIDEO_STATE[seedIdx] = {status, video_url, target_seconds, message} | |
| /** Auto-pipeline (top seed only): | |
| * Phase 1 — generate images for ALL beats with sliding-window ref chain | |
| * Phase 2 — ONE single i2v call (Seedance 2.0) using the first chained | |
| * image + a combined multi-scene prompt → one full-spot video | |
| */ | |
| async function autoMediaChain(seeds) { | |
| if (!seeds || !seeds.length) return; | |
| const sIdx = 0; | |
| const beats = seeds[sIdx].seed.beats || []; | |
| if (!beats.length) return; | |
| // Phase 1 — image chain (same as before) | |
| for (let i = 0; i < beats.length; i++) { | |
| const ok = await _genImageForBeat(sIdx, i, /*useChain*/ true); | |
| if (!ok) break; | |
| } | |
| // Phase 2 — single full-spot Seedance video | |
| await generateFinalVideo(sIdx); | |
| renderStitchSection(sIdx); // backward-compat: clears any old stitch UI | |
| } | |
| /** Build a single multi-scene prompt that walks Seedance through every beat. */ | |
| function buildCombinedScenePrompt(beats) { | |
| const total = beats[beats.length - 1]?.time_range?.[1] || 15; | |
| const lines = [ | |
| `다음은 ${total}초 길이의 시네마틱 광고 영상이다. ${beats.length}개의 씬이 부드럽게 이어진다.`, | |
| "각 씬을 명시된 타임코드 동안 연출하고, 씬 사이에 자연스러운 컷·매치 컷·디졸브를 사용한다.", | |
| "", | |
| ]; | |
| beats.forEach((b, i) => { | |
| lines.push(`Scene ${i + 1} (${b.time_range[0]}-${b.time_range[1]}초)`); | |
| if (b.content) lines.push(b.content); | |
| lines.push(""); | |
| }); | |
| lines.push("전체 영상은 영화적 연속성을 유지하며, 첫 컷의 인물·환경이 자연스럽게 발전한다. 자막·텍스트·로고 일체 없음."); | |
| return lines.join("\n"); | |
| } | |
| function _isContentPolicyError(msg) { | |
| const m = String(msg || '').toLowerCase(); | |
| return m.includes('content_policy_violation') | |
| || m.includes('content policy') | |
| || m.includes('likenesses of real people') | |
| || m.includes('private information'); | |
| } | |
| /** Generate a single full-spot video (Seedance 2.0). | |
| * Anchor = first chained image. If Seedance rejects on content policy, | |
| * automatically retry with the next image in the chain. */ | |
| async function generateFinalVideo(sIdx, anchorBeatIdx = 0) { | |
| const seed = LATEST_RESULTS?.seeds?.[sIdx]?.seed; | |
| if (!seed) return; | |
| const beats = seed.beats || []; | |
| if (!beats.length) return; | |
| // Build candidate list (any beat with an image) starting from anchorBeatIdx | |
| const allCandidates = []; | |
| for (let i = 0; i < beats.length; i++) { | |
| const u = MEDIA_STATE[sIdx]?.[i]?.image_url; | |
| if (u) allCandidates.push({ beatIdx: i, url: u }); | |
| } | |
| if (!allCandidates.length) { | |
| FINAL_VIDEO_STATE[sIdx] = { | |
| status: 'error', | |
| message: '이미지가 아직 생성되지 않아 비디오를 시작할 수 없습니다.', | |
| }; | |
| renderFinalVideoSection(sIdx); | |
| return; | |
| } | |
| // Reorder so anchorBeatIdx comes first, then the rest in beat order | |
| const candidates = [ | |
| ...allCandidates.filter(c => c.beatIdx === anchorBeatIdx), | |
| ...allCandidates.filter(c => c.beatIdx !== anchorBeatIdx), | |
| ]; | |
| const totalSec = beats[beats.length - 1].time_range[1]; | |
| const combinedScene = buildCombinedScenePrompt(beats); | |
| let lastErr = ''; | |
| let policyRejections = 0; | |
| for (let attempt = 0; attempt < candidates.length; attempt++) { | |
| const cand = candidates[attempt]; | |
| FINAL_VIDEO_STATE[sIdx] = { | |
| status: 'loading', | |
| target_seconds: totalSec, | |
| anchor_beat: cand.beatIdx, | |
| message: attempt === 0 | |
| ? `🎬 Seedance 2.0이 ${totalSec}초 영상 생성 중… (앵커: 비트 ${cand.beatIdx + 1}, 3~6분)` | |
| : `⚠️ 정책 거부 ${policyRejections}회 — 비트 ${cand.beatIdx + 1} 이미지로 재시도 중…`, | |
| }; | |
| renderFinalVideoSection(sIdx); | |
| try { | |
| const r = await fetch('/api/media/video', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| scene: combinedScene, | |
| image_url: cand.url, | |
| duration_seconds: totalSec, | |
| }), | |
| }); | |
| const data = await r.json(); | |
| if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); | |
| FINAL_VIDEO_STATE[sIdx] = { | |
| status: 'ready', | |
| video_url: data.video_url, | |
| target_seconds: totalSec, | |
| anchor_beat: cand.beatIdx, | |
| policy_retries: policyRejections, | |
| }; | |
| renderFinalVideoSection(sIdx); | |
| return; | |
| } catch (e) { | |
| const msg = e.message || String(e); | |
| lastErr = msg; | |
| if (_isContentPolicyError(msg)) { | |
| policyRejections += 1; | |
| continue; // try next candidate | |
| } | |
| // non-policy error: stop, show | |
| FINAL_VIDEO_STATE[sIdx] = { | |
| status: 'error', | |
| target_seconds: totalSec, | |
| message: `비디오 생성 실패: ${msg}`, | |
| }; | |
| renderFinalVideoSection(sIdx); | |
| return; | |
| } | |
| } | |
| // All candidates rejected by policy | |
| FINAL_VIDEO_STATE[sIdx] = { | |
| status: 'error', | |
| target_seconds: totalSec, | |
| message: `Seedance가 모든 이미지를 정책으로 거부 (${policyRejections}회). ` | |
| + `이미지를 재생성한 뒤 (인물 정면 포트레이트 회피) 다시 시도하세요. ${lastErr.slice(0, 200)}`, | |
| }; | |
| renderFinalVideoSection(sIdx); | |
| } | |
| /** Render the single-final-video section at the bottom of the seed card. */ | |
| function renderFinalVideoSection(sIdx) { | |
| const host = document.querySelector(`[data-stitch-host="${sIdx}"]`); | |
| if (!host) return; | |
| const seed = LATEST_RESULTS?.seeds?.[sIdx]?.seed; | |
| if (!seed) { host.innerHTML = ''; return; } | |
| const totalSec = seed.beats[seed.beats.length - 1]?.time_range?.[1] || 15; | |
| const st = FINAL_VIDEO_STATE[sIdx] || {}; | |
| const beats = seed.beats || []; | |
| const imagesReady = beats.filter((_, i) => MEDIA_STATE[sIdx]?.[i]?.image_url).length; | |
| const html = []; | |
| html.push(`<div class="seed-section-title">🎬 최종 ${totalSec}초 광고 영상 (Bytedance Seedance 2.0)</div>`); | |
| html.push(`<div class="final-stitch">`); | |
| if (st.status === 'loading') { | |
| html.push(`<div class="media-status media-loading">${escapeHtml(st.message)}</div>`); | |
| } else if (st.status === 'error') { | |
| html.push(`<div class="media-status media-error-msg">${escapeHtml(st.message)}</div>`); | |
| } | |
| if (st.video_url) { | |
| const anchorIdx = (typeof st.anchor_beat === 'number') ? st.anchor_beat + 1 : 1; | |
| const retryNote = st.policy_retries | |
| ? ` · 정책 거부 ${st.policy_retries}회 후 재시도 성공` | |
| : ''; | |
| html.push(` | |
| <div class="media-thumb stitch-thumb"> | |
| <video src="${escapeAttr(st.video_url)}" controls playsinline preload="metadata"></video> | |
| </div> | |
| <div class="stitch-progress"> | |
| ${totalSec}초 영상 · ${beats.length}개 씬 통합 i2v · 앵커 이미지: 비트 ${anchorIdx}${retryNote} | |
| </div> | |
| <div class="stitch-actions"> | |
| <a class="btn-stitch-download" | |
| href="/api/media/download?url=${encodeURIComponent(st.video_url)}&filename=aether_final_seed${sIdx}_${totalSec}s.mp4" | |
| title="최종 영상 다운로드">↓ ${totalSec}초 광고 다운로드</a> | |
| <button type="button" class="btn-stitch" data-final-seed="${sIdx}">🔄 영상 재생성</button> | |
| </div> | |
| `); | |
| } else if (st.status !== 'loading') { | |
| const enough = imagesReady >= 1; | |
| const disabled = !enough ? 'disabled' : ''; | |
| // If a previous run errored on policy, surface a "Try with another anchor" hint | |
| const anchorButtons = (st.status === 'error' && imagesReady > 1) | |
| ? beats.map((_, i) => MEDIA_STATE[sIdx]?.[i]?.image_url | |
| ? `<button type="button" class="btn-media" data-final-seed="${sIdx}" data-anchor-beat="${i}">🎯 비트 ${i + 1} 이미지로 재시도</button>` | |
| : '' | |
| ).join('') | |
| : ''; | |
| html.push(` | |
| <div class="stitch-progress"> | |
| 이미지 ${imagesReady}/${beats.length} 준비 | |
| ${enough ? `· ${totalSec}초 단일 영상 생성 가능` : '— 첫 비트 이미지부터 필요'} | |
| </div> | |
| <button type="button" class="btn-stitch" data-final-seed="${sIdx}" ${disabled}> | |
| 🎬 ${totalSec}초 광고 영상 생성 (Seedance 2.0, 한 번 호출) | |
| </button> | |
| ${anchorButtons ? `<div class="media-controls" style="flex-direction:row;flex-wrap:wrap;">${anchorButtons}</div>` : ''} | |
| `); | |
| } | |
| html.push(`</div>`); | |
| host.innerHTML = html.join(''); | |
| } | |
| /** Backward-compat: keep the old name pointing at the new section. */ | |
| function renderStitchSection(sIdx) { | |
| renderFinalVideoSection(sIdx); | |
| } | |
| /** Generate image for one beat. If useChain, attach last 3 prior images as refs. */ | |
| async function _genImageForBeat(sIdx, bIdx, useChain) { | |
| const beat = LATEST_RESULTS?.seeds?.[sIdx]?.seed?.beats?.[bIdx]; | |
| if (!beat?.content) return false; | |
| const total = LATEST_RESULTS.seeds[sIdx].seed.beats.length; | |
| const refs = []; | |
| if (useChain) { | |
| for (let k = Math.max(0, bIdx - 3); k < bIdx; k++) { | |
| const u = MEDIA_STATE[sIdx]?.[k]?.image_url; | |
| if (u) refs.push(u); | |
| } | |
| } | |
| _setMediaState(sIdx, bIdx, { | |
| status: 'loading', | |
| kind: 'image', | |
| message: refs.length | |
| ? `🖼 이미지 ${bIdx + 1}/${total} (참조 ${refs.length})…` | |
| : `🖼 이미지 ${bIdx + 1}/${total} 생성 중…`, | |
| }); | |
| try { | |
| const body = { scene: beat.content }; | |
| if (refs.length) body.ref_image_urls = refs; | |
| const r = await fetch('/api/media/image', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify(body), | |
| }); | |
| const data = await r.json(); | |
| if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); | |
| _setMediaState(sIdx, bIdx, { image_url: data.image_url, status: 'ready' }); | |
| return true; | |
| } catch (e) { | |
| _setMediaState(sIdx, bIdx, { | |
| status: 'error', kind: 'image', | |
| message: `이미지 실패: ${e.message || e}`, | |
| }); | |
| return false; | |
| } | |
| } | |
| async function _genVideoForBeat(sIdx, bIdx) { | |
| const beat = LATEST_RESULTS?.seeds?.[sIdx]?.seed?.beats?.[bIdx]; | |
| if (!beat?.content) return false; | |
| const imgUrl = MEDIA_STATE[sIdx]?.[bIdx]?.image_url; | |
| if (!imgUrl) return false; | |
| const total = LATEST_RESULTS.seeds[sIdx].seed.beats.length; | |
| _setMediaState(sIdx, bIdx, { | |
| status: 'loading', kind: 'video', | |
| message: `🎬 비디오 ${bIdx + 1}/${total} 생성 중… (60-180초)`, | |
| }); | |
| try { | |
| const r = await fetch('/api/media/video', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({scene: beat.content, image_url: imgUrl}), | |
| }); | |
| const data = await r.json(); | |
| if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); | |
| _setMediaState(sIdx, bIdx, { video_url: data.video_url, status: 'ready' }); | |
| return true; | |
| } catch (e) { | |
| _setMediaState(sIdx, bIdx, { | |
| status: 'error', kind: 'video', | |
| message: `비디오 실패: ${e.message || e}`, | |
| }); | |
| return false; | |
| } | |
| } | |
| /** Manual handlers — buttons call these. */ | |
| async function manualGenerateImage(sIdx, bIdx) { | |
| await _genImageForBeat(sIdx, bIdx, /*useChain*/ true); | |
| // re-render stitch in case state changed | |
| renderStitchSection(sIdx); | |
| } | |
| // manualGenerateVideo removed — single full-spot Seedance video replaces per-beat i2v. | |
| /** Manual: text-to-image for any beat (no refs). */ | |
| async function manualGenerateImage(sIdx, bIdx) { | |
| const beat = LATEST_RESULTS?.seeds?.[sIdx]?.seed?.beats?.[bIdx]; | |
| if (!beat || !beat.content) return; | |
| _setMediaState(sIdx, bIdx, { | |
| status: 'loading', | |
| kind: 'image', | |
| message: '🖼 이미지 생성 중…', | |
| }); | |
| try { | |
| const r = await fetch('/api/media/image', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({scene: beat.content}), | |
| }); | |
| const data = await r.json(); | |
| if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); | |
| _setMediaState(sIdx, bIdx, { image_url: data.image_url, status: 'ready' }); | |
| } catch (e) { | |
| _setMediaState(sIdx, bIdx, { | |
| status: 'error', | |
| kind: 'image', | |
| message: `이미지 실패: ${e.message || e}`, | |
| }); | |
| } | |
| } | |
| /* manualGenerateVideo: removed. Single Seedance 2.0 call replaces it. */ | |
| /** Merge partial state, then re-render the slot. */ | |
| function _setMediaState(sIdx, bIdx, patch) { | |
| MEDIA_STATE[sIdx] = MEDIA_STATE[sIdx] || {}; | |
| MEDIA_STATE[sIdx][bIdx] = Object.assign( | |
| {}, | |
| MEDIA_STATE[sIdx][bIdx] || {}, | |
| patch, | |
| ); | |
| renderMediaSlot(sIdx, bIdx); | |
| } | |
| function renderMediaSlot(sIdx, bIdx) { | |
| const slot = document.querySelector(`[data-media="${sIdx}-${bIdx}"]`); | |
| if (!slot) return; | |
| const st = (MEDIA_STATE[sIdx] || {})[bIdx] || {}; | |
| const isAuto = bIdx === 0 && sIdx < 3; // first beat of top 3 seeds is auto | |
| slot.innerHTML = mediaSlotHtml(sIdx, bIdx, st, isAuto); | |
| } | |
| function mediaSlotHtml(sIdx, bIdx, st, _isAuto) { | |
| const parts = []; | |
| // Status (loading / error) — image only; per-beat video is gone. | |
| if (st.status === 'loading' && st.message) { | |
| parts.push(`<div class="media-status media-loading">${escapeHtml(st.message)}</div>`); | |
| } else if (st.status === 'error' && st.message) { | |
| parts.push(`<div class="media-status media-error-msg">${escapeHtml(st.message)}</div>`); | |
| } | |
| const fname = (kind) => `aether_${sIdx}_${String(bIdx).padStart(2,'0')}.${kind}`; | |
| // Image preview | |
| if (st.image_url) { | |
| parts.push(` | |
| <div class="media-thumb"> | |
| <img src="${escapeAttr(st.image_url)}" alt="scene ${bIdx + 1}" loading="lazy" /> | |
| <a class="media-download" | |
| href="/api/media/download?url=${encodeURIComponent(st.image_url)}&filename=${encodeURIComponent(fname('jpg'))}" | |
| title="이미지 다운로드">↓</a> | |
| </div> | |
| `); | |
| } | |
| // (Per-beat video preview removed — single full-spot video lives at card bottom.) | |
| // Image-only controls — single i2v video is generated once for the whole spot. | |
| if (HAS_FAL) { | |
| const imgDisabled = st.status === 'loading' ? 'disabled' : ''; | |
| const imgLabel = st.image_url ? '🔄 이미지 재생성' : '🖼 이미지 생성'; | |
| parts.push(` | |
| <div class="media-controls"> | |
| <button type="button" class="btn-media" data-media-btn="image" | |
| data-seed="${sIdx}" data-beat="${bIdx}" ${imgDisabled}>${imgLabel}</button> | |
| </div> | |
| `); | |
| } | |
| return parts.join(''); | |
| } | |
| /* (Legacy renderStitchSection / stitchAllVideos removed — replaced by | |
| * renderFinalVideoSection + generateFinalVideo defined earlier in this file. | |
| * The forwarder near the top still exposes `renderStitchSection` for any | |
| * call sites that haven't migrated yet.) | |
| */ | |
| function escapeAttr(s) { | |
| return String(s || '').replace(/"/g, '"').replace(/</g, '<'); | |
| } | |
| /* Delegate clicks on per-beat image buttons + final-video regen + anchor retry. */ | |
| document.addEventListener('click', (e) => { | |
| // Final-video regen / try-another-anchor (.btn-media or .btn-stitch with data-final-seed) | |
| const finalEl = e.target.closest('[data-final-seed]'); | |
| if (finalEl && !finalEl.disabled) { | |
| const sIdx = parseInt(finalEl.dataset.finalSeed ?? finalEl.dataset.stitchSeed, 10); | |
| const anchor = finalEl.dataset.anchorBeat; | |
| if (!Number.isNaN(sIdx)) { | |
| generateFinalVideo(sIdx, anchor !== undefined ? parseInt(anchor, 10) : 0); | |
| } | |
| return; | |
| } | |
| const finalBtn = e.target.closest('.btn-stitch'); | |
| if (finalBtn && !finalBtn.disabled) { | |
| const sIdx = parseInt(finalBtn.dataset.stitchSeed, 10); | |
| if (!Number.isNaN(sIdx)) generateFinalVideo(sIdx); | |
| return; | |
| } | |
| const btn = e.target.closest('.btn-media'); | |
| if (!btn) return; | |
| const sIdx = parseInt(btn.dataset.seed, 10); | |
| const bIdx = parseInt(btn.dataset.beat, 10); | |
| if (btn.dataset.mediaBtn === 'image') { | |
| manualGenerateImage(sIdx, bIdx); | |
| } | |
| }); | |
| function renderRawDetails(seed) { | |
| const parts = []; | |
| if (seed.raw_collision) { | |
| parts.push(` | |
| <details class="raw-details"> | |
| <summary>📝 모델 원본 출력 · Concept (참고자료)</summary> | |
| <pre class="raw-body">${escapeHtml(seed.raw_collision)}</pre> | |
| </details> | |
| `); | |
| } | |
| if (seed.raw_spine) { | |
| parts.push(` | |
| <details class="raw-details"> | |
| <summary>📝 모델 원본 출력 · Pixar Spine (참고자료)</summary> | |
| <pre class="raw-body">${escapeHtml(seed.raw_spine)}</pre> | |
| </details> | |
| `); | |
| } | |
| return parts.join(''); | |
| } | |
| function renderBeatBar(beats, duration) { | |
| return beats.map((b, i) => { | |
| const pct = ((b.time_range[1] - b.time_range[0]) / duration) * 100; | |
| const short = (b.beat || '').split('_')[0] || b.beat; | |
| return ` | |
| <div class="beat-seg beat-color-${Math.min(i, 5)}" style="width: ${pct}%;" | |
| title="${escapeHtml(b.beat)} · ${b.time_range[0]}-${b.time_range[1]}s"> | |
| <div class="beat-seg-name">${escapeHtml(short)}</div> | |
| <div class="beat-seg-time">${b.time_range[0]}-${b.time_range[1]}s</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function renderTicks(duration) { | |
| const ticks = duration === 15 ? [0, 3, 6, 9, 12, 15] : [0, 5, 10, 15, 20, 25, 30]; | |
| return ticks.map(t => { | |
| const left = (t / duration) * 100; | |
| return `<span class="timeline-tick" style="left: ${left}%;">${t}s</span>`; | |
| }).join(''); | |
| } | |
| function scoreItem(label, val, opts = {}) { | |
| const v = opts.raw ? val : (typeof val === 'number' ? val.toFixed(3) : val); | |
| const style = []; | |
| if (opts.risk && Number(val) > 0) style.push('color: var(--danger);'); | |
| if (opts.bold) style.push('color: var(--primary); font-size: 15px;'); | |
| return ` | |
| <div class="score-item"> | |
| <span class="score-item-label">${label}</span> | |
| <span class="score-item-value" style="${style.join(' ')}">${v}</span> | |
| </div> | |
| `; | |
| } | |
| function escapeHtml(s) { | |
| if (s == null) return ''; | |
| return String(s).replace(/[&<>"']/g, ch => ({ | |
| '&':'&','<':'<','>':'>','"':'"',"'":''', | |
| }[ch])); | |
| } | |