返回 html-video
app.js
1 // html-video studio v0.4 — chat-driven HTML + template gallery + text-node editor
2
3 import { t, getLocale, setLocale, AVAILABLE_LOCALES } from './i18n.js';
4
5 // Re-render whole UI on language change.
6 document.addEventListener('hv-locale-change', () => {
7 document.documentElement.lang = getLocale();
8 if (typeof renderToolbar === 'function') renderToolbar();
9 if (typeof renderMain === 'function') renderMain();
10 if (typeof renderSidebar === 'function') renderSidebar();
11 });
12 document.documentElement.lang = getLocale();
13
14 // Background-music style presets. Clicking a chip fills the prompt textarea
15 // with a tuned English MiniMax prompt (the model follows English best); the
16 // label is localized via i18n (soundtrack.preset_<key>). Still editable after.
17 const MUSIC_PRESETS = [
18 { key: 'energetic', prompt: 'energetic upbeat electronic, driving beat, punchy synths, modern and confident' },
19 { key: 'calm', prompt: 'calm ambient pad, soft piano, slow and soothing, gentle and warm' },
20 { key: 'tech', prompt: 'sleek tech corporate, pulsing synth arpeggio, clean minimal beat, futuristic' },
21 { key: 'narrative', prompt: 'cinematic storytelling score, emotional strings, building piano, reflective' },
22 { key: 'minimal', prompt: 'minimal lo-fi, sparse beat, mellow keys, understated background bed' },
23 { key: 'epic', prompt: 'epic orchestral, powerful drums, soaring brass, dramatic and inspiring' },
24 ];
25
26 // Narration voices — MiniMax built-in voice_ids, all verified usable.
27 // `key` maps to a localized label (soundtrack.voice_<key>).
28 const NARRATION_VOICES = [
29 { key: 'male_warm', voiceId: 'male-qn-qingse' },
30 { key: 'male_pro', voiceId: 'male-qn-jingying' },
31 { key: 'male_deep', voiceId: 'audiobook_male_1' },
32 { key: 'female_anchor', voiceId: 'presenter_female' },
33 { key: 'female_mature', voiceId: 'female-yujie' },
34 { key: 'female_sweet', voiceId: 'female-shaonv' },
35 ];
36
37 const API = {
38 projects: () => fetch('/api/projects').then(r => r.json()),
39 createProject: b => fetch('/api/projects', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(r => r.json()),
40 getProject: id => fetch(`/api/projects/${id}`).then(r => r.json()),
41 patchProject: (id, b) => fetch(`/api/projects/${id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(r => r.json()),
42 deleteProject: id => fetch(`/api/projects/${id}`, { method: 'DELETE' }).then(r => r.json()),
43 templates: () => fetch('/api/templates').then(r => r.json()),
44 agents: () => fetch('/api/agents').then(r => r.json()),
45 setTemplate: (id, tid) => fetch(`/api/projects/${id}/template`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ template_id: tid }) }).then(r => r.json()),
46 setAgent: (id, aid, model) => fetch(`/api/projects/${id}/agent`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent_id: aid, ...(model !== undefined && { agent_model: model }) }) }).then(r => r.json()),
47 exportMp4: id => fetch(`/api/projects/${id}/export`, { method: 'POST' }).then(r => r.json()),
48 getMessages: id => fetch(`/api/projects/${id}/messages`).then(r => r.json()),
49 rawHtml: id => fetch(`/api/projects/${id}/raw-html`).then(r => r.ok ? r.text() : null),
50 putRawHtml: (id, html) => fetch(`/api/projects/${id}/raw-html`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ html }) }).then(r => r.json()),
51 contentGraph: id => fetch(`/api/projects/${id}/content-graph`).then(r => r.ok ? r.json() : null),
52 unenhanceFrame: (id, nodeId) => fetch(`/api/projects/${id}/frames/${encodeURIComponent(nodeId)}/unenhance`, { method: 'POST' }).then(r => r.json()),
53 testAgent: id => fetch(`/api/agents/${encodeURIComponent(id)}/test`, { method: 'POST' }).then(r => r.json()),
54 rescanAgents: () => fetch('/api/agents?force=1').then(r => r.json()),
55 };
56
57 const state = {
58 projects: [],
59 templates: [],
60 agents: [],
61 selectedId: null,
62 selected: null,
63 messages: [],
64 composing: false,
65 textFields: [], // [{key, original, current}]
66 textSaveTimer: null,
67 pendingAttachments: [], // [{file, dataUrl?, name, kind, size}] before send
68 // v0.8: multi-frame timeline state
69 activeFrameId: null, // graphNodeId currently shown in iframe
70 iterateFocusFrameId: null, // graphNodeId iterations should target only (null = whole video)
71 editTextMode: false, // when true, preview iframe accepts inline text edits
72 exporting: false, // export run in progress
73 exportProgress: null, // { pct, stage } during a streamed export
74 lastGraph: null, // last fetched ContentGraph (for download)
75 // Phase C: per-frame native Remotion enhancement
76 frameKinds: {}, // { [graphNodeId]: 'entity'|'data'|'text' } for the selected project
77 enhancing: null, // { nodeId, pct, stage } while a single-frame enhance render is in flight
78 };
79
80 // ============== boot ==============
81 async function init() {
82 // Kick off agent detection in the background — `which` + `<bin> --version`
83 // can take ~400ms+ cold and there's no point holding the whole UI for it.
84 // Composer renders disabled-but-visible; we re-render it once agents land.
85 const agentsPromise = refreshAgents().then(() => {
86 renderToolbar();
87 if (state.selected) renderComposer();
88 });
89 await Promise.all([refreshTemplates(), refreshProjects()]);
90 renderToolbar();
91 wireToolbar();
92 wireModals();
93 // Don't block — but surface failures in the console.
94 agentsPromise.catch((e) => console.warn('agent detection failed:', e));
95
96 // Empty list → spin up a default project so the user lands inside one
97 // instead of an empty gallery.
98 if (state.projects.length === 0) {
99 const r = await API.createProject({ name: defaultProjectName(0) });
100 if (r && r.project) {
101 await refreshProjects();
102 await selectProject(r.project.id);
103 return;
104 }
105 }
106 // First load with existing projects → open the most recently updated one.
107 if (!state.selected && state.projects.length > 0) {
108 await selectProject(state.projects[0].id);
109 }
110 }
111
112 function defaultProjectName(seed) {
113 const n = (state.projects?.length ?? 0) + (seed ?? 0) + 1;
114 return `Untitled ${String(n).padStart(2, '0')}`;
115 }
116
117 /**
118 * Format a percent value for inline progress UI.
119 * - integer pcts stay integer ("56" → "56")
120 * - fractional pcts truncate to 1 decimal place ("98.333…" → "98.3")
121 * Avoids the JS-default "98.33333333334%" tail when sources publish
122 * (frame_index + sub_pct/100) / total style fractions.
123 */
124 function formatPct(value) {
125 const n = Number(value);
126 if (!Number.isFinite(n)) return '0';
127 if (Number.isInteger(n)) return String(n);
128 return n.toFixed(1);
129 }
130
131 async function createDefaultProject() {
132 const r = await API.createProject({ name: defaultProjectName(0) });
133 if (!r?.project) {
134 toast(t('modal.new.failed'), 'error');
135 return;
136 }
137 await refreshProjects();
138 await selectProject(r.project.id);
139 }
140
141 // ============== Export MP4 (streamed) ==============
142 async function startExportStream() {
143 if (!state.selected) return;
144 const projectId = state.selected.id;
145 state.exporting = true;
146 state.exportProgress = { pct: 0, stage: 'starting' };
147 renderToolbar();
148 state.messages.push({ role: 'preview-event', content: t('export.starting'), ts: Date.now() });
149 renderChatLog();
150
151 let res;
152 try {
153 res = await fetch(`/api/projects/${projectId}/export`, {
154 method: 'POST',
155 headers: { accept: 'text/event-stream', 'content-type': 'application/json' },
156 body: JSON.stringify({}),
157 });
158 } catch (e) {
159 state.exporting = false;
160 state.exportProgress = null;
161 toast(t('export.failed_short', { message: (e?.message ?? e) }), 'error');
162 renderToolbar();
163 return;
164 }
165 if (!res.ok || !res.body) {
166 state.exporting = false;
167 state.exportProgress = null;
168 const err = await res.text().catch(() => '');
169 toast(t('export.failed_short', { message: err.slice(0, 200) }), 'error');
170 renderToolbar();
171 return;
172 }
173
174 const reader = res.body.getReader();
175 const decoder = new TextDecoder();
176 let buf = '';
177 try {
178 while (true) {
179 const { done, value } = await reader.read();
180 if (done) break;
181 buf += decoder.decode(value, { stream: true });
182 const events = buf.split('\n\n');
183 buf = events.pop() ?? '';
184 for (const line of events) {
185 if (!line.startsWith('data: ')) continue;
186 let ev;
187 try { ev = JSON.parse(line.slice(6)); } catch { continue; }
188 if (ev.type === 'export_progress') {
189 state.exportProgress = { pct: ev.pct, stage: ev.stage };
190 renderToolbar();
191 } else if (ev.type === 'export_done') {
192 state.exporting = false;
193 state.exportProgress = null;
194 if (ev.project) state.selected = ev.project;
195 const seconds = ev.elapsed_ms ? `${(ev.elapsed_ms / 1000).toFixed(1)}s` : '';
196 state.messages.push({
197 role: 'preview-event',
198 content: seconds ? t('export.done_seconds', { seconds }) : t('export.done_no_seconds'),
199 ts: Date.now(),
200 });
201 state.messages.push({
202 role: 'export-done',
203 content: ev.output_path,
204 ts: Date.now(),
205 });
206 renderChatLog();
207 renderToolbar();
208 refreshProjects();
209 } else if (ev.type === 'export_failed') {
210 state.exporting = false;
211 state.exportProgress = null;
212 state.messages.push({
213 role: 'system',
214 content: t('export.failed', { message: ev.message }),
215 ts: Date.now(),
216 });
217 renderChatLog();
218 renderToolbar();
219 }
220 }
221 }
222 } catch (e) {
223 state.exporting = false;
224 state.exportProgress = null;
225 toast(t('export.stream_interrupted', { message: (e?.message ?? e) }), 'error');
226 renderToolbar();
227 }
228 }
229
230 // ============== Per-frame native enhancement (streamed) ==============
231 // Render ONE data frame with the native Remotion template and stream progress,
232 // mirroring startExportStream. On done, swap that frame's thumbnail + centre
233 // preview to the rendered <video>. User-initiated only (the toggle's click).
234 async function startEnhanceStream(nodeId, nativeTemplateId = 'frame-data-rollup') {
235 if (!state.selected || state.enhancing) return;
236 const projectId = state.selected.id;
237 state.enhancing = { nodeId, pct: 0, stage: 'starting' };
238 renderFramesStrip();
239
240 let res;
241 try {
242 res = await fetch(`/api/projects/${projectId}/frames/${encodeURIComponent(nodeId)}/enhance`, {
243 method: 'POST',
244 headers: { accept: 'text/event-stream', 'content-type': 'application/json' },
245 body: JSON.stringify({ nativeTemplateId }),
246 });
247 } catch (e) {
248 state.enhancing = null;
249 toast(t('enhance.failed', { message: (e?.message ?? e) }), 'error');
250 renderFramesStrip();
251 return;
252 }
253 if (!res.ok || !res.body) {
254 state.enhancing = null;
255 const err = await res.text().catch(() => '');
256 toast(t('enhance.failed', { message: err.slice(0, 200) }), 'error');
257 renderFramesStrip();
258 return;
259 }
260
261 const reader = res.body.getReader();
262 const decoder = new TextDecoder();
263 let buf = '';
264 try {
265 while (true) {
266 const { done, value } = await reader.read();
267 if (done) break;
268 buf += decoder.decode(value, { stream: true });
269 const events = buf.split('\n\n');
270 buf = events.pop() ?? '';
271 for (const line of events) {
272 if (!line.startsWith('data: ')) continue;
273 let ev;
274 try { ev = JSON.parse(line.slice(6)); } catch { continue; }
275 if (ev.type === 'enhance_progress') {
276 if (state.enhancing) { state.enhancing.pct = ev.pct; state.enhancing.stage = ev.stage; }
277 renderFramesStrip();
278 } else if (ev.type === 'enhance_done') {
279 state.enhancing = null;
280 if (ev.project) state.selected = ev.project; // bumped updatedAt → fresh <video> URL
281 state.messages.push({ role: 'preview-event', content: t('enhance.done'), ts: Date.now() });
282 renderChatLog();
283 renderFramesStrip();
284 renderPreview();
285 refreshProjects();
286 } else if (ev.type === 'enhance_failed') {
287 state.enhancing = null;
288 toast(t('enhance.failed', { message: ev.message }), 'error');
289 renderFramesStrip();
290 }
291 }
292 }
293 } catch (e) {
294 state.enhancing = null;
295 toast(t('enhance.failed', { message: (e?.message ?? e) }), 'error');
296 renderFramesStrip();
297 }
298 }
299
300 async function unenhanceFrameAction(nodeId) {
301 if (!state.selected || state.enhancing) return;
302 try {
303 const r = await API.unenhanceFrame(state.selected.id, nodeId);
304 if (r?.project) state.selected = r.project;
305 renderFramesStrip();
306 renderPreview();
307 refreshProjects();
308 } catch (e) {
309 toast(t('enhance.failed', { message: (e?.message ?? e) }), 'error');
310 }
311 }
312
313 /**
314 * Detect "I want to export this to MP4" intent in a chat message.
315 * Hits both Chinese + English without leaning on the agent.
316 */
317 function isExportIntent(text) {
318 if (!text) return false;
319 const t = text.trim();
320 if (t.length > 40) return false; // long messages are content / iterate requests
321 if (/https?:\/\//i.test(t)) return false; // a link is ALWAYS source material to build from, never "export"
322 // "生成/做一个视频" is the most common way to ask to CREATE a video — it must
323 // NOT count as export. Only match explicit export/render verbs that target an
324 // already-produced result: 导出 / 出片 / 渲染 / export / render / encode / 输出mp4.
325 return /^\s*(?:export|render|encode|导出(?:视频|为?\s?mp4)?|出片|渲染|输出\s?mp4|存为\s?mp4)\s*$/i.test(t)
326 || /(?:^|\s)(?:导出|出片|渲染成?|export|render|encode)(?:$|\s|视频|为?\s?mp4|成\s?mp4)/i.test(t);
327 }
328
329 async function revealExportedFile() {
330 if (!state.selected) return;
331 try {
332 const r = await fetch(`/api/projects/${state.selected.id}/reveal`, { method: 'POST' });
333 const data = await r.json();
334 if (!r.ok) throw new Error(data.error || `${r.status}`);
335 } catch (e) {
336 toast(t('export.reveal_failed', { message: (e?.message ?? e) }), 'error');
337 }
338 }
339 async function refreshTemplates() {
340 const r = await API.templates();
341 state.templates = r.templates ?? [];
342 }
343 async function refreshAgents() {
344 try { state.agents = (await API.agents()).agents ?? []; }
345 catch { state.agents = []; }
346 }
347 async function refreshProjects() {
348 state.projects = (await API.projects()).projects ?? [];
349 renderSidebar();
350 }
351
352 async function selectProject(id) {
353 state.selectedId = id;
354 state.selected = (await API.getProject(id)).project;
355 state.activeFrameId = null; // reset frame selection on project switch
356 state.iterateFocusFrameId = null;
357 state.editTextMode = false;
358 state.enhancing = null;
359 // Phase C: map graph node id → kind so the strip can show the "⚡ Enhance"
360 // toggle only on data frames. One fetch per project switch.
361 state.frameKinds = {};
362 try {
363 const cg = await API.contentGraph(id);
364 if (cg?.graph?.nodes) for (const n of cg.graph.nodes) state.frameKinds[n.id] = n.kind;
365 } catch { /* no graph (single-frame project) — no toggles, fine */ }
366 // A generation running for the PREVIOUS project keeps going on the backend
367 // (its result persists); just release the composer so this project is usable.
368 // The in-flight SSE loop self-stops once it sees selectedId changed.
369 state.composing = false;
370 try { state.messages = (await API.getMessages(id)).messages ?? []; }
371 catch { state.messages = []; }
372 // Export history is persisted on the project — surface the latest export so
373 // its "MP4 ready" card survives a session/project switch (it was previously
374 // only an in-memory chat message and vanished on switch).
375 const exports = state.selected?.exports ?? [];
376 if (exports.length && exports[exports.length - 1]?.path) {
377 state.messages.push({ role: 'export-done', content: exports[exports.length - 1].path, ts: Date.now() });
378 } else if (state.selected?.lastOutputMp4Path) {
379 state.messages.push({ role: 'export-done', content: state.selected.lastOutputMp4Path, ts: Date.now() });
380 }
381 // If a generation is still running on the backend for this project, surface a
382 // live "still generating" line (the in-memory progress lines were lost on the
383 // switch; the result will appear in messages once it finishes — reload to see).
384 try {
385 const g = await fetch(`/api/projects/${id}/generating`).then((r) => r.json());
386 if (g?.generating && id === state.selectedId) {
387 state.messages.push({ role: 'preview-event', content: t('chat.still_generating'), ts: Date.now() });
388 }
389 } catch { /* non-fatal */ }
390 renderSidebar();
391 renderToolbar(); // <-- bug fix: toolbar buttons (template / agent / export) must
392 // be re-enabled after a project is selected
393 renderMain();
394 await refreshTextFields();
395 }
396
397 // ============== sidebar ==============
398 function renderSidebar() {
399 const list = document.getElementById('project-list');
400 if (!state.projects.length) {
401 list.innerHTML = `<div class="empty-list">${t('sidebar.empty_list')}</div>`;
402 return;
403 }
404 list.innerHTML = '';
405 for (const p of state.projects) {
406 const div = document.createElement('div');
407 div.className = 'project-row' + (p.id === state.selectedId ? ' active' : '');
408 div.innerHTML = `
409 <div class="name">${esc(p.name)}</div>
410 <div class="meta">${p.template_id ? esc(p.template_id) : 'no template'} · ${p.status}</div>
411 <button class="row-menu-btn" title="More" data-pid="${esc(p.id)}">⋯</button>
412 `;
413 div.onclick = (e) => {
414 // Ignore clicks that started inside the menu button.
415 if (e.target.closest('.row-menu-btn') || e.target.closest('.row-menu')) return;
416 selectProject(p.id);
417 };
418 list.appendChild(div);
419 }
420 list.querySelectorAll('.row-menu-btn').forEach((btn) => {
421 btn.onclick = (e) => {
422 e.stopPropagation();
423 openProjectMenu(btn);
424 };
425 });
426 }
427
428 function openProjectMenu(anchor) {
429 // Close any existing menu.
430 document.querySelectorAll('.row-menu').forEach((m) => m.remove());
431 const pid = anchor.dataset.pid;
432 const proj = state.projects.find((p) => p.id === pid);
433 if (!proj) return;
434 const menu = document.createElement('div');
435 menu.className = 'row-menu';
436 menu.innerHTML = `
437 <button data-act="rename">${t('sidebar.menu.rename')}</button>
438 <button data-act="delete">${t('sidebar.menu.delete')}</button>
439 `;
440 // Position below the button.
441 const r = anchor.getBoundingClientRect();
442 menu.style.top = `${r.bottom + 4}px`;
443 menu.style.left = `${r.right - 140}px`;
444 document.body.appendChild(menu);
445 menu.querySelector('[data-act="rename"]').onclick = async () => {
446 menu.remove();
447 const next = prompt(t('sidebar.rename_prompt'), proj.name);
448 if (next == null) return;
449 const trimmed = next.trim();
450 if (!trimmed || trimmed === proj.name) return;
451 await API.patchProject(proj.id, { name: trimmed });
452 await refreshProjects();
453 if (state.selectedId === proj.id) {
454 state.selected = (await API.getProject(proj.id)).project;
455 renderToolbar();
456 renderFooter();
457 }
458 };
459 menu.querySelector('[data-act="delete"]').onclick = async () => {
460 menu.remove();
461 if (!confirm(t('sidebar.delete_confirm', { name: proj.name }))) return;
462 await API.deleteProject(proj.id);
463 await refreshProjects();
464 if (state.selectedId === proj.id) {
465 state.selectedId = null;
466 state.selected = null;
467 state.messages = [];
468 // Pick the next available project, or build a fresh default.
469 if (state.projects.length > 0) {
470 await selectProject(state.projects[0].id);
471 } else {
472 const r = await API.createProject({ name: defaultProjectName(0) });
473 await refreshProjects();
474 if (r?.project) await selectProject(r.project.id);
475 }
476 }
477 };
478 // Close on outside click / Escape.
479 const close = (e) => {
480 if (menu.contains(e.target)) return;
481 menu.remove();
482 document.removeEventListener('mousedown', close);
483 document.removeEventListener('keydown', escClose);
484 };
485 const escClose = (e) => {
486 if (e.key === 'Escape') {
487 menu.remove();
488 document.removeEventListener('mousedown', close);
489 document.removeEventListener('keydown', escClose);
490 }
491 };
492 setTimeout(() => {
493 document.addEventListener('mousedown', close);
494 document.addEventListener('keydown', escClose);
495 }, 0);
496 }
497
498 // ============== toolbar ==============
499 function renderToolbar() {
500 const p = state.selected;
501 const nameInput = document.getElementById('proj-name');
502 const pickBtn = document.getElementById('btn-pick-template');
503 const exportBtn = document.getElementById('btn-export');
504
505 nameInput.disabled = !p;
506 nameInput.placeholder = p ? '' : t('app.no_project');
507 nameInput.value = p?.name ?? '';
508
509 pickBtn.disabled = !p;
510 if (p && p.templateId) {
511 const tpl = state.templates.find(x => x.id === p.templateId);
512 pickBtn.classList.remove('empty');
513 pickBtn.querySelector('.label').textContent = tpl ? tpl.name : p.templateId;
514 } else {
515 pickBtn.classList.add('empty');
516 pickBtn.querySelector('.label').textContent = t('toolbar.template_pick');
517 }
518
519 // Frames-mode projects don't need a template to export — they have
520 // frames[] directly. Single-frame projects still need a template until
521 // the v0.x stub is gone.
522 const hasFrames = !!(p && Array.isArray(p.frames) && p.frames.length > 0);
523 exportBtn.disabled = !p || (!p.templateId && !hasFrames) || !!state.exporting;
524 if (state.exporting) {
525 exportBtn.textContent = state.exportProgress
526 ? t('export.button_running', {
527 pct: formatPct(state.exportProgress.pct),
528 stage: state.exportProgress.stage,
529 })
530 : t('export.starting');
531 } else {
532 exportBtn.textContent = t('toolbar.export_mp4');
533 }
534 renderAgentPill();
535
536 // Re-wire on every render so handlers always match the current DOM.
537 wireToolbar();
538 }
539
540 /** Fill the top-bar Agent pill: current agent's logo + name + connection dot. */
541 function renderAgentPill() {
542 const pill = document.getElementById('btn-agent');
543 if (!pill) return;
544 const p = state.selected;
545 pill.disabled = !p;
546 const dot = document.getElementById('agent-dot');
547 const logo = document.getElementById('agent-pill-logo');
548 const label = document.getElementById('agent-pill-label');
549 if (!p) {
550 label.textContent = t('toolbar.agent_none');
551 logo.innerHTML = '';
552 dot.className = 'agent-dot';
553 return;
554 }
555 const currentId = p.agentId ?? (state.agents.find((a) => a.available && a.id !== 'amr')?.id ?? 'anthropic-api');
556 const a = state.agents.find((x) => x.id === currentId);
557 const available = a?.available ?? false;
558 label.textContent = a?.name ?? currentId;
559 logo.innerHTML = AGENT_LOGOS[currentId] ? `<img src="${esc(AGENT_LOGOS[currentId])}" alt="" />` : '';
560 dot.className = 'agent-dot ' + (available ? 'ok' : 'missing');
561 pill.title = available ? t('toolbar.agent_ready') : t('settings.agent.unavailable');
562 renderModelSwitch(currentId);
563 }
564
565 /** Model picker — only for AMR (the one agent with a model catalog). Lazily
566 * fetches the live list, fills the dropdown, and persists the choice to the
567 * project so generation drives session/set_model with it. */
568 async function renderModelSwitch(currentAgentId) {
569 const wrap = document.getElementById('model-switch');
570 const sel = document.getElementById('model-select');
571 if (!wrap || !sel) return;
572 if (!state.selected || currentAgentId !== 'amr') { wrap.hidden = true; return; }
573 wrap.hidden = false;
574 // Fetch once per session; cache on state.
575 if (!state._amrModels) {
576 try {
577 const data = await fetch('/api/agents/amr/models').then((r) => r.json());
578 state._amrModels = data.models ?? [];
579 state._amrDefaultModel = data.default ?? null;
580 } catch { state._amrModels = []; }
581 }
582 const models = state._amrModels;
583 if (!models.length) { wrap.hidden = true; return; }
584 const chosen = state.selected.agentModel ?? state._amrDefaultModel ?? models[0].id;
585 sel.innerHTML = models.map((m) => `<option value="${esc(m.id)}"${m.id === chosen ? ' selected' : ''}>${esc(m.label)}</option>`).join('');
586 sel.onchange = async () => {
587 if (!state.selected) return;
588 try {
589 await API.setAgent(state.selected.id, 'amr', sel.value);
590 state.selected = (await API.getProject(state.selected.id)).project;
591 toast(`✓ ${sel.value}`, 'success');
592 } catch (e) { toast(`${e?.message ?? e}`, 'error'); }
593 };
594 }
595
596 /** Open/refresh the top-bar agent dropdown. */
597 function renderAgentMenu() {
598 const menu = document.getElementById('agent-menu');
599 if (!menu || !state.selected) return;
600 const currentId = state.selected.agentId ?? (state.agents.find((a) => a.available && a.id !== 'amr')?.id ?? 'anthropic-api');
601 menu.innerHTML = state.agents.map((a) => {
602 const cur = a.id === currentId ? ' current' : '';
603 const logo = AGENT_LOGOS[a.id] ? `<img src="${esc(AGENT_LOGOS[a.id])}" alt="" />` : '';
604 // AMR is "found but needs login": it can be made available by signing in,
605 // unlike a genuinely missing CLI. Offer a login button instead of just
606 // greying it out + the misleading "Not installed".
607 const needsLogin = !a.available && a.id === 'amr' && !!a.hint;
608 // Star the recommended agent (AMR) to draw the eye.
609 const star = a.id === 'amr' ? `<span class="mi-star" title="${esc(t('agent.recommended'))}">★</span>` : '';
610 const inner = `<span class="mi-dot ${a.available ? 'ok' : ''}"></span>
611 <span class="mi-logo">${logo}</span>
612 <span class="mi-name">${esc(a.name)}</span>${star}`;
613 // AMR-needs-login: render the row as a DIV (not a button) so a real, separate
614 // Sign-in <button> can live beside it — nesting a button inside a button is
615 // invalid HTML and the outer one eats the inner one's clicks.
616 if (needsLogin) {
617 return `<div class="agent-menu-item is-unselectable" title="${esc(a.hint ?? '')}">
618 ${inner}
619 <button type="button" class="mi-login" data-login-agent="${esc(a.id)}">${esc(t('agent.sign_in'))}</button>
620 </div>`;
621 }
622 const tag = a.available ? '' : `<span class="mi-tag">${esc(t('settings.agent.unavailable'))}</span>`;
623 const unsel = a.available ? '' : ' is-unselectable';
624 return `<button type="button" class="agent-menu-item${cur}${unsel}" data-agent-id="${esc(a.id)}" data-selectable="${a.available ? '1' : '0'}" title="${esc(a.hint ?? '')}">
625 ${inner}${tag}
626 </button>`;
627 }).join('');
628 menu.querySelectorAll('.agent-menu-item').forEach((item) => {
629 item.onclick = async (e) => {
630 // Login button inside the item: don't treat as agent-select.
631 if (e.target.closest('.mi-login')) return;
632 const aid = item.dataset.agentId;
633 if (!state.selected || item.dataset.selectable !== '1') return;
634 try {
635 await API.setAgent(state.selected.id, aid);
636 state.selected = (await API.getProject(state.selected.id)).project;
637 toast(`✓ ${aid}`, 'success');
638 } catch (e) {
639 toast(`${e?.message ?? e}`, 'error');
640 }
641 closeAgentMenu();
642 renderToolbar();
643 };
644 });
645 // AMR "Sign in" → spawn `vela login` server-side (opens the browser), then
646 // re-detect so the agent flips to available.
647 menu.querySelectorAll('.mi-login').forEach((btn) => {
648 btn.onclick = async (e) => {
649 e.preventDefault();
650 e.stopPropagation();
651 if (btn.dataset.busy === '1') return;
652 const label = btn.textContent;
653 btn.textContent = t('agent.signing_in');
654 btn.dataset.busy = '1';
655 btn.classList.add('busy');
656 try {
657 const res = await fetch(`/api/agents/${btn.dataset.loginAgent}/login`, { method: 'POST' });
658 const data = await res.json();
659 if (res.ok && data.ok) {
660 toast(t('agent.signed_in'), 'success');
661 state.agents = (await fetch('/api/agents?force=1').then((r) => r.json())).agents ?? state.agents;
662 renderAgentMenu();
663 renderToolbar();
664 } else {
665 toast(data.error || t('agent.sign_in_failed'), 'error');
666 btn.textContent = label; delete btn.dataset.busy; btn.classList.remove('busy');
667 }
668 } catch (err) {
669 toast(`${err?.message ?? err}`, 'error');
670 btn.textContent = label; delete btn.dataset.busy; btn.classList.remove('busy');
671 }
672 };
673 });
674 }
675
676 function closeAgentMenu() {
677 const menu = document.getElementById('agent-menu');
678 if (menu) menu.hidden = true;
679 document.removeEventListener('click', _agentMenuOutside, true);
680 }
681 function _agentMenuOutside(e) {
682 const sw = document.getElementById('agent-switch');
683 if (sw && !sw.contains(e.target)) closeAgentMenu();
684 }
685
686 // Wire toolbar elements — re-bind on every renderToolbar() so any DOM
687 // reuse / re-render can't strand stale event handlers. (Joey reported
688 // template + agent picks not responding in v0.6.2.)
689 function wireToolbar() {
690 const settingsBtn = document.getElementById('btn-settings');
691 if (settingsBtn) settingsBtn.onclick = openSettingsModal;
692 const pickBtn = document.getElementById('btn-pick-template');
693 if (pickBtn) {
694 pickBtn.onclick = (e) => {
695 e.preventDefault();
696 if (!state.selected) {
697 toast(t('composer.placeholder.no_project'), 'error');
698 return;
699 }
700 openGallery();
701 };
702 }
703 // Top-bar agent switcher: pill toggles a dropdown to view status + switch.
704 const agentBtn = document.getElementById('btn-agent');
705 if (agentBtn) {
706 agentBtn.onclick = (e) => {
707 e.preventDefault();
708 e.stopPropagation();
709 if (!state.selected) { toast(t('composer.placeholder.no_project'), 'error'); return; }
710 const menu = document.getElementById('agent-menu');
711 if (!menu) return;
712 if (menu.hidden) {
713 renderAgentMenu();
714 menu.hidden = false;
715 // close on outside click (capture so it fires before re-open)
716 setTimeout(() => document.addEventListener('click', _agentMenuOutside, true), 0);
717 } else {
718 closeAgentMenu();
719 }
720 };
721 }
722 const exportBtn = document.getElementById('btn-export');
723 if (exportBtn) {
724 exportBtn.onclick = () => {
725 if (!state.selected) return;
726 if (state.exporting) return;
727 startExportStream();
728 };
729 }
730 const nameInput = document.getElementById('proj-name');
731 if (nameInput) {
732 nameInput.onblur = () => {
733 if (state.selected) nameInput.value = state.selected.name;
734 };
735 }
736 const sidebarToggle = document.getElementById('btn-sidebar-toggle');
737 if (sidebarToggle) {
738 sidebarToggle.onclick = () => {
739 document.body.classList.toggle('sidebar-collapsed');
740 };
741 }
742 }
743
744 // ============== main: 4-column body ==============
745 function renderMain() {
746 const body = document.getElementById('body');
747 body.innerHTML = `
748 <aside class="sidebar">
749 <div class="sidebar-head">
750 <h2>${t('sidebar.projects')}</h2>
751 <button class="new-project" id="btn-new">${t('sidebar.new')}</button>
752 <button class="sidebar-toggle" id="btn-sidebar-toggle" title="${t('sidebar.collapse')}">‹</button>
753 </div>
754 <div class="project-list" id="project-list"></div>
755 </aside>
756
757 ${state.selected
758 ? `
759 <section class="chat-pane">
760 <div class="chat-log" id="chat-log"></div>
761 <div class="composer">
762 <div class="composer-shell" id="composer-shell">
763 <div class="attachments" id="attachments"></div>
764 <textarea id="composer-input" placeholder="..." rows="2"></textarea>
765 <div class="actions">
766 <button class="icon-btn" id="btn-attach" title="${t('composer.attach')}">📎</button>
767 <input type="file" id="file-input" multiple style="display:none" />
768 <span class="hint">${t('composer.hint')}</span>
769 <button class="send-btn" id="btn-send" disabled>${t('composer.send')}</button>
770 </div>
771 </div>
772 </div>
773 </section>
774
775 <section class="right-pane">
776 <div class="preview-stage" id="preview-stage">
777 <div class="preview-placeholder"><div><div class="ico">🎞️</div>${t('preview.placeholder.pick_template')}</div></div>
778 </div>
779 <div class="frames-strip" id="frames-strip"></div>
780 <div class="right-footer">
781 <span class="status" id="footer-status">${t('app.no_project')}</span>
782 <span class="grow"></span>
783 <button class="reload-btn" id="btn-reload">${t('preview.reload')}</button>
784 </div>
785 <details class="soundtrack-panel" id="soundtrack-panel">
786 <summary>
787 <span class="st-summary-main">${t('soundtrack.title')}</span>
788 <span class="st-summary-sub">${t('soundtrack.summary_sub')}</span>
789 <span class="soundtrack-badge">${t('soundtrack.optional')}</span>
790 </summary>
791 <div class="soundtrack-body">
792 <!-- ===== Background music: its own input + generate ===== -->
793 <div class="st-section">
794 <div class="st-section-title">${t('soundtrack.music_label')}</div>
795 <div class="st-presets" id="st-music-presets">
796 ${MUSIC_PRESETS.map((p) => `<button type="button" class="st-preset" data-prompt="${p.prompt}">${t('soundtrack.preset_' + p.key)}</button>`).join('')}
797 </div>
798 <textarea id="st-music-prompt" rows="2" placeholder="${t('soundtrack.music_placeholder')}"></textarea>
799 <div class="st-vol-row"><label>${t('soundtrack.music_volume')} <input type="range" id="st-music-vol" min="-40" max="0" value="-18" /><b id="st-music-vol-val">-18 dB</b></label></div>
800 <div class="st-section-actions">
801 <button class="st-generate" id="btn-st-gen-music">${t('soundtrack.gen_music')}</button>
802 <span class="st-status" id="st-music-status"></span>
803 </div>
804 </div>
805
806 <!-- ===== Narration / voiceover ===== -->
807 <!-- Two explicit steps so users don't confuse "write the text"
808 (AI drafts words, no audio) with "synthesize the voice"
809 (calls MiniMax, produces an mp3). See issues #4 / #5. -->
810 <div class="st-section st-narration">
811 <div class="st-section-title">${t('soundtrack.narration_label')}</div>
812
813 <!-- Step 1: write the script (text only) -->
814 <div class="st-substep">
815 <div class="st-substep-head">
816 <span class="st-step-badge">1</span>
817 <span class="st-step-label">${t('soundtrack.step_write')}</span>
818 <span class="st-narration-which" id="st-narration-which"></span>
819 </div>
820 <textarea id="st-narration-text" rows="2" placeholder="${t('soundtrack.narration_placeholder')}"></textarea>
821 <div class="st-draft-group">
822 <button type="button" class="st-draft" id="btn-st-draft-frame">${t('soundtrack.draft_frame')}</button>
823 <button type="button" class="st-draft" id="btn-st-draft-all">${t('soundtrack.draft_all')}</button>
824 </div>
825 </div>
826
827 <!-- Step 2: synthesize the voice (audio) -->
828 <div class="st-substep">
829 <div class="st-substep-head">
830 <span class="st-step-badge">2</span>
831 <span class="st-step-label">${t('soundtrack.step_voice')}</span>
832 </div>
833 <div class="st-voice-row">
834 <span class="st-voice-label">${t('soundtrack.voice_label')}</span>
835 <select id="st-narration-voice" class="st-voice-select">
836 ${NARRATION_VOICES.map((v) => `<option value="${v.voiceId}">${t('soundtrack.voice_' + v.key)}</option>`).join('')}
837 </select>
838 <button type="button" class="st-fit" id="btn-st-fit" title="${t('soundtrack.fit_hint')}">${t('soundtrack.fit_durations')}</button>
839 </div>
840 <div class="st-vol-row"><label>${t('soundtrack.narration_volume')} <input type="range" id="st-narration-vol" min="-20" max="6" value="0" /><b id="st-narration-vol-val">0 dB</b></label></div>
841 <div class="st-section-actions">
842 <button class="st-generate" id="btn-st-gen-narration">${t('soundtrack.gen_narration')}</button>
843 <span class="st-status" id="st-narration-status"></span>
844 </div>
845 </div>
846 </div>
847
848 <div class="soundtrack-actions">
849 <button class="st-clear" id="btn-st-clear">${t('soundtrack.clear')}</button>
850 </div>
851 <div class="soundtrack-preview" id="st-preview"></div>
852 </div>
853 </details>
854 </section>
855
856 <section class="text-pane">
857 <div class="text-pane-head">
858 <h2>${t('text_pane.title')}</h2>
859 <span class="save-state" id="text-save-state">${t('text_pane.save_state.idle')}</span>
860 <button class="textfields-toggle" id="btn-textfields-toggle" title="${t('text_pane.collapse')}">›</button>
861 </div>
862 <div class="text-fields" id="text-fields">
863 <div class="text-empty">${t('text_pane.empty_no_frames')}</div>
864 </div>
865 </section>
866 <div class="graph-modal" id="graph-modal">
867 <div class="panel">
868 <header>
869 <h3>Content graph</h3>
870 <span class="grow"></span>
871 <button class="download-btn" id="graph-download">⬇ Download JSON</button>
872 <button class="close-btn" id="graph-close">✕</button>
873 </header>
874 <pre id="graph-json"></pre>
875 </div>
876 </div>
877 `
878 : `<div class="empty-state"><div><div class="ico">🎬</div>
879 <h2>${t('app.empty_pick_create')}</h2>
880 <p>${t('app.empty_subtitle')}</p></div></div>`}
881 `;
882 // Re-attach sidebar handlers (renderMain rebuilt the DOM)
883 renderSidebar();
884 document.getElementById('btn-new').onclick = createDefaultProject;
885 const togBtn = document.getElementById('btn-sidebar-toggle');
886 if (togBtn) togBtn.onclick = () => document.body.classList.toggle('sidebar-collapsed');
887 const tfTog = document.getElementById('btn-textfields-toggle');
888 if (tfTog) tfTog.onclick = () => document.body.classList.toggle('textfields-collapsed');
889 if (state.selected) {
890 renderChatLog();
891 renderComposer();
892 renderPreview();
893 renderFooter();
894 document.getElementById('btn-send').onclick = sendMessage;
895 document.getElementById('composer-input').addEventListener('keydown', (e) => {
896 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
897 e.preventDefault();
898 sendMessage();
899 }
900 });
901 document.getElementById('btn-attach').onclick = () => document.getElementById('file-input').click();
902 document.getElementById('file-input').onchange = (e) => addAttachments([...e.target.files]);
903 wireDragAndPaste();
904 document.getElementById('btn-reload').onclick = () => { reloadPreview(); refreshTextFields(); };
905 wireSoundtrackPanel();
906 }
907 }
908
909 /**
910 * Soundtrack panel: generate MiniMax music + narration, stream SSE progress,
911 * preview the resulting MP3s. The generated tracks are stored on the project's
912 * soundtrack and mixed in automatically at export time.
913 */
914 function wireSoundtrackPanel() {
915 const panel = document.getElementById('soundtrack-panel');
916 if (!panel) return;
917 const musicPrompt = document.getElementById('st-music-prompt');
918 const narrationText = document.getElementById('st-narration-text');
919 const musicVol = document.getElementById('st-music-vol');
920 const narrationVol = document.getElementById('st-narration-vol');
921 const musicVolVal = document.getElementById('st-music-vol-val');
922 const narrationVolVal = document.getElementById('st-narration-vol-val');
923 const genMusicBtn = document.getElementById('btn-st-gen-music');
924 const genNarrationBtn = document.getElementById('btn-st-gen-narration');
925 const clearBtn = document.getElementById('btn-st-clear');
926 const musicStatusEl = document.getElementById('st-music-status');
927 const narrationStatusEl = document.getElementById('st-narration-status');
928 const previewEl = document.getElementById('st-preview');
929 const draftFrameBtn = document.getElementById('btn-st-draft-frame');
930 const draftAllBtn = document.getElementById('btn-st-draft-all');
931 const whichEl = document.getElementById('st-narration-which');
932
933 // Music style presets: click fills the prompt textarea (editable after).
934 document.querySelectorAll('#st-music-presets .st-preset').forEach((btn) => {
935 btn.onclick = () => {
936 musicPrompt.value = btn.dataset.prompt || '';
937 document.querySelectorAll('#st-music-presets .st-preset').forEach((b) => b.classList.remove('active'));
938 btn.classList.add('active');
939 };
940 });
941
942 // ---- Per-frame narration model ----------------------------------------
943 // narrationByFrame: { [graphNodeId]: text }. The textarea always shows the
944 // line for the CURRENTLY SELECTED frame (state.activeFrameId); editing it
945 // writes back to that frame. Switching frames in the strip swaps the text.
946 const sortedFrames = [...(state.selected?.frames ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
947 const hasFrames = sortedFrames.length > 0;
948 // Seed from saved soundtrack; migrate a legacy single narrationText onto frame 1.
949 state._narrationByFrame = { ...(state.selected?.soundtrack?.narrationByFrame ?? {}) };
950 if (!Object.keys(state._narrationByFrame).length && state.selected?.soundtrack?.narrationText && sortedFrames[0]) {
951 state._narrationByFrame[sortedFrames[0].graphNodeId] = state.selected.soundtrack.narrationText;
952 }
953 const frameLabel = (fid) => {
954 const i = sortedFrames.findIndex((f) => f.graphNodeId === fid);
955 return i >= 0 ? `${t('soundtrack.frame_word')} ${i + 1}/${sortedFrames.length}` : '';
956 };
957 // Read frames LIVE from state (not the wire-time snapshot) so button state is
958 // always correct no matter what changed it (generate / regen / switch / clear).
959 const liveFrames = () => [...(state.selected?.frames ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
960 const currentFrameId = () => state.activeFrameId ?? liveFrames()[0]?.graphNodeId ?? null;
961 const syncNarrationField = () => {
962 const frames = liveFrames();
963 const has = frames.length > 0;
964 const fid = currentFrameId();
965 if (whichEl) {
966 const i = frames.findIndex((f) => f.graphNodeId === fid);
967 // Spell out which frame the script + "✨ draft this frame" act on, so it's
968 // obvious the per-frame buttons follow the selected frame (issues #5):
969 // users couldn't tell "draft this frame" only touched the active one.
970 whichEl.textContent = has && i >= 0
971 ? (frames.length > 1
972 ? t('soundtrack.editing_frame', { n: i + 1, total: frames.length })
973 : '')
974 : '';
975 }
976 // Only overwrite the textarea when it isn't the user's in-progress edit.
977 if (document.activeElement !== narrationText) {
978 narrationText.value = (fid && state._narrationByFrame[fid]) || '';
979 }
980 const dis = !has || !fid;
981 if (draftFrameBtn) { draftFrameBtn.disabled = dis; draftFrameBtn.title = dis ? t('soundtrack.draft_need_frames') : ''; }
982 if (draftAllBtn) { draftAllBtn.disabled = !has; draftAllBtn.title = has ? '' : t('soundtrack.draft_need_frames'); }
983 const fitBtn = document.getElementById('btn-st-fit');
984 if (fitBtn) {
985 const anyNarr = Object.values(state._narrationByFrame || {}).some((v) => (v || '').trim());
986 fitBtn.disabled = !has || !anyNarr;
987 }
988 };
989 // Persist edits back to the active frame as the user types.
990 narrationText.oninput = () => {
991 const fid = currentFrameId();
992 if (fid) state._narrationByFrame[fid] = narrationText.value;
993 };
994 // Expose so ANY state change (frame switch, generation finished, regen, etc.)
995 // can re-evaluate button enablement + the shown line without re-rendering the
996 // whole panel. Called from renderPreview() — the convergence point all those
997 // paths already hit — so buttons can never get stuck stale.
998 window.__hvSyncNarration = syncNarrationField;
999 syncNarrationField();
1000
1001 async function draftNarration(frameId /* null = all */) {
1002 if (!state.selected) return;
1003 const btn = frameId ? draftFrameBtn : draftAllBtn;
1004 const label = btn?.textContent;
1005 if (btn) { btn.disabled = true; btn.textContent = t('soundtrack.drafting'); }
1006 try {
1007 const res = await fetch(`/api/projects/${state.selected.id}/draft-narration`, {
1008 method: 'POST',
1009 headers: { 'content-type': 'application/json' },
1010 body: JSON.stringify({
1011 agentId: state.selected.agentId ?? (state.agents.find((a) => a.available && a.id !== 'amr')?.id ?? 'anthropic-api'),
1012 ...(frameId && { frameId }),
1013 }),
1014 });
1015 const data = await res.json();
1016 if (res.ok && data.narrationByFrame) {
1017 // Merge (single-frame draft only returns that frame; global returns all).
1018 Object.assign(state._narrationByFrame, data.narrationByFrame);
1019 syncNarrationField();
1020 } else {
1021 if (narrationStatusEl) narrationStatusEl.textContent = t('soundtrack.draft_failed', { message: data.error || `HTTP ${res.status}` });
1022 }
1023 } catch (e) {
1024 if (narrationStatusEl) narrationStatusEl.textContent = t('soundtrack.draft_failed', { message: (e?.message ?? e) });
1025 } finally {
1026 if (btn) { btn.textContent = label; }
1027 syncNarrationField();
1028 }
1029 }
1030 if (draftFrameBtn) draftFrameBtn.onclick = () => draftNarration(currentFrameId());
1031 if (draftAllBtn) draftAllBtn.onclick = () => draftNarration(null);
1032
1033 // "Fit to narration": re-pace each frame's duration by its narration length.
1034 const fitBtn = document.getElementById('btn-st-fit');
1035 if (fitBtn) {
1036 const anyNarration = () => Object.values(state._narrationByFrame || {}).some((v) => (v || '').trim());
1037 fitBtn.disabled = !hasFrames || !anyNarration();
1038 fitBtn.onclick = async () => {
1039 if (!state.selected || !anyNarration()) return;
1040 const label = fitBtn.textContent;
1041 fitBtn.disabled = true; fitBtn.textContent = t('soundtrack.fitting');
1042 try {
1043 const res = await fetch(`/api/projects/${state.selected.id}/fit-durations`, {
1044 method: 'POST', headers: { 'content-type': 'application/json' },
1045 body: JSON.stringify({ narrationByFrame: state._narrationByFrame }),
1046 });
1047 const data = await res.json();
1048 if (res.ok && data.ok) {
1049 toast(t('soundtrack.fitted', { sec: data.totalSec }), 'success');
1050 // Refresh frames so the strip + preview reflect the new per-frame durations.
1051 if (typeof renderPreview === 'function') renderPreview();
1052 if (typeof renderFramesStrip === 'function') renderFramesStrip();
1053 } else {
1054 toast(data.error || t('soundtrack.fit_failed'), 'error');
1055 }
1056 } catch (e) {
1057 toast(`${e?.message ?? e}`, 'error');
1058 } finally {
1059 fitBtn.textContent = label; fitBtn.disabled = !anyNarration();
1060 }
1061 };
1062 }
1063
1064 // Restore previously generated soundtrack (music prompt + audio previews).
1065 const st = state.selected?.soundtrack;
1066 if (st) {
1067 if (st.musicPrompt) musicPrompt.value = st.musicPrompt;
1068 if (typeof st.musicVolumeDb === 'number') musicVol.value = String(st.musicVolumeDb);
1069 if (typeof st.narrationVolumeDb === 'number') narrationVol.value = String(st.narrationVolumeDb);
1070 renderSoundtrackPreview(st);
1071 }
1072 musicVolVal.textContent = `${musicVol.value} dB`;
1073 narrationVolVal.textContent = `${narrationVol.value} dB`;
1074 musicVol.oninput = () => { musicVolVal.textContent = `${musicVol.value} dB`; };
1075 narrationVol.oninput = () => { narrationVolVal.textContent = `${narrationVol.value} dB`; };
1076
1077 clearBtn.onclick = async () => {
1078 if (!state.selected) return;
1079 await fetch(`/api/projects/${state.selected.id}/soundtrack`, { method: 'DELETE' });
1080 musicPrompt.value = '';
1081 narrationText.value = '';
1082 previewEl.innerHTML = '';
1083 if (musicStatusEl) musicStatusEl.textContent = '';
1084 if (narrationStatusEl) narrationStatusEl.textContent = '';
1085 if (state.selected) delete state.selected.soundtrack;
1086 };
1087
1088 // Music and narration generate INDEPENDENTLY. `kind` decides which part of
1089 // the generate-audio payload we send + which button/status to drive.
1090 async function runGenerate(kind /* 'music' | 'narration' */) {
1091 if (!state.selected) return;
1092 const btn = kind === 'music' ? genMusicBtn : genNarrationBtn;
1093 const statusEl = kind === 'music' ? musicStatusEl : narrationStatusEl;
1094 const payload = {};
1095 if (kind === 'music') {
1096 const mp = musicPrompt.value.trim();
1097 if (!mp) { if (statusEl) statusEl.textContent = t('soundtrack.empty_music'); return; }
1098 payload.music = { prompt: mp, instrumental: true, volumeDb: Number(musicVol.value) };
1099 } else {
1100 // Stitch every frame's line in order into one narration track.
1101 const stitched = sortedFrames
1102 .map((f) => (state._narrationByFrame[f.graphNodeId] || '').trim())
1103 .filter((s) => s.length > 0).join('\n');
1104 const nt = stitched || narrationText.value.trim();
1105 if (!nt) { if (statusEl) statusEl.textContent = t('soundtrack.empty_narration'); return; }
1106 const voiceSel = document.getElementById('st-narration-voice');
1107 payload.narration = { text: nt, volumeDb: Number(narrationVol.value), byFrame: state._narrationByFrame, ...(voiceSel?.value && { voiceId: voiceSel.value }) };
1108 }
1109
1110 const label = btn?.textContent;
1111 if (btn) btn.disabled = true;
1112 clearBtn.disabled = true;
1113 if (statusEl) statusEl.textContent = t('soundtrack.starting');
1114
1115 let res;
1116 try {
1117 res = await fetch(`/api/projects/${state.selected.id}/generate-audio`, {
1118 method: 'POST',
1119 headers: { accept: 'text/event-stream', 'content-type': 'application/json' },
1120 body: JSON.stringify(payload),
1121 });
1122 } catch (e) {
1123 if (statusEl) statusEl.textContent = t('soundtrack.failed', { message: (e?.message ?? e) });
1124 if (btn) btn.disabled = false; clearBtn.disabled = false; return;
1125 }
1126 if (!res.ok || !res.body) {
1127 if (statusEl) statusEl.textContent = t('soundtrack.failed', { message: `HTTP ${res.status}` });
1128 if (btn) btn.disabled = false; clearBtn.disabled = false; return;
1129 }
1130
1131 const reader = res.body.getReader();
1132 const decoder = new TextDecoder();
1133 let buf = '';
1134 try {
1135 while (true) {
1136 const { done, value } = await reader.read();
1137 if (done) break;
1138 buf += decoder.decode(value, { stream: true });
1139 const events = buf.split('\n\n');
1140 buf = events.pop() ?? '';
1141 for (const line of events) {
1142 if (!line.startsWith('data: ')) continue;
1143 let ev;
1144 try { ev = JSON.parse(line.slice(6)); } catch { continue; }
1145 if (ev.type === 'audio_progress' && statusEl) {
1146 statusEl.textContent = ev.stage === 'music' ? t('soundtrack.progress_music') : t('soundtrack.progress_narration');
1147 } else if (ev.type === 'audio_done') {
1148 if (statusEl) statusEl.textContent = t('soundtrack.done');
1149 if (ev.project) state.selected = ev.project;
1150 renderSoundtrackPreview(ev.soundtrack);
1151 } else if (ev.type === 'audio_failed' && statusEl) {
1152 statusEl.textContent = t('soundtrack.failed', { message: ev.message });
1153 }
1154 }
1155 }
1156 } catch (e) {
1157 if (statusEl) statusEl.textContent = t('soundtrack.failed', { message: (e?.message ?? e) });
1158 } finally {
1159 if (btn) { btn.disabled = false; btn.textContent = label; }
1160 clearBtn.disabled = false;
1161 }
1162 }
1163 if (genMusicBtn) genMusicBtn.onclick = () => runGenerate('music');
1164 if (genNarrationBtn) genNarrationBtn.onclick = () => runGenerate('narration');
1165 }
1166
1167 function renderSoundtrackPreview(soundtrack) {
1168 const previewEl = document.getElementById('st-preview');
1169 if (!previewEl || !soundtrack || !state.selected) return;
1170 const assets = state.selected.assets || [];
1171 const srcFor = (id) => {
1172 const a = assets.find((x) => x.id === id);
1173 return a?.path ? `/asset?path=${encodeURIComponent(a.path)}` : null;
1174 };
1175 const blocks = [];
1176 const musicSrc = soundtrack.musicAssetId && srcFor(soundtrack.musicAssetId);
1177 const narrSrc = soundtrack.narrationAssetId && srcFor(soundtrack.narrationAssetId);
1178 if (musicSrc) blocks.push(`<div class="st-track"><span>${t('soundtrack.music_ready')}</span><audio controls src="${musicSrc}"></audio></div>`);
1179 if (narrSrc) blocks.push(`<div class="st-track"><span>${t('soundtrack.narration_ready')}</span><audio controls src="${narrSrc}"></audio></div>`);
1180 previewEl.innerHTML = blocks.join('');
1181 }
1182
1183 // ============== composer attachments ==============
1184 function attachmentKind(file) {
1185 const t = (file.type || '').toLowerCase();
1186 if (t.startsWith('image/')) return 'image';
1187 if (t.startsWith('video/')) return 'video';
1188 if (t.startsWith('audio/')) return 'audio';
1189 if (t === 'application/json' || t === 'text/csv' || /\.(csv|tsv|json)$/i.test(file.name)) return 'data';
1190 if (t.startsWith('text/')) return 'text';
1191 return 'reference-link';
1192 }
1193 function iconForKind(k) {
1194 return { image: '🖼', video: '🎬', audio: '🎵', data: '📊', text: '📝' }[k] ?? '📎';
1195 }
1196
1197 function addAttachments(files) {
1198 for (const f of files) {
1199 const kind = attachmentKind(f);
1200 const att = { file: f, name: f.name, kind, size: f.size };
1201 state.pendingAttachments.push(att);
1202 if (kind === 'image') {
1203 const r = new FileReader();
1204 r.onload = (e) => { att.dataUrl = e.target.result; renderAttachments(); };
1205 r.readAsDataURL(f);
1206 }
1207 }
1208 renderAttachments();
1209 }
1210
1211 function removeAttachment(i) {
1212 state.pendingAttachments.splice(i, 1);
1213 renderAttachments();
1214 }
1215
1216 function renderAttachments() {
1217 const wrap = document.getElementById('attachments');
1218 if (!wrap) return;
1219 wrap.innerHTML = state.pendingAttachments.map((a, i) => {
1220 const thumb = a.dataUrl ? `<img src="${a.dataUrl}" alt="" />` : `<span class="ico">${iconForKind(a.kind)}</span>`;
1221 return `<span class="att-chip">
1222 ${thumb}
1223 <span class="name" title="${esc(a.name)}">${esc(a.name)}</span>
1224 <button data-i="${i}" title="Remove">×</button>
1225 </span>`;
1226 }).join('');
1227 wrap.querySelectorAll('button[data-i]').forEach(btn => {
1228 btn.onclick = () => removeAttachment(Number(btn.dataset.i));
1229 });
1230 }
1231
1232 function wireDragAndPaste() {
1233 const shell = document.getElementById('composer-shell');
1234 const ta = document.getElementById('composer-input');
1235 if (!shell) return;
1236 shell.addEventListener('dragover', (e) => {
1237 e.preventDefault();
1238 shell.classList.add('dragging');
1239 });
1240 shell.addEventListener('dragleave', () => shell.classList.remove('dragging'));
1241 shell.addEventListener('drop', (e) => {
1242 e.preventDefault();
1243 shell.classList.remove('dragging');
1244 if (e.dataTransfer?.files?.length) addAttachments([...e.dataTransfer.files]);
1245 });
1246 ta.addEventListener('paste', (e) => {
1247 const items = e.clipboardData?.items;
1248 if (!items) return;
1249 const files = [];
1250 for (const it of items) {
1251 if (it.kind === 'file') {
1252 const f = it.getAsFile();
1253 if (f) files.push(f);
1254 }
1255 }
1256 if (files.length > 0) {
1257 e.preventDefault();
1258 addAttachments(files);
1259 }
1260 });
1261 }
1262
1263 function renderComposer() {
1264 const p = state.selected;
1265 const ta = document.getElementById('composer-input');
1266 const sendBtn = document.getElementById('btn-send');
1267 if (!ta) return;
1268 const availableAgents = state.agents.filter(a => a.available);
1269 const agentsKnown = state.agents.length > 0;
1270 const canType = !!p && !state.composing;
1271 const canSend = !!(p && availableAgents.length > 0 && !state.composing);
1272 ta.disabled = !canType;
1273 sendBtn.disabled = !canSend;
1274
1275 // Focus chip: when a frame is pinned for single-frame iterate, show it
1276 // above the textarea so the user knows their next message will only
1277 // rewrite that frame. Click to clear.
1278 const shell = document.getElementById('composer-shell');
1279 if (shell) {
1280 let chip = shell.querySelector('.focus-chip');
1281 const focus = state.iterateFocusFrameId;
1282 if (focus) {
1283 const order = (p?.frames ?? []).find((f) => f.graphNodeId === focus)?.order ?? 0;
1284 const orderStr = String(order + 1).padStart(2, '0');
1285 const html = `🎯 ${t('composer.focus_chip', { order: orderStr, fid: '' })}<span class="fid">${esc(focus)}</span><button title="${t('composer.focus_clear')}" type="button">✕</button>`;
1286 if (!chip) {
1287 chip = document.createElement('div');
1288 chip.className = 'focus-chip';
1289 // Insert above attachments (or as first child).
1290 shell.insertBefore(chip, shell.firstChild);
1291 }
1292 chip.innerHTML = html;
1293 chip.querySelector('button').onclick = (e) => {
1294 e.stopPropagation();
1295 state.iterateFocusFrameId = null;
1296 renderComposer();
1297 renderFramesStrip();
1298 };
1299 } else if (chip) {
1300 chip.remove();
1301 }
1302 }
1303
1304 ta.placeholder = !p ? t('composer.placeholder.no_project')
1305 : !agentsKnown ? t('composer.placeholder.detecting_agents')
1306 : availableAgents.length === 0 ? t('composer.placeholder.no_agent')
1307 : state.iterateFocusFrameId ? t('composer.placeholder.focus')
1308 : !p.templateId ? t('composer.placeholder.no_template')
1309 : t('composer.placeholder.with_template');
1310 }
1311
1312 function renderFooter() {
1313 const p = state.selected;
1314 const fs = document.getElementById('footer-status');
1315 if (!fs) return;
1316 if (p) {
1317 fs.innerHTML = `<b>${esc(p.name)}</b> · ${p.templateId ? `template <b>${esc(p.templateId)}</b>` : '<i>no template</i>'} · ${p.status}`;
1318 } else {
1319 fs.textContent = 'no project';
1320 }
1321 }
1322
1323 // ============== chat log ==============
1324 function renderChatLog() {
1325 const log = document.getElementById('chat-log');
1326 if (!log) return;
1327 if (!state.messages.length) {
1328 log.innerHTML = `<div class="chat-empty"><div><div class="ico">💬</div>
1329 <div style="font-weight:500;margin-bottom:6px;">${t('chat.empty.title')}</div>
1330 ${t('chat.empty.body')}
1331 <div class="examples">
1332 <b>"Warm-grain magazine outro: Open Design — design that evolves itself"</b>
1333 <b>"Cyberpunk glitch title saying SYSTEM ONLINE, neon cyan/magenta"</b>
1334 <b>"Swiss-grid data card: Templates 231, Skills 15, Systems 150, Craft 11"</b>
1335 </div>
1336 </div></div>`;
1337 return;
1338 }
1339 log.innerHTML = state.messages.map((m, i) => renderMessage(m, i)).join('');
1340 log.querySelectorAll('button.opt[data-opt-msg]').forEach((btn) => {
1341 btn.onclick = () => {
1342 const msgIdx = Number(btn.dataset.optMsg);
1343 const optI = Number(btn.dataset.optI);
1344 const m = state.messages[msgIdx];
1345 if (!m || m.pickedOption) return;
1346 const { options } = parseHvOptions(m.content ?? '');
1347 if (!options) return;
1348 const picked = options.options[optI];
1349 const label = picked?.label ?? '';
1350 m.pickedOption = label;
1351 // Fire as a new user turn
1352 pickAndSend(label);
1353 };
1354 });
1355 // Inline freeform input on each hv-options card
1356 log.querySelectorAll('textarea[data-freeform-msg]').forEach((ta) => {
1357 const msgIdx = Number(ta.dataset.freeformMsg);
1358 const sendBtn = log.querySelector(`button.freeform-send[data-freeform-msg="${msgIdx}"]`);
1359 const submit = () => {
1360 const text = ta.value.trim();
1361 if (!text) return;
1362 const m = state.messages[msgIdx];
1363 if (!m || m.pickedOption) return;
1364 m.pickedOption = text; // mark answered so options collapse
1365 pickAndSend(text);
1366 };
1367 const autoResize = () => {
1368 ta.style.height = 'auto';
1369 ta.style.height = Math.min(ta.scrollHeight + 2, 160) + 'px';
1370 };
1371 ta.addEventListener('input', () => {
1372 if (sendBtn) sendBtn.disabled = ta.value.trim().length === 0;
1373 autoResize();
1374 });
1375 ta.addEventListener('keydown', (e) => {
1376 if (e.key === 'Enter' && !e.shiftKey) {
1377 e.preventDefault();
1378 submit();
1379 }
1380 });
1381 if (sendBtn) sendBtn.onclick = submit;
1382 });
1383 // hv-form: collect field values + optional file attachments, submit as
1384 // [hv-form:submit]\n<json>. Files go through the existing pendingAttachments
1385 // path so the server multipart handler treats them like normal uploads.
1386 // Segmented buttons: click writes to the hidden input + flips .selected.
1387 // Update the live "total = per_frame × frames" readout for a form card.
1388 const updateFormTotal = (msgIdx) => {
1389 const totalEl = document.getElementById(`form-total-${msgIdx}`);
1390 if (!totalEl) return;
1391 const card = totalEl.closest('.form-card');
1392 const val = (key) => {
1393 const h = card?.querySelector(`.form-seg[data-form-key="${CSS.escape(key)}"] input[type="hidden"]`);
1394 return Number(h?.value || 0);
1395 };
1396 const pf = val('per_frame'), fc = val('frame_count');
1397 totalEl.textContent = pf > 0 && fc > 0 ? `${t('soundtrack.total_word') || 'Total'} ≈ ${pf * fc}s` : '';
1398 };
1399 log.querySelectorAll('.form-seg-btn[data-form-msg]').forEach((btn) => {
1400 btn.onclick = (e) => {
1401 e.preventDefault();
1402 if (btn.disabled) return;
1403 const seg = btn.closest('.form-seg');
1404 if (!seg) return;
1405 seg.querySelectorAll('.form-seg-btn').forEach((b) => b.classList.remove('selected'));
1406 btn.classList.add('selected');
1407 const hidden = seg.querySelector('input[type="hidden"]');
1408 if (hidden) hidden.value = btn.dataset.val ?? '';
1409 updateFormTotal(Number(btn.dataset.formMsg));
1410 };
1411 });
1412 // Initial paint of any total readouts present.
1413 log.querySelectorAll('[id^="form-total-"]').forEach((el) => updateFormTotal(Number(el.id.replace('form-total-', ''))));
1414 log.querySelectorAll('button.form-submit[data-form-msg]').forEach((btn) => {
1415 btn.onclick = async () => {
1416 const msgIdx = Number(btn.dataset.formMsg);
1417 const m = state.messages[msgIdx];
1418 if (!m || m.formSubmitted) return;
1419 const card = btn.closest('.form-card');
1420 if (!card) return;
1421 const collected = {};
1422 let missing = null;
1423 // Only grab inputs / textareas / selects — buttons share the data-form-key
1424 // attribute but their .value is empty, would clobber the real one.
1425 card.querySelectorAll(
1426 'input[data-form-key], textarea[data-form-key], select[data-form-key]',
1427 ).forEach((el) => {
1428 const key = el.dataset.formKey;
1429 const val = (el.value || '').trim();
1430 if (!val && card.querySelector(`label .req`) &&
1431 card.querySelector(`[data-form-key="${CSS.escape(key)}"]`).closest('.form-field')
1432 ?.querySelector('label .req')) {
1433 // Required field that's empty
1434 missing = key;
1435 }
1436 collected[key] = val;
1437 });
1438 if (missing) {
1439 toast(`${t('text_pane.save_state.error')}: ${missing}`, 'warn');
1440 return;
1441 }
1442 m.formSubmitted = collected;
1443 // Files: read from the existing form-att-<msgIdx> tray and route them
1444 // through state.pendingAttachments so sendMessage's multipart path picks
1445 // them up.
1446 const submitText = `[hv-form:submit]\n${JSON.stringify(collected, null, 2)}`;
1447 const ta = document.getElementById('composer-input');
1448 if (ta) ta.value = submitText;
1449 await sendMessage();
1450 };
1451 });
1452 // hv-form attach button — same flow as composer's 📎 button, scoped to the card.
1453 log.querySelectorAll('button.form-attach-btn[data-form-msg]').forEach((btn) => {
1454 btn.onclick = () => {
1455 const msgIdx = Number(btn.dataset.formMsg);
1456 const fi = document.getElementById(`form-file-${msgIdx}`);
1457 if (fi) fi.click();
1458 };
1459 });
1460 log.querySelectorAll('input[type="file"][id^="form-file-"]').forEach((fi) => {
1461 fi.onchange = (e) => addAttachments([...e.target.files]);
1462 });
1463 // hv-confirm: generate / edit buttons
1464 log.querySelectorAll('[data-confirm-msg]').forEach((btn) => {
1465 btn.onclick = async () => {
1466 const msgIdx = Number(btn.dataset.confirmMsg);
1467 const action = btn.dataset.action;
1468 const m = state.messages[msgIdx];
1469 if (!m) return;
1470 // In-flight guard only — don't permanently mark resolved here. Whether
1471 // the card stays locked is recomputed from history each render
1472 // (renderMessage inspects whether the click actually produced output).
1473 if (m.confirmInFlight) return;
1474 m.confirmInFlight = true;
1475 try {
1476 const ta = document.getElementById('composer-input');
1477 if (ta) ta.value = action === 'generate' ? '[hv-confirm:generate]' : '[hv-confirm:edit]';
1478 await sendMessage();
1479 } finally {
1480 m.confirmInFlight = false;
1481 }
1482 };
1483 });
1484 log.querySelectorAll('[data-export-action]').forEach((btn) => {
1485 btn.addEventListener('click', async () => {
1486 const action = btn.dataset.exportAction;
1487 const card = btn.closest('.export-done');
1488 const path = card?.querySelector('.export-path code')?.textContent ?? '';
1489 if (action === 'reveal') {
1490 await revealExportedFile();
1491 } else if (action === 'copy' && path) {
1492 try {
1493 await navigator.clipboard.writeText(path);
1494 toast(t('export.copied'), 'success');
1495 } catch (e) {
1496 toast(t('export.copy_failed', { message: (e?.message ?? e) }), 'error');
1497 }
1498 }
1499 });
1500 });
1501 log.scrollTop = log.scrollHeight;
1502 }
1503
1504 async function pickAndSend(label) {
1505 // Stuff the textarea with the chosen label and send it as a normal turn
1506 const ta = document.getElementById('composer-input');
1507 if (ta) ta.value = label;
1508 renderChatLog(); // shows the picked highlight on the previous message
1509 await sendMessage();
1510 }
1511
1512 function renderMessage(m, idx) {
1513 if (m.role === 'user') {
1514 // User-side form-submission marker carries hidden JSON the user can't read;
1515 // show a friendlier label instead of a wall of "topic=foo\nheadline=bar…".
1516 const formMatch = /^\[hv-form:submit\]\n([\s\S]*)$/.exec(m.content ?? '');
1517 if (formMatch) {
1518 return `<div class="msg user">${t('chat.summary.form_submitted')}</div>`;
1519 }
1520 if ((m.content ?? '').trim() === '[hv-confirm:generate]') {
1521 return `<div class="msg user">${t('chat.summary.confirm_generate')}</div>`;
1522 }
1523 if ((m.content ?? '').trim() === '[hv-confirm:edit]') {
1524 return `<div class="msg user">${t('chat.summary.confirm_edit')}</div>`;
1525 }
1526 return `<div class="msg user">${esc(m.content)}</div>`;
1527 }
1528 if (m.role === 'system') return `<div class="msg system">${esc(m.content)}</div>`;
1529 if (m.role === 'preview-event') return `<div class="msg preview-event">${esc(m.content)}</div>`;
1530 if (m.role === 'thinking') return `<div class="msg thinking">${esc(m.content || t('chat.thinking'))}</div>`;
1531 if (m.role === 'export-done') {
1532 const path = m.content || '';
1533 const fname = path.split('/').pop() || 'output.mp4';
1534 return `<div class="msg export-done">
1535 <div class="export-title">${t('export.title')}</div>
1536 <div class="export-path"><code>${esc(path)}</code></div>
1537 <div class="export-actions">
1538 <button class="btn-reveal" data-export-action="reveal">${t('export.reveal')}</button>
1539 <button class="btn-copy-path" data-export-action="copy">${t('export.copy_path')}</button>
1540 </div>
1541 <div class="export-fname">${esc(fname)}</div>
1542 </div>`;
1543 }
1544 // assistant: try each card protocol in turn
1545 const raw = m.content ?? '';
1546 const formP = parseHvForm(raw);
1547 if (formP.form) {
1548 // Resolve "submitted" from history: any user turn after this card with
1549 // [hv-form:submit] marker counts as the answer.
1550 let submitted = m.formSubmitted;
1551 if (!submitted) {
1552 const nextUser = state.messages.slice(idx + 1).find((x) => x.role === 'user');
1553 if (nextUser) {
1554 const fm = /^\[hv-form:submit\]\n([\s\S]*)$/.exec(nextUser.content ?? '');
1555 if (fm && fm[1]) {
1556 try { submitted = JSON.parse(fm[1]); } catch { submitted = null; }
1557 }
1558 }
1559 }
1560 const formHtml = renderFormCard(formP.form, submitted, idx);
1561 return `<div class="msg assistant">
1562 <div class="role">${esc(m.agent ?? 'agent')}</div>
1563 <div class="body">${md(sanitizeAssistantProse(formP.prose))}${formHtml}</div>
1564 </div>`;
1565 }
1566 const confirmP = parseHvConfirm(raw);
1567 if (confirmP.confirm) {
1568 // Only lock the card when the click actually led somewhere:
1569 // - "✏️ 改一下" → next assistant turn re-emitted hv-form (the edit landed)
1570 // - "✓ 开始生成" → next assistant turn produced real output
1571 // (preview-event / ✓ HTML preview / storyboard summary)
1572 // If the click triggered an empty reply or generate failed, treat the
1573 // card as live so the user can press the button again.
1574 let resolved = m.confirmResolved;
1575 if (!resolved) {
1576 const after = state.messages.slice(idx + 1);
1577 const nextUser = after.find((x) => x.role === 'user');
1578 if (nextUser) {
1579 const t = (nextUser.content ?? '').trim();
1580 if (t === '[hv-confirm:generate]') {
1581 // Did anything productive happen between this user click and the
1582 // next user turn?
1583 const userIdx = after.indexOf(nextUser);
1584 const between = after.slice(userIdx + 1);
1585 const sawSuccess = between.some((x) => {
1586 if (x.role === 'preview-event') return true;
1587 if (x.role === 'assistant') {
1588 const c = (x.content ?? '').trim();
1589 if (!c) return false;
1590 if (/^⚠️/.test(c)) return false;
1591 if (/^✓\s/.test(c)) return true;
1592 if (/storyboard generated|HTML preview updated/i.test(c)) return true;
1593 }
1594 return false;
1595 });
1596 if (sawSuccess) resolved = '✓ 开始生成';
1597 } else if (t === '[hv-confirm:edit]') {
1598 resolved = '✏️ 改一下';
1599 }
1600 }
1601 }
1602 const confirmHtml = renderConfirmCard(confirmP.confirm, resolved, idx);
1603 return `<div class="msg assistant">
1604 <div class="role">${esc(m.agent ?? 'agent')}</div>
1605 <div class="body">${md(sanitizeAssistantProse(confirmP.prose))}${confirmHtml}</div>
1606 </div>`;
1607 }
1608 // Default: hv-options + prose
1609 const { prose, options } = parseHvOptions(raw);
1610 // m.pickedOption is in-memory only — wiped on reload. Recover it from
1611 // history: any user turn AFTER this card is implicitly the answer.
1612 let picked = m.pickedOption;
1613 if (options && !picked) {
1614 const nextUser = state.messages.slice(idx + 1).find((x) => x.role === 'user');
1615 if (nextUser) picked = nextUser.content;
1616 }
1617 const optionsHtml = options ? renderOptionCard(options, picked, idx) : '';
1618 return `<div class="msg assistant">
1619 <div class="role">${esc(m.agent ?? 'agent')}</div>
1620 <div class="body">${md(sanitizeAssistantProse(prose))}${optionsHtml}</div>
1621 </div>`;
1622 }
1623
1624 /**
1625 * Strip HTML / content-graph code blocks from assistant text before render.
1626 * Streaming text comes in raw — without this the user sees a wall of CSS /
1627 * JSX / HTML scrolling past. We replace each block with a one-line collapsed
1628 * marker so they know something is being generated, but don't have to read
1629 * 600 lines of style declarations.
1630 *
1631 * Acts on render only; the underlying message content is untouched, so the
1632 * server's persisted "✓ frame X updated" summary still wins on reload.
1633 */
1634 function sanitizeAssistantProse(text) {
1635 if (!text) return text;
1636 let out = text;
1637 const genHtml = t('chat.placeholder.gen_html');
1638 const planGraph = t('chat.placeholder.plan_graph');
1639 // ```html ... ``` (full block) — closed
1640 out = out.replace(/```html(?:#[\w-]+)?\s*\n[\s\S]*?```/gi, `\n${genHtml}\n`);
1641 // ```html ... (still open, mid-stream) — clip everything after the fence
1642 out = out.replace(/```html(?:#[\w-]+)?\s*\n[\s\S]*$/i, `\n${genHtml}`);
1643 // ```json#content-graph ...```
1644 out = out.replace(/```json#content-graph\s*\n[\s\S]*?```/gi, `\n${planGraph}\n`);
1645 out = out.replace(/```json#content-graph\s*\n[\s\S]*$/i, `\n${planGraph}`);
1646 // ```hv-form / ```hv-confirm / ```hv-options blocks are parsed by their
1647 // own renderers above; if we got here they slipped past — collapse them.
1648 out = out.replace(/```hv-(?:form|confirm|options)\s*\n[\s\S]*?```/gi, '');
1649 return out;
1650 }
1651
1652 // === Markdown rendering ===
1653 // Uses `marked` from CDN for proper headings/lists/bold/links/code,
1654 // then DOMPurify to sanitize, so user prompts can't inject script tags
1655 // even if the agent echos them back.
1656 function md(text) {
1657 if (!text) return '';
1658 let html;
1659 if (typeof window.marked !== 'undefined') {
1660 try {
1661 html = window.marked.parse(String(text), { breaks: true, gfm: true });
1662 } catch {
1663 html = esc(text);
1664 }
1665 } else {
1666 // Fallback: render bare with line breaks if CDN failed to load
1667 html = esc(text).replace(/\n/g, '<br>');
1668 }
1669 if (typeof window.DOMPurify !== 'undefined') {
1670 return window.DOMPurify.sanitize(html, {
1671 ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
1672 'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote', 'hr', 'span'],
1673 ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
1674 });
1675 }
1676 return html;
1677 }
1678
1679 // === hv-options block parsing ===
1680 // Splits assistant text into prose + an optional ```hv-options``` block.
1681 function parseHvOptions(text) {
1682 const m = /```hv-options\s*\n([\s\S]*?)```/i.exec(text);
1683 if (!m) return { prose: text, options: null };
1684 const prose = (text.slice(0, m.index) + text.slice(m.index + m[0].length)).trim();
1685 let parsed;
1686 try { parsed = JSON.parse(m[1].trim()); }
1687 catch { return { prose: text, options: null }; }
1688 if (!parsed || !Array.isArray(parsed.options) || !parsed.question) {
1689 return { prose: text, options: null };
1690 }
1691 return { prose, options: parsed };
1692 }
1693
1694 // === hv-form block parsing ===
1695 // Multi-field input card. Schema:
1696 // ```hv-form
1697 // {
1698 // "title": "讲一下你想做的视频…",
1699 // "fields": [
1700 // { "key": "topic", "label": "主题 / who-what", "kind": "text", "required": true },
1701 // { "key": "headline", "label": "Headline", "kind": "text", "required": true },
1702 // { "key": "data", "label": "关键数字 / 数据", "kind": "textarea" },
1703 // { "key": "aspect", "label": "尺寸", "kind": "select", "options": ["16:9","9:16","1:1","4:5"], "default": "16:9" },
1704 // { "key": "duration", "label": "时长(秒)", "kind": "select", "options": ["3","5","10","15","30"], "default": "5" },
1705 // { "key": "frame_count","label": "帧数 / 画面数", "kind": "text", "default": "1" },
1706 // { "key": "style", "label": "风格描述", "kind": "textarea" }
1707 // ],
1708 // "allow_attachments": true
1709 // }
1710 function parseHvForm(text) {
1711 const m = /```hv-form\s*\n([\s\S]*?)```/i.exec(text);
1712 if (!m) return { prose: text, form: null };
1713 const prose = (text.slice(0, m.index) + text.slice(m.index + m[0].length)).trim();
1714 let parsed;
1715 try { parsed = JSON.parse(m[1].trim()); }
1716 catch { return { prose: text, form: null }; }
1717 if (!parsed || !Array.isArray(parsed.fields) || parsed.fields.length === 0) {
1718 return { prose: text, form: null };
1719 }
1720 return { prose, form: parsed };
1721 }
1722
1723 // === hv-confirm block parsing ===
1724 // ```hv-confirm
1725 // {
1726 // "title": "按这些信息开始生成?",
1727 // "summary": [{ "label": "主题", "value": "nexu-io" }, ...],
1728 // "actions": ["generate","edit"] // optional, defaults to both
1729 // }
1730 function parseHvConfirm(text) {
1731 const m = /```hv-confirm\s*\n([\s\S]*?)```/i.exec(text);
1732 if (!m) return { prose: text, confirm: null };
1733 const prose = (text.slice(0, m.index) + text.slice(m.index + m[0].length)).trim();
1734 let parsed;
1735 try { parsed = JSON.parse(m[1].trim()); }
1736 catch { return { prose: text, confirm: null }; }
1737 if (!parsed || !Array.isArray(parsed.summary)) {
1738 return { prose: text, confirm: null };
1739 }
1740 return { prose, confirm: parsed };
1741 }
1742
1743 // === hv-form render ===
1744 function renderFormCard(form, submitted, msgIdx) {
1745 const title = form.title || 'Tell me a bit more…';
1746 const fields = form.fields || [];
1747 const allowAttachments = form.allow_attachments !== false;
1748 const fieldsHtml = fields.map((f, i) => {
1749 const key = f.key || `field_${i}`;
1750 const label = f.label || key;
1751 const ph = f.placeholder || '';
1752 const required = f.required ? '<span class="req">*</span>' : '';
1753 const def = (submitted && submitted[key] !== undefined ? submitted[key] : (f.default ?? ''));
1754 const dis = submitted ? 'disabled' : '';
1755 let control;
1756 if (f.kind === 'textarea') {
1757 control = `<textarea data-form-msg="${msgIdx}" data-form-key="${esc(key)}" rows="2" placeholder="${esc(ph)}" ${dis}>${esc(def)}</textarea>`;
1758 } else if (f.kind === 'select') {
1759 const opts = (f.options || []).map((o) => {
1760 const v = typeof o === 'string' ? o : o.value;
1761 const lbl = typeof o === 'string' ? o : (o.label || o.value);
1762 const sel = String(v) === String(def) ? 'selected' : '';
1763 return `<option value="${esc(v)}" ${sel}>${esc(lbl)}</option>`;
1764 }).join('');
1765 control = `<select data-form-msg="${msgIdx}" data-form-key="${esc(key)}" ${dis}>${opts}</select>`;
1766 } else if (f.kind === 'buttons') {
1767 // Segmented control: a hidden input carries the value, visible buttons
1768 // toggle. Wired up in renderChatLog.
1769 const optsHtml = (f.options || []).map((o) => {
1770 const v = typeof o === 'string' ? o : o.value;
1771 const lbl = typeof o === 'string' ? o : (o.label || o.value);
1772 const sel = String(v) === String(def) ? 'selected' : '';
1773 return `<button type="button" class="form-seg-btn ${sel}" data-form-msg="${msgIdx}" data-form-key="${esc(key)}" data-val="${esc(v)}" ${dis}>${esc(lbl)}</button>`;
1774 }).join('');
1775 control = `<div class="form-seg" data-form-key="${esc(key)}">
1776 <input type="hidden" data-form-msg="${msgIdx}" data-form-key="${esc(key)}" value="${esc(def)}" />
1777 ${optsHtml}
1778 </div>`;
1779 } else {
1780 control = `<input type="text" data-form-msg="${msgIdx}" data-form-key="${esc(key)}" placeholder="${esc(ph)}" value="${esc(def)}" ${dis} />`;
1781 }
1782 const hintHtml = f.hint ? `<span class="form-hint">${esc(f.hint)}</span>` : '';
1783 return `<div class="form-field">
1784 <label>${esc(label)}${required}${hintHtml}</label>
1785 ${control}
1786 </div>`;
1787 }).join('');
1788 // Live total-duration readout when the form paces by per-frame × frames.
1789 const hasPerFrame = fields.some((f) => f.key === 'per_frame') && fields.some((f) => f.key === 'frame_count');
1790 const totalHtml = hasPerFrame && !submitted
1791 ? `<div class="form-total" id="form-total-${msgIdx}"></div>`
1792 : '';
1793 const dropHtml = allowAttachments && !submitted ? `
1794 <div class="form-attachments" data-form-msg="${msgIdx}">
1795 <div class="form-drop-hint">📎 拖拽 / 粘贴 / 选择文件作为素材(logo、截图、数据 CSV…可选)</div>
1796 <div class="form-attachment-list" id="form-att-${msgIdx}"></div>
1797 <input type="file" id="form-file-${msgIdx}" multiple style="display:none" />
1798 <button type="button" class="form-attach-btn" data-form-msg="${msgIdx}">+ 添加文件</button>
1799 </div>` : '';
1800 const actionsHtml = submitted ? '' : `
1801 <div class="form-actions">
1802 <button class="form-submit" data-form-msg="${msgIdx}">提交 ↵</button>
1803 </div>`;
1804 return `<div class="form-card${submitted ? ' submitted' : ''}">
1805 <div class="form-title">${esc(title)}</div>
1806 <div class="form-fields">${fieldsHtml}</div>
1807 ${totalHtml}
1808 ${dropHtml}
1809 ${actionsHtml}
1810 </div>`;
1811 }
1812
1813 // === hv-confirm render ===
1814 function renderConfirmCard(confirm, resolved, msgIdx) {
1815 const title = confirm.title || 'Looks right?';
1816 const summary = confirm.summary || [];
1817 const actions = confirm.actions || ['generate', 'edit'];
1818 const summaryHtml = summary.map((s) => {
1819 const label = s.label || s.key || '';
1820 const value = s.value !== undefined ? String(s.value) : '';
1821 return `<div class="confirm-row">
1822 <div class="confirm-label">${esc(label)}</div>
1823 <div class="confirm-value">${esc(value) || '<span class="muted">—</span>'}</div>
1824 </div>`;
1825 }).join('');
1826 const actionsHtml = resolved ? '' : `
1827 <div class="confirm-actions">
1828 ${actions.includes('generate') ? `<button class="confirm-go" data-confirm-msg="${msgIdx}" data-action="generate">✓ 开始生成</button>` : ''}
1829 ${actions.includes('edit') ? `<button class="confirm-edit" data-confirm-msg="${msgIdx}" data-action="edit">✏️ 修改</button>` : ''}
1830 </div>`;
1831 return `<div class="confirm-card${resolved ? ' resolved' : ''}">
1832 <div class="confirm-title">${esc(title)}</div>
1833 <div class="confirm-summary">${summaryHtml}</div>
1834 ${actionsHtml}
1835 ${resolved ? `<div class="confirm-resolved-mark">${esc(resolved)}</div>` : ''}
1836 </div>`;
1837 }
1838
1839 function renderOptionCard(opts, picked, msgIdx) {
1840 const allowFreeform = opts.allow_freeform !== false;
1841 const optsHtml = (opts.options || []).map((o, i) => {
1842 const label = o.label ?? String(o);
1843 const hint = o.hint ?? '';
1844 const isPicked = picked === label;
1845 const cls = 'opt' + (isPicked ? ' picked' : '');
1846 // Once the user has picked anything on this card, ALL buttons lock —
1847 // including the picked one, so the same option can't fire twice.
1848 const disabled = picked ? 'disabled' : '';
1849 return `<button class="${cls}" data-opt-msg="${msgIdx}" data-opt-i="${i}" ${disabled}>
1850 <span class="label">${esc(label)}</span>
1851 ${hint ? `<span class="hint">${esc(hint)}</span>` : ''}
1852 </button>`;
1853 }).join('');
1854 // Inline freeform input — saves a trip to the bottom composer when the
1855 // user just wants to type a custom answer to this card's question.
1856 const freeformHtml = allowFreeform && !picked ? `
1857 <div class="freeform-input">
1858 <textarea data-freeform-msg="${msgIdx}" rows="1"
1859 placeholder="…or type your own answer"></textarea>
1860 <button class="freeform-send" data-freeform-msg="${msgIdx}" disabled>↵ Send</button>
1861 </div>` : '';
1862 return `<div class="opt-card">
1863 <div class="question">${esc(opts.question)}</div>
1864 <div class="opts">${optsHtml}</div>
1865 ${freeformHtml}
1866 </div>`;
1867 }
1868
1869 // ============== preview ==============
1870 function renderPreview() {
1871 const stage = document.getElementById('preview-stage');
1872 if (!stage) return;
1873 const p = state.selected;
1874 if (!p) {
1875 stage.innerHTML = `<div class="preview-placeholder"><div><div class="ico">🎞️</div>${t('preview.placeholder.pick_project')}</div></div>`;
1876 renderFramesStrip();
1877 return;
1878 }
1879 // No template + no prior preview → show "send a chat first" placeholder
1880 if (!p.templateId && !p.lastPreviewHtmlPath) {
1881 stage.innerHTML = `<div class="preview-placeholder"><div><div class="ico">🎞️</div>${t('preview.placeholder.pick_template')}</div></div>`;
1882 renderFramesStrip();
1883 return;
1884 }
1885 // v0.8: if multi-frame, default-iframe shows the active frame (first by default).
1886 const frames = Array.isArray(p.frames) ? p.frames : [];
1887 const sortedFrames = [...frames].sort((a, b) => a.order - b.order);
1888 if (sortedFrames.length > 0 && !state.activeFrameId) {
1889 state.activeFrameId = sortedFrames[0].graphNodeId;
1890 }
1891 if (sortedFrames.length > 0 && state.activeFrameId
1892 && !sortedFrames.find((f) => f.graphNodeId === state.activeFrameId)) {
1893 state.activeFrameId = sortedFrames[0].graphNodeId;
1894 }
1895 const iframeSrc = sortedFrames.length > 0 && state.activeFrameId
1896 ? `/preview/${p.id}/frame/${encodeURIComponent(state.activeFrameId)}?t=${Date.now()}`
1897 : `/preview/${p.id}?t=${Date.now()}`;
1898 const stamp = sortedFrames.length > 0 && state.activeFrameId
1899 ? state.activeFrameId
1900 : (p.templateId || '');
1901 // Respect the project's chosen resolution so the preview box matches the real
1902 // export aspect (4:5 / 9:16 / 1:1), not a hardcoded 16:9. The iframe renders
1903 // at the design's native pixel size and is scaled to fit (scale set on resize).
1904 const res = p.preferences?.resolution ?? { width: 1920, height: 1080 };
1905 const vw = res.width || 1920, vh = res.height || 1080;
1906 // Constrain the preview frame along the *long* axis so the whole frame stays
1907 // contained in the (bounded-height) stage. The base CSS only limits width
1908 // (width:100%; max-width:1280px) which is right for landscape, but for a
1909 // portrait frame (vh>vw) that lets it grow ~2275px tall and overflow — you'd
1910 // only see the top slice. For portrait, limit height instead and let width
1911 // follow the aspect-ratio. Square stays width-bound.
1912 const sizeStyle = vh > vw
1913 ? 'width:auto;max-width:none;height:100%;max-height:100%'
1914 : 'width:100%;max-width:1280px';
1915 // A native (enhanced) frame has no HTML — play its rendered preview MP4 and
1916 // hide the data-hv-text edit affordance (there's no HTML text to edit).
1917 const activeFrame = sortedFrames.find((f) => f.graphNodeId === state.activeFrameId);
1918 const activeEnhanced = activeFrame?.engine === 'remotion';
1919 if (activeEnhanced) {
1920 const videoSrc = `/preview/${p.id}/frame/${encodeURIComponent(state.activeFrameId)}.mp4?t=${Date.now()}`;
1921 stage.innerHTML = `<div class="preview-frame" style="aspect-ratio:${vw}/${vh};${sizeStyle}">
1922 <video id="preview-iframe" src="${videoSrc}" autoplay muted loop controls playsinline style="width:${vw}px;height:${vh}px"></video>
1923 ${stamp ? `<div class="stamp">${esc(stamp)} · ⚡</div>` : ''}
1924 </div>`;
1925 attachPreviewScaler();
1926 renderFramesStrip();
1927 return;
1928 }
1929 // sandbox now grants same-origin so we can attach a text-edit overlay
1930 // from the parent window. allow-scripts keeps the page's own animations
1931 // running. forms / popups / top-navigation stay blocked.
1932 stage.innerHTML = `<div class="preview-frame ${state.editTextMode ? 'editing' : ''}" style="aspect-ratio:${vw}/${vh};${sizeStyle}">
1933 <iframe id="preview-iframe" sandbox="allow-scripts allow-same-origin" src="${iframeSrc}" style="width:${vw}px;height:${vh}px"></iframe>
1934 ${stamp ? `<div class="stamp">${esc(stamp)}</div>` : ''}
1935 <button class="edit-toggle" id="btn-edit-text"
1936 title="${state.editTextMode ? t('preview.edit_text_done_title') : t('preview.edit_text_title')}">
1937 ${state.editTextMode ? t('preview.edit_text_on') : t('preview.edit_text_off')}
1938 </button>
1939 </div>`;
1940 attachPreviewScaler();
1941 const editBtn = document.getElementById('btn-edit-text');
1942 if (editBtn) editBtn.onclick = togglePreviewEdit;
1943 // If the user just toggled into edit mode, attach the overlay once the
1944 // iframe loads. If already in edit mode and we re-rendered, attach now
1945 // (iframe might already be loaded when reusing a cached preview).
1946 const iframe = document.getElementById('preview-iframe');
1947 if (iframe && state.editTextMode) {
1948 if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
1949 attachTextEditOverlay(iframe);
1950 } else {
1951 iframe.addEventListener('load', () => attachTextEditOverlay(iframe), { once: true });
1952 }
1953 }
1954 renderFramesStrip();
1955 // Convergence point for every frame/preview change → keep soundtrack buttons
1956 // (draft / fit) and the per-frame narration line in sync, regardless of which
1957 // path triggered the change.
1958 if (typeof window.__hvSyncNarration === 'function') window.__hvSyncNarration();
1959 }
1960
1961 function togglePreviewEdit() {
1962 state.editTextMode = !state.editTextMode;
1963 // When leaving edit mode, force-reload preview so any in-iframe styling
1964 // is dropped cleanly.
1965 renderPreview();
1966 }
1967
1968 // Inject hover highlight + click-to-edit on every [data-hv-text] node in
1969 // the preview iframe. On commit we replace text content in the iframe DOM,
1970 // serialize it, and PUT to the right endpoint (frame-specific or whole-
1971 // project preview).
1972 function attachTextEditOverlay(iframe) {
1973 let doc;
1974 try { doc = iframe.contentDocument; } catch (err) {
1975 console.warn('[hv-edit] iframe.contentDocument blocked:', err);
1976 return;
1977 }
1978 if (!doc) {
1979 console.warn('[hv-edit] iframe.contentDocument is null (still loading? sandbox blocking?)');
1980 return;
1981 }
1982 if (!doc.body) {
1983 console.warn('[hv-edit] iframe document has no body yet — re-attaching on next load tick');
1984 iframe.addEventListener('load', () => attachTextEditOverlay(iframe), { once: true });
1985 return;
1986 }
1987 const tagged = doc.querySelectorAll('[data-hv-text]');
1988 console.log(`[hv-edit] attached overlay; found ${tagged.length} [data-hv-text] elements`);
1989 if (tagged.length === 0) {
1990 toast(t('preview.no_hv_text'), 'warn');
1991 }
1992 // Idempotent: tear down any prior overlay first.
1993 doc.querySelectorAll('[data-hv-edit-style]').forEach((el) => el.remove());
1994 const style = doc.createElement('style');
1995 style.setAttribute('data-hv-edit-style', '');
1996 style.textContent = `
1997 [data-hv-text] { outline: 1px dashed rgba(201, 100, 66, .6) !important;
1998 outline-offset: 3px !important; cursor: text !important;
1999 transition: outline-color .12s, background .12s; }
2000 [data-hv-text]:hover { outline: 2px solid rgb(201, 100, 66) !important;
2001 background: rgba(201, 100, 66, .08) !important; }
2002 [data-hv-text][contenteditable="true"] { outline: 2px solid rgb(201, 100, 66) !important;
2003 outline-offset: 3px !important; background: rgba(201, 100, 66, .12) !important; }
2004 `;
2005 (doc.head || doc.documentElement).appendChild(style);
2006
2007 let dirty = false;
2008 const enableEdit = (el) => {
2009 if (el.getAttribute('contenteditable') === 'true') return;
2010 el.setAttribute('contenteditable', 'true');
2011 el.focus();
2012 // Place caret at end
2013 const range = doc.createRange();
2014 range.selectNodeContents(el);
2015 range.collapse(false);
2016 const sel = doc.getSelection();
2017 if (sel) { sel.removeAllRanges(); sel.addRange(range); }
2018 };
2019 const finishEdit = async (el) => {
2020 if (el.getAttribute('contenteditable') !== 'true') return;
2021 el.removeAttribute('contenteditable');
2022 if (!dirty) return;
2023 dirty = false;
2024 await commitInlineTextEdits(iframe);
2025 };
2026
2027 doc.addEventListener('click', (e) => {
2028 const target = e.target.closest('[data-hv-text]');
2029 if (!target) return;
2030 e.preventDefault();
2031 e.stopPropagation();
2032 enableEdit(target);
2033 }, true);
2034 doc.addEventListener('input', (e) => {
2035 if (e.target.closest && e.target.closest('[data-hv-text]')) {
2036 dirty = true;
2037 }
2038 });
2039 doc.addEventListener('keydown', (e) => {
2040 const target = e.target.closest && e.target.closest('[data-hv-text][contenteditable="true"]');
2041 if (!target) return;
2042 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); target.blur(); }
2043 if (e.key === 'Escape') { e.preventDefault(); target.blur(); }
2044 });
2045 doc.addEventListener('focusout', (e) => {
2046 const t = e.target;
2047 if (t && t.matches && t.matches('[data-hv-text][contenteditable="true"]')) {
2048 finishEdit(t);
2049 }
2050 }, true);
2051 }
2052
2053 async function commitInlineTextEdits(iframe) {
2054 if (!state.selected) return;
2055 const projectId = state.selected.id;
2056 const fid = state.activeFrameId;
2057 const url = fid
2058 ? `/api/projects/${projectId}/frames/${encodeURIComponent(fid)}/raw-html`
2059 : `/api/projects/${projectId}/raw-html`;
2060 // Read the current frame HTML from disk, walk its [data-hv-text] nodes,
2061 // sync each one's text from the iframe DOM. We do server-side merging
2062 // on the client to keep it simple.
2063 let serverHtml;
2064 try {
2065 const r = await fetch(url);
2066 if (!r.ok) throw new Error(`fetch failed ${r.status}`);
2067 serverHtml = await r.text();
2068 } catch (e) {
2069 toast(`保存失败:${e.message}`, 'error');
2070 return;
2071 }
2072 const parser = new DOMParser();
2073 const target = parser.parseFromString(serverHtml, 'text/html');
2074 const live = iframe.contentDocument;
2075 const liveByKey = new Map();
2076 if (live) {
2077 live.querySelectorAll('[data-hv-text]').forEach((el) => {
2078 const k = el.getAttribute('data-hv-text');
2079 if (k) liveByKey.set(k, el.textContent ?? '');
2080 });
2081 }
2082 let changed = 0;
2083 target.querySelectorAll('[data-hv-text]').forEach((el) => {
2084 const k = el.getAttribute('data-hv-text');
2085 if (!k || !liveByKey.has(k)) return;
2086 const newText = liveByKey.get(k);
2087 if (el.textContent !== newText) {
2088 el.textContent = newText;
2089 changed += 1;
2090 }
2091 });
2092 if (changed === 0) return;
2093 // Serialize the doc + ship it back.
2094 const out = '<!doctype html>\n' + target.documentElement.outerHTML;
2095 try {
2096 const r = await fetch(url, {
2097 method: 'PUT',
2098 headers: { 'content-type': 'application/json' },
2099 body: JSON.stringify({ html: out }),
2100 });
2101 if (!r.ok) throw new Error(`save failed ${r.status}`);
2102 toast(`已保存 ${changed} 处修改`, 'success');
2103 // Refresh local project state so frames-strip thumbnails cache-bust.
2104 if (fid) {
2105 const pr = await API.getProject(projectId);
2106 state.selected = pr.project;
2107 renderFramesStrip();
2108 }
2109 } catch (e) {
2110 toast(`保存失败:${e.message}`, 'error');
2111 }
2112 }
2113
2114 // Keep --preview-scale on .preview-frame in sync with its rendered width
2115 // so the 1920×1080 iframe shrinks proportionally rather than getting
2116 // cropped by a smaller viewport.
2117 let _previewResizeObserver = null;
2118 function attachPreviewScaler() {
2119 const frame = document.querySelector('.preview-frame');
2120 if (!frame) return;
2121 const apply = () => {
2122 const w = frame.clientWidth;
2123 if (!w) return;
2124 // Scale by the inner element's native design width (not a hardcoded 1920)
2125 // so non-16:9 aspects (1080-wide) shrink correctly too. A native (enhanced)
2126 // frame uses a <video> instead of an <iframe> — scale it the same way, else
2127 // the 1920×1080 MP4 overflows and the frame gets cropped.
2128 const inner = frame.querySelector('iframe, video');
2129 const nativeW = inner ? (parseFloat(inner.style.width) || 1920) : 1920;
2130 frame.style.setProperty('--preview-scale', (w / nativeW).toFixed(4));
2131 };
2132 apply();
2133 if (_previewResizeObserver) _previewResizeObserver.disconnect();
2134 _previewResizeObserver = new ResizeObserver(apply);
2135 _previewResizeObserver.observe(frame);
2136 }
2137
2138 function reloadPreview() {
2139 const iframe = document.getElementById('preview-iframe');
2140 if (!iframe || !state.selected) return;
2141 const p = state.selected;
2142 const frames = Array.isArray(p.frames) ? p.frames : [];
2143 if (frames.length > 0 && state.activeFrameId) {
2144 iframe.src = `/preview/${p.id}/frame/${encodeURIComponent(state.activeFrameId)}?t=${Date.now()}`;
2145 } else {
2146 iframe.src = `/preview/${p.id}?t=${Date.now()}`;
2147 }
2148 }
2149
2150 // ============== v0.8: frames timeline + graph modal ==============
2151 function renderFramesStrip() {
2152 const strip = document.getElementById('frames-strip');
2153 if (!strip) return;
2154 const p = state.selected;
2155 const frames = p && Array.isArray(p.frames) ? [...p.frames].sort((a, b) => a.order - b.order) : [];
2156 if (frames.length === 0) {
2157 strip.classList.remove('has-frames');
2158 strip.innerHTML = '';
2159 return;
2160 }
2161 strip.classList.add('has-frames');
2162 // Each chip = label + mini iframe of the frame's actual HTML, transform-
2163 // scaled so the 1920×1080 page fits in a ~180×100 thumb. sandbox blocks
2164 // navigation; allow-scripts so any opening animation runs.
2165 // Bust cache when frame content changes (re-renders point to a new
2166 // versioned URL via `?v=<timestamp>` derived from project.updatedAt).
2167 const ver = p.updatedAt ? new Date(p.updatedAt).getTime() : Date.now();
2168 const tabs = frames.map((f) => {
2169 const isActive = f.graphNodeId === state.activeFrameId;
2170 const isFocus = f.graphNodeId === state.iterateFocusFrameId;
2171 const cls = ['frame-tab', isActive && 'active', isFocus && 'focus']
2172 .filter(Boolean).join(' ');
2173 // A native (enhanced) frame has no HTML — play its rendered preview MP4.
2174 const enhanced = f.engine === 'remotion';
2175 const thumbInner = enhanced
2176 ? `<video src="/preview/${p.id}/frame/${encodeURIComponent(f.graphNodeId)}.mp4?v=${ver}" autoplay muted loop playsinline tabindex="-1"></video>`
2177 : `<iframe sandbox="allow-scripts" src="/preview/${p.id}/frame/${encodeURIComponent(f.graphNodeId)}?thumb=1&v=${ver}" tabindex="-1" loading="lazy"></iframe>`;
2178 // The "⚡ Enhance" control shows only on data frames (kind==='data'). It's an
2179 // overlay badge ON the thumbnail (top area) so it's obvious + always visible.
2180 const isData = state.frameKinds[f.graphNodeId] === 'data';
2181 const busy = state.enhancing && state.enhancing.nodeId === f.graphNodeId;
2182 let enhanceCtl = '';
2183 if (isData) {
2184 if (busy) {
2185 enhanceCtl = `<span class="frame-enhance busy" data-fid="${esc(f.graphNodeId)}">${t('frames.enhancing', { pct: state.enhancing.pct ?? 0 })}</span>`;
2186 } else if (enhanced) {
2187 enhanceCtl = `<span class="frame-enhance on" data-fid="${esc(f.graphNodeId)}" data-act="unenhance" title="${esc(t('frames.enhanced_revert'))}">${t('frames.enhanced_revert')}</span>`;
2188 } else {
2189 enhanceCtl = `<span class="frame-enhance" data-fid="${esc(f.graphNodeId)}" data-act="enhance" title="${esc(t('frames.enhance_hint'))}">${t('frames.enhance')}</span>`;
2190 }
2191 }
2192 return `<button class="${cls}${isData ? ' is-data' : ''}" data-fid="${esc(f.graphNodeId)}">
2193 <div class="frame-thumb">
2194 ${thumbInner}
2195 ${enhanceCtl}
2196 ${isFocus ? '<div class="focus-mark" title="正在编辑此帧">✎</div>' : ''}
2197 </div>
2198 <div class="frame-tab-label">
2199 <span class="order">${String(f.order + 1).padStart(2, '0')}</span>
2200 <span class="fid">${esc(f.graphNodeId)}</span>
2201 </div>
2202 </button>`;
2203 }).join('');
2204 strip.innerHTML = `<span class="label">${t('frames.label')}</span>${tabs}
2205 <button class="frame-graph-btn" id="btn-show-graph">${t('frames.view_graph')}</button>`;
2206 // Single-click: switch which frame is shown in the centre preview.
2207 // Double-click: pin this frame as the iteration target so subsequent
2208 // chat messages only rewrite this frame. Click another / dbl-click the
2209 // same one to clear.
2210 strip.querySelectorAll('button.frame-tab').forEach((btn) => {
2211 btn.addEventListener('click', () => {
2212 const fid = btn.dataset.fid;
2213 state.activeFrameId = fid;
2214 // First click also pins focus so the user doesn't have to dbl-click —
2215 // but only when nothing else is focused, or they're switching to a new
2216 // frame. Clicking the already-focused frame again clears focus.
2217 if (state.iterateFocusFrameId === fid) {
2218 state.iterateFocusFrameId = null;
2219 } else {
2220 state.iterateFocusFrameId = fid;
2221 }
2222 renderPreview();
2223 renderComposer();
2224 // Refresh the right-pane Frame text editor to point at the newly
2225 // active frame's data-hv-text values.
2226 refreshTextFields();
2227 // Soundtrack narration is per-frame — point the textarea at this frame.
2228 if (typeof window.__hvSyncNarration === 'function') window.__hvSyncNarration();
2229 });
2230 });
2231 // Per-frame enhance / revert toggle (data frames only). stopPropagation so
2232 // clicking it doesn't also fire the parent tab's frame-switch handler.
2233 strip.querySelectorAll('.frame-enhance').forEach((el) => {
2234 el.addEventListener('click', (e) => {
2235 e.stopPropagation();
2236 if (state.enhancing) return; // single in-flight; ignore double-clicks
2237 const fid = el.dataset.fid;
2238 if (el.dataset.act === 'unenhance') unenhanceFrameAction(fid);
2239 else if (el.dataset.act === 'enhance') startEnhanceStream(fid);
2240 });
2241 });
2242 const gbtn = document.getElementById('btn-show-graph');
2243 if (gbtn) gbtn.addEventListener('click', openGraphModal);
2244 }
2245
2246 async function openGraphModal() {
2247 if (!state.selected) return;
2248 const modal = document.getElementById('graph-modal');
2249 const pre = document.getElementById('graph-json');
2250 if (!modal || !pre) return;
2251 try {
2252 const r = await fetch(`/api/projects/${state.selected.id}/content-graph`);
2253 if (!r.ok) {
2254 pre.textContent = '(no graph for this project)';
2255 } else {
2256 const { graph } = await r.json();
2257 pre.textContent = JSON.stringify(graph, null, 2);
2258 state.lastGraph = graph;
2259 }
2260 } catch (e) {
2261 pre.textContent = `error loading graph: ${e.message}`;
2262 }
2263 modal.classList.add('open');
2264 const close = document.getElementById('graph-close');
2265 const dl = document.getElementById('graph-download');
2266 if (close) close.onclick = () => modal.classList.remove('open');
2267 if (dl) dl.onclick = () => {
2268 if (!state.lastGraph) return;
2269 const blob = new Blob([JSON.stringify(state.lastGraph, null, 2)], { type: 'application/json' });
2270 const a = document.createElement('a');
2271 a.href = URL.createObjectURL(blob);
2272 a.download = `content-graph-${state.selected.id}.json`;
2273 document.body.appendChild(a);
2274 a.click();
2275 a.remove();
2276 };
2277 modal.addEventListener('click', (e) => {
2278 if (e.target === modal) modal.classList.remove('open');
2279 }, { once: true });
2280 }
2281
2282 // ============== text fields (data-hv-text editor) ==============
2283 /**
2284 * Source the HTML the right-side editor reads. For multi-frame projects
2285 * we follow `state.activeFrameId` so clicking a frame in the strip swaps
2286 * the editor over to that frame; otherwise fall back to the whole-project
2287 * preview HTML.
2288 */
2289 async function fetchActiveFrameHtml() {
2290 if (!state.selected) return null;
2291 const fid = state.activeFrameId;
2292 const url = fid
2293 ? `/api/projects/${state.selected.id}/frames/${encodeURIComponent(fid)}/raw-html`
2294 : `/api/projects/${state.selected.id}/raw-html`;
2295 try {
2296 const r = await fetch(url);
2297 if (!r.ok) return null;
2298 return await r.text();
2299 } catch {
2300 return null;
2301 }
2302 }
2303
2304 async function refreshTextFields() {
2305 if (!state.selected) {
2306 state.textFields = [];
2307 renderTextFields();
2308 return;
2309 }
2310 // We used to gate this on a templateId, but frames-mode projects are
2311 // template-free and still have hv-text fields worth showing.
2312 const html = await fetchActiveFrameHtml();
2313 if (!html) {
2314 state.textFields = [];
2315 renderTextFields();
2316 return;
2317 }
2318 const doc = new DOMParser().parseFromString(html, 'text/html');
2319 const nodes = doc.querySelectorAll('[data-hv-text]');
2320 const seen = new Set();
2321 const fields = [];
2322 for (const el of nodes) {
2323 const key = el.getAttribute('data-hv-text');
2324 if (!key || seen.has(key)) continue;
2325 seen.add(key);
2326 const text = el.textContent ?? '';
2327 fields.push({ key, original: text, current: text });
2328 }
2329 state.textFields = fields;
2330 renderTextFields();
2331 }
2332
2333 function renderTextFields() {
2334 const wrap = document.getElementById('text-fields');
2335 if (!wrap) return;
2336 if (!state.selected) {
2337 wrap.innerHTML = `<div class="text-empty">${t('text_pane.no_project')}</div>`;
2338 return;
2339 }
2340 if (state.textFields.length === 0) {
2341 const hasFrames = (state.selected.frames?.length ?? 0) > 0;
2342 const hint = hasFrames ? t('text_pane.empty_with_frames') : t('text_pane.empty_no_frames');
2343 wrap.innerHTML = `<div class="text-empty">${hint}</div>`;
2344 return;
2345 }
2346 // Always render as textarea — agent decides text length, no hard cap.
2347 wrap.innerHTML = state.textFields.map((f, i) => {
2348 const labelKey = humanizeKey(f.key);
2349 return `<div class="text-field">
2350 <div class="key">${esc(labelKey)}<span class="badge">${esc(f.key)}</span></div>
2351 <textarea data-i="${i}" rows="1" placeholder="(empty)">${esc(f.current)}</textarea>
2352 </div>`;
2353 }).join('');
2354 wrap.querySelectorAll('textarea[data-i]').forEach((el) => {
2355 autoResize(el);
2356 el.addEventListener('input', (e) => {
2357 const i = Number(e.target.dataset.i);
2358 state.textFields[i].current = e.target.value;
2359 autoResize(el);
2360 scheduleTextSave();
2361 });
2362 });
2363 }
2364
2365 function autoResize(el) {
2366 el.style.height = 'auto';
2367 el.style.height = Math.min(el.scrollHeight + 2, 320) + 'px';
2368 }
2369
2370 function humanizeKey(key) {
2371 return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
2372 }
2373
2374 function scheduleTextSave() {
2375 clearTimeout(state.textSaveTimer);
2376 setSaveState('typing…');
2377 state.textSaveTimer = setTimeout(commitTextEdits, 500);
2378 }
2379
2380 function setSaveState(text, kind = '') {
2381 const el = document.getElementById('text-save-state');
2382 if (el) {
2383 el.textContent = text;
2384 el.className = 'save-state ' + kind;
2385 }
2386 }
2387
2388 async function commitTextEdits() {
2389 if (!state.selected) return;
2390 const dirty = state.textFields.filter((f) => f.current !== f.original);
2391 if (dirty.length === 0) {
2392 setSaveState('—');
2393 return;
2394 }
2395 setSaveState('saving…', 'saving');
2396 // Read the SAME source we'll write back to — the active frame's HTML
2397 // when there is one, otherwise the whole-project preview.
2398 const html = await fetchActiveFrameHtml();
2399 if (!html) { setSaveState('error', 'error'); return; }
2400 const doc = new DOMParser().parseFromString(html, 'text/html');
2401 for (const f of state.textFields) {
2402 const nodes = doc.querySelectorAll(`[data-hv-text="${cssEscape(f.key)}"]`);
2403 nodes.forEach((n) => { n.textContent = f.current; });
2404 f.original = f.current;
2405 }
2406 // Serialize back: include doctype because DOMParser drops it
2407 const serialized = '<!doctype html>\n' + doc.documentElement.outerHTML;
2408 const fid = state.activeFrameId;
2409 const url = fid
2410 ? `/api/projects/${state.selected.id}/frames/${encodeURIComponent(fid)}/raw-html`
2411 : `/api/projects/${state.selected.id}/raw-html`;
2412 let r;
2413 try {
2414 const res = await fetch(url, {
2415 method: 'PUT',
2416 headers: { 'content-type': 'application/json' },
2417 body: JSON.stringify({ html: serialized }),
2418 });
2419 r = await res.json();
2420 } catch (e) {
2421 setSaveState('error: ' + (e?.message ?? e), 'error');
2422 return;
2423 }
2424 if (r?.error) {
2425 setSaveState('error: ' + r.error, 'error');
2426 return;
2427 }
2428 // Refresh project so frames-strip thumbnails cache-bust.
2429 if (fid) {
2430 try {
2431 const pr = await API.getProject(state.selected.id);
2432 state.selected = pr.project;
2433 renderFramesStrip();
2434 } catch {}
2435 } else if (r?.project) {
2436 state.selected = r.project;
2437 }
2438 setSaveState('saved', 'saved');
2439 reloadPreview();
2440 }
2441
2442 function cssEscape(s) {
2443 return String(s).replace(/["\\]/g, '\\$&');
2444 }
2445
2446 // ============== send message ==============
2447 async function sendMessage() {
2448 if (state.composing || !state.selected) return;
2449 const ta = document.getElementById('composer-input');
2450 const text = ta.value.trim();
2451 const hasAttachments = state.pendingAttachments.length > 0;
2452 if (!text && !hasAttachments) return;
2453
2454 // Intent shortcut: if the message is a clear "export to MP4" command
2455 // and there's something to export, run the export flow directly
2456 // instead of routing through the agent. The agent has nothing useful
2457 // to add for a deterministic export action.
2458 const p = state.selected;
2459 const canExport = !!(p && (p.templateId || (p.frames?.length ?? 0) > 0));
2460 if (canExport && !hasAttachments && isExportIntent(text)) {
2461 ta.value = '';
2462 state.messages.push({ role: 'user', content: text, ts: Date.now() });
2463 renderChatLog();
2464 startExportStream();
2465 return;
2466 }
2467
2468 ta.value = '';
2469 state.composing = true;
2470 // The project this send belongs to — used to ignore late events / not clobber
2471 // a different project if the user switches away mid-generation.
2472 const genProjectId = state.selectedId;
2473 renderComposer();
2474
2475 // Iterate scope: when the user has selected a specific frame in the
2476 // strip, the iterate-phase server route should only rewrite that frame.
2477 // We pass the focus along on every send (server uses it only for iterate).
2478 const focusFrame = state.iterateFocusFrameId || '';
2479
2480 // User message includes attachment summary + focus chip
2481 const attSummary = hasAttachments
2482 ? `\n\n📎 ${state.pendingAttachments.length} attachment(s): ${state.pendingAttachments.map(a => a.name).join(', ')}`
2483 : '';
2484 const focusSummary = focusFrame ? `\n\n🎯 focus: frame ${focusFrame}` : '';
2485 state.messages.push({
2486 role: 'user',
2487 content: text + attSummary + focusSummary,
2488 ts: Date.now(),
2489 ...(focusFrame ? { focusFrameId: focusFrame } : {}),
2490 });
2491 state.messages.push({ role: 'thinking', content: t('chat.thinking'), ts: Date.now() });
2492 const thinkingIdx = state.messages.length - 1;
2493 renderChatLog();
2494
2495 let assistantIdx = -1;
2496
2497 try {
2498 let res;
2499 if (hasAttachments) {
2500 const fd = new FormData();
2501 fd.append('content', text);
2502 if (focusFrame) fd.append('focus_frame_id', focusFrame);
2503 for (const a of state.pendingAttachments) fd.append('file', a.file, a.name);
2504 // Clear UI attachments before request so user sees them disappear
2505 state.pendingAttachments = [];
2506 renderAttachments();
2507 res = await fetch(`/api/projects/${state.selected.id}/messages`, {
2508 method: 'POST',
2509 body: fd,
2510 });
2511 } else {
2512 res = await fetch(`/api/projects/${state.selected.id}/messages`, {
2513 method: 'POST',
2514 headers: { 'content-type': 'application/json' },
2515 body: JSON.stringify({
2516 content: text,
2517 ...(focusFrame ? { focus_frame_id: focusFrame } : {}),
2518 }),
2519 });
2520 }
2521 if (!res.ok || !res.body) {
2522 const err = await res.json().catch(() => ({}));
2523 state.messages[thinkingIdx] = { role: 'system', content: '⚠️ ' + (err.error ?? 'agent failed'), ts: Date.now() };
2524 renderChatLog();
2525 } else {
2526 const reader = res.body.getReader();
2527 const decoder = new TextDecoder();
2528 let buf = '';
2529 // If the user switches away mid-generation, stop rendering its events into
2530 // the (now different) active project — the backend keeps running and
2531 // persists the result, so it's there when they switch back.
2532 while (true) {
2533 const { done, value } = await reader.read();
2534 if (done) break;
2535 if (state.selectedId !== genProjectId) { try { await reader.cancel(); } catch {} break; }
2536 buf += decoder.decode(value, { stream: true });
2537 const lines = buf.split('\n\n');
2538 buf = lines.pop() ?? '';
2539 for (const line of lines) {
2540 if (!line.startsWith('data: ')) continue;
2541 let ev;
2542 try { ev = JSON.parse(line.slice(6)); } catch { continue; }
2543 if (ev.type === 'text') {
2544 if (assistantIdx === -1) {
2545 // Replace thinking with assistant message
2546 state.messages[thinkingIdx] = { role: 'assistant', agent: state.selected.agentId ?? 'claude', content: '', ts: Date.now() };
2547 assistantIdx = thinkingIdx;
2548 }
2549 state.messages[assistantIdx].content += ev.chunk;
2550 renderChatLog();
2551 } else if (ev.type === 'preview_ready') {
2552 const frameCount = ev.frames || 0;
2553 const focusedFrame = ev.focused_frame;
2554 const summary = focusedFrame
2555 ? `✓ frame ${focusedFrame} updated`
2556 : frameCount > 0
2557 ? `✓ ${frameCount}-frame storyboard generated`
2558 : '✓ HTML preview updated';
2559 const event = focusedFrame
2560 ? `🎞 frame ${focusedFrame} reloaded`
2561 : frameCount > 0
2562 ? `🎞 storyboard reloaded (${frameCount} frames)`
2563 : '🎞 preview reloaded';
2564 if (assistantIdx === -1) {
2565 state.messages[thinkingIdx] = { role: 'assistant', agent: state.selected.agentId ?? 'claude', content: summary, ts: Date.now() };
2566 assistantIdx = thinkingIdx;
2567 } else {
2568 state.messages[assistantIdx].content = summary;
2569 }
2570 state.messages.push({ role: 'preview-event', content: event, ts: Date.now() });
2571 renderChatLog();
2572 // Multi-frame turn replaces frames[]; reset active frame so the
2573 // first frame becomes the default again.
2574 if (frameCount > 0) state.activeFrameId = null;
2575 const pr = await API.getProject(state.selected.id);
2576 state.selected = pr.project;
2577 // Generating in-place writes a fresh content-graph, so the node→kind
2578 // map must be rebuilt — otherwise data frames don't get their ⚡
2579 // Remotion badge until the user switches projects and back.
2580 if (frameCount > 0) {
2581 state.frameKinds = {};
2582 try {
2583 const cg = await API.contentGraph(state.selected.id);
2584 if (cg?.graph?.nodes) for (const n of cg.graph.nodes) state.frameKinds[n.id] = n.kind;
2585 } catch { /* no graph — single-frame, fine */ }
2586 }
2587 renderPreview(); // also re-syncs soundtrack buttons via __hvSyncNarration
2588 await refreshTextFields();
2589 renderToolbar();
2590 renderFooter();
2591 } else if (ev.type === 'warning') {
2592 if (assistantIdx === -1) {
2593 state.messages[thinkingIdx] = { role: 'assistant', agent: state.selected.agentId ?? 'claude', content: '', ts: Date.now() };
2594 assistantIdx = thinkingIdx;
2595 }
2596 state.messages[assistantIdx].content += '\n\n⚠️ ' + ev.message;
2597 renderChatLog();
2598 } else if (ev.type === 'error') {
2599 if (assistantIdx === -1) {
2600 state.messages[thinkingIdx] = { role: 'system', content: '⚠️ ' + ev.message, ts: Date.now() };
2601 } else {
2602 state.messages[assistantIdx].content += '\n\n⚠️ ' + ev.message;
2603 }
2604 renderChatLog();
2605 }
2606 }
2607 }
2608 }
2609 } catch (e) {
2610 // Only surface the error if we're still on the project that started this
2611 // send — otherwise it's just the user having navigated away.
2612 if (state.selectedId === genProjectId) {
2613 state.messages[thinkingIdx] = { role: 'system', content: '⚠️ ' + (e.message ?? e), ts: Date.now() };
2614 renderChatLog();
2615 }
2616 }
2617 // Don't clobber composing if the user already switched to another project
2618 // (which may have its own generation running).
2619 if (state.selectedId === genProjectId) {
2620 state.composing = false;
2621 renderComposer();
2622 }
2623 }
2624
2625 // ============== gallery modal ==============
2626 function openGallery() {
2627 if (!state.selected) return;
2628 document.getElementById('gallery-modal').classList.add('show');
2629 const grid = document.getElementById('gallery');
2630
2631 // Each card's iframe loads the template's actual entry HTML (`index.html`,
2632 // dropped under templates/<id>/ so /template-asset/<id>/index.html serves
2633 // it). The 1920×1080 (or 1080×1920) source is transform-scaled to fit
2634 // the card via a CSS variable set per-card after layout.
2635 grid.innerHTML = state.templates.map(t => {
2636 const sel = state.selected?.templateId === t.id ? ' selected' : '';
2637 const tags = (t.tags || []).slice(0, 4).map((tg) => `<span class="tag">${esc(tg)}</span>`).join('');
2638 const portrait = isPortraitTemplate(t);
2639 const entry = templateEntryPath(t);
2640 // Poster-mode templates (entry only stitches sub-comps via
2641 // data-composition-src) iframe-render blank until the HF player ships —
2642 // show the shipped poster instead. Falls back to the iframe when the
2643 // backend couldn't find a poster file (poster_url null).
2644 const inner =
2645 t.preview_mode === 'poster' && t.poster_url
2646 ? `<img class="poster" src="${esc(t.poster_url)}" alt="${esc(t.name ?? t.id)}" loading="lazy" />`
2647 : `<iframe sandbox="allow-scripts allow-same-origin" src="/template-asset/${esc(t.id)}/${esc(entry)}" loading="lazy"></iframe>`;
2648 return `<div class="gallery-card${sel}" data-id="${t.id}">
2649 <div class="preview ${portrait ? 'portrait' : ''}" data-portrait="${portrait}">
2650 ${inner}
2651 </div>
2652 <div class="meta">
2653 <div class="name">${esc(t.name)}</div>
2654 <div class="desc">${esc(t.description ?? '')}</div>
2655 <div class="tags">${tags}</div>
2656 </div>
2657 </div>`;
2658 }).join('');
2659
2660 // Click → open the fullscreen preview modal so the user can confirm
2661 // before applying. Replaces the old "click immediately replaces template"
2662 // behaviour, which never let the user actually see the candidate first.
2663 grid.querySelectorAll('.gallery-card').forEach(card => {
2664 card.onclick = () => {
2665 const tid = card.dataset.id;
2666 const tpl = state.templates.find((x) => x.id === tid);
2667 if (tpl) openTemplatePreviewModal(tpl);
2668 };
2669 });
2670
2671 // Resize observer recomputes --gallery-scale per card so 1920×1080 fits
2672 // the actual rendered card width.
2673 setTimeout(() => applyGalleryScales(grid), 0);
2674 if (galleryResizeObserver) galleryResizeObserver.disconnect();
2675 galleryResizeObserver = new ResizeObserver(() => applyGalleryScales(grid));
2676 grid.querySelectorAll('.gallery-card .preview').forEach((p) => galleryResizeObserver.observe(p));
2677 }
2678
2679 let galleryResizeObserver = null;
2680 function applyGalleryScales(grid) {
2681 grid.querySelectorAll('.gallery-card .preview').forEach((p) => {
2682 const w = p.clientWidth;
2683 if (!w) return;
2684 const portrait = p.dataset.portrait === 'true';
2685 // Landscape fills the 16:9 box by width. Portrait keeps the same 16:9
2686 // box but is scaled to fit the box HEIGHT (1080×1920 → fit by height,
2687 // centred), so its card stays the same height as the rest of the grid.
2688 const scale = portrait ? p.clientHeight / 1920 : w / 1920;
2689 p.style.setProperty('--gallery-scale', scale.toFixed(4));
2690 });
2691 }
2692
2693 function isPortraitTemplate(t) {
2694 const aspects = t?.output?.resolution?.supported_aspects ?? [];
2695 return aspects.includes('9:16') && !aspects.includes('16:9');
2696 }
2697
2698 function templateEntryPath(t) {
2699 // The template's entry HTML is declared as `source_entry` in its
2700 // template.html-video.yaml — some templates use `source/index.html`,
2701 // others a top-level `index.html`. The /api/templates response now
2702 // surfaces this field; fall back to `index.html` only if it's missing.
2703 const entry = t?.source_entry;
2704 return typeof entry === 'string' && entry ? entry : 'index.html';
2705 }
2706
2707 function closeGallery() {
2708 document.getElementById('gallery-modal').classList.remove('show');
2709 if (galleryResizeObserver) {
2710 galleryResizeObserver.disconnect();
2711 galleryResizeObserver = null;
2712 }
2713 }
2714
2715 // ============== Template fullscreen preview ==============
2716 let _tplPreviewResizeObserver = null;
2717 let _tplPreviewCurrent = null;
2718 function openTemplatePreviewModal(tpl) {
2719 _tplPreviewCurrent = tpl;
2720 const modal = document.getElementById('tpl-preview-modal');
2721 if (!modal) return;
2722 modal.classList.add('show');
2723
2724 document.getElementById('tpl-preview-name').textContent = tpl.name ?? tpl.id;
2725 document.getElementById('tpl-preview-desc').textContent = tpl.description ?? '';
2726 const dur = tpl?.output?.duration?.default_sec ?? tpl?.output?.duration?.max_sec ?? '?';
2727 const fps = tpl?.output?.fps?.default ?? '?';
2728 const aspect = (tpl?.output?.resolution?.supported_aspects ?? [])[0] ?? '16:9';
2729 document.getElementById('tpl-preview-meta').textContent = t('tpl_preview.fps_dur', {
2730 fps, duration: dur, aspect,
2731 });
2732
2733 renderTemplateSource(tpl);
2734
2735 const frame = document.getElementById('tpl-preview-frame');
2736 const portrait = isPortraitTemplate(tpl);
2737 frame.classList.toggle('portrait', portrait);
2738
2739 const iframe = document.getElementById('tpl-preview-iframe');
2740 const poster = document.getElementById('tpl-preview-poster');
2741 const entry = templateEntryPath(tpl);
2742 // Poster-mode templates render blank in a live iframe (need the unbuilt HF
2743 // player) — show the shipped poster instead. Fall back to the iframe if the
2744 // backend reported no poster file (poster_url null).
2745 const usePoster = tpl.preview_mode === 'poster' && tpl.poster_url;
2746 if (usePoster) {
2747 iframe.src = 'about:blank';
2748 iframe.hidden = true;
2749 poster.src = `${tpl.poster_url}?t=${Date.now()}`;
2750 poster.hidden = false;
2751 } else {
2752 poster.src = '';
2753 poster.hidden = true;
2754 iframe.hidden = false;
2755 iframe.src = `/template-asset/${encodeURIComponent(tpl.id)}/${entry}?t=${Date.now()}`;
2756 }
2757
2758 const apply = () => {
2759 const w = frame.clientWidth;
2760 const h = frame.clientHeight;
2761 if (!w || !h) return;
2762 const baseW = portrait ? 1080 : 1920;
2763 const baseH = portrait ? 1920 : 1080;
2764 const s = Math.min(w / baseW, h / baseH);
2765 frame.style.setProperty('--tpl-preview-scale', s.toFixed(4));
2766 };
2767 apply();
2768 if (_tplPreviewResizeObserver) _tplPreviewResizeObserver.disconnect();
2769 _tplPreviewResizeObserver = new ResizeObserver(apply);
2770 _tplPreviewResizeObserver.observe(frame);
2771
2772 const useBtn = document.getElementById('tpl-preview-use');
2773 const cancelBtn = document.getElementById('tpl-preview-cancel');
2774 const closeBtn = document.getElementById('tpl-preview-close');
2775
2776 // If the project already has this template applied, downgrade the primary
2777 // action to a no-op "in use" label so the user doesn't reapply needlessly.
2778 const isCurrent = state.selected?.templateId === tpl.id;
2779 useBtn.textContent = isCurrent
2780 ? t('settings.agent.in_use')
2781 : t('tpl_preview.use');
2782 useBtn.disabled = isCurrent;
2783
2784 useBtn.onclick = async () => {
2785 if (!state.selected) return;
2786 // If the project already has a different template applied, confirm
2787 // before replacing — the user may have been just exploring.
2788 const current = state.selected.templateId;
2789 if (current && current !== tpl.id) {
2790 if (!confirm(t('tpl_preview.replace_confirm', { name: tpl.name ?? tpl.id }))) return;
2791 }
2792 useBtn.disabled = true;
2793 try {
2794 await API.setTemplate(state.selected.id, tpl.id);
2795 closeTemplatePreviewModal();
2796 closeGallery();
2797 await selectProject(state.selected.id);
2798 toast(t('tpl_preview.applied', { name: tpl.name ?? tpl.id }), 'success');
2799 } finally {
2800 useBtn.disabled = false;
2801 }
2802 };
2803 cancelBtn.onclick = closeTemplatePreviewModal;
2804 closeBtn.onclick = closeTemplatePreviewModal;
2805 }
2806
2807 // Render the three-layer provenance (RFC-07) for the previewed template so the
2808 // upstream skill, its real author + license, and the original design lineage
2809 // are visible in the studio — not just buried in the template's yaml.
2810 function renderTemplateSource(tpl) {
2811 const box = document.getElementById('tpl-preview-source');
2812 if (!box) return;
2813 const p = tpl.provenance;
2814 const lic = tpl.license?.spdx;
2815 if (!p && !lic) {
2816 box.hidden = true;
2817 box.innerHTML = '';
2818 return;
2819 }
2820 const rows = [];
2821 const via = p?.via_skill;
2822 if (via?.name) {
2823 // "Adapted from <skill link> · <author> · <license>"
2824 const skill = via.url
2825 ? `<a href="${esc(via.url)}" target="_blank" rel="noopener">${esc(via.name)}</a>`
2826 : esc(via.name);
2827 const bits = [skill];
2828 if (via.author) bits.push(esc(via.author));
2829 if (via.license) bits.push(`<span class="lic">${esc(via.license)}</span>`);
2830 rows.push(`<div class="row"><span class="lbl">${esc(t('tpl_preview.source_skill'))}</span><span class="val">${bits.join(' · ')}</span></div>`);
2831 }
2832 const origin = p?.origin;
2833 if (origin?.name && origin.name.toLowerCase() !== 'none') {
2834 rows.push(`<div class="row"><span class="lbl">${esc(t('tpl_preview.source_origin'))}</span><span class="val">${esc(origin.name)}</span></div>`);
2835 }
2836 // License row only stands alone when it wasn't already shown next to the skill.
2837 if (lic && !via?.license) {
2838 rows.push(`<div class="row"><span class="lbl">${esc(t('tpl_preview.source_license'))}</span><span class="val"><span class="lic">${esc(lic)}</span></span></div>`);
2839 }
2840 if (!rows.length) {
2841 box.hidden = true;
2842 box.innerHTML = '';
2843 return;
2844 }
2845 box.innerHTML = rows.join('');
2846 box.hidden = false;
2847 }
2848
2849 function closeTemplatePreviewModal() {
2850 const modal = document.getElementById('tpl-preview-modal');
2851 if (modal) modal.classList.remove('show');
2852 if (_tplPreviewResizeObserver) {
2853 _tplPreviewResizeObserver.disconnect();
2854 _tplPreviewResizeObserver = null;
2855 }
2856 // Stop the iframe from continuing to play in the background.
2857 const iframe = document.getElementById('tpl-preview-iframe');
2858 if (iframe) iframe.src = 'about:blank';
2859 const poster = document.getElementById('tpl-preview-poster');
2860 if (poster) { poster.src = ''; poster.hidden = true; }
2861 _tplPreviewCurrent = null;
2862 }
2863
2864 // ============== new-project modal ==============
2865 function openNewModal() {
2866 document.getElementById('new-modal').classList.add('show');
2867 document.getElementById('new-name').focus();
2868 }
2869 function closeNewModal() {
2870 document.getElementById('new-modal').classList.remove('show');
2871 document.getElementById('new-name').value = '';
2872 document.getElementById('new-intent').value = '';
2873 }
2874
2875 function wireModals() {
2876 document.getElementById('new-cancel').onclick = closeNewModal;
2877 document.getElementById('new-ok').onclick = async () => {
2878 const name = document.getElementById('new-name').value.trim();
2879 const intent = document.getElementById('new-intent').value.trim();
2880 if (!name) { toast(t('modal.new.name_required'), 'error'); return; }
2881 const r = await API.createProject({ name, ...(intent && { intent }) });
2882 closeNewModal();
2883 await refreshProjects();
2884 await selectProject(r.project.id);
2885 toast(t('modal.new.created', { name }), 'success');
2886 };
2887 document.getElementById('new-modal').addEventListener('click', e => {
2888 if (e.target.id === 'new-modal') closeNewModal();
2889 });
2890 document.getElementById('gallery-close').onclick = closeGallery;
2891 document.getElementById('gallery-modal').addEventListener('click', e => {
2892 if (e.target.id === 'gallery-modal') closeGallery();
2893 });
2894 // Settings
2895 const settingsModal = document.getElementById('settings-modal');
2896 if (settingsModal) {
2897 document.getElementById('settings-close').onclick = closeSettingsModal;
2898 settingsModal.addEventListener('click', (e) => {
2899 if (e.target.id === 'settings-modal') closeSettingsModal();
2900 });
2901 settingsModal.querySelectorAll('.settings-nav-item').forEach((btn) => {
2902 btn.onclick = () => {
2903 settingsModal.querySelectorAll('.settings-nav-item').forEach((b) => b.classList.remove('active'));
2904 btn.classList.add('active');
2905 renderSettingsPanel(btn.dataset.settingsTab);
2906 };
2907 });
2908 }
2909 document.addEventListener('keydown', e => {
2910 if (e.key === 'Escape') {
2911 closeNewModal();
2912 closeGallery();
2913 closeSettingsModal();
2914 }
2915 });
2916 }
2917
2918 // ============== Settings modal ==============
2919 // Real brand logos (SVG, copied from open-design/agent-icons). Served from
2920 // /agent-icons/<id>.svg. Agents without a brand logo fall back to a glyph.
2921 const AGENT_LOGOS = {
2922 'anthropic-api': '/agent-icons/anthropic.svg',
2923 'claude': '/agent-icons/claude.svg',
2924 'cursor-agent': '/agent-icons/cursor-agent.svg',
2925 'codex': '/agent-icons/codex.svg',
2926 'hermes': '/agent-icons/hermes.svg',
2927 'amr': '/agent-icons/amr.svg',
2928 'gemini': '/agent-icons/gemini.svg',
2929 'grok': '/agent-icons/grok.svg',
2930 'qwen': '/agent-icons/qwen.svg',
2931 'opencode': '/agent-icons/opencode.svg',
2932 'copilot': '/agent-icons/copilot.svg',
2933 'aider': '/agent-icons/aider.png',
2934 'qoder-cli': '/agent-icons/qoder.svg',
2935 };
2936 const AGENT_ICON_FALLBACK = {
2937 'anthropic-api': '☁️',
2938 };
2939 function agentIconHtml(id) {
2940 const logo = AGENT_LOGOS[id];
2941 if (logo) return `<img src="${esc(logo)}" alt="" class="agent-logo" />`;
2942 return AGENT_ICON_FALLBACK[id] || '⚙️';
2943 }
2944 const AGENT_DESC = {
2945 'anthropic-api': 'Direct Messages API · streams reliably',
2946 'claude': 'Claude Code (claude --print)',
2947 'cursor-agent': 'Cursor command line',
2948 'codex': 'Codex CLI (codex exec)',
2949 'hermes': 'Hermes ACP CLI',
2950 'qoder-cli': 'Qoder CLI (qodercli -p)',
2951 };
2952
2953 function openSettingsModal(tab = 'agent') {
2954 const modal = document.getElementById('settings-modal');
2955 if (!modal) return;
2956 modal.classList.add('show');
2957 modal.querySelectorAll('.settings-nav-item').forEach((b) => {
2958 b.classList.toggle('active', b.dataset.settingsTab === tab);
2959 });
2960 renderSettingsPanel(tab);
2961 }
2962 function closeSettingsModal() {
2963 const modal = document.getElementById('settings-modal');
2964 if (modal) modal.classList.remove('show');
2965 }
2966
2967 function renderSettingsPanel(tab) {
2968 const panel = document.getElementById('settings-panel');
2969 if (!panel) return;
2970 if (tab === 'audio') return renderSettingsAudio(panel);
2971 if (tab === 'language') return renderSettingsLanguage(panel);
2972 if (tab === 'about') return renderSettingsAbout(panel);
2973 return renderSettingsAgent(panel);
2974 }
2975
2976 async function renderSettingsAudio(panel) {
2977 panel.innerHTML = `
2978 <h3>${esc(t('settings.audio.title'))}</h3>
2979 <div class="panel-sub">${esc(t('settings.audio.subtitle'))}</div>
2980 <div class="audio-config" id="audio-config">
2981 <div class="audio-status" id="audio-status">${esc(t('settings.audio.loading'))}</div>
2982 <label class="audio-field">
2983 <span>${esc(t('settings.audio.api_key'))}</span>
2984 <input type="password" id="mm-api-key" placeholder="${esc(t('settings.audio.api_key_placeholder'))}" autocomplete="off" />
2985 </label>
2986 <label class="audio-field">
2987 <span>${esc(t('settings.audio.region'))}</span>
2988 <div class="audio-region" id="mm-region">
2989 <button type="button" class="st-preset" data-url="https://api.minimax.io/v1">${esc(t('settings.audio.region_intl'))}</button>
2990 <button type="button" class="st-preset" data-url="https://api.minimaxi.com/v1">${esc(t('settings.audio.region_cn'))}</button>
2991 </div>
2992 </label>
2993 <label class="audio-field">
2994 <span>${esc(t('settings.audio.base_url'))}</span>
2995 <input type="text" id="mm-base-url" placeholder="https://api.minimax.io/v1" autocomplete="off" />
2996 </label>
2997 <div class="audio-actions">
2998 <button class="audio-save primary-action" id="mm-save" style="background:var(--accent);border-color:var(--accent);color:var(--accent-fg)">${esc(t('settings.audio.save'))}</button>
2999 <button class="audio-clear" id="mm-clear">${esc(t('settings.audio.clear'))}</button>
3000 <span class="audio-save-state" id="mm-save-state"></span>
3001 </div>
3002 <p class="panel-sub" style="font-size:11.5px;margin-top:4px">${esc(t('settings.audio.hint'))}</p>
3003 </div>
3004 `;
3005
3006 const statusEl = panel.querySelector('#audio-status');
3007 const keyInput = panel.querySelector('#mm-api-key');
3008 const baseInput = panel.querySelector('#mm-base-url');
3009 const saveState = panel.querySelector('#mm-save-state');
3010
3011 const refresh = async () => {
3012 try {
3013 const s = await fetch('/api/config/minimax').then((r) => r.json());
3014 if (s.configured) {
3015 const src = s.source === 'env' ? t('settings.audio.source_env') : t('settings.audio.source_config');
3016 statusEl.innerHTML = `<span class="agent-status-dot ok"></span>${esc(t('settings.audio.configured', { key: s.maskedKey, source: src }))}`;
3017 if (s.baseUrl) baseInput.value = s.baseUrl;
3018 } else {
3019 statusEl.innerHTML = `<span class="agent-status-dot missing"></span>${esc(t('settings.audio.not_configured'))}`;
3020 }
3021 } catch {
3022 statusEl.textContent = t('settings.audio.not_configured');
3023 }
3024 };
3025 await refresh();
3026
3027 // Region quick-pick: fills the Base URL with the correct regional endpoint.
3028 // MiniMax keys are region-bound (an api.minimax.io key won't auth against
3029 // api.minimaxi.com and vice-versa), so picking the wrong region is the #1
3030 // cause of voiceover failures (issue #4).
3031 panel.querySelectorAll('#mm-region .st-preset').forEach((btn) => {
3032 btn.onclick = () => {
3033 baseInput.value = btn.dataset.url;
3034 panel.querySelectorAll('#mm-region .st-preset').forEach((b) => b.classList.toggle('active', b === btn));
3035 };
3036 });
3037
3038 panel.querySelector('#mm-save').onclick = async () => {
3039 const apiKey = keyInput.value.trim();
3040 if (!apiKey) { saveState.textContent = t('settings.audio.need_key'); return; }
3041 saveState.textContent = t('settings.audio.saving');
3042 try {
3043 const r = await fetch('/api/config/minimax', {
3044 method: 'POST',
3045 headers: { 'content-type': 'application/json' },
3046 body: JSON.stringify({ apiKey, baseUrl: baseInput.value.trim() }),
3047 });
3048 if (!r.ok) throw new Error(`HTTP ${r.status}`);
3049 keyInput.value = '';
3050 saveState.textContent = t('settings.audio.saved');
3051 await refresh();
3052 } catch (e) {
3053 saveState.textContent = t('settings.audio.save_failed', { message: (e?.message ?? e) });
3054 }
3055 };
3056
3057 panel.querySelector('#mm-clear').onclick = async () => {
3058 await fetch('/api/config/minimax', { method: 'DELETE' });
3059 keyInput.value = '';
3060 baseInput.value = '';
3061 saveState.textContent = '';
3062 await refresh();
3063 };
3064 }
3065
3066 function renderSettingsAgent(panel) {
3067 // Default to local CLI mode; BYOK = anthropic-api which is itself an HTTP agent
3068 const mode = panel.dataset.mode || 'local';
3069 const agents = state.agents ?? [];
3070 const localAgents = agents.filter((a) => a.id !== 'anthropic-api');
3071 const httpAgents = agents.filter((a) => a.id === 'anthropic-api');
3072 const list = mode === 'byok' ? httpAgents : localAgents;
3073 const currentId = state.selected?.agentId
3074 || (agents.find((a) => a.available)?.id ?? 'anthropic-api');
3075
3076 panel.innerHTML = `
3077 <h3>${esc(t('settings.agent.title'))}</h3>
3078 <div class="panel-sub">${esc(t('settings.agent.subtitle'))}</div>
3079
3080 <div class="settings-mode-tabs">
3081 <button data-mode="local" class="${mode === 'local' ? 'active' : ''}">${esc(t('settings.agent.mode.local'))}</button>
3082 <button data-mode="byok" class="${mode === 'byok' ? 'active' : ''}">${esc(t('settings.agent.mode.byok'))}</button>
3083 </div>
3084
3085 ${mode === 'byok' ? `
3086 <div class="panel-sub" style="margin-bottom:14px">
3087 ${esc(t('settings.agent.byok.intro'))}
3088 <ul style="margin:6px 0 0 18px;padding:0;font-family:var(--font-mono);font-size:11.5px">
3089 <li>${esc(t('settings.agent.byok.env_key'))}</li>
3090 <li>${esc(t('settings.agent.byok.env_base'))}</li>
3091 </ul>
3092 </div>
3093 ` : ''}
3094
3095 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
3096 <div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono);letter-spacing:.08em;text-transform:uppercase">
3097 ${esc(t('settings.agent.detected', { count: list.length }))}
3098 </div>
3099 <button class="btn-rescan" style="background:transparent;border:1px solid var(--border);color:var(--text-muted);padding:5px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:11px;font-family:var(--font-mono)">
3100 ${esc(t('settings.agent.rescan'))}
3101 </button>
3102 </div>
3103
3104 <div class="agent-list">
3105 ${list.map((a) => {
3106 const isCurrent = a.id === currentId && a.available;
3107 const desc = AGENT_DESC[a.id] || (a.bin ?? '');
3108 const ver = a.version ? esc(a.version) : (a.available ? '' : esc(t('settings.agent.unavailable')));
3109 const icon = agentIconHtml(a.id);
3110 return `<div class="agent-card ${isCurrent ? 'selected' : ''}" data-agent-id="${esc(a.id)}">
3111 <div class="agent-icon">${icon}</div>
3112 <div class="agent-meta">
3113 <div class="agent-name">
3114 <span class="agent-status-dot ${a.available ? 'ok' : 'missing'}"></span>${esc(a.name)}
3115 </div>
3116 <div class="agent-desc">${esc(desc)}</div>
3117 ${ver ? `<div class="agent-version">${ver}</div>` : ''}
3118 </div>
3119 <div class="agent-actions">
3120 ${a.available ? `<button data-act="test">${esc(t('settings.agent.test'))}</button>` : ''}
3121 ${a.available
3122 ? (isCurrent
3123 ? `<span style="font-size:11px;color:var(--accent);font-family:var(--font-mono)">${esc(t('settings.agent.in_use'))}</span>`
3124 : `<button data-act="use" class="primary-action" style="background:var(--accent);border-color:var(--accent);color:var(--accent-fg)">${esc(t('settings.agent.use'))}</button>`)
3125 : (a.installUrl ? `<a href="${a.installUrl}" target="_blank" rel="noopener" style="font-size:11px;color:var(--text-faint)">install ↗</a>` : '')}
3126 </div>
3127 <div class="agent-test-result" data-test-result="${esc(a.id)}" style="display:none;grid-column:1 / -1"></div>
3128 </div>`;
3129 }).join('')}
3130 </div>
3131 `;
3132
3133 panel.querySelectorAll('.settings-mode-tabs button').forEach((btn) => {
3134 btn.onclick = () => {
3135 panel.dataset.mode = btn.dataset.mode;
3136 renderSettingsAgent(panel);
3137 };
3138 });
3139 panel.querySelectorAll('.btn-rescan').forEach((btn) => {
3140 btn.onclick = async () => {
3141 btn.disabled = true;
3142 btn.textContent = '…';
3143 try {
3144 const r = await API.rescanAgents();
3145 state.agents = r.agents ?? state.agents;
3146 renderSettingsAgent(panel);
3147 toast(t('settings.agent.rescanned'), 'success');
3148 } finally {
3149 btn.disabled = false;
3150 }
3151 };
3152 });
3153 panel.querySelectorAll('.agent-card [data-act]').forEach((btn) => {
3154 btn.onclick = async () => {
3155 const card = btn.closest('.agent-card');
3156 const aid = card.dataset.agentId;
3157 const act = btn.dataset.act;
3158 if (act === 'use') {
3159 if (!state.selected) {
3160 toast(t('composer.placeholder.no_project'), 'error');
3161 return;
3162 }
3163 await API.setAgent(state.selected.id, aid);
3164 state.selected = (await API.getProject(state.selected.id)).project;
3165 renderSettingsAgent(panel);
3166 toast(`✓ ${aid}`, 'success');
3167 } else if (act === 'test') {
3168 const result = panel.querySelector(`[data-test-result="${aid}"]`);
3169 result.style.display = 'block';
3170 result.className = 'agent-test-result';
3171 result.textContent = t('settings.agent.testing');
3172 btn.disabled = true;
3173 try {
3174 const r = await API.testAgent(aid);
3175 if (r.ok) {
3176 result.classList.add('ok');
3177 result.textContent = t('settings.agent.test_ok', { ms: r.ms, bytes: r.bytes })
3178 + (r.stdout_head ? ` — ${r.stdout_head.slice(0, 60).replace(/\n/g, ' ')}` : '');
3179 } else {
3180 result.classList.add('error');
3181 result.textContent = t('settings.agent.test_fail', { message: r.error || `exit ${r.exit_code}` });
3182 }
3183 } catch (e) {
3184 result.classList.add('error');
3185 result.textContent = t('settings.agent.test_fail', { message: e?.message ?? String(e) });
3186 } finally {
3187 btn.disabled = false;
3188 }
3189 }
3190 };
3191 });
3192 }
3193
3194 function renderSettingsLanguage(panel) {
3195 const cur = getLocale();
3196 panel.innerHTML = `
3197 <h3>${esc(t('settings.language.title'))}</h3>
3198 <div class="panel-sub">${esc(t('settings.language.subtitle'))}</div>
3199 <div class="lang-options">
3200 <button data-lang="en" class="${cur === 'en' ? 'active' : ''}">
3201 <div class="lang-name">${esc(t('settings.language.en'))}</div>
3202 <div class="lang-sub">${esc(t('settings.language.en_sub'))}</div>
3203 </button>
3204 <button data-lang="zh" class="${cur === 'zh' ? 'active' : ''}">
3205 <div class="lang-name">${esc(t('settings.language.zh'))}</div>
3206 <div class="lang-sub">${esc(t('settings.language.zh_sub'))}</div>
3207 </button>
3208 </div>
3209 `;
3210 panel.querySelectorAll('[data-lang]').forEach((btn) => {
3211 btn.onclick = () => {
3212 setLocale(btn.dataset.lang);
3213 // re-render this panel itself with the new locale
3214 renderSettingsLanguage(panel);
3215 };
3216 });
3217 }
3218
3219 function renderSettingsAbout(panel) {
3220 panel.innerHTML = `
3221 <h3>${esc(t('settings.about.title'))}</h3>
3222 <div class="panel-sub">${esc(t('settings.about.subtitle'))}</div>
3223 <div class="about-block">
3224 <div class="about-line"><span class="k">${esc(t('settings.about.version'))}</span><span class="v">studio · v0.7</span></div>
3225 <div class="about-line"><span class="k">${esc(t('settings.about.repo'))}</span><span class="v"><a href="https://github.com/nexu-io/html-video" target="_blank" rel="noopener">github.com/nexu-io/html-video</a></span></div>
3226 <div class="about-line"><span class="k">${esc(t('settings.about.discord'))}</span><span class="v"><a href="https://discord.com/invite/keeVPMrueT" target="_blank" rel="noopener">discord.com/invite/keeVPMrueT</a></span></div>
3227 <div class="about-line"><span class="k">${esc(t('settings.about.license'))}</span><span class="v">Apache-2.0</span></div>
3228 <div class="about-line"><span class="k">${esc(t('settings.about.related'))}</span><span class="v"><a href="https://github.com/nexu-io/open-design" target="_blank" rel="noopener">Open Design</a></span></div>
3229 </div>
3230 `;
3231 }
3232
3233 // ============== utils ==============
3234 function toast(msg, kind = '') {
3235 const t = document.getElementById('toast');
3236 t.textContent = msg;
3237 t.className = `toast show ${kind}`;
3238 setTimeout(() => t.classList.remove('show'), 2500);
3239 }
3240 function esc(s) {
3241 return String(s ?? '').replace(/[&<>"']/g, c =>
3242 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
3243 }
3244
3245 window.addEventListener('error', (e) => {
3246 console.error('[hv-studio] uncaught:', e.error || e.message);
3247 try { toast(`错误:${e.error?.message || e.message}`, 'error'); } catch {}
3248 });
3249 window.addEventListener('unhandledrejection', (e) => {
3250 console.error('[hv-studio] unhandled rejection:', e.reason);
3251 try { toast(`错误:${e.reason?.message || e.reason}`, 'error'); } catch {}
3252 });
3253 init().catch((e) => {
3254 console.error('[hv-studio] init failed:', e);
3255 try { toast(`init 失败:${e.message}`, 'error'); } catch {}
3256 });
3257
3258
3258 lines JAVASCRIPT