'use strict'; /* ── 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:" 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 = [ `
rule_id
name
pattern
theoretical_anchor
`, ]; rules.forEach(r => { rows.push(`
${r.rule_id}
${escapeHtml(r.name)}
${escapeHtml(r.pattern)}
${escapeHtml(r.theoretical_anchor)}
`); }); 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', `✓ ${escapeHtml(t.name)} · ${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 || '')}
payoff: ${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', `✓ ${escapeHtml(product.brand)} · ${escapeHtml(product.category)} · ${product.atom_count} atoms` + (atoms ? `
${escapeHtml(atoms)}` : '') ); 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 = `
Rank
#${rank}
${escapeHtml(seed.seed_id)}
${seed.rules_applied.map(r => `${escapeHtml(r)}`).join('')}
${final.toFixed(2)}
final
${seed.scene_summary ? `
📜 한 줄 요약 ${escapeHtml(seed.scene_summary)}
` : ''} ${seed.wow_anchor ? `
${escapeHtml(seed.wow_anchor)}
` : ''} ${renderTournament(seed.tournament)} ${renderConstraints(seed.creative_constraints)}
Concept
${escapeHtml(seed.concept)}
Pixar Story Spine · ${seed.duration}s · ${seed.beats.length} beats
${renderBeatBar(seed.beats, seed.duration)}
${renderTicks(seed.duration)}
Beats · 이미지/비디오
${seed.beats.map((b, bi) => `
${b.time_range[0]}–${b.time_range[1]}s
${escapeHtml(b.beat)}
${escapeHtml(b.content || '—')}
`).join('')}
⚙️ AETHER 5-생성자 메타인지: 木(씨앗) → 火(증폭) → 土(지반) → 金(편집) → 水(통합)
${renderAetherCritique(seed.aether_critique)} ${renderRawDetails(seed)}
${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 })}
`; 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 `
🏆 토너먼트 우승 LLM 판사가 ${escapeHtml(t.winner_index !== undefined ? '#' + (Number(t.winner_index) + 1) : '')} 후보를 구조적 창발 1위로 선정
${inv ? `
구조적 발명 ${escapeHtml(inv)}
` : ''} ${why ? `
결정적 이유 ${escapeHtml(why)}
` : ''} ${ru ? `
차점자 약점 ${escapeHtml(ru)}
` : ''}
`; } function renderAetherCritique(c) { if (!c || typeof c !== 'object') return ''; const verdict = (c.verdict || 'PASS').toUpperCase(); const isRevise = verdict === 'REVISE'; const verdictBadge = isRevise ? 'REVISE → 재집필 적용됨' : 'PASS → 추가 수정 없음'; 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() ? `
재집필 지시 ${escapeHtml(c.revision_brief)}
` : ''; return `
🧠 AETHER 메타인지 비평 ${verdictBadge}
${rows.map(([k, v, tip]) => `
${k} ${escapeHtml(v)}
`).join('')}
${briefBlock}
`; } 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 `
창발 강제 조건 · Kimi-K2P6에 하드 주입된 구체 제약
${rows.map(([k, v]) => `
${k} ${escapeHtml(v)}
`).join('')}
`; } /* ── 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(`
🎬 최종 ${totalSec}초 광고 영상 (Bytedance Seedance 2.0)
`); html.push(`
`); if (st.status === 'loading') { html.push(`
${escapeHtml(st.message)}
`); } else if (st.status === 'error') { html.push(`
${escapeHtml(st.message)}
`); } 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(`
${totalSec}초 영상 · ${beats.length}개 씬 통합 i2v · 앵커 이미지: 비트 ${anchorIdx}${retryNote}
`); } 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 ? `` : '' ).join('') : ''; html.push(`
이미지 ${imagesReady}/${beats.length} 준비 ${enough ? `· ${totalSec}초 단일 영상 생성 가능` : '— 첫 비트 이미지부터 필요'}
${anchorButtons ? `
${anchorButtons}
` : ''} `); } html.push(`
`); 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(`
${escapeHtml(st.message)}
`); } else if (st.status === 'error' && st.message) { parts.push(`
${escapeHtml(st.message)}
`); } const fname = (kind) => `aether_${sIdx}_${String(bIdx).padStart(2,'0')}.${kind}`; // Image preview if (st.image_url) { parts.push(`
scene ${bIdx + 1}
`); } // (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(`
`); } 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(/ { // 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(`
📝 모델 원본 출력 · Concept (참고자료)
${escapeHtml(seed.raw_collision)}
`); } if (seed.raw_spine) { parts.push(`
📝 모델 원본 출력 · Pixar Spine (참고자료)
${escapeHtml(seed.raw_spine)}
`); } 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 `
${escapeHtml(short)}
${b.time_range[0]}-${b.time_range[1]}s
`; }).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 `${t}s`; }).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 `
${label} ${v}
`; } function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''', }[ch])); }