SeaWolf-AI's picture
feat: real emergence — anti-imitation system prompt + LLM tournament selection (structural-invention judge replaces heuristic top-1)
3b19377 verified
'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:<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, '&quot;').replace(/</g, '&lt;');
}
/* 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 => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
}[ch]));
}