| 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 | ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 |