| 1 | /** |
| 2 | * HTTP server for the project studio (RFC-05 §UI). |
| 3 | * Serves @html-video/project-studio static UI + project / template REST APIs. |
| 4 | */ |
| 5 | |
| 6 | import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; |
| 7 | import { readFile, copyFile, mkdir } from 'node:fs/promises'; |
| 8 | import { existsSync, statSync } from 'node:fs'; |
| 9 | import { dirname, extname, join, resolve, basename } from 'node:path'; |
| 10 | import { randomUUID } from 'node:crypto'; |
| 11 | import { fileURLToPath } from 'node:url'; |
| 12 | import { tmpdir } from 'node:os'; |
| 13 | import type { CliContext } from './context.js'; |
| 14 | import { AssetStore, generateTts, generateMusic } from '@html-video/core'; |
| 15 | import { extractUrls, fetchSource } from './fetch-source.js'; |
| 16 | import { detectAll, findAgent, spawnAgent } from '@html-video/runtime'; |
| 17 | |
| 18 | interface StudioHandle { |
| 19 | url: string; |
| 20 | port: number; |
| 21 | close: () => void; |
| 22 | } |
| 23 | |
| 24 | const MIME: Record<string, string> = { |
| 25 | '.html': 'text/html; charset=utf-8', |
| 26 | '.css': 'text/css; charset=utf-8', |
| 27 | '.js': 'application/javascript; charset=utf-8', |
| 28 | '.png': 'image/png', |
| 29 | '.jpg': 'image/jpeg', |
| 30 | '.jpeg': 'image/jpeg', |
| 31 | '.svg': 'image/svg+xml', |
| 32 | '.json': 'application/json; charset=utf-8', |
| 33 | '.webp': 'image/webp', |
| 34 | '.mp4': 'video/mp4', |
| 35 | '.webm': 'video/webm', |
| 36 | '.txt': 'text/plain; charset=utf-8', |
| 37 | }; |
| 38 | |
| 39 | function resolveUiRoot(): string { |
| 40 | const here = dirname(fileURLToPath(import.meta.url)); |
| 41 | const candidates = [ |
| 42 | resolve(here, '..', '..', 'project-studio', 'public'), |
| 43 | resolve(here, '..', 'public'), |
| 44 | resolve(here, '..', '..', 'storyboard-ui', 'public'), |
| 45 | ]; |
| 46 | for (const c of candidates) if (existsSync(c)) return c; |
| 47 | return candidates[0]!; |
| 48 | } |
| 49 | |
| 50 | export async function startStudioServer(ctx: CliContext, port: number): Promise<StudioHandle> { |
| 51 | const uiRoot = resolveUiRoot(); |
| 52 | |
| 53 | const server = createServer(async (req, res) => { |
| 54 | try { |
| 55 | if (!req.url) { |
| 56 | res.writeHead(400); |
| 57 | res.end(); |
| 58 | return; |
| 59 | } |
| 60 | const url = new URL(req.url, 'http://x'); |
| 61 | const m = req.method ?? 'GET'; |
| 62 | |
| 63 | // ============== API ============== |
| 64 | |
| 65 | // List projects |
| 66 | if (url.pathname === '/api/projects' && m === 'GET') { |
| 67 | const list = await ctx.orchestrator.list(); |
| 68 | return json(res, 200, { projects: list }); |
| 69 | } |
| 70 | |
| 71 | // Create project |
| 72 | if (url.pathname === '/api/projects' && m === 'POST') { |
| 73 | const body = await readBody(req); |
| 74 | const project = await ctx.orchestrator.create({ |
| 75 | name: (body.name as string) ?? 'Untitled', |
| 76 | ...(body.intent !== undefined && { intent: body.intent as string }), |
| 77 | preferences: (body.preferences as Record<string, unknown>) ?? {}, |
| 78 | }); |
| 79 | return json(res, 200, { project }); |
| 80 | } |
| 81 | |
| 82 | // Get / update / delete single project |
| 83 | const projMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/); |
| 84 | if (projMatch && projMatch[1]) { |
| 85 | const id = projMatch[1]; |
| 86 | if (m === 'GET') { |
| 87 | return json(res, 200, { project: await ctx.orchestrator.load(id) }); |
| 88 | } |
| 89 | if (m === 'PATCH') { |
| 90 | const body = await readBody(req); |
| 91 | const project = await ctx.orchestrator.load(id); |
| 92 | if (typeof body.name === 'string' && body.name.trim()) { |
| 93 | project.name = body.name.trim().slice(0, 80); |
| 94 | } |
| 95 | if (typeof body.intent === 'string') { |
| 96 | project.intent = body.intent.slice(0, 280); |
| 97 | } |
| 98 | await ctx.projects.save(project); |
| 99 | return json(res, 200, { project: await ctx.orchestrator.load(id) }); |
| 100 | } |
| 101 | if (m === 'DELETE') { |
| 102 | await ctx.orchestrator.remove(id); |
| 103 | MESSAGES.delete(id); |
| 104 | return json(res, 200, { ok: true }); |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // List engines + templates |
| 109 | if (url.pathname === '/api/templates' && m === 'GET') { |
| 110 | return json(res, 200, { |
| 111 | templates: ctx.templates.list().map((t) => { |
| 112 | // Decide how the gallery should preview this template: |
| 113 | // - 'iframe' → the entry HTML is self-contained; render it live. |
| 114 | // - 'poster' → the entry only references sub-compositions via |
| 115 | // data-composition-src and needs the Hyperframes player (not yet |
| 116 | // built, v0.9) to show anything, so a live iframe is blank. |
| 117 | // Fall back to the shipped poster image instead. |
| 118 | const { mode, posterUrl } = templatePreviewMode(t); |
| 119 | return { |
| 120 | id: t.id, |
| 121 | name: t.name, |
| 122 | description: t.description, |
| 123 | engine: t.engine, |
| 124 | source_entry: t.source_entry, |
| 125 | category: t.category, |
| 126 | tags: t.tags, |
| 127 | best_for: t.best_for, |
| 128 | inputs_schema: t.inputs.schema, |
| 129 | inputs_examples: t.inputs.examples, |
| 130 | license: t.license, |
| 131 | provenance: t.provenance, |
| 132 | preview: t.preview, |
| 133 | preview_mode: mode, |
| 134 | poster_url: posterUrl, |
| 135 | output: t.output, |
| 136 | }; |
| 137 | }), |
| 138 | }); |
| 139 | } |
| 140 | |
| 141 | // Add asset (multipart-style via JSON for v0.1: paths or inline content) |
| 142 | const addAssetMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/assets$/); |
| 143 | if (addAssetMatch && addAssetMatch[1] && m === 'POST') { |
| 144 | const id = addAssetMatch[1]; |
| 145 | const ct = req.headers['content-type'] ?? ''; |
| 146 | let project; |
| 147 | if (ct.startsWith('multipart/form-data')) { |
| 148 | // Save uploaded file to /tmp then add |
| 149 | const saved = await receiveMultipartFile(req, ct); |
| 150 | project = await ctx.orchestrator.addFileAsset(id, saved.filePath); |
| 151 | } else { |
| 152 | const body = await readBody(req); |
| 153 | if (body.kind === 'text') { |
| 154 | project = await ctx.orchestrator.addInlineAsset( |
| 155 | id, |
| 156 | (body.content as string) ?? '', |
| 157 | 'text', |
| 158 | body.caption as string | undefined, |
| 159 | ); |
| 160 | } else if (body.kind === 'data') { |
| 161 | project = await ctx.orchestrator.addInlineAsset( |
| 162 | id, |
| 163 | (body.content as string) ?? '', |
| 164 | 'data', |
| 165 | body.caption as string | undefined, |
| 166 | ); |
| 167 | } else if (body.kind === 'file' && body.path) { |
| 168 | project = await ctx.orchestrator.addFileAsset(id, body.path as string); |
| 169 | } else { |
| 170 | return json(res, 400, { error: 'Provide kind=text|data|file with content/path' }); |
| 171 | } |
| 172 | } |
| 173 | return json(res, 200, { project }); |
| 174 | } |
| 175 | |
| 176 | // Remove asset |
| 177 | const rmAssetMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/assets\/([^/]+)$/); |
| 178 | if (rmAssetMatch && rmAssetMatch[1] && rmAssetMatch[2] && m === 'DELETE') { |
| 179 | const project = await ctx.orchestrator.removeAsset(rmAssetMatch[1], rmAssetMatch[2]); |
| 180 | return json(res, 200, { project }); |
| 181 | } |
| 182 | |
| 183 | // Set template |
| 184 | const tplMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/template$/); |
| 185 | if (tplMatch && tplMatch[1] && m === 'PUT') { |
| 186 | const body = await readBody(req); |
| 187 | const project = await ctx.orchestrator.setTemplate(tplMatch[1], body.template_id as string); |
| 188 | // Auto-seed preview with the template's own example.html so the user sees |
| 189 | // something immediately (before any chat-driven rewrite). |
| 190 | const tmpl = ctx.templates.get(body.template_id as string); |
| 191 | const exampleHtmlPath = join(tmpl.__dir!, tmpl.source_entry); |
| 192 | if (existsSync(exampleHtmlPath)) { |
| 193 | const html = await readFile(exampleHtmlPath, 'utf8'); |
| 194 | await ctx.orchestrator.writePreviewHtmlRaw(project.id, html); |
| 195 | } |
| 196 | return json(res, 200, { project: await ctx.orchestrator.load(project.id) }); |
| 197 | } |
| 198 | |
| 199 | // Set agent (runtime selection) |
| 200 | const agentMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/agent$/); |
| 201 | if (agentMatch && agentMatch[1] && m === 'PUT') { |
| 202 | const body = await readBody(req); |
| 203 | const project = await ctx.orchestrator.setAgent( |
| 204 | agentMatch[1], |
| 205 | (body.agent_id as string) || null, |
| 206 | body.agent_model === undefined ? undefined : ((body.agent_model as string) || null), |
| 207 | ); |
| 208 | return json(res, 200, { project }); |
| 209 | } |
| 210 | |
| 211 | // Set variables (whole bag) |
| 212 | const varsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/variables$/); |
| 213 | if (varsMatch && varsMatch[1] && m === 'PUT') { |
| 214 | const body = await readBody(req); |
| 215 | const project = await ctx.orchestrator.setVariables( |
| 216 | varsMatch[1], |
| 217 | (body.variables as Record<string, unknown>) ?? {}, |
| 218 | ); |
| 219 | return json(res, 200, { project }); |
| 220 | } |
| 221 | |
| 222 | // Render preview HTML (legacy; v0.3+ uses chat-driven path) |
| 223 | const prevMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/preview$/); |
| 224 | if (prevMatch && prevMatch[1] && m === 'POST') { |
| 225 | const { project, htmlPath } = await ctx.orchestrator.renderPreviewHtml(prevMatch[1]); |
| 226 | return json(res, 200, { |
| 227 | project, |
| 228 | preview_url: `/preview/${project.id}`, |
| 229 | html_path: htmlPath, |
| 230 | }); |
| 231 | } |
| 232 | |
| 233 | // Get raw preview HTML (frontend reads to parse data-hv-text nodes) |
| 234 | const rawGetMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/raw-html$/); |
| 235 | if (rawGetMatch && rawGetMatch[1] && m === 'GET') { |
| 236 | const project = await ctx.orchestrator.load(rawGetMatch[1]); |
| 237 | if (!project.lastPreviewHtmlPath || !existsSync(project.lastPreviewHtmlPath)) { |
| 238 | return json(res, 404, { error: 'No preview HTML yet — pick a template or send a chat first' }); |
| 239 | } |
| 240 | const html = await readFile(project.lastPreviewHtmlPath, 'utf8'); |
| 241 | res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); |
| 242 | res.end(html); |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | // Write raw preview HTML (frontend posts back the modified HTML |
| 247 | // after the user edits a data-hv-text field in the middle column) |
| 248 | if (rawGetMatch && rawGetMatch[1] && m === 'PUT') { |
| 249 | const project = await ctx.orchestrator.load(rawGetMatch[1]); |
| 250 | const ct = req.headers['content-type'] ?? ''; |
| 251 | let html: string; |
| 252 | if (ct.includes('application/json')) { |
| 253 | const body = await readBody(req); |
| 254 | html = (body.html as string) ?? ''; |
| 255 | } else { |
| 256 | html = await readBodyText(req); |
| 257 | } |
| 258 | if (!html || !/<\/html>/i.test(html)) { |
| 259 | return json(res, 400, { error: 'Body must be a complete HTML document' }); |
| 260 | } |
| 261 | await ctx.orchestrator.writePreviewHtmlRaw(project.id, html); |
| 262 | return json(res, 200, { project: await ctx.orchestrator.load(project.id) }); |
| 263 | } |
| 264 | |
| 265 | // Frame-specific raw HTML — keeps frames[] intact (writePreviewHtmlRaw |
| 266 | // resets the storyboard, which is wrong for multi-frame edits). |
| 267 | const frameRawMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/frames\/([^/]+)\/raw-html$/); |
| 268 | if (frameRawMatch && frameRawMatch[1] && frameRawMatch[2]) { |
| 269 | const projId = frameRawMatch[1]; |
| 270 | const nodeId = frameRawMatch[2]; |
| 271 | if (m === 'GET') { |
| 272 | const project = await ctx.orchestrator.load(projId); |
| 273 | const frame = (project.frames ?? []).find((f) => f.graphNodeId === nodeId); |
| 274 | if (!frame || !existsSync(frame.htmlPath)) { |
| 275 | return json(res, 404, { error: `Frame ${nodeId} not found` }); |
| 276 | } |
| 277 | const html = await readFile(frame.htmlPath, 'utf8'); |
| 278 | res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); |
| 279 | res.end(html); |
| 280 | return; |
| 281 | } |
| 282 | if (m === 'PUT') { |
| 283 | const ct = req.headers['content-type'] ?? ''; |
| 284 | let html: string; |
| 285 | if (ct.includes('application/json')) { |
| 286 | const body = await readBody(req); |
| 287 | html = (body.html as string) ?? ''; |
| 288 | } else { |
| 289 | html = await readBodyText(req); |
| 290 | } |
| 291 | if (!html || !/<\/html>/i.test(html)) { |
| 292 | return json(res, 400, { error: 'Body must be a complete HTML document' }); |
| 293 | } |
| 294 | await ctx.orchestrator.writeFrameHtml(projId, nodeId, html); |
| 295 | return json(res, 200, { ok: true }); |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | // Enhance a data frame with a native Remotion template (user-initiated |
| 300 | // motion enhancement, RFC-08/09). Sets the frame's engine + renders a |
| 301 | // short single-frame preview MP4 so the studio can play the native |
| 302 | // animation before a full export. Streams SSE progress like export. |
| 303 | const enhMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/frames\/([^/]+)\/enhance$/); |
| 304 | if (enhMatch && enhMatch[1] && enhMatch[2] && m === 'POST') { |
| 305 | const projectId = enhMatch[1]; |
| 306 | const nodeId = enhMatch[2]; |
| 307 | const body = await readBody(req).catch(() => ({} as Record<string, unknown>)); |
| 308 | const nativeTemplateId = (body.nativeTemplateId as string) || 'frame-data-rollup'; |
| 309 | const wantsStream = (req.headers.accept ?? '').includes('text/event-stream'); |
| 310 | if (!wantsStream) { |
| 311 | try { |
| 312 | await ctx.orchestrator.enhanceFrameNative(projectId, nodeId, nativeTemplateId); |
| 313 | const { project } = await ctx.orchestrator.renderFrameNativePreview({ projectId, graphNodeId: nodeId }); |
| 314 | return json(res, 200, { ok: true, project, node_id: nodeId }); |
| 315 | } catch (err) { |
| 316 | return json(res, 500, { error: err instanceof Error ? err.message : String(err) }); |
| 317 | } |
| 318 | } |
| 319 | res.writeHead(200, { |
| 320 | 'content-type': 'text/event-stream; charset=utf-8', |
| 321 | 'cache-control': 'no-cache', |
| 322 | connection: 'keep-alive', |
| 323 | }); |
| 324 | const sse = (obj: unknown) => { |
| 325 | try { if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); } |
| 326 | catch { /* client gone — work keeps running, result is persisted */ } |
| 327 | }; |
| 328 | const t0 = Date.now(); |
| 329 | try { |
| 330 | sse({ type: 'enhance_started' }); |
| 331 | sse({ type: 'enhance_progress', pct: 5, stage: 'preparing' }); |
| 332 | await ctx.orchestrator.enhanceFrameNative(projectId, nodeId, nativeTemplateId); |
| 333 | const { project } = await ctx.orchestrator.renderFrameNativePreview({ |
| 334 | projectId, |
| 335 | graphNodeId: nodeId, |
| 336 | onProgress: (pct, stage) => sse({ type: 'enhance_progress', pct, stage }), |
| 337 | }); |
| 338 | const ms = Date.now() - t0; |
| 339 | process.stderr.write(`[studio:enhance] proj=${projectId} frame=${nodeId} done in ${ms}ms\n`); |
| 340 | sse({ type: 'enhance_done', project, node_id: nodeId, elapsed_ms: ms }); |
| 341 | } catch (err) { |
| 342 | const msg = err instanceof Error ? err.message : String(err); |
| 343 | process.stderr.write(`[studio:enhance] proj=${projectId} frame=${nodeId} failed: ${msg}\n`); |
| 344 | sse({ type: 'enhance_failed', message: msg }); |
| 345 | } |
| 346 | res.end(); |
| 347 | return; |
| 348 | } |
| 349 | |
| 350 | // Revert a frame's native enhancement back to its base hyperframes HTML. |
| 351 | // Instant (no render) — the original HTML at frame.htmlPath is untouched. |
| 352 | const unenhMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/frames\/([^/]+)\/unenhance$/); |
| 353 | if (unenhMatch && unenhMatch[1] && unenhMatch[2] && m === 'POST') { |
| 354 | try { |
| 355 | const { project } = await ctx.orchestrator.unenhanceFrame(unenhMatch[1], unenhMatch[2]); |
| 356 | return json(res, 200, { ok: true, project, node_id: unenhMatch[2] }); |
| 357 | } catch (err) { |
| 358 | return json(res, 500, { error: err instanceof Error ? err.message : String(err) }); |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | // Export MP4 — streams progress via SSE so the user sees per-frame |
| 363 | // recording status during a multi-minute multi-frame export. |
| 364 | const expMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/export$/); |
| 365 | if (expMatch && expMatch[1] && m === 'POST') { |
| 366 | const projectId = expMatch[1]; |
| 367 | // The studio uses the SSE branch by default. A plain POST (curl / |
| 368 | // tests) gets the legacy blocking response. |
| 369 | const wantsStream = (req.headers.accept ?? '').includes('text/event-stream'); |
| 370 | if (!wantsStream) { |
| 371 | try { |
| 372 | const { project, outputPath } = await ctx.orchestrator.exportMp4({ projectId }); |
| 373 | return json(res, 200, { project, output_path: outputPath }); |
| 374 | } catch (err) { |
| 375 | const msg = err instanceof Error ? err.message : String(err); |
| 376 | return json(res, 500, { error: msg }); |
| 377 | } |
| 378 | } |
| 379 | res.writeHead(200, { |
| 380 | 'content-type': 'text/event-stream; charset=utf-8', |
| 381 | 'cache-control': 'no-cache', |
| 382 | connection: 'keep-alive', |
| 383 | }); |
| 384 | const sse = (obj: unknown) => { |
| 385 | try { if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); } |
| 386 | catch { /* client gone — generation keeps running, result is persisted */ } |
| 387 | }; |
| 388 | const t0 = Date.now(); |
| 389 | try { |
| 390 | sse({ type: 'export_started' }); |
| 391 | const { project, outputPath } = await ctx.orchestrator.exportMp4({ |
| 392 | projectId, |
| 393 | onProgress: (pct, stage) => { |
| 394 | sse({ type: 'export_progress', pct, stage }); |
| 395 | }, |
| 396 | }); |
| 397 | const ms = Date.now() - t0; |
| 398 | process.stderr.write( |
| 399 | `[studio:export] proj=${projectId} done in ${ms}ms → ${outputPath}\n`, |
| 400 | ); |
| 401 | sse({ type: 'export_done', output_path: outputPath, project, elapsed_ms: ms }); |
| 402 | } catch (err) { |
| 403 | const msg = err instanceof Error ? err.message : String(err); |
| 404 | process.stderr.write(`[studio:export] proj=${projectId} failed: ${msg}\n`); |
| 405 | sse({ type: 'export_failed', message: msg }); |
| 406 | } |
| 407 | res.end(); |
| 408 | return; |
| 409 | } |
| 410 | |
| 411 | // Generate soundtrack: background music (MiniMax music_generation) and/or |
| 412 | // narration (MiniMax t2a_v2). Streams SSE progress like export. The |
| 413 | // generated MP3s are stored as project assets; their ids land in |
| 414 | // project.soundtrack so exportMp4 mixes them in. Generation itself does |
| 415 | // NOT need ffmpeg — only the export-time mux does. |
| 416 | const genAudioMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/generate-audio$/); |
| 417 | if (genAudioMatch && genAudioMatch[1] && m === 'POST') { |
| 418 | const projectId = genAudioMatch[1]; |
| 419 | const body = (await readBody(req)) as { |
| 420 | music?: { prompt?: string; instrumental?: boolean; volumeDb?: number }; |
| 421 | narration?: { text?: string; voiceId?: string; volumeDb?: number; languageBoost?: string; byFrame?: Record<string, string> }; |
| 422 | fadeInSec?: number; |
| 423 | fadeOutSec?: number; |
| 424 | }; |
| 425 | res.writeHead(200, { |
| 426 | 'content-type': 'text/event-stream; charset=utf-8', |
| 427 | 'cache-control': 'no-cache', |
| 428 | connection: 'keep-alive', |
| 429 | }); |
| 430 | const sse = (obj: unknown) => { |
| 431 | try { if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); } |
| 432 | catch { /* client gone — generation keeps running, result is persisted */ } |
| 433 | }; |
| 434 | try { |
| 435 | sse({ type: 'audio_started' }); |
| 436 | const creds = ctx.mediaConfig.resolveMinimax(); |
| 437 | if (!creds) { |
| 438 | sse({ |
| 439 | type: 'audio_failed', |
| 440 | message: |
| 441 | 'MiniMax API key not configured — add it in Settings → Audio (or set OD_MINIMAX_API_KEY).', |
| 442 | }); |
| 443 | res.end(); |
| 444 | return; |
| 445 | } |
| 446 | |
| 447 | const project = await ctx.orchestrator.load(projectId); |
| 448 | const soundtrack = { ...(project.soundtrack ?? {}) }; |
| 449 | const wantMusic = !!body.music?.prompt?.trim(); |
| 450 | const wantNarration = !!body.narration?.text?.trim(); |
| 451 | if (!wantMusic && !wantNarration) { |
| 452 | sse({ type: 'audio_failed', message: 'Nothing to generate — provide a music prompt and/or narration text.' }); |
| 453 | res.end(); |
| 454 | return; |
| 455 | } |
| 456 | |
| 457 | if (wantMusic) { |
| 458 | sse({ type: 'audio_progress', stage: 'music', message: 'generating background music…' }); |
| 459 | const music = await generateMusic({ |
| 460 | prompt: body.music!.prompt!.trim(), |
| 461 | instrumental: body.music!.instrumental ?? true, |
| 462 | creds, |
| 463 | }); |
| 464 | const { asset } = await ctx.orchestrator.addBufferAsset( |
| 465 | projectId, |
| 466 | music.bytes, |
| 467 | music.ext, |
| 468 | `background music · ${body.music!.prompt!.trim().slice(0, 60)}`, |
| 469 | ); |
| 470 | soundtrack.musicAssetId = asset.id; |
| 471 | soundtrack.musicPrompt = body.music!.prompt!.trim(); |
| 472 | if (body.music!.volumeDb !== undefined) soundtrack.musicVolumeDb = body.music!.volumeDb; |
| 473 | sse({ type: 'audio_progress', stage: 'music', message: music.providerNote, asset_id: asset.id }); |
| 474 | } |
| 475 | |
| 476 | if (wantNarration) { |
| 477 | sse({ type: 'audio_progress', stage: 'narration', message: 'generating narration…' }); |
| 478 | const nar = await generateTts({ |
| 479 | text: body.narration!.text!.trim(), |
| 480 | ...(body.narration!.voiceId !== undefined && { voiceId: body.narration!.voiceId }), |
| 481 | ...(body.narration!.languageBoost !== undefined && { languageBoost: body.narration!.languageBoost }), |
| 482 | creds, |
| 483 | }); |
| 484 | const { asset } = await ctx.orchestrator.addBufferAsset( |
| 485 | projectId, |
| 486 | nar.bytes, |
| 487 | nar.ext, |
| 488 | `narration · ${body.narration!.text!.trim().slice(0, 60)}`, |
| 489 | ); |
| 490 | soundtrack.narrationAssetId = asset.id; |
| 491 | soundtrack.narrationText = body.narration!.text!.trim(); |
| 492 | if (body.narration!.byFrame) soundtrack.narrationByFrame = body.narration!.byFrame; |
| 493 | if (body.narration!.volumeDb !== undefined) soundtrack.narrationVolumeDb = body.narration!.volumeDb; |
| 494 | sse({ type: 'audio_progress', stage: 'narration', message: nar.providerNote, asset_id: asset.id }); |
| 495 | } |
| 496 | |
| 497 | if (body.fadeInSec !== undefined) soundtrack.fadeInSec = body.fadeInSec; |
| 498 | if (body.fadeOutSec !== undefined) soundtrack.fadeOutSec = body.fadeOutSec; |
| 499 | |
| 500 | // Persist soundtrack onto the project (reload to avoid clobbering the |
| 501 | // asset pushes addBufferAsset already saved). |
| 502 | const fresh = await ctx.orchestrator.load(projectId); |
| 503 | fresh.soundtrack = soundtrack; |
| 504 | await ctx.projects.save(fresh); |
| 505 | sse({ type: 'audio_done', project: fresh, soundtrack }); |
| 506 | } catch (err) { |
| 507 | const msg = err instanceof Error ? err.message : String(err); |
| 508 | process.stderr.write(`[studio:generate-audio] proj=${projectId} failed: ${msg}\n`); |
| 509 | sse({ type: 'audio_failed', message: msg }); |
| 510 | } |
| 511 | res.end(); |
| 512 | return; |
| 513 | } |
| 514 | |
| 515 | // Draft a narration script from the project's already-generated frames. |
| 516 | // Reads the content-graph (per-frame text) and asks the agent for a short |
| 517 | // spoken voiceover IN THE SAME LANGUAGE as that text. Returns plain JSON |
| 518 | // { narration } — the user edits it before generating audio. |
| 519 | const draftNarrMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/draft-narration$/); |
| 520 | if (draftNarrMatch && draftNarrMatch[1] && m === 'POST') { |
| 521 | const projectId = draftNarrMatch[1]; |
| 522 | try { |
| 523 | // body.frameId set → draft ONLY that frame (single-frame regenerate). |
| 524 | // unset → draft every frame (global). Either way returns a per-frame map. |
| 525 | const body = (await readBody(req)) as { agentId?: string; frameId?: string }; |
| 526 | const graph = await ctx.orchestrator.readContentGraph(projectId); |
| 527 | if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { |
| 528 | return json(res, 400, { error: 'No frames yet — generate the video first.' }); |
| 529 | } |
| 530 | if (!body.agentId) return json(res, 400, { error: 'No agent selected.' }); |
| 531 | const agentDef = findAgent(body.agentId); |
| 532 | if (!agentDef) return json(res, 400, { error: `agent "${body.agentId}" not registered` }); |
| 533 | const projectDir = await ctx.projects.ensureDir(projectId); |
| 534 | // Only TextNode carries copy; fall back to label/id for entity/data. |
| 535 | const nodeText = (n: typeof graph.nodes[number]): string => |
| 536 | (n.kind === 'text' ? n.text : undefined) ?? n.label ?? n.id; |
| 537 | const allFrames = graph.nodes.map((n, i) => ({ id: n.id, idx: i, text: nodeText(n).replace(/\n/g, ' ').slice(0, 240) })); |
| 538 | const frameLines = allFrames.map((f) => `${f.idx + 1}. ${f.text}`).join('\n'); |
| 539 | |
| 540 | const narrationByFrame: Record<string, string> = {}; |
| 541 | |
| 542 | if (body.frameId) { |
| 543 | // ---- single frame: narrate just this one, with the rest as context ---- |
| 544 | const target = allFrames.find((f) => f.id === body.frameId); |
| 545 | if (!target) return json(res, 400, { error: `frame "${body.frameId}" not in content-graph` }); |
| 546 | const prompt = [ |
| 547 | `This is a ${allFrames.length}-frame video. Write the spoken NARRATION for FRAME ${target.idx + 1} ONLY.`, |
| 548 | ``, |
| 549 | `All frames (for context):`, |
| 550 | frameLines, |
| 551 | ``, |
| 552 | graph.synopsis ? `Synopsis: ${graph.synopsis}` : '', |
| 553 | ``, |
| 554 | `Write ONE short spoken sentence narrating frame ${target.idx + 1} ("${target.text}") specifically — distinct, not generic.`, |
| 555 | `Same language as the frame text. Plain text only: just the sentence, no numbering, quotes, or markdown.`, |
| 556 | ].filter((l) => l !== undefined).join('\n'); |
| 557 | const raw = (await callAgentSimple(agentDef, prompt, projectDir)).trim(); |
| 558 | const line = raw.split('\n').map((l) => l.replace(/^\s*(?:\d+[.)、]|[-*•])\s*/, '').trim()).find((l) => l.length > 0) ?? raw; |
| 559 | narrationByFrame[target.id] = line; |
| 560 | } else { |
| 561 | // ---- global: one line per frame, in order ---- |
| 562 | const prompt = [ |
| 563 | `Write a spoken NARRATION script for this ${allFrames.length}-frame video — ONE line per frame, IN FRAME ORDER.`, |
| 564 | ``, |
| 565 | `Frames (in order):`, |
| 566 | frameLines, |
| 567 | ``, |
| 568 | graph.synopsis ? `Synopsis: ${graph.synopsis}` : '', |
| 569 | ``, |
| 570 | `Rules:`, |
| 571 | `- Output EXACTLY ${allFrames.length} lines, one per frame, in the SAME order. Line 1 narrates frame 1, etc.`, |
| 572 | `- Each line is ONE short spoken sentence about THAT specific frame's content — distinct per frame, not a generic restatement.`, |
| 573 | `- The lines should still flow as a continuous voiceover read top to bottom.`, |
| 574 | `- Same language as the frame text. Plain text only: one sentence per line, no numbering, bullets, blank lines, or markdown.`, |
| 575 | ].filter((l) => l !== undefined).join('\n'); |
| 576 | const raw = (await callAgentSimple(agentDef, prompt, projectDir)).trim(); |
| 577 | const lines = raw.split('\n').map((l) => l.replace(/^\s*(?:\d+[.)、]|[-*•])\s*/, '').trim()).filter((l) => l.length > 0); |
| 578 | // Map lines onto frames positionally; if the model under/over-produced, |
| 579 | // pair as far as they line up and leave the rest blank. |
| 580 | allFrames.forEach((f, i) => { if (lines[i]) narrationByFrame[f.id] = lines[i]!; }); |
| 581 | } |
| 582 | return json(res, 200, { narrationByFrame }); |
| 583 | } catch (err) { |
| 584 | const msg = err instanceof Error ? err.message : String(err); |
| 585 | process.stderr.write(`[studio:draft-narration] proj=${projectId} failed: ${msg}\n`); |
| 586 | return json(res, 500, { error: msg }); |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | // Clear a project's soundtrack (keeps the asset files, just drops the |
| 591 | // references so the next export has no audio). |
| 592 | const clearAudioMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/soundtrack$/); |
| 593 | if (clearAudioMatch && clearAudioMatch[1] && m === 'DELETE') { |
| 594 | const project = await ctx.orchestrator.load(clearAudioMatch[1]); |
| 595 | delete project.soundtrack; |
| 596 | await ctx.projects.save(project); |
| 597 | return json(res, 200, { project }); |
| 598 | } |
| 599 | |
| 600 | // Reveal an exported file in the OS file browser. macOS: `open -R` |
| 601 | // opens Finder with the file selected. Other platforms fall through |
| 602 | // to a plain `open` which the OS handles best-effort. |
| 603 | const revealMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/reveal$/); |
| 604 | if (revealMatch && revealMatch[1] && m === 'POST') { |
| 605 | const project = await ctx.orchestrator.load(revealMatch[1]); |
| 606 | const target = project.lastOutputMp4Path; |
| 607 | if (!target || !existsSync(target)) { |
| 608 | return json(res, 404, { error: 'No exported MP4 to reveal' }); |
| 609 | } |
| 610 | const { spawn } = await import('node:child_process'); |
| 611 | const platform = process.platform; |
| 612 | const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'explorer' : 'xdg-open'; |
| 613 | const args = platform === 'darwin' ? ['-R', target] : [target]; |
| 614 | spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); |
| 615 | return json(res, 200, { ok: true, target, platform }); |
| 616 | } |
| 617 | |
| 618 | // MiniMax audio API config — GET status (masked), POST to save, DELETE to clear. |
| 619 | // Lets users configure the key in the Settings UI instead of env vars. |
| 620 | if (url.pathname === '/api/config/minimax' && m === 'GET') { |
| 621 | return json(res, 200, ctx.mediaConfig.getMinimaxStatus()); |
| 622 | } |
| 623 | if (url.pathname === '/api/config/minimax' && m === 'POST') { |
| 624 | const body = (await readBody(req)) as { apiKey?: string; baseUrl?: string }; |
| 625 | const key = (body.apiKey ?? '').trim(); |
| 626 | if (!key) return json(res, 400, { error: 'apiKey is required' }); |
| 627 | ctx.mediaConfig.setMinimax(key, body.baseUrl); |
| 628 | return json(res, 200, ctx.mediaConfig.getMinimaxStatus()); |
| 629 | } |
| 630 | if (url.pathname === '/api/config/minimax' && m === 'DELETE') { |
| 631 | ctx.mediaConfig.clearMinimax(); |
| 632 | return json(res, 200, ctx.mediaConfig.getMinimaxStatus()); |
| 633 | } |
| 634 | |
| 635 | // Agents (detected on each call; cheap thanks to the in-process cache) |
| 636 | if (url.pathname === '/api/agents' && m === 'GET') { |
| 637 | const force = url.searchParams.get('force') === '1'; |
| 638 | const agents = await detectAll(force ? { force: true } : undefined); |
| 639 | return json(res, 200, { agents }); |
| 640 | } |
| 641 | |
| 642 | // Agent models — currently AMR only. Lists the live `vela model list` |
| 643 | // catalog so the UI can offer a model picker (deepseek/claude/gpt/…). |
| 644 | const modelsMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/models$/); |
| 645 | if (modelsMatch && modelsMatch[1] && m === 'GET') { |
| 646 | const agentId = modelsMatch[1]; |
| 647 | if (agentId !== 'amr') return json(res, 200, { models: [] }); |
| 648 | const def = findAgent(agentId); |
| 649 | if (!def) return json(res, 404, { error: `agent "${agentId}" not registered` }); |
| 650 | const { resolveBin, listAmrModels } = await import('@html-video/runtime'); |
| 651 | const bin = await resolveBin(def); |
| 652 | if (!bin) return json(res, 400, { error: 'vela binary not found' }); |
| 653 | try { |
| 654 | const models = await listAmrModels(bin); |
| 655 | return json(res, 200, { models, default: def.defaultModel ?? null }); |
| 656 | } catch (err) { |
| 657 | return json(res, 200, { models: [], error: err instanceof Error ? err.message : String(err) }); |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | // Agent login — currently AMR/vela only. Spawns `vela login`, which opens |
| 662 | // the browser for OAuth; we wait for the process to exit (auth complete or |
| 663 | // cancelled). The user signs in with their OWN Open Design account. |
| 664 | const loginMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/login$/); |
| 665 | if (loginMatch && loginMatch[1] && m === 'POST') { |
| 666 | const agentId = loginMatch[1]; |
| 667 | if (agentId !== 'amr') return json(res, 400, { error: `agent "${agentId}" has no login flow` }); |
| 668 | const def = findAgent(agentId); |
| 669 | if (!def) return json(res, 404, { error: `agent "${agentId}" not registered` }); |
| 670 | const { resolveBin } = await import('@html-video/runtime'); |
| 671 | const bin = await resolveBin(def); |
| 672 | if (!bin) return json(res, 400, { error: 'vela binary not found' }); |
| 673 | try { |
| 674 | const { spawn } = await import('node:child_process'); |
| 675 | const code = await new Promise<number>((resolveCode, rejectCode) => { |
| 676 | const child = spawn(bin, ['login'], { stdio: 'ignore' }); |
| 677 | // vela login opens the browser itself; it exits once auth completes |
| 678 | // or is cancelled. Cap the wait so a never-finished login can't hang. |
| 679 | const timer = setTimeout(() => { try { child.kill('SIGTERM'); } catch { /* */ } rejectCode(new Error('login timed out (5 min)')); }, 5 * 60_000); |
| 680 | child.on('error', (e: Error) => { clearTimeout(timer); rejectCode(e); }); |
| 681 | child.on('exit', (c: number | null) => { clearTimeout(timer); resolveCode(c ?? -1); }); |
| 682 | }); |
| 683 | if (code !== 0) return json(res, 400, { ok: false, error: `vela login exited with code ${code}` }); |
| 684 | // Re-detect (force) so the agent flips to available immediately. |
| 685 | const agents = await detectAll({ force: true }); |
| 686 | const amr = agents.find((a) => a.id === 'amr'); |
| 687 | return json(res, 200, { ok: !!amr?.available, available: !!amr?.available, ...(amr?.hint && { hint: amr.hint }) }); |
| 688 | } catch (err) { |
| 689 | return json(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) }); |
| 690 | } |
| 691 | } |
| 692 | |
| 693 | // Agent smoke test — fires a tiny prompt at the requested agent and |
| 694 | // reports timing + bytes. Used by the Settings modal so the user can |
| 695 | // confirm a CLI is actually responding (not just on PATH). |
| 696 | const testMatch = url.pathname.match(/^\/api\/agents\/([^/]+)\/test$/); |
| 697 | if (testMatch && testMatch[1] && m === 'POST') { |
| 698 | const agentId = testMatch[1]; |
| 699 | const def = findAgent(agentId); |
| 700 | if (!def) return json(res, 404, { error: `agent "${agentId}" not registered` }); |
| 701 | const prompt = 'Reply with one word: hello.'; |
| 702 | const t0 = Date.now(); |
| 703 | let out = ''; |
| 704 | let err = ''; |
| 705 | const handle = spawnAgent({ |
| 706 | def, |
| 707 | prompt, |
| 708 | context: { cwd: process.cwd() }, |
| 709 | onEvent: (ev) => { |
| 710 | if (ev.type === 'text') out += ev.chunk; |
| 711 | else if (ev.type === 'error') err = ev.message; |
| 712 | }, |
| 713 | }); |
| 714 | const exit = await handle.done; |
| 715 | return json(res, 200, { |
| 716 | ok: exit.exitCode === 0 && out.trim().length > 0, |
| 717 | exit_code: exit.exitCode, |
| 718 | ms: Date.now() - t0, |
| 719 | bytes: out.length, |
| 720 | stdout_head: out.slice(0, 200), |
| 721 | error: err || (out.trim().length === 0 ? 'empty reply' : undefined), |
| 722 | }); |
| 723 | } |
| 724 | |
| 725 | // Messages: GET history (lazy-loads from messages.json on first hit) |
| 726 | const msgsMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/messages$/); |
| 727 | if (msgsMatch && msgsMatch[1] && m === 'GET') { |
| 728 | const arr = await loadMessages(ctx, msgsMatch[1]); |
| 729 | return json(res, 200, { messages: arr }); |
| 730 | } |
| 731 | |
| 732 | // Messages: POST = send + stream agent reply via SSE |
| 733 | // v0.5: accepts multipart (text + files) OR JSON. Files become real |
| 734 | // project assets via AssetStore; their paths are passed to the agent |
| 735 | // prompt as attachments. |
| 736 | if (msgsMatch && msgsMatch[1] && m === 'POST') { |
| 737 | const id = msgsMatch[1]; |
| 738 | const ct = req.headers['content-type'] ?? ''; |
| 739 | let userText = ''; |
| 740 | let focusFrameId = ''; |
| 741 | const attachments: Attachment[] = []; |
| 742 | |
| 743 | const project0 = await ctx.orchestrator.load(id); |
| 744 | if (ct.startsWith('multipart/form-data')) { |
| 745 | const parts = await receiveMultipart(req, ct); |
| 746 | for (const p of parts) { |
| 747 | if (p.kind === 'field' && p.name === 'content') { |
| 748 | userText = p.value; |
| 749 | } else if (p.kind === 'field' && p.name === 'focus_frame_id') { |
| 750 | focusFrameId = p.value; |
| 751 | } else if (p.kind === 'file') { |
| 752 | const updatedProject = await ctx.orchestrator.addFileAsset(id, p.tmpPath); |
| 753 | const newAsset = updatedProject.assets[updatedProject.assets.length - 1]; |
| 754 | if (newAsset) { |
| 755 | const att: Attachment = { |
| 756 | path: newAsset.path ?? p.tmpPath, |
| 757 | kind: newAsset.type as Attachment['kind'], |
| 758 | filename: p.filename, |
| 759 | size: newAsset.metadata.sizeBytes ?? 0, |
| 760 | }; |
| 761 | // Inline small text/data uploads so the agent (incl. HTTP ones) |
| 762 | // actually sees the content, not just a local path. |
| 763 | if ((newAsset.type === 'text' || newAsset.type === 'data') && newAsset.path) { |
| 764 | try { |
| 765 | const txt = await readFile(newAsset.path, 'utf8'); |
| 766 | if (txt.length <= 20_000) att.inlineText = txt; |
| 767 | } catch { /* fall back to path-only */ } |
| 768 | } |
| 769 | attachments.push(att); |
| 770 | } |
| 771 | } |
| 772 | } |
| 773 | } else { |
| 774 | const body = await readBody(req); |
| 775 | userText = (body.content as string) ?? ''; |
| 776 | focusFrameId = (body.focus_frame_id as string) ?? ''; |
| 777 | } |
| 778 | |
| 779 | if (!userText && attachments.length === 0) { |
| 780 | return json(res, 400, { error: 'content or attachments required' }); |
| 781 | } |
| 782 | |
| 783 | // External content sources: any URL (web article or GitHub repo) in the |
| 784 | // user's message is fetched server-side and turned into a text asset, so |
| 785 | // the offline agent can base the video on it. Reuses the attachment |
| 786 | // pipeline (kind:'text' flows into the prompt downstream). Lossless |
| 787 | // degradation: a fetch that fails is logged and skipped, never a 400. |
| 788 | for (const sourceUrl of extractUrls(userText)) { |
| 789 | try { |
| 790 | const src = await fetchSource(sourceUrl); |
| 791 | const label = src.kind === 'repo' ? 'GitHub repo' : 'Web article'; |
| 792 | const updated = await ctx.orchestrator.addInlineAsset( |
| 793 | id, |
| 794 | src.markdown, |
| 795 | 'text', |
| 796 | `${label}: ${src.title || sourceUrl}`, |
| 797 | ); |
| 798 | const asset = updated.assets[updated.assets.length - 1]; |
| 799 | if (asset?.path) { |
| 800 | let host = sourceUrl; |
| 801 | try { host = new URL(sourceUrl).hostname; } catch { /* keep raw */ } |
| 802 | attachments.push({ |
| 803 | path: asset.path, |
| 804 | kind: 'text', |
| 805 | filename: `${host}.md`, |
| 806 | size: src.markdown.length, |
| 807 | inlineText: src.markdown, |
| 808 | }); |
| 809 | process.stderr.write( |
| 810 | `[studio:fetch-source] ${src.kind} ${sourceUrl} → ${src.markdown.length} chars${src.truncated ? ' (truncated)' : ''}\n`, |
| 811 | ); |
| 812 | } |
| 813 | } catch (e) { |
| 814 | const msg = e instanceof Error ? e.message : String(e); |
| 815 | process.stderr.write(`[studio:fetch-source] skip ${sourceUrl}: ${msg}\n`); |
| 816 | } |
| 817 | } |
| 818 | |
| 819 | // Re-fetch project after potential addFileAsset side-effects |
| 820 | const project = await ctx.orchestrator.load(id); |
| 821 | const tmpl = project.templateId ? ctx.templates.get(project.templateId) : null; |
| 822 | // No template required — agent can synthesize from scratch when none picked. |
| 823 | |
| 824 | // Resolve the agent. Pinned project agent wins. Otherwise pick the first |
| 825 | // available agent that needs no extra setup (skip AMR — it's available |
| 826 | // but billed/needs balance, so it must be an explicit choice, not a |
| 827 | // silent default). anthropic-api is the final HTTP fallback. This keeps |
| 828 | // "what the toolbar shows" === "what actually runs". |
| 829 | let agentId = project.agentId; |
| 830 | if (!agentId) { |
| 831 | const detected = await detectAll(); |
| 832 | // Prefer a real, ready-to-run CLI agent (claude/codex/…). Only fall |
| 833 | // back to anthropic-api if it's actually configured (has a key) — |
| 834 | // otherwise picking it would fail mid-flow with "No ANTHROPIC_API_KEY" |
| 835 | // on a later turn (e.g. after the detect cache expires and a transient |
| 836 | // probe miss drops the CLI agent). Persist the choice so every |
| 837 | // subsequent turn in this project uses the same agent, not whatever |
| 838 | // a fresh probe happens to return. |
| 839 | const ready = detected.filter((a) => a.available && a.id !== 'amr'); |
| 840 | const apiReady = ready.find((a) => a.id === 'anthropic-api'); |
| 841 | agentId = |
| 842 | ready.find((a) => a.id !== 'anthropic-api')?.id ?? |
| 843 | apiReady?.id ?? |
| 844 | 'anthropic-api'; |
| 845 | if (project.agentId !== agentId) { |
| 846 | try { |
| 847 | await ctx.orchestrator.setAgent(id, agentId, undefined); |
| 848 | } catch { |
| 849 | /* persist is best-effort; resolution above still holds for this turn */ |
| 850 | } |
| 851 | } |
| 852 | } |
| 853 | const agentDef = findAgent(agentId); |
| 854 | if (!agentDef) { |
| 855 | return json(res, 400, { error: `agent "${agentId}" not registered` }); |
| 856 | } |
| 857 | // Model the user picked for this agent (AMR); undefined → agent default. |
| 858 | const agentModel = project.agentModel ?? undefined; |
| 859 | |
| 860 | // Append user message to history (with attachment summary) |
| 861 | const attachmentSummary = attachments.length > 0 |
| 862 | ? `\n\n📎 ${attachments.length} attachment(s): ${attachments.map((a) => a.filename).join(', ')}` |
| 863 | : ''; |
| 864 | const history = await loadMessages(ctx, id); |
| 865 | history.push({ |
| 866 | role: 'user', |
| 867 | content: userText + attachmentSummary, |
| 868 | ts: Date.now(), |
| 869 | }); |
| 870 | MESSAGES.set(id, history); |
| 871 | // Persist immediately so the user message survives even if the |
| 872 | // streaming agent call below crashes mid-flight. |
| 873 | await saveMessages(ctx, id, history); |
| 874 | |
| 875 | // Compose prompt — template-aware OR template-free |
| 876 | const projectDir = await ctx.projects.ensureDir(id); |
| 877 | // Frame focus: when iterating, the user can pin a specific frame |
| 878 | // so the next turn only rewrites that frame's HTML instead of the |
| 879 | // whole-project preview.html. |
| 880 | const focusFrame = focusFrameId |
| 881 | ? (project.frames ?? []).find((f) => f.graphNodeId === focusFrameId) |
| 882 | : undefined; |
| 883 | const focusFrameHtml = focusFrame && existsSync(focusFrame.htmlPath) |
| 884 | ? await readFile(focusFrame.htmlPath, 'utf8') |
| 885 | : ''; |
| 886 | const priorHtmlPath = join(projectDir, 'preview.html'); |
| 887 | const priorHtml = focusFrameHtml |
| 888 | || (existsSync(priorHtmlPath) ? await readFile(priorHtmlPath, 'utf8') : ''); |
| 889 | let exampleHtml = ''; |
| 890 | if (tmpl) { |
| 891 | const exampleHtmlPath = join(tmpl.__dir!, tmpl.source_entry); |
| 892 | if (existsSync(exampleHtmlPath)) { |
| 893 | exampleHtml = await readFile(exampleHtmlPath, 'utf8'); |
| 894 | } |
| 895 | } |
| 896 | |
| 897 | // Carry source material across turns: a link/file is usually attached |
| 898 | // on an early turn (e.g. while picking a content type), but generation |
| 899 | // happens several turns later with no attachment on that request. Merge |
| 900 | // the project's stored text/data assets (fetched articles/repos, |
| 901 | // uploaded docs) into this turn's attachments so they reach the prompt. |
| 902 | const seenPaths = new Set(attachments.map((a) => a.path)); |
| 903 | for (const asset of project.assets) { |
| 904 | if ((asset.type === 'text' || asset.type === 'data') && asset.path && !seenPaths.has(asset.path)) { |
| 905 | let inlineText: string | undefined; |
| 906 | try { |
| 907 | const txt = await readFile(asset.path, 'utf8'); |
| 908 | if (txt.length <= 20_000) inlineText = txt; |
| 909 | } catch { /* path-only fallback */ } |
| 910 | attachments.push({ |
| 911 | path: asset.path, |
| 912 | kind: asset.type as Attachment['kind'], |
| 913 | filename: asset.metadata.filename ?? `${asset.type}-${asset.id.slice(0, 8)}`, |
| 914 | size: asset.metadata.sizeBytes ?? 0, |
| 915 | ...(inlineText !== undefined && { inlineText }), |
| 916 | }); |
| 917 | seenPaths.add(asset.path); |
| 918 | } |
| 919 | } |
| 920 | |
| 921 | const fullPrompt = buildHtmlGenerationPrompt({ |
| 922 | tmpl, |
| 923 | exampleHtml, |
| 924 | priorHtml, |
| 925 | history, |
| 926 | userText, |
| 927 | attachments, |
| 928 | focusFrameId: focusFrameId || undefined, |
| 929 | openingTopic: resolveOpeningTopic(project, history), |
| 930 | }); |
| 931 | const phaseInfo = detectPhase( |
| 932 | history, |
| 933 | userText, |
| 934 | !!project.templateId, |
| 935 | attachments.some((a) => !!a.inlineText), |
| 936 | focusFrameId, |
| 937 | ); |
| 938 | const t0 = Date.now(); |
| 939 | // Save the prompt next to the project so we can inspect what we sent. |
| 940 | // Also dump the previous one as .prev for diffing across turns. |
| 941 | const promptDumpPath = join(projectDir, 'last-prompt.txt'); |
| 942 | try { |
| 943 | if (existsSync(promptDumpPath)) { |
| 944 | const prev = await readFile(promptDumpPath, 'utf8'); |
| 945 | const fs = await import('node:fs/promises'); |
| 946 | await fs.writeFile(join(projectDir, 'last-prompt.prev.txt'), prev, 'utf8'); |
| 947 | } |
| 948 | const fs = await import('node:fs/promises'); |
| 949 | await fs.writeFile(promptDumpPath, fullPrompt, 'utf8'); |
| 950 | } catch {/* non-fatal */} |
| 951 | process.stderr.write( |
| 952 | `[studio:msg] proj=${id} phase=${phaseInfo.phase} prompt=${fullPrompt.length}B user=${JSON.stringify(userText.slice(0, 80))} attachments=${attachments.length}\n`, |
| 953 | ); |
| 954 | |
| 955 | // Mark this project as generating so a returning client knows the task |
| 956 | // is still alive. Cleared in the finally below (covers all exit paths). |
| 957 | GENERATING.add(id); |
| 958 | try { |
| 959 | |
| 960 | // SSE response |
| 961 | res.writeHead(200, { |
| 962 | 'content-type': 'text/event-stream; charset=utf-8', |
| 963 | 'cache-control': 'no-cache', |
| 964 | connection: 'keep-alive', |
| 965 | }); |
| 966 | |
| 967 | // Tolerant write: if the client navigated away (switched project) the |
| 968 | // socket is gone and res.write throws. Swallow it so generation keeps |
| 969 | // running to completion and still persists to messages.json — the user |
| 970 | // sees the finished result when they come back, instead of a killed task. |
| 971 | const sseWrite = (obj: unknown) => { |
| 972 | try { if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`); } |
| 973 | catch { /* client disconnected — keep generating, result is persisted below */ } |
| 974 | }; |
| 975 | |
| 976 | let assistantText = ''; |
| 977 | let textChunks = 0; |
| 978 | let summaryLine = ''; |
| 979 | |
| 980 | // ---- generate-phase: multi-frame path runs split (graph + per-frame) ---- |
| 981 | // Empirically claude --print returns 1 byte ~50% of the time when asked |
| 982 | // to emit a graph and 4-6 full HTML pages in a single response. Each |
| 983 | // call individually is reliable, so we orchestrate them ourselves and |
| 984 | // stream progress events to the UI. |
| 985 | const isMultiGenerate = |
| 986 | phaseInfo.phase === 'generate' && |
| 987 | Number(phaseInfo.inputs.collected?.frame_count ?? '1') > 1; |
| 988 | |
| 989 | // Post-generation iteration: the card-driven sub-flow resolved to a |
| 990 | // concrete change. Re-use the existing storyboard rather than guessing. |
| 991 | // restyle → keep graph text, re-render every frame in the newly |
| 992 | // picked style. |
| 993 | // iterate-content → re-plan the whole storyboard around new content. |
| 994 | // iterate-format → re-time and re-render with the new per-frame length. |
| 995 | const isMultiFrameProject = |
| 996 | (project.frames ?? []).length > 1 || |
| 997 | Number(phaseInfo.inputs.collected?.frame_count ?? '1') > 1; |
| 998 | let rewriteInputs: PhaseInputs | undefined; |
| 999 | let restyleOnly = false; |
| 1000 | if (phaseInfo.phase === 'restyle' && isMultiFrameProject) { |
| 1001 | // Keep text, change visual style. pickedStyle is the user's new pick. |
| 1002 | restyleOnly = true; |
| 1003 | rewriteInputs = { |
| 1004 | ...phaseInfo.inputs, |
| 1005 | pickedType: lastCardPickByPhase(history, 'type') ?? phaseInfo.inputs.pickedType, |
| 1006 | pickedStyle: phaseInfo.inputs.pickedStyle || userText.trim(), |
| 1007 | contentTurns: collectContentTurns(history), |
| 1008 | }; |
| 1009 | } else if (phaseInfo.phase === 'iterate-content' && isMultiFrameProject) { |
| 1010 | // Re-plan around the user's new content instruction. |
| 1011 | const turns = [...collectContentTurns(history), userText].filter((s) => !isControlPhrase(s)); |
| 1012 | rewriteInputs = { |
| 1013 | ...phaseInfo.inputs, |
| 1014 | pickedType: lastCardPickByPhase(history, 'type') ?? phaseInfo.inputs.pickedType, |
| 1015 | pickedStyle: lastCardPickByPhase(history, 'style') ?? phaseInfo.inputs.pickedStyle ?? '', |
| 1016 | contentTurns: turns, |
| 1017 | }; |
| 1018 | } else if (phaseInfo.phase === 'iterate-format' && isMultiFrameProject) { |
| 1019 | // New per-frame timing was submitted; keep content + style, re-render. |
| 1020 | restyleOnly = true; // reuse the existing graph text; only timing/visual recompute |
| 1021 | rewriteInputs = { |
| 1022 | ...phaseInfo.inputs, |
| 1023 | pickedType: lastCardPickByPhase(history, 'type') ?? phaseInfo.inputs.pickedType, |
| 1024 | pickedStyle: lastCardPickByPhase(history, 'style') ?? phaseInfo.inputs.pickedStyle ?? '', |
| 1025 | contentTurns: collectContentTurns(history), |
| 1026 | }; |
| 1027 | } |
| 1028 | |
| 1029 | if (isMultiGenerate || rewriteInputs) { |
| 1030 | if (rewriteInputs) { |
| 1031 | const n = (project.frames ?? []).length || Number(phaseInfo.inputs.collected?.frame_count ?? '3'); |
| 1032 | const notice = restyleOnly |
| 1033 | ? `🎨 沿用文案,按新风格重做全部 ${n} 帧…\n` |
| 1034 | : `🔄 基于新内容重做全部 ${n} 帧(已手动修改过的帧会被覆盖)…\n`; |
| 1035 | assistantText += notice; |
| 1036 | sseWrite({ type: 'text', chunk: notice }); |
| 1037 | } |
| 1038 | try { |
| 1039 | const result = await runSplitMultiFrameGenerate({ |
| 1040 | ctx, |
| 1041 | projectId: id, |
| 1042 | projectDir, |
| 1043 | agentDef, |
| 1044 | agentModel, |
| 1045 | tmpl, |
| 1046 | priorHtml, |
| 1047 | inputs: rewriteInputs ?? phaseInfo.inputs, |
| 1048 | attachments, |
| 1049 | openingTopic: resolveOpeningTopic(project, history), |
| 1050 | restyleOnly, |
| 1051 | onProgress: (msg) => { |
| 1052 | assistantText += msg + '\n'; |
| 1053 | textChunks += 1; |
| 1054 | sseWrite({ type: 'text', chunk: msg + '\n' }); |
| 1055 | }, |
| 1056 | onSse: sseWrite, |
| 1057 | }); |
| 1058 | summaryLine = rewriteInputs |
| 1059 | ? `✓ ${result.frameCount}-frame storyboard ${restyleOnly ? 'restyled' : 'regenerated'} (intent: ${result.intent})` |
| 1060 | : `✓ ${result.frameCount}-frame storyboard generated (intent: ${result.intent})`; |
| 1061 | sseWrite({ type: 'preview_ready', preview_url: `/preview/${id}`, frames: result.frameCount }); |
| 1062 | sseWrite({ type: 'message_end', reason: 'ok' }); |
| 1063 | } catch (err) { |
| 1064 | const msg = err instanceof Error ? err.message : String(err); |
| 1065 | process.stderr.write(`[studio:msg] proj=${id} split-generate failed: ${msg}\n`); |
| 1066 | sseWrite({ type: 'text', chunk: `\n⚠️ Split generate failed: ${msg}` }); |
| 1067 | sseWrite({ type: 'message_end', reason: 'error' }); |
| 1068 | assistantText = `⚠️ Split generate failed: ${msg}`; |
| 1069 | } |
| 1070 | process.stderr.write( |
| 1071 | `[studio:msg] proj=${id} phase=split-generate done text=${assistantText.length}B\n`, |
| 1072 | ); |
| 1073 | } else { |
| 1074 | // ---- single-shot path (all other phases + single-frame generate) ---- |
| 1075 | const handle = spawnAgent({ |
| 1076 | def: agentDef, |
| 1077 | prompt: fullPrompt, |
| 1078 | context: { cwd: projectDir, ...(agentModel && { model: agentModel }) }, |
| 1079 | onEvent: (ev) => { |
| 1080 | if (ev.type === 'text') { |
| 1081 | assistantText += ev.chunk; |
| 1082 | textChunks += 1; |
| 1083 | sseWrite(ev); |
| 1084 | } else if (ev.type === 'error' || ev.type === 'message_end') { |
| 1085 | if (ev.type === 'error') { |
| 1086 | process.stderr.write(`[studio:msg] proj=${id} agent-error: ${ev.message}\n`); |
| 1087 | } |
| 1088 | sseWrite(ev); |
| 1089 | } |
| 1090 | }, |
| 1091 | }); |
| 1092 | const exitInfo = await handle.done; |
| 1093 | const elapsedMs = Date.now() - t0; |
| 1094 | process.stderr.write( |
| 1095 | `[studio:msg] proj=${id} phase=${phaseInfo.phase} done in ${elapsedMs}ms exit=${exitInfo.exitCode} text=${assistantText.length}B chunks=${textChunks}\n`, |
| 1096 | ); |
| 1097 | |
| 1098 | // Empty-reply retry: if the agent returned almost nothing AND we |
| 1099 | // were on the iterate path with prior HTML, try a tighter prompt |
| 1100 | // that only ships the user's request + a tiny instruction. This |
| 1101 | // catches the 6-8KB-prompt empty-reply mode. |
| 1102 | if (assistantText.trim().length < 32 && phaseInfo.phase === 'iterate' && priorHtml) { |
| 1103 | sseWrite({ type: 'text', chunk: '\n↻ 第一次输出为空,重试中…\n' }); |
| 1104 | // Retry without inlining the prior HTML — same observation as |
| 1105 | // the iterate prompt itself: claude --print silently no-ops |
| 1106 | // when fed multi-KB of HTML to rewrite. |
| 1107 | const sum = summariseHtmlForIterate(priorHtml); |
| 1108 | const retryPrompt = [ |
| 1109 | `Output ONE complete \`\`\`html block — full self-contained 1920×1080 page. Nothing else.`, |
| 1110 | ``, |
| 1111 | `User request: ${userText.slice(0, 300)}`, |
| 1112 | sum.headline ? `Headline: ${sum.headline}` : '', |
| 1113 | sum.subheads.length ? `Subheads:\n${sum.subheads.slice(0, 4).map((s) => ` · ${s}`).join('\n')}` : '', |
| 1114 | sum.bgColors.length ? `Palette: ${sum.bgColors.join(' / ')}` : '', |
| 1115 | sum.fontFamilies.length ? `Fonts: ${sum.fontFamilies.join(', ')}` : '', |
| 1116 | ``, |
| 1117 | `Begin reply with \`\`\`html. Tag visible text with data-hv-text. No prose outside the block.`, |
| 1118 | ].filter(Boolean).join('\n'); |
| 1119 | let retryText = ''; |
| 1120 | const retryHandle = spawnAgent({ |
| 1121 | def: agentDef, |
| 1122 | prompt: retryPrompt, |
| 1123 | context: { cwd: projectDir }, |
| 1124 | onEvent: (ev) => { |
| 1125 | if (ev.type === 'text') { |
| 1126 | retryText += ev.chunk; |
| 1127 | textChunks += 1; |
| 1128 | sseWrite(ev); |
| 1129 | } else if (ev.type === 'error' || ev.type === 'message_end') { |
| 1130 | sseWrite(ev); |
| 1131 | } |
| 1132 | }, |
| 1133 | }); |
| 1134 | await retryHandle.done; |
| 1135 | assistantText += retryText; |
| 1136 | process.stderr.write( |
| 1137 | `[studio:msg] proj=${id} retry done text=${retryText.length}B\n`, |
| 1138 | ); |
| 1139 | } |
| 1140 | |
| 1141 | // Single-frame iterate: result HTML goes back to the focused frame |
| 1142 | // only — never overwrites the whole preview.html. |
| 1143 | if (focusFrameId) { |
| 1144 | const extracted = extractHtmlDocument(assistantText); |
| 1145 | if (extracted) { |
| 1146 | try { |
| 1147 | await ctx.orchestrator.writeFrameHtml(id, focusFrameId, extracted); |
| 1148 | sseWrite({ type: 'preview_ready', preview_url: `/preview/${id}`, focused_frame: focusFrameId }); |
| 1149 | summaryLine = `✓ frame ${focusFrameId} updated`; |
| 1150 | } catch (err) { |
| 1151 | const msg = err instanceof Error ? err.message : String(err); |
| 1152 | sseWrite({ type: 'text', chunk: `\n[frame ${focusFrameId} write failed: ${msg}]\n` }); |
| 1153 | } |
| 1154 | } |
| 1155 | } else { |
| 1156 | // Multi-frame extraction on the off chance the agent did emit it |
| 1157 | // (e.g. on a free-text iterate turn the user's text triggered it). |
| 1158 | const multi = extractContentGraphAndFrames(assistantText); |
| 1159 | if (multi && multi.frames.length > 0) { |
| 1160 | await ctx.orchestrator.writeContentGraph(id, multi.graph); |
| 1161 | for (const f of multi.frames) { |
| 1162 | try { |
| 1163 | await ctx.orchestrator.writeFrameHtml(id, f.nodeId, f.html); |
| 1164 | } catch (err) { |
| 1165 | const msg = err instanceof Error ? err.message : String(err); |
| 1166 | sseWrite({ type: 'text', chunk: `\n[frame ${f.nodeId} skipped: ${msg}]\n` }); |
| 1167 | } |
| 1168 | } |
| 1169 | sseWrite({ type: 'preview_ready', preview_url: `/preview/${id}`, frames: multi.frames.length }); |
| 1170 | summaryLine = `✓ ${multi.frames.length}-frame storyboard generated (intent: ${multi.graph.intent})`; |
| 1171 | } else { |
| 1172 | const extracted = extractHtmlDocument(assistantText); |
| 1173 | if (extracted) { |
| 1174 | await ctx.orchestrator.writePreviewHtmlRaw(id, extracted); |
| 1175 | sseWrite({ type: 'preview_ready', preview_url: `/preview/${id}` }); |
| 1176 | summaryLine = '✓ updated the HTML preview'; |
| 1177 | } |
| 1178 | } |
| 1179 | } |
| 1180 | } |
| 1181 | |
| 1182 | // Auto-advance: the content prompt instructs the agent to append |
| 1183 | // <!-- hv-phase:content-question --> when it still needs more info. |
| 1184 | // Absence of that marker means it has enough — immediately run the |
| 1185 | // style phase in the same SSE stream so the user sees the style card |
| 1186 | // without having to send an extra "ok" message. |
| 1187 | if (phaseInfo.phase === 'content' && !/<!--\s*hv-phase:content-question\s*-->/i.test(assistantText)) { |
| 1188 | const autoPickedType = lastCardPickByPhase(history, 'type') ?? phaseInfo.inputs.pickedType ?? ''; |
| 1189 | const stylePrompt = buildStylePhasePrompt(autoPickedType); |
| 1190 | const styleHandle = spawnAgent({ |
| 1191 | def: agentDef, |
| 1192 | prompt: stylePrompt, |
| 1193 | context: { cwd: projectDir, ...(agentModel && { model: agentModel }) }, |
| 1194 | onEvent: (ev) => { |
| 1195 | if (ev.type === 'text') { |
| 1196 | assistantText += ev.chunk; |
| 1197 | textChunks += 1; |
| 1198 | sseWrite(ev); |
| 1199 | } else if (ev.type === 'error') { |
| 1200 | process.stderr.write(`[studio:msg] proj=${id} style-autoadvance error: ${ev.message}\n`); |
| 1201 | } |
| 1202 | }, |
| 1203 | }); |
| 1204 | await styleHandle.done; |
| 1205 | } |
| 1206 | |
| 1207 | // Persist assistant message — strip the html / graph blocks when present (UI sees summary line) |
| 1208 | let persistText = summaryLine |
| 1209 | ? assistantText |
| 1210 | .replace(/```html[#\w-]*[\s\S]*?```/gi, '') |
| 1211 | .replace(/```json#content-graph[\s\S]*?```/i, '') |
| 1212 | .replace(/```json[\s\S]*?```/i, (m) => |
| 1213 | /content-graph|"intent"\s*:|"nodes"\s*:/i.test(m) ? '' : m, |
| 1214 | ) |
| 1215 | .trim() || summaryLine |
| 1216 | : assistantText; |
| 1217 | |
| 1218 | // Empty agent reply (no HTML, no graph, no prose) usually means the |
| 1219 | // prompt confused the model into doing nothing. Give the user something |
| 1220 | // actionable instead of a blank speech bubble. |
| 1221 | if (!persistText.trim()) { |
| 1222 | const fallback = '⚠️ The agent returned an empty reply. Try rephrasing your request — e.g. tell it the brand / topic / 1-2 concrete details, or which kind of frame you want first.'; |
| 1223 | sseWrite({ type: 'text', chunk: fallback }); |
| 1224 | persistText = fallback; |
| 1225 | } |
| 1226 | history.push({ |
| 1227 | role: 'assistant', |
| 1228 | agent: agentDef.id, |
| 1229 | content: persistText, |
| 1230 | ts: Date.now(), |
| 1231 | }); |
| 1232 | MESSAGES.set(id, history); |
| 1233 | await saveMessages(ctx, id, history); |
| 1234 | // discard project0 reference to keep TS happy |
| 1235 | void project0; |
| 1236 | res.end(); |
| 1237 | return; |
| 1238 | } finally { |
| 1239 | GENERATING.delete(id); |
| 1240 | } |
| 1241 | } |
| 1242 | |
| 1243 | // Is a generation currently running for this project? Lets a returning |
| 1244 | // client show "still generating…" instead of a blank where the live |
| 1245 | // progress lines used to be. |
| 1246 | const genStatusMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/generating$/); |
| 1247 | if (genStatusMatch && genStatusMatch[1] && m === 'GET') { |
| 1248 | return json(res, 200, { generating: GENERATING.has(genStatusMatch[1]) }); |
| 1249 | } |
| 1250 | |
| 1251 | // ============== v0.8: content-graph + frames API ============== |
| 1252 | |
| 1253 | // GET content graph as JSON |
| 1254 | const cgMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/content-graph$/); |
| 1255 | if (cgMatch && cgMatch[1] && m === 'GET') { |
| 1256 | const graph = await ctx.orchestrator.readContentGraph(cgMatch[1]); |
| 1257 | if (!graph) return json(res, 404, { error: 'No content graph for this project' }); |
| 1258 | return json(res, 200, { graph }); |
| 1259 | } |
| 1260 | |
| 1261 | // Re-pace each frame's duration to match the narration: split the total |
| 1262 | // duration across frames in proportion to each frame's narration length |
| 1263 | // (a frame with twice the words holds twice as long), so a generated |
| 1264 | // voiceover and the visuals stay in step. Min 2s per frame. |
| 1265 | const fitMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/fit-durations$/); |
| 1266 | if (fitMatch && fitMatch[1] && m === 'POST') { |
| 1267 | const projectId = fitMatch[1]; |
| 1268 | const graph = await ctx.orchestrator.readContentGraph(projectId); |
| 1269 | if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { |
| 1270 | return json(res, 400, { error: 'No frames yet — generate the video first.' }); |
| 1271 | } |
| 1272 | const byFrame = ((await readBody(req)) as { narrationByFrame?: Record<string, string> }).narrationByFrame ?? {}; |
| 1273 | const lenOf = (id: string) => (byFrame[id]?.trim().length ?? 0); |
| 1274 | const totalChars = graph.nodes.reduce((s, n) => s + lenOf(n.id), 0); |
| 1275 | if (totalChars === 0) { |
| 1276 | return json(res, 400, { error: 'No narration yet — draft narration first, then fit.' }); |
| 1277 | } |
| 1278 | const MIN = 2; |
| 1279 | // Keep total duration, but if there isn't enough to give every frame the |
| 1280 | // minimum at its char-share, scale the total up so MIN is always honored |
| 1281 | // (≈0.18s of speech per character is a comfortable narration pace). |
| 1282 | const SEC_PER_CHAR = 0.18; |
| 1283 | const currentTotal = graph.nodes.reduce((s, n) => s + (n.durationSec ?? MIN), 0); |
| 1284 | const neededForSpeech = Math.ceil(totalChars * SEC_PER_CHAR); |
| 1285 | const total = Math.max(currentTotal, neededForSpeech, MIN * graph.nodes.length); |
| 1286 | // Proportional by char share, then lift any frame below MIN. |
| 1287 | let durs = graph.nodes.map((n) => ({ n, d: Math.max(MIN, Math.round((lenOf(n.id) / totalChars) * total)) })); |
| 1288 | // Re-normalize so the rounded sum matches `total` (adjust the longest frame). |
| 1289 | const sum = durs.reduce((s, x) => s + x.d, 0); |
| 1290 | if (sum !== total && durs.length) { |
| 1291 | const longest = durs.reduce((a, b) => (b.d > a.d ? b : a)); |
| 1292 | longest.d = Math.max(MIN, longest.d + (total - sum)); |
| 1293 | } |
| 1294 | for (const { n, d } of durs) n.durationSec = d; |
| 1295 | // preserveFrames: fit only re-times an EXISTING storyboard — must not |
| 1296 | // wipe the rendered frames (that left export with no frames → it fell |
| 1297 | // back to a single 5s template still instead of the multi-frame video). |
| 1298 | await ctx.orchestrator.writeContentGraph(projectId, graph, { preserveFrames: true }); |
| 1299 | const durations = Object.fromEntries(graph.nodes.map((n) => [n.id, n.durationSec])); |
| 1300 | return json(res, 200, { ok: true, durations, totalSec: graph.nodes.reduce((s, n) => s + (n.durationSec ?? 0), 0) }); |
| 1301 | } |
| 1302 | |
| 1303 | // ============== File serving ============== |
| 1304 | |
| 1305 | // Project preview HTML (and any sibling files like assets/) |
| 1306 | const previewServeMatch = url.pathname.match(/^\/preview\/([^/]+)(\/.*)?$/); |
| 1307 | if (previewServeMatch && previewServeMatch[1]) { |
| 1308 | const projId = previewServeMatch[1]; |
| 1309 | const sub = previewServeMatch[2] ?? '/preview.html'; |
| 1310 | const project = await ctx.orchestrator.load(projId); |
| 1311 | |
| 1312 | // Phase C: serve an enhanced frame's preview MP4 (native Remotion frames |
| 1313 | // have no HTML). Match the `.mp4` suffix BEFORE the plain HTML frame route. |
| 1314 | const frameMp4Match = sub.match(/^\/frame\/([a-z0-9_-]+)\.mp4$/i); |
| 1315 | if (frameMp4Match && frameMp4Match[1]) { |
| 1316 | const frame = (project.frames ?? []).find((f) => f.graphNodeId === frameMp4Match[1]); |
| 1317 | if (frame?.previewMp4Path && existsSync(frame.previewMp4Path)) { |
| 1318 | return serveFile(frame.previewMp4Path, res); |
| 1319 | } |
| 1320 | res.writeHead(404); |
| 1321 | return res.end('No preview MP4 for frame'); |
| 1322 | } |
| 1323 | |
| 1324 | // v0.8: serve a specific frame HTML by graph node id |
| 1325 | const frameMatch = sub.match(/^\/frame\/([a-z0-9_-]+)$/i); |
| 1326 | if (frameMatch && frameMatch[1]) { |
| 1327 | const nodeId = frameMatch[1]; |
| 1328 | const frame = (project.frames ?? []).find((f) => f.graphNodeId === nodeId); |
| 1329 | if (frame && existsSync(frame.htmlPath)) { |
| 1330 | return serveFile(frame.htmlPath, res); |
| 1331 | } |
| 1332 | res.writeHead(404); |
| 1333 | return res.end('Frame not found'); |
| 1334 | } |
| 1335 | |
| 1336 | const baseDir = project.lastPreviewHtmlPath |
| 1337 | ? dirname(project.lastPreviewHtmlPath) |
| 1338 | : null; |
| 1339 | if (!baseDir) { |
| 1340 | res.writeHead(404); |
| 1341 | return res.end('Preview not rendered yet'); |
| 1342 | } |
| 1343 | const filePath = sub === '/preview.html' || sub === '/' |
| 1344 | ? project.lastPreviewHtmlPath! |
| 1345 | : join(baseDir, sub); |
| 1346 | if (existsSync(filePath) && statSync(filePath).isFile()) { |
| 1347 | return serveFile(filePath, res); |
| 1348 | } |
| 1349 | // Fallback: also try project assets/ |
| 1350 | const projAssets = join(dirname(baseDir), 'assets', basename(sub)); |
| 1351 | if (existsSync(projAssets)) return serveFile(projAssets, res); |
| 1352 | // Fallback 2 (multi-composition templates): hyperframes templates ship |
| 1353 | // with sibling files like compositions/intro.html that the entry |
| 1354 | // index.html references via data-composition-src. Project dir only |
| 1355 | // holds the rewritten preview.html — sibling files live in the |
| 1356 | // template's own dir. Resolve relative to that, but only when the |
| 1357 | // requested path is below the project's selected template (so a |
| 1358 | // project can't read a different template's files). |
| 1359 | if (project.templateId) { |
| 1360 | try { |
| 1361 | const tmpl = ctx.templates.get(project.templateId); |
| 1362 | if (tmpl?.__dir && sub.length > 1) { |
| 1363 | const tmplFile = join(tmpl.__dir, sub.replace(/^\//, '')); |
| 1364 | const tmplResolved = resolve(tmplFile); |
| 1365 | const tmplRoot = resolve(tmpl.__dir); |
| 1366 | if ( |
| 1367 | tmplResolved.startsWith(tmplRoot + '/') && |
| 1368 | existsSync(tmplResolved) && |
| 1369 | statSync(tmplResolved).isFile() |
| 1370 | ) { |
| 1371 | return serveFile(tmplResolved, res); |
| 1372 | } |
| 1373 | } |
| 1374 | } catch { |
| 1375 | /* template lookup failed → just 404 */ |
| 1376 | } |
| 1377 | } |
| 1378 | res.writeHead(404); |
| 1379 | return res.end('Not found'); |
| 1380 | } |
| 1381 | |
| 1382 | // Asset direct serve (so iframe can load image_path etc) |
| 1383 | // /asset?path=<absolute-path> — must be inside .html-video/projects |
| 1384 | if (url.pathname === '/asset' && m === 'GET') { |
| 1385 | const p = url.searchParams.get('path'); |
| 1386 | if (!p) { |
| 1387 | res.writeHead(400); |
| 1388 | return res.end('missing ?path'); |
| 1389 | } |
| 1390 | const safe = resolve(p); |
| 1391 | if (!safe.includes('/.html-video/projects/')) { |
| 1392 | res.writeHead(403); |
| 1393 | return res.end('forbidden'); |
| 1394 | } |
| 1395 | if (existsSync(safe)) return serveFile(safe, res); |
| 1396 | res.writeHead(404); |
| 1397 | return res.end(); |
| 1398 | } |
| 1399 | |
| 1400 | // Template poster (e.g. /template-asset/<id>/preview.png) |
| 1401 | const tplAssetMatch = url.pathname.match(/^\/template-asset\/([^/]+)\/(.+)$/); |
| 1402 | if (tplAssetMatch && tplAssetMatch[1] && tplAssetMatch[2]) { |
| 1403 | const t = ctx.templates.get(tplAssetMatch[1]); |
| 1404 | const rel = tplAssetMatch[2]; |
| 1405 | const filePath = join(t.__dir!, rel); |
| 1406 | if (!existsSync(filePath)) { |
| 1407 | res.writeHead(404); |
| 1408 | return res.end(); |
| 1409 | } |
| 1410 | // Multi-composition templates ship an entry HTML that only stitches |
| 1411 | // sub-comps via data-composition-src; a raw iframe renders blank |
| 1412 | // because nothing assembles them. For the studio *preview* we inject a |
| 1413 | // tiny client-side player that fetches each composition, instantiates |
| 1414 | // its <template>, wires placeholders, and plays the GSAP timelines so |
| 1415 | // the gallery shows live motion. The template files on disk are never |
| 1416 | // touched — this rewrite happens only on the way out the wire. |
| 1417 | if (extname(filePath).toLowerCase() === '.html') { |
| 1418 | let html = await readFile(filePath, 'utf8'); |
| 1419 | if (/data-composition-src/.test(html)) { |
| 1420 | html = injectCompositionPlayer(html); |
| 1421 | res.writeHead(200, { |
| 1422 | 'content-type': MIME['.html']!, |
| 1423 | 'cache-control': 'no-store, no-cache, must-revalidate', |
| 1424 | pragma: 'no-cache', |
| 1425 | }); |
| 1426 | return res.end(html); |
| 1427 | } |
| 1428 | } |
| 1429 | return serveFile(filePath, res); |
| 1430 | } |
| 1431 | |
| 1432 | // ============== Static UI ============== |
| 1433 | const path = url.pathname === '/' ? '/index.html' : url.pathname; |
| 1434 | const filePath = join(uiRoot, path); |
| 1435 | if (filePath.startsWith(uiRoot) && existsSync(filePath) && statSync(filePath).isFile()) { |
| 1436 | return serveFile(filePath, res); |
| 1437 | } |
| 1438 | |
| 1439 | res.writeHead(404); |
| 1440 | res.end('Not found'); |
| 1441 | } catch (e) { |
| 1442 | const msg = e instanceof Error ? e.message : String(e); |
| 1443 | const code = (e as { code?: string }).code ?? 'unknown'; |
| 1444 | json(res, 500, { error: msg, code }); |
| 1445 | } |
| 1446 | }); |
| 1447 | |
| 1448 | return new Promise((resolveFn) => { |
| 1449 | server.listen(port, '127.0.0.1', () => { |
| 1450 | const addr = server.address(); |
| 1451 | const actualPort = typeof addr === 'object' && addr ? addr.port : port; |
| 1452 | resolveFn({ |
| 1453 | url: `http://127.0.0.1:${actualPort}`, |
| 1454 | port: actualPort, |
| 1455 | close: () => server.close(), |
| 1456 | }); |
| 1457 | }); |
| 1458 | }); |
| 1459 | } |
| 1460 | |
| 1461 | // --------------------------------------------------------------------------- |
| 1462 | // helpers |
| 1463 | // --------------------------------------------------------------------------- |
| 1464 | |
| 1465 | function json(res: ServerResponse, code: number, body: unknown): void { |
| 1466 | res.writeHead(code, { 'content-type': MIME['.json']! }); |
| 1467 | res.end(JSON.stringify(body)); |
| 1468 | } |
| 1469 | |
| 1470 | /** |
| 1471 | * Decide how the gallery should preview a template. Both self-contained |
| 1472 | * entries and multi-composition entries now render live in an iframe: the |
| 1473 | * latter get an injected composition player (see injectCompositionPlayer) that |
| 1474 | * assembles the sub-comps and plays their timelines, so 'iframe' is the right |
| 1475 | * mode for everything that has a readable entry. |
| 1476 | * |
| 1477 | * `posterUrl` is still surfaced (when the poster file exists) so the frontend |
| 1478 | * can fall back to a static poster if the live iframe ever fails to render. |
| 1479 | */ |
| 1480 | function templatePreviewMode( |
| 1481 | t: import('@html-video/core').TemplateMetadata, |
| 1482 | ): { mode: 'iframe' | 'poster'; posterUrl: string | null } { |
| 1483 | const posterRel = t.preview?.poster; |
| 1484 | const posterPath = posterRel && t.__dir ? join(t.__dir, posterRel) : null; |
| 1485 | const posterUrl = |
| 1486 | posterPath && existsSync(posterPath) |
| 1487 | ? `/template-asset/${t.id}/${posterRel}` |
| 1488 | : null; |
| 1489 | return { mode: 'iframe', posterUrl }; |
| 1490 | } |
| 1491 | |
| 1492 | /** |
| 1493 | * Inject a minimal client-side composition player into a multi-comp entry |
| 1494 | * HTML so the studio preview shows live motion instead of a blank iframe. |
| 1495 | * |
| 1496 | * Hyperframes templates declare their scenes as `<div data-composition-src= |
| 1497 | * "compositions/x.html">` placeholders; each composition file is a `<template>` |
| 1498 | * wrapping markup + <style> + a <script> that registers a paused GSAP timeline |
| 1499 | * on `window.__timelines[name]`. The real (v0.9) renderer assembles these for |
| 1500 | * frame-accurate export; this player is a lightweight stand-in that just makes |
| 1501 | * the preview move: |
| 1502 | * 1. swap the two known placeholders so nothing 404s / NaNs, |
| 1503 | * 2. fetch each composition (relative to /template-asset/<id>/), graft its |
| 1504 | * <template>.content into the placeholder div, and re-run its scripts |
| 1505 | * (cloned <script> nodes never execute on their own), |
| 1506 | * 3. once every timeline has registered, play them all on a loop. |
| 1507 | * Templates on disk are untouched — this is a serve-time transform only. |
| 1508 | */ |
| 1509 | function injectCompositionPlayer(html: string): string { |
| 1510 | // 15s is a sane default duration for the preview loop; __VIDEO_SRC__ has no |
| 1511 | // real asset in-repo, so point it at an empty data URI to avoid a 404 fetch. |
| 1512 | let out = html |
| 1513 | .replace(/__VIDEO_DURATION__/g, '15') |
| 1514 | .replace(/__VIDEO_SRC__/g, 'data:video/mp4;base64,'); |
| 1515 | |
| 1516 | // The entry's own inline scripts assign window.__timelines["background"] |
| 1517 | // etc. before the entry ever initialises the registry — in the real HF |
| 1518 | // runtime the player defines it first. Mirror that: seed the registry in |
| 1519 | // <head> so those early assignments don't throw on an undefined object. |
| 1520 | const seed = '<script>window.__timelines = window.__timelines || {};</script>'; |
| 1521 | if (/<head[^>]*>/i.test(out)) { |
| 1522 | out = out.replace(/<head[^>]*>/i, (m) => m + '\n' + seed); |
| 1523 | } else { |
| 1524 | out = seed + '\n' + out; |
| 1525 | } |
| 1526 | |
| 1527 | const player = ` |
| 1528 | <script> |
| 1529 | (function () { |
| 1530 | function reexec(root) { |
| 1531 | // Cloned/innerHTML'd <script> nodes don't run — recreate them so each |
| 1532 | // composition's timeline-registration IIFE actually executes. Skip the |
| 1533 | // external gsap CDN tag: the entry already loaded gsap synchronously, and |
| 1534 | // re-adding it would race (async load) ahead of the inline IIFE that calls |
| 1535 | // gsap.timeline() right after it. |
| 1536 | root.querySelectorAll('script').forEach(function (old) { |
| 1537 | if (old.src) { old.parentNode.removeChild(old); return; } |
| 1538 | var s = document.createElement('script'); |
| 1539 | // Each composition's inline script declares top-level \`const tl = ...\`. |
| 1540 | // Re-injecting several into the shared global scope collides ("tl has |
| 1541 | // already been declared"). Wrap each in its own block so those locals |
| 1542 | // stay private; window.__timelines assignments still escape the block. |
| 1543 | s.textContent = '{\\n' + old.textContent + '\\n}'; |
| 1544 | old.parentNode.replaceChild(s, old); |
| 1545 | }); |
| 1546 | } |
| 1547 | async function mountOne(host) { |
| 1548 | var src = host.getAttribute('data-composition-src'); |
| 1549 | if (!src) return; |
| 1550 | try { |
| 1551 | var res = await fetch(src); |
| 1552 | if (!res.ok) return; |
| 1553 | var text = await res.text(); |
| 1554 | var holder = document.createElement('div'); |
| 1555 | holder.innerHTML = text; |
| 1556 | var tpl = holder.querySelector('template'); |
| 1557 | var frag = tpl ? tpl.content.cloneNode(true) : holder; |
| 1558 | host.appendChild(frag); |
| 1559 | reexec(host); |
| 1560 | } catch (e) { /* a missing comp shouldn't blank the whole preview */ } |
| 1561 | } |
| 1562 | async function boot() { |
| 1563 | window.__timelines = window.__timelines || {}; |
| 1564 | var hosts = Array.prototype.slice.call( |
| 1565 | document.querySelectorAll('[data-composition-src]')); |
| 1566 | await Promise.all(hosts.map(mountOne)); |
| 1567 | // Give the just-injected <script> tags a tick to register timelines. |
| 1568 | setTimeout(function () { |
| 1569 | var tls = window.__timelines || {}; |
| 1570 | Object.keys(tls).forEach(function (k) { |
| 1571 | var tl = tls[k]; |
| 1572 | if (tl && typeof tl.play === 'function') { |
| 1573 | try { tl.repeat(-1); } catch (e) {} |
| 1574 | tl.play(0); |
| 1575 | } |
| 1576 | }); |
| 1577 | }, 120); |
| 1578 | } |
| 1579 | if (document.readyState === 'loading') { |
| 1580 | document.addEventListener('DOMContentLoaded', boot); |
| 1581 | } else { boot(); } |
| 1582 | })(); |
| 1583 | </script>`; |
| 1584 | |
| 1585 | if (out.includes('</body>')) return out.replace('</body>', player + '\n</body>'); |
| 1586 | return out + player; |
| 1587 | } |
| 1588 | |
| 1589 | async function serveFile(filePath: string, res: ServerResponse): Promise<void> { |
| 1590 | const ext = extname(filePath).toLowerCase(); |
| 1591 | const buf = await readFile(filePath); |
| 1592 | res.writeHead(200, { |
| 1593 | 'content-type': MIME[ext] ?? 'application/octet-stream', |
| 1594 | // Studio is a local dev tool — always serve fresh so v0.x updates show |
| 1595 | // up immediately on page load instead of being held in disk cache. |
| 1596 | 'cache-control': 'no-store, no-cache, must-revalidate', |
| 1597 | pragma: 'no-cache', |
| 1598 | }); |
| 1599 | res.end(buf); |
| 1600 | } |
| 1601 | |
| 1602 | async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> { |
| 1603 | return new Promise((resolveFn, reject) => { |
| 1604 | let data = ''; |
| 1605 | req.on('data', (chunk) => { |
| 1606 | data += chunk; |
| 1607 | }); |
| 1608 | req.on('end', () => { |
| 1609 | try { |
| 1610 | resolveFn(data ? JSON.parse(data) : {}); |
| 1611 | } catch (e) { |
| 1612 | reject(e); |
| 1613 | } |
| 1614 | }); |
| 1615 | req.on('error', reject); |
| 1616 | }); |
| 1617 | } |
| 1618 | |
| 1619 | async function readBodyText(req: IncomingMessage): Promise<string> { |
| 1620 | return new Promise((resolveFn, reject) => { |
| 1621 | let data = ''; |
| 1622 | req.on('data', (chunk) => { |
| 1623 | data += chunk; |
| 1624 | }); |
| 1625 | req.on('end', () => resolveFn(data)); |
| 1626 | req.on('error', reject); |
| 1627 | }); |
| 1628 | } |
| 1629 | |
| 1630 | /** |
| 1631 | * Minimal multipart parser — returns ALL parts (fields + files). |
| 1632 | * Files are written to a tmp path and the path is returned. |
| 1633 | * For production switch to formidable / busboy. |
| 1634 | */ |
| 1635 | type MultipartPart = |
| 1636 | | { kind: 'field'; name: string; value: string } |
| 1637 | | { kind: 'file'; name: string; filename: string; tmpPath: string }; |
| 1638 | |
| 1639 | /** |
| 1640 | * Recover the real filename from a multipart part header (issue #9). |
| 1641 | * |
| 1642 | * Two encodings can appear: |
| 1643 | * - `filename*=UTF-8''%E4%B8%AD%E6%96%87.md` (RFC 5987, percent-encoded) — |
| 1644 | * decode the percent-escapes after stripping the charset prefix. |
| 1645 | * - `filename="中文.md"` — the bytes are UTF-8, but the multipart body was |
| 1646 | * read as a latin1 string, so each UTF-8 byte became one latin1 char. Round |
| 1647 | * -trip latin1→utf8 to restore the original. If the name was plain ASCII the |
| 1648 | * round-trip is a no-op. |
| 1649 | */ |
| 1650 | export function decodeUploadFilename(star: string | undefined, plain: string | undefined): string { |
| 1651 | if (star) { |
| 1652 | // RFC 5987 ext-value: charset "'" [language] "'" value (e.g. |
| 1653 | // UTF-8''%E4%B8%AD.md or UTF-8'zh-CN'%E6%95%B0%E6%8D%AE.json). |
| 1654 | const m = /^[\w-]*'[^']*'(.*)$/.exec(star.trim()); |
| 1655 | const enc = m?.[1] ?? star.trim(); |
| 1656 | try { return decodeURIComponent(enc); } catch { return enc; } |
| 1657 | } |
| 1658 | if (plain !== undefined) { |
| 1659 | try { return Buffer.from(plain, 'latin1').toString('utf8'); } catch { return plain; } |
| 1660 | } |
| 1661 | return 'upload'; |
| 1662 | } |
| 1663 | |
| 1664 | async function receiveMultipart( |
| 1665 | req: IncomingMessage, |
| 1666 | contentType: string, |
| 1667 | ): Promise<MultipartPart[]> { |
| 1668 | const boundaryMatch = contentType.match(/boundary=(.+)/); |
| 1669 | if (!boundaryMatch) throw new Error('No multipart boundary'); |
| 1670 | const boundary = `--${boundaryMatch[1]}`; |
| 1671 | const chunks: Buffer[] = []; |
| 1672 | for await (const chunk of req) chunks.push(chunk as Buffer); |
| 1673 | const buf = Buffer.concat(chunks); |
| 1674 | const text = buf.toString('binary'); |
| 1675 | const parts = text.split(boundary).slice(1, -1); |
| 1676 | const out: MultipartPart[] = []; |
| 1677 | const fs = await import('node:fs/promises'); |
| 1678 | for (const part of parts) { |
| 1679 | const headerEnd = part.indexOf('\r\n\r\n'); |
| 1680 | if (headerEnd === -1) continue; |
| 1681 | const headers = part.slice(0, headerEnd); |
| 1682 | const bodyRaw = part.slice(headerEnd + 4, part.length - 2); |
| 1683 | const nameMatch = headers.match(/name="([^"]+)"/); |
| 1684 | if (!nameMatch || !nameMatch[1]) continue; |
| 1685 | const name = nameMatch[1]; |
| 1686 | // RFC 5987 `filename*=UTF-8''...` (percent-encoded) wins when present; |
| 1687 | // otherwise fall back to the plain `filename="..."`. The plain form carries |
| 1688 | // raw UTF-8 BYTES, but the part was sliced out of a latin1 string above, so |
| 1689 | // a CJK filename arrives mojibake'd — re-decode latin1→utf8 to restore it |
| 1690 | // (issue #9). decodeUploadFilename handles both. |
| 1691 | const fnStarMatch = headers.match(/filename\*=([^;\r\n]+)/i); |
| 1692 | const fnMatch = headers.match(/filename="([^"]*)"/); |
| 1693 | if (fnStarMatch || fnMatch) { |
| 1694 | const filename = decodeUploadFilename(fnStarMatch?.[1], fnMatch?.[1]); |
| 1695 | // Keep the tmp path ASCII-safe; the real (possibly CJK) name rides on the |
| 1696 | // returned part, not the on-disk temp file. |
| 1697 | const ext = (filename.match(/\.[A-Za-z0-9]{1,8}$/)?.[0]) ?? ''; |
| 1698 | const tmpPath = join(tmpdir(), `hv-upload-${randomUUID().slice(0, 8)}${ext}`); |
| 1699 | await mkdir(dirname(tmpPath), { recursive: true }); |
| 1700 | await fs.writeFile(tmpPath, Buffer.from(bodyRaw, 'binary')); |
| 1701 | out.push({ kind: 'file', name, filename, tmpPath }); |
| 1702 | } else { |
| 1703 | // Field — body is utf8 text |
| 1704 | out.push({ kind: 'field', name, value: Buffer.from(bodyRaw, 'binary').toString('utf8') }); |
| 1705 | } |
| 1706 | } |
| 1707 | return out; |
| 1708 | } |
| 1709 | |
| 1710 | // Backward-compat shim used by the older /api/projects/:id/assets endpoint |
| 1711 | async function receiveMultipartFile( |
| 1712 | req: IncomingMessage, |
| 1713 | contentType: string, |
| 1714 | ): Promise<{ filePath: string; filename: string }> { |
| 1715 | const parts = await receiveMultipart(req, contentType); |
| 1716 | const file = parts.find((p): p is Extract<MultipartPart, { kind: 'file' }> => p.kind === 'file'); |
| 1717 | if (!file) throw new Error('No file field in multipart body'); |
| 1718 | return { filePath: file.tmpPath, filename: file.filename }; |
| 1719 | } |
| 1720 | |
| 1721 | // Keep TS aware that copyFile / AssetStore are used somewhere (they're indirectly via orchestrator) |
| 1722 | void copyFile; |
| 1723 | void AssetStore; |
| 1724 | |
| 1725 | // --------------------------------------------------------------------------- |
| 1726 | // Message history — in-memory cache, JSON file as source of truth. |
| 1727 | // |
| 1728 | // v0.8.2: previously memory-only, so chat history evaporated on every studio |
| 1729 | // restart. Now persisted to <projectDir>/messages.json. Cache is lazy-loaded |
| 1730 | // on first GET / POST per project; writes go through saveMessages(). |
| 1731 | // --------------------------------------------------------------------------- |
| 1732 | |
| 1733 | interface ChatMessage { |
| 1734 | role: 'user' | 'assistant' | 'system' | 'tool'; |
| 1735 | content: string; |
| 1736 | agent?: string; |
| 1737 | tool?: string; |
| 1738 | output?: unknown; |
| 1739 | ts: number; |
| 1740 | } |
| 1741 | |
| 1742 | const MESSAGES = new Map<string, ChatMessage[]>(); |
| 1743 | |
| 1744 | /** Projects with a generation running right now (detached from any request). |
| 1745 | * Lets a client that switched away and came back learn the task is still alive |
| 1746 | * ("⏳ still generating…") instead of seeing the progress lines vanish. */ |
| 1747 | const GENERATING = new Set<string>(); |
| 1748 | |
| 1749 | async function loadMessages(ctx: CliContext, projectId: string): Promise<ChatMessage[]> { |
| 1750 | const cached = MESSAGES.get(projectId); |
| 1751 | if (cached) return cached; |
| 1752 | const projectDir = await ctx.projects.ensureDir(projectId); |
| 1753 | const filePath = join(projectDir, 'messages.json'); |
| 1754 | if (!existsSync(filePath)) { |
| 1755 | MESSAGES.set(projectId, []); |
| 1756 | return MESSAGES.get(projectId)!; |
| 1757 | } |
| 1758 | try { |
| 1759 | const raw = await readFile(filePath, 'utf8'); |
| 1760 | const parsed = JSON.parse(raw); |
| 1761 | const arr = Array.isArray(parsed) ? (parsed as ChatMessage[]) : []; |
| 1762 | MESSAGES.set(projectId, arr); |
| 1763 | return arr; |
| 1764 | } catch { |
| 1765 | // Corrupt file — start fresh in memory but don't overwrite the file |
| 1766 | // until the next save (gives the user a chance to recover by hand). |
| 1767 | MESSAGES.set(projectId, []); |
| 1768 | return MESSAGES.get(projectId)!; |
| 1769 | } |
| 1770 | } |
| 1771 | |
| 1772 | async function saveMessages( |
| 1773 | ctx: CliContext, |
| 1774 | projectId: string, |
| 1775 | messages: ChatMessage[], |
| 1776 | ): Promise<void> { |
| 1777 | const projectDir = await ctx.projects.ensureDir(projectId); |
| 1778 | const filePath = join(projectDir, 'messages.json'); |
| 1779 | const fs = await import('node:fs/promises'); |
| 1780 | await fs.writeFile(filePath, JSON.stringify(messages, null, 2), 'utf8'); |
| 1781 | } |
| 1782 | |
| 1783 | // `Attachment` is declared above (at the buildHtmlGenerationPrompt section) |
| 1784 | |
| 1785 | interface BuildPromptArgs { |
| 1786 | tmpl: import('@html-video/core').TemplateMetadata | null; |
| 1787 | exampleHtml: string; |
| 1788 | priorHtml: string; |
| 1789 | history: ChatMessage[]; |
| 1790 | userText: string; |
| 1791 | attachments: Attachment[]; |
| 1792 | /** When set, iterate-phase prompts target only this frame's HTML. */ |
| 1793 | focusFrameId?: string; |
| 1794 | /** The user's original opening subject, locked across phases. */ |
| 1795 | openingTopic?: string; |
| 1796 | } |
| 1797 | |
| 1798 | interface Attachment { |
| 1799 | /** absolute path on disk */ |
| 1800 | path: string; |
| 1801 | /** type the AssetStore detected */ |
| 1802 | kind: 'image' | 'video' | 'audio' | 'data' | 'text' | 'reference-link'; |
| 1803 | /** display name */ |
| 1804 | filename: string; |
| 1805 | /** byte size */ |
| 1806 | size: number; |
| 1807 | /** |
| 1808 | * For text sources (fetched articles/repos, uploaded .md/.txt), the actual |
| 1809 | * content — inlined directly into the prompt. A bare path is useless to HTTP |
| 1810 | * agents (Messages API runs in the cloud, can't read local disk), and even |
| 1811 | * for CLI agents the content should be the source material, not a file ref. |
| 1812 | */ |
| 1813 | inlineText?: string; |
| 1814 | } |
| 1815 | |
| 1816 | /** |
| 1817 | * v0.5 chat prompt — guidance-first, not write-HTML-immediately. |
| 1818 | * |
| 1819 | * The system prompt tells the agent to: |
| 1820 | * - On a vague first turn, ask 1–3 sharp questions instead of writing HTML |
| 1821 | * - When the request + context are concrete enough, generate the full HTML |
| 1822 | * - Use attachments as references / actual assets |
| 1823 | * - Never use a fixed 4-question script — judge per turn what's missing |
| 1824 | * |
| 1825 | * Whether the agent writes HTML this turn is up to the agent. The server |
| 1826 | * extracts a fenced ```html block if present; if not, it's just a chat reply. |
| 1827 | */ |
| 1828 | /** |
| 1829 | * Conversation phases — fully sequential. Each card the assistant emits has |
| 1830 | * a `meta.phase` JSON field so the server can route the user's reply without |
| 1831 | * guessing. |
| 1832 | * |
| 1833 | * opener → hv-options{meta.phase:"type"} → user picks content type |
| 1834 | * content → free chat: agent asks about topic / headline / data, user |
| 1835 | * can answer in 1+ turns or say "skip" / "随便" |
| 1836 | * style → hv-options{meta.phase:"style"} → user picks style preset |
| 1837 | * (skipped automatically if a project template is already set) |
| 1838 | * format → hv-form{meta.phase:"format"} → 3 segmented controls |
| 1839 | * (aspect, duration, frame_count) |
| 1840 | * confirm → hv-confirm{meta.phase:"confirm"} → ✓ generate / ✏️ edit |
| 1841 | * generate → emits HTML / content-graph + frames |
| 1842 | * |
| 1843 | * info-edit → user clicked edit on confirm; re-emit format hv-form |
| 1844 | * iterate → after successful generate, free-form revision pass |
| 1845 | */ |
| 1846 | type ConvPhase = |
| 1847 | | 'opener' |
| 1848 | | 'content' |
| 1849 | | 'style' |
| 1850 | | 'need-template' |
| 1851 | | 'format' |
| 1852 | | 'format-edit' |
| 1853 | | 'confirm' |
| 1854 | | 'generate' |
| 1855 | | 'iterate' |
| 1856 | // Post-generation iteration sub-flow: |
| 1857 | | 'edit-menu' // ask what to change (style / content / duration) |
| 1858 | | 'restyle' // re-render every frame in a new style, text unchanged |
| 1859 | | 'iterate-content' // re-plan the storyboard around new content |
| 1860 | | 'iterate-format'; // re-time / re-render with a new per-frame length |
| 1861 | |
| 1862 | /** Did the user pick the "choose from design templates" style option? */ |
| 1863 | function isFromTemplateStyle(style: string): boolean { |
| 1864 | return /^从设计模板选|design template|pick.*template|from template/i.test(style.trim()); |
| 1865 | } |
| 1866 | |
| 1867 | interface PhaseInputs { |
| 1868 | collected?: Record<string, string>; // last submitted hv-form values (format only) |
| 1869 | pickedType?: string; |
| 1870 | pickedStyle?: string; |
| 1871 | contentTurns?: string[]; // free-text user messages between type-pick and style/format |
| 1872 | } |
| 1873 | |
| 1874 | /** A phase reached during post-generation iteration carries postGen=true so the |
| 1875 | * prompt builder re-uses a card but bases the final regeneration on the existing |
| 1876 | * storyboard rather than starting fresh. */ |
| 1877 | type PhaseResult = { phase: ConvPhase; inputs: PhaseInputs; postGen?: boolean }; |
| 1878 | |
| 1879 | function detectPhase( |
| 1880 | history: ChatMessage[], |
| 1881 | userText: string, |
| 1882 | hasTemplate: boolean, |
| 1883 | hasSourceMaterial = false, |
| 1884 | focusFrameId = '', |
| 1885 | ): PhaseResult { |
| 1886 | const trimmed = userText.trim(); |
| 1887 | const inputs: PhaseInputs = {}; |
| 1888 | |
| 1889 | // Explicit markers always win. |
| 1890 | if (trimmed.startsWith('[hv-form:submit]')) { |
| 1891 | const body = trimmed.slice('[hv-form:submit]'.length).trim(); |
| 1892 | try { inputs.collected = JSON.parse(body); } catch { /* ignore */ } |
| 1893 | return { phase: 'confirm', inputs }; |
| 1894 | } |
| 1895 | if (trimmed === '[hv-confirm:generate]') { |
| 1896 | inputs.collected = lastFormSubmission(history); |
| 1897 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1898 | inputs.pickedStyle = lastCardPickByPhase(history, 'style') ?? ''; |
| 1899 | inputs.contentTurns = collectContentTurns(history); |
| 1900 | return { phase: 'generate', inputs }; |
| 1901 | } |
| 1902 | if (trimmed === '[hv-confirm:edit]') { |
| 1903 | inputs.collected = lastFormSubmission(history); |
| 1904 | return { phase: 'format-edit', inputs }; |
| 1905 | } |
| 1906 | |
| 1907 | // Free-text format reply rescue (issue #2): if the previous assistant turn |
| 1908 | // was asking for format params (whether it rendered the hv-form card or — as |
| 1909 | // the model sometimes does — just asked in prose), and this user turn parses |
| 1910 | // as a format answer, treat it like a card submit and advance to confirm. |
| 1911 | // This stops the loop where a typed "16:9 横屏 / 5s / 10" goes unrecognised |
| 1912 | // and the flow re-asks the same params in a different shape. |
| 1913 | if (!hadGenerationYet(history) && lastAssistantAskedFormat(history)) { |
| 1914 | const parsed = parseFormatReply(trimmed); |
| 1915 | if (parsed) { |
| 1916 | // Merge over any earlier card submit so partial typed answers keep |
| 1917 | // the defaults the user already had. |
| 1918 | inputs.collected = { ...(lastFormSubmission(history) ?? {}), ...parsed }; |
| 1919 | return { phase: 'confirm', inputs }; |
| 1920 | } |
| 1921 | } |
| 1922 | |
| 1923 | // Post-generation iteration. Previously ANY message after a generation was |
| 1924 | // forced to phase 'iterate', which only ever did a vague single-frame rewrite |
| 1925 | // of preview.html — so "换个风格" / "改内容" looked like nothing happened |
| 1926 | // (the user's recurring "后面的指令好像都没用了"). Instead, run a small |
| 1927 | // card-driven sub-flow: a vague "改一下" pops an edit-menu (change style / |
| 1928 | // content / duration); picking an option re-uses the existing style / content |
| 1929 | // / format cards; the final regeneration is based on the existing storyboard. |
| 1930 | if (hadGenerationYet(history)) { |
| 1931 | const last = lastAssistantCardWithMeta(history); |
| 1932 | // Mid-iteration: the user is answering one of the edit sub-flow cards. |
| 1933 | if (last?.metaPhase === 'edit-menu') { |
| 1934 | // Route the menu choice. Match by label keywords (works for clicks, which |
| 1935 | // send the option label, and for free text). |
| 1936 | if (/风格|style|视觉|配色|换个?样子/i.test(trimmed)) { |
| 1937 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1938 | return { phase: 'style', inputs, postGen: true }; |
| 1939 | } |
| 1940 | if (/时长|时间|duration|长度|快|慢|秒|节奏/i.test(trimmed)) { |
| 1941 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1942 | return { phase: 'format', inputs, postGen: true }; |
| 1943 | } |
| 1944 | // default / "内容 / content / 文案 / 主题 / 重写" |
| 1945 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1946 | inputs.contentTurns = collectContentTurns(history); |
| 1947 | return { phase: 'content', inputs, postGen: true }; |
| 1948 | } |
| 1949 | // The user is answering a re-shown card during iteration. |
| 1950 | if (last?.metaPhase === 'style') { |
| 1951 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1952 | inputs.pickedStyle = trimmed; |
| 1953 | return { phase: 'restyle', inputs, postGen: true }; |
| 1954 | } |
| 1955 | if (last?.metaPhase === 'format' || last?.kind === 'hv-form') { |
| 1956 | inputs.collected = lastFormSubmission(history); |
| 1957 | return { phase: 'iterate-format', inputs, postGen: true }; |
| 1958 | } |
| 1959 | if (last?.kind === 'content-question') { |
| 1960 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1961 | inputs.contentTurns = [...collectContentTurns(history), trimmed]; |
| 1962 | return { phase: 'iterate-content', inputs, postGen: true }; |
| 1963 | } |
| 1964 | // A fresh post-generation instruction. The DEFAULT is the card-driven |
| 1965 | // sub-flow, not a single-frame rewrite — a whitelist of trigger phrases was |
| 1966 | // the bug (e.g. "换个模板重新生成一下" didn't match and silently fell back to |
| 1967 | // a no-op preview rewrite). So: |
| 1968 | // - pinned frame → single-frame iterate (the user explicitly scoped it). |
| 1969 | // - clearly names style / content / duration → jump straight there. |
| 1970 | // - everything else (incl. vague "改一下" / "换个模板" / "重新生成") → pop |
| 1971 | // the edit-menu and ask, rather than guess or no-op. |
| 1972 | const pinned = !!focusFrameId; |
| 1973 | if (pinned) { |
| 1974 | return { phase: 'iterate', inputs: { collected: lastFormSubmission(history) } }; |
| 1975 | } |
| 1976 | // Direct shortcuts when the instruction is unambiguous about WHAT to change. |
| 1977 | if (/风格|样式|配色|视觉|主题色|模板|template|style|换个?样子|赛博|极简|杂志|brutal|cyber|swiss/i.test(trimmed)) { |
| 1978 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1979 | return { phase: 'style', inputs, postGen: true }; |
| 1980 | } |
| 1981 | if (/时长|时间|duration|时间长度|节奏|快一点|慢一点|更短|更长|多少秒/i.test(trimmed)) { |
| 1982 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1983 | return { phase: 'format', inputs, postGen: true }; |
| 1984 | } |
| 1985 | if (/文案|内容|主题|改成|换成|重写|讲|介绍|加.{0,4}(信息|数据|卖点)|text|content|rewrite/i.test(trimmed)) { |
| 1986 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 1987 | inputs.contentTurns = [...collectContentTurns(history), trimmed].filter((s) => !isControlPhrase(s)); |
| 1988 | return { phase: 'iterate-content', inputs, postGen: true }; |
| 1989 | } |
| 1990 | // Default: ask via the edit-menu (never silently no-op). |
| 1991 | return { phase: 'edit-menu', inputs }; |
| 1992 | } |
| 1993 | |
| 1994 | // Walk backwards; what was the most recent CARD with a meta.phase tag? |
| 1995 | // (Skip empty / warning assistant turns.) |
| 1996 | const prev = lastAssistantCardWithMeta(history); |
| 1997 | |
| 1998 | if (!prev) { |
| 1999 | // No prior card → opener. |
| 2000 | return { phase: 'opener', inputs }; |
| 2001 | } |
| 2002 | |
| 2003 | // Last card was an opener type-pick → user just answered with their type. |
| 2004 | if (prev.kind === 'hv-options' && prev.metaPhase === 'type') { |
| 2005 | inputs.pickedType = trimmed; |
| 2006 | // With source material already attached, there is nothing more to collect — |
| 2007 | // the article/repo IS the content. Skip the content-question step (which |
| 2008 | // otherwise stalls: the agent emits a statement, not an interactive card, |
| 2009 | // and the flow waits forever for a user reply that never comes) and go |
| 2010 | // straight to format (if a template is picked) or style. |
| 2011 | if (hasSourceMaterial) { |
| 2012 | inputs.contentTurns = collectContentTurns(history); |
| 2013 | return hasTemplate |
| 2014 | ? { phase: 'format', inputs } |
| 2015 | : { phase: 'style', inputs }; |
| 2016 | } |
| 2017 | return { phase: 'content', inputs }; |
| 2018 | } |
| 2019 | |
| 2020 | // Last card was a style-pick → user answered with style choice. |
| 2021 | if (prev.kind === 'hv-options' && prev.metaPhase === 'style') { |
| 2022 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 2023 | inputs.pickedStyle = trimmed; |
| 2024 | inputs.contentTurns = collectContentTurns(history); |
| 2025 | // "从设计模板选" but no template actually picked → don't silently fall back |
| 2026 | // to a default look; ask the user to pick one (top-bar) or choose a style. |
| 2027 | if (isFromTemplateStyle(trimmed) && !hasTemplate) { |
| 2028 | return { phase: 'need-template', inputs }; |
| 2029 | } |
| 2030 | return { phase: 'format', inputs }; |
| 2031 | } |
| 2032 | |
| 2033 | // User was told to pick a template (need-template card is an hv-options). |
| 2034 | if (prev.kind === 'hv-options' && prev.metaPhase === 'need-template') { |
| 2035 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 2036 | inputs.contentTurns = collectContentTurns(history); |
| 2037 | // Picked a built-in style instead → use it. |
| 2038 | if (!isFromTemplateStyle(trimmed) && !/^我已选好模板|继续|done|ready|next$/i.test(trimmed)) { |
| 2039 | inputs.pickedStyle = trimmed; |
| 2040 | return { phase: 'format', inputs }; |
| 2041 | } |
| 2042 | // Said "I've picked one / continue": proceed only if a template is now set. |
| 2043 | if (hasTemplate) { |
| 2044 | inputs.pickedStyle = '从设计模板选'; |
| 2045 | return { phase: 'format', inputs }; |
| 2046 | } |
| 2047 | return { phase: 'need-template', inputs }; // still none → ask again |
| 2048 | } |
| 2049 | |
| 2050 | // Last card was content-question (a plain assistant message asking for content). |
| 2051 | // We detect this by phase metadata in a hidden HTML comment we embed. |
| 2052 | if (prev.kind === 'content-question') { |
| 2053 | // User is replying to content question. Could be (a) more content, or |
| 2054 | // (b) a "skip / I'm done" signal. |
| 2055 | const isSkip = /^(skip|跳过|够了|够|done|next|下一步|ok|好|不知道)$/i.test(trimmed) |
| 2056 | || trimmed.length <= 3; |
| 2057 | // "Free rein" answers — the user is handing the subject's details to the |
| 2058 | // agent ("随便生成 / 随便发挥 / 你定 / 都行 / 随机"). These should advance the |
| 2059 | // flow (and pop the style card) just like a skip, instead of being treated |
| 2060 | // as more content to collect — which left the user stuck re-typing "风格选择". |
| 2061 | // Substring match (not anchored) with a length guard so it doesn't swallow a |
| 2062 | // real sentence that merely contains "随便". |
| 2063 | const isFreeRein = |
| 2064 | trimmed.length <= 16 && |
| 2065 | /(随便|随机|随意|你定|你来定|你决定|都行|都可以|看着办|自由发挥|发挥|无所谓|任意|随你)/.test(trimmed); |
| 2066 | // With source material attached there's nothing to collect — advance as |
| 2067 | // soon as the user says anything (the article already is the content). |
| 2068 | if (isSkip || isFreeRein || hasSourceMaterial || hasEnoughContent(history, trimmed)) { |
| 2069 | // Move forward: style if no template, else format. |
| 2070 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 2071 | inputs.contentTurns = [...collectContentTurns(history), trimmed]; |
| 2072 | return hasTemplate |
| 2073 | ? { phase: 'format', inputs } |
| 2074 | : { phase: 'style', inputs }; |
| 2075 | } |
| 2076 | // Continue chatting (still in content phase). |
| 2077 | inputs.pickedType = lastCardPickByPhase(history, 'type'); |
| 2078 | inputs.contentTurns = [...collectContentTurns(history), trimmed]; |
| 2079 | return { phase: 'content', inputs }; |
| 2080 | } |
| 2081 | |
| 2082 | // Default fallback: treat as iterate. |
| 2083 | inputs.collected = lastFormSubmission(history); |
| 2084 | return { phase: 'iterate', inputs }; |
| 2085 | } |
| 2086 | |
| 2087 | /** Heuristic: how many content turns has the user given. Beyond 2 we move on. */ |
| 2088 | function hasEnoughContent(history: ChatMessage[], pending: string): boolean { |
| 2089 | const turns = collectContentTurns(history); |
| 2090 | return turns.length >= 2 || (turns.length >= 1 && pending.length > 60); |
| 2091 | } |
| 2092 | |
| 2093 | /** Find the most recent assistant card with a meta.phase, plus its kind. */ |
| 2094 | function lastAssistantCardWithMeta(history: ChatMessage[]): { |
| 2095 | kind: 'hv-options' | 'hv-form' | 'hv-confirm' | 'content-question'; |
| 2096 | metaPhase: string | null; |
| 2097 | } | null { |
| 2098 | for (let i = history.length - 1; i >= 0; i--) { |
| 2099 | const m = history[i]!; |
| 2100 | if (m.role !== 'assistant') continue; |
| 2101 | const c = m.content; |
| 2102 | if (!c.trim() || /^⚠️/.test(c.trim())) continue; |
| 2103 | // Try each card kind, JSON-parse the body, look for meta.phase. |
| 2104 | const cards: { kind: 'hv-options' | 'hv-form' | 'hv-confirm'; re: RegExp }[] = [ |
| 2105 | { kind: 'hv-confirm', re: /```hv-confirm\s*\n([\s\S]*?)```/i }, |
| 2106 | { kind: 'hv-form', re: /```hv-form\s*\n([\s\S]*?)```/i }, |
| 2107 | { kind: 'hv-options', re: /```hv-options\s*\n([\s\S]*?)```/i }, |
| 2108 | ]; |
| 2109 | for (const { kind, re } of cards) { |
| 2110 | const match = re.exec(c); |
| 2111 | if (match && match[1]) { |
| 2112 | let metaPhase: string | null = null; |
| 2113 | try { |
| 2114 | const parsed = JSON.parse(match[1].trim()); |
| 2115 | metaPhase = parsed?.meta?.phase ?? null; |
| 2116 | } catch { /* unparseable card body — treat as untagged */ } |
| 2117 | return { kind, metaPhase }; |
| 2118 | } |
| 2119 | } |
| 2120 | // No card → was this a content-question? Look for our marker. |
| 2121 | if (/<!--\s*hv-phase:content-question\s*-->/i.test(c)) { |
| 2122 | return { kind: 'content-question', metaPhase: 'content' }; |
| 2123 | } |
| 2124 | // A real assistant turn with no card and no marker — bail. |
| 2125 | return null; |
| 2126 | } |
| 2127 | return null; |
| 2128 | } |
| 2129 | |
| 2130 | /** Look back for the user message that answered an hv-options card with meta.phase=X. */ |
| 2131 | function lastCardPickByPhase(history: ChatMessage[], phase: string): string | undefined { |
| 2132 | for (let i = 0; i < history.length - 1; i++) { |
| 2133 | const a = history[i]!; |
| 2134 | const u = history[i + 1]!; |
| 2135 | if (a.role !== 'assistant' || u.role !== 'user') continue; |
| 2136 | const m = /```hv-options\s*\n([\s\S]*?)```/i.exec(a.content); |
| 2137 | if (!m || !m[1]) continue; |
| 2138 | try { |
| 2139 | const parsed = JSON.parse(m[1].trim()); |
| 2140 | if (parsed?.meta?.phase === phase) return u.content.trim(); |
| 2141 | } catch { /* ignore */ } |
| 2142 | } |
| 2143 | return undefined; |
| 2144 | } |
| 2145 | |
| 2146 | /** All free-text user replies during the content phase (between type-pick and style/format). */ |
| 2147 | /** A short user turn that just nudges the flow forward ("continue", "go", |
| 2148 | * "下一步", "开始生成") rather than supplying video content. Such turns must |
| 2149 | * not be collected as content — otherwise they end up as on-screen text. */ |
| 2150 | function isControlPhrase(t: string): boolean { |
| 2151 | const s = t.trim().toLowerCase().replace(/[。.!!~\s]+$/u, ''); |
| 2152 | if (s.length > 12) return false; // real content is longer; keep it |
| 2153 | return /^(继续|继续(刚刚|上次|之前)的?任务|接着|接着(来|做|生成)|下一步|开始(生成)?|生成(吧)?|go|continue|next|start|ok|好的?|行|走|动手|可以|确认)$/u.test(s); |
| 2154 | } |
| 2155 | |
| 2156 | function collectContentTurns(history: ChatMessage[]): string[] { |
| 2157 | const out: string[] = []; |
| 2158 | let inContent = false; |
| 2159 | for (let i = 0; i < history.length; i++) { |
| 2160 | const m = history[i]!; |
| 2161 | if (m.role === 'assistant') { |
| 2162 | const c = m.content; |
| 2163 | // Type pick assistant card opens content phase |
| 2164 | const typeMatch = /```hv-options\s*\n([\s\S]*?)```/i.exec(c); |
| 2165 | if (typeMatch && typeMatch[1]) { |
| 2166 | try { |
| 2167 | const parsed = JSON.parse(typeMatch[1].trim()); |
| 2168 | if (parsed?.meta?.phase === 'type') { inContent = true; continue; } |
| 2169 | if (parsed?.meta?.phase === 'style') { inContent = false; continue; } |
| 2170 | } catch { /* ignore */ } |
| 2171 | } |
| 2172 | if (/```hv-form\s*\n/i.test(c)) inContent = false; |
| 2173 | continue; |
| 2174 | } |
| 2175 | if (m.role !== 'user') continue; |
| 2176 | if (!inContent) continue; |
| 2177 | const t = m.content.trim(); |
| 2178 | if (!t) continue; |
| 2179 | if (t.startsWith('[hv-')) continue; // skip marker messages |
| 2180 | // Skip control phrases ("continue / next / go / 开始生成 …"). These are the |
| 2181 | // user nudging the flow forward, NOT video content — otherwise e.g. |
| 2182 | // "继续刚刚的任务" gets baked in as the opening frame's headline. |
| 2183 | if (isControlPhrase(t)) continue; |
| 2184 | // Skip the "trimmed answer" that picks the type — it's the first user turn |
| 2185 | // immediately after the type card; keep only later ones. |
| 2186 | if (out.length === 0) { |
| 2187 | // The very first user turn after a type card IS the type pick. Skip it. |
| 2188 | // (Subsequent turns in content phase get collected.) |
| 2189 | out.push('__TYPE_PICK__'); |
| 2190 | continue; |
| 2191 | } |
| 2192 | out.push(t); |
| 2193 | } |
| 2194 | return out.filter((t) => t !== '__TYPE_PICK__'); |
| 2195 | } |
| 2196 | |
| 2197 | /** |
| 2198 | * The video's LOCKED subject, in the user's own words. The opening message |
| 2199 | * ("帮我生成一个关于 Open Design 的介绍视频") names the subject, but it never |
| 2200 | * reached the generate / storyboard prompts: collectContentTurns() only keeps |
| 2201 | * turns after the type-pick card, so a later vague answer like "随机" became the |
| 2202 | * entire content input and the video came out about randomness instead of Open |
| 2203 | * Design. This recovers the opening subject so every downstream prompt can lock |
| 2204 | * onto it. |
| 2205 | * |
| 2206 | * Prefer the persisted project.intent, but the studio UI creates projects with |
| 2207 | * a name only (intent is almost always empty), so fall back to the first user |
| 2208 | * message in history — which is the genuine opening request. Strip the |
| 2209 | * attachment summary suffix appended to message content. |
| 2210 | */ |
| 2211 | function resolveOpeningTopic(project: { intent?: string }, history: ChatMessage[]): string { |
| 2212 | const fromIntent = project.intent?.trim(); |
| 2213 | if (fromIntent) return fromIntent.slice(0, 200); |
| 2214 | const firstUser = history.find((m) => m.role === 'user')?.content ?? ''; |
| 2215 | const clean = (firstUser.split('\n\n📎')[0] ?? '').trim(); |
| 2216 | // Don't lock onto a bare control phrase ("继续" / "ok") if that's somehow first. |
| 2217 | if (!clean || isControlPhrase(clean)) return ''; |
| 2218 | return clean.slice(0, 200); |
| 2219 | } |
| 2220 | |
| 2221 | // Legacy helper retained for backward calls — now delegates to detectPhase's |
| 2222 | // metadata-aware lookup. |
| 2223 | function lastAssistantCardKind(history: ChatMessage[]): 'hv-options' | 'hv-form' | 'hv-confirm' | null { |
| 2224 | for (let i = history.length - 1; i >= 0; i--) { |
| 2225 | const m = history[i]!; |
| 2226 | if (m.role !== 'assistant') continue; |
| 2227 | if (/```hv-confirm\s*\n/i.test(m.content)) return 'hv-confirm'; |
| 2228 | if (/```hv-form\s*\n/i.test(m.content)) return 'hv-form'; |
| 2229 | if (/```hv-options\s*\n/i.test(m.content)) return 'hv-options'; |
| 2230 | // Skip empty / warning-only assistant turns — the live card is one further back. |
| 2231 | if (!m.content.trim()) continue; |
| 2232 | if (/^⚠️/.test(m.content.trim())) continue; |
| 2233 | // A real assistant message with no card resets the search. |
| 2234 | return null; |
| 2235 | } |
| 2236 | return null; |
| 2237 | } |
| 2238 | |
| 2239 | function lastFormSubmission(history: ChatMessage[]): Record<string, string> | undefined { |
| 2240 | for (let i = history.length - 1; i >= 0; i--) { |
| 2241 | const m = history[i]!; |
| 2242 | if (m.role !== 'user') continue; |
| 2243 | const match = /^\[hv-form:submit\]\s*\n([\s\S]+)$/.exec(m.content.trim()); |
| 2244 | if (match && match[1]) { |
| 2245 | try { return JSON.parse(match[1]); } catch { /* keep scanning */ } |
| 2246 | } |
| 2247 | } |
| 2248 | return undefined; |
| 2249 | } |
| 2250 | |
| 2251 | /** Has a successful generation already happened in this conversation? */ |
| 2252 | function hadGenerationYet(history: ChatMessage[]): boolean { |
| 2253 | // Only count a real storyboard/video generation, not any assistant turn that |
| 2254 | // happens to contain a "✓". The old broad check (`✓\s`) matched the persisted |
| 2255 | // summary lines of the iteration sub-flow itself, so once you'd generated, the |
| 2256 | // flow could never leave 'iterate'. Look for concrete generation markers. |
| 2257 | return history.some( |
| 2258 | (m) => |
| 2259 | m.role === 'assistant' && |
| 2260 | /```json#content-graph|故事板规划完成|storyboard (generated|regenerated|restyled)|帧完成|frame .* (done|完成)/i.test(m.content), |
| 2261 | ); |
| 2262 | } |
| 2263 | |
| 2264 | /** |
| 2265 | * Was the most recent assistant turn asking the user for format params |
| 2266 | * (aspect / duration / frame count)? True for the proper `hv-form` card AND |
| 2267 | * for the prose fallback the model sometimes emits instead. Used to decide |
| 2268 | * whether a free-text user reply should be parsed as a format answer. |
| 2269 | */ |
| 2270 | function lastAssistantAskedFormat(history: ChatMessage[]): boolean { |
| 2271 | for (let i = history.length - 1; i >= 0; i--) { |
| 2272 | const m = history[i]!; |
| 2273 | if (m.role !== 'assistant') continue; |
| 2274 | const c = m.content; |
| 2275 | if (!c.trim() || /^⚠️/.test(c.trim())) continue; // skip empty / warning turns |
| 2276 | // The real hv-form card. |
| 2277 | const form = /```hv-form\s*\n([\s\S]*?)```/i.exec(c); |
| 2278 | if (form?.[1]) { |
| 2279 | try { return JSON.parse(form[1].trim())?.meta?.phase === 'format'; } catch { return true; } |
| 2280 | } |
| 2281 | // Prose fallback: the turn talks about size/duration/frames without a card. |
| 2282 | // Require at least two of the three concepts so an unrelated mention of |
| 2283 | // "时长" elsewhere doesn't trigger it. |
| 2284 | const hits = [/尺寸|横屏|竖屏|方形|aspect|比例/i, /时?长|秒|duration|\bs\b/i, /帧|frames?/i] |
| 2285 | .filter((re) => re.test(c)).length; |
| 2286 | return hits >= 2; |
| 2287 | } |
| 2288 | return false; |
| 2289 | } |
| 2290 | |
| 2291 | /** |
| 2292 | * Best-effort parse of format params from a FREE-TEXT user reply. |
| 2293 | * |
| 2294 | * The format step is supposed to render an `hv-form` card (segmented buttons) |
| 2295 | * whose submit carries an explicit `[hv-form:submit]` marker. But the model |
| 2296 | * sometimes ignores that instruction and instead asks for the params in prose |
| 2297 | * ("9:16 竖屏 / 3s / 6 …"); the user then types the answer free-form, with no |
| 2298 | * marker. Without this parser the state machine can't tell the params were |
| 2299 | * already given, so it loops — re-asking the same thing in a different shape |
| 2300 | * (issue #2). We extract aspect / duration / frame_count heuristically so a |
| 2301 | * typed reply is treated the same as a card submit. |
| 2302 | * |
| 2303 | * Returns undefined when the text carries no recognisable format signal, so |
| 2304 | * callers can fall through to other phase logic. |
| 2305 | */ |
| 2306 | export function parseFormatReply(text: string): Record<string, string> | undefined { |
| 2307 | const t = text.trim(); |
| 2308 | if (!t || t.length > 80) return undefined; // long text is content, not a format answer |
| 2309 | const out: Record<string, string> = {}; |
| 2310 | |
| 2311 | // --- aspect: explicit ratio (16:9 / 9:16 / 1:1 / 4:5) or a keyword --- |
| 2312 | const ratio = /\b(16\s*[::]\s*9|9\s*[::]\s*16|1\s*[::]\s*1|4\s*[::]\s*5)\b/.exec(t); |
| 2313 | const ratioNorm = ratio?.[1]?.replace(/\s/g, '').replace(':', ':'); |
| 2314 | if (ratioNorm === '16:9' || /横屏|landscape|宽屏/i.test(t)) out.aspect = '16:9 横屏'; |
| 2315 | else if (ratioNorm === '9:16' || /竖屏|手机|portrait|vertical/i.test(t)) out.aspect = '9:16 手机竖屏'; |
| 2316 | else if (ratioNorm === '1:1' || /方形|square/i.test(t)) out.aspect = '1:1 方形'; |
| 2317 | else if (ratioNorm === '4:5' || /小红书|xiaohongshu|rednote/i.test(t)) out.aspect = '4:5 小红书'; |
| 2318 | |
| 2319 | // --- duration: a number directly tied to seconds (5s / 5秒 / 5 sec) --- |
| 2320 | const dur = /(\d{1,3})\s*(?:s\b|秒|sec)/i.exec(t); |
| 2321 | if (dur?.[1]) out.duration = dur[1]; |
| 2322 | |
| 2323 | // --- frame_count: a number tied to 帧/frame, or the lone trailing number in |
| 2324 | // a "a / b / c" triple where a=ratio, b=duration. --- |
| 2325 | const fr = /(\d{1,2})\s*(?:帧|frames?)\b/i.exec(t); |
| 2326 | if (fr?.[1]) out.frame_count = fr[1]; |
| 2327 | else { |
| 2328 | // "16:9 横屏 / 5s / 10" — after stripping ratio+duration tokens, a bare |
| 2329 | // small integer left over is the frame count. |
| 2330 | const parts = t.split(/[/、,,]+/).map((s) => s.trim()).filter(Boolean); |
| 2331 | if (parts.length >= 2) { |
| 2332 | const last = parts[parts.length - 1]!; |
| 2333 | const bare = /^(\d{1,2})\s*帧?$/.exec(last); |
| 2334 | if (bare?.[1] && !/[::s秒]/.test(last)) out.frame_count = bare[1]; |
| 2335 | } |
| 2336 | } |
| 2337 | |
| 2338 | // Need at least one positively-identified signal to count as a format reply. |
| 2339 | return Object.keys(out).length > 0 ? out : undefined; |
| 2340 | } |
| 2341 | |
| 2342 | function lastTypePick(history: ChatMessage[]): string | undefined { |
| 2343 | // The first user turn that immediately follows the opener hv-options card. |
| 2344 | for (let i = 0; i < history.length - 1; i++) { |
| 2345 | const a = history[i]!; |
| 2346 | const u = history[i + 1]!; |
| 2347 | if (a.role === 'assistant' && u.role === 'user' && /```hv-options\s*\n/i.test(a.content)) { |
| 2348 | return u.content.trim(); |
| 2349 | } |
| 2350 | } |
| 2351 | return undefined; |
| 2352 | } |
| 2353 | |
| 2354 | /** |
| 2355 | * Render one attachment for the prompt. Text sources with inlined content get |
| 2356 | * their actual content fenced inline (so HTTP agents that can't read local |
| 2357 | * disk still see it); binary/path-only attachments stay a one-line reference. |
| 2358 | */ |
| 2359 | function renderAttachment(a: Attachment): string[] { |
| 2360 | if (a.inlineText) { |
| 2361 | return [ |
| 2362 | `- [${a.kind}] ${a.filename} — full content below:`, |
| 2363 | '```', |
| 2364 | a.inlineText, |
| 2365 | '```', |
| 2366 | ]; |
| 2367 | } |
| 2368 | return [`- [${a.kind}] ${a.filename} — ${a.path}`]; |
| 2369 | } |
| 2370 | |
| 2371 | /** A design.md / frame.md / DESIGN.md attachment is a brand + motion SPEC the |
| 2372 | * video must FOLLOW (palette, type, tokens, pacing/scale/dwell/motion), not |
| 2373 | * content to be narrated. Detect by filename or by the spec's tell-tale |
| 2374 | * headings, so users can drop in a design.md (portable design system) or |
| 2375 | * HeyGen-style frame.md (motion spec). */ |
| 2376 | function isDesignSpec(a: Attachment): boolean { |
| 2377 | const name = (a.filename || '').toLowerCase(); |
| 2378 | if (/(^|\/)(design|frame)\.md$/.test(name) || /\bframe\.md\b|\bdesign\.md\b/.test(name)) return true; |
| 2379 | const txt = a.inlineText ?? ''; |
| 2380 | if (!txt) return false; |
| 2381 | // Heading/section fingerprints shared by design.md & frame.md specs. |
| 2382 | return /#\s*(design|frame)\s*[—\-]/i.test(txt) |
| 2383 | || /(^|\n)##\s*(System|Theme|Tokens|Motion|Pacing|Composition)\b/i.test(txt) |
| 2384 | || /\b(pacing|dwell)\b.*\b(scale|motion)\b/i.test(txt); |
| 2385 | } |
| 2386 | |
| 2387 | /** Split attachments into design/motion specs vs ordinary source material. */ |
| 2388 | function partitionAttachments(atts: Attachment[]): { specs: Attachment[]; content: Attachment[] } { |
| 2389 | const specs: Attachment[] = []; |
| 2390 | const content: Attachment[] = []; |
| 2391 | for (const a of atts) (a.inlineText && isDesignSpec(a) ? specs : content).push(a); |
| 2392 | return { specs, content }; |
| 2393 | } |
| 2394 | |
| 2395 | /** Prompt block telling the agent to OBEY a design/frame spec. */ |
| 2396 | function renderDesignSpecBlock(specs: Attachment[]): string[] { |
| 2397 | if (!specs.length) return []; |
| 2398 | const out: string[] = [ |
| 2399 | `DESIGN SYSTEM / MOTION SPEC (REQUIRED — obey this for every frame): the file(s)`, |
| 2400 | `below define the brand's visual + motion language. Honour their palette,`, |
| 2401 | `typography, tokens, layout AND any motion direction (pacing, scale, dwell,`, |
| 2402 | `motion) over your own defaults. This is HOW the video must look/move; the`, |
| 2403 | `actual subject still comes from the user's content.`, |
| 2404 | ]; |
| 2405 | for (const a of specs) { |
| 2406 | out.push(`--- ${a.filename} ---`); |
| 2407 | out.push((a.inlineText ?? '').slice(0, 6000)); |
| 2408 | } |
| 2409 | out.push(''); |
| 2410 | return out; |
| 2411 | } |
| 2412 | |
| 2413 | /** LLMs emit not-quite-valid JSON for the content-graph more often than not: |
| 2414 | * trailing commas, and (now that we ask them to quote article terms) stray |
| 2415 | * straight double-quotes inside string values. Try strict parse first, then |
| 2416 | * escalate through cheap, safe repairs before giving up. */ |
| 2417 | function parseGraphJsonTolerant(raw: string): unknown { |
| 2418 | try { |
| 2419 | return JSON.parse(raw); |
| 2420 | } catch { |
| 2421 | /* fall through to repairs */ |
| 2422 | } |
| 2423 | // 1) Strip trailing commas before } or ] — the most common LLM slip. |
| 2424 | const noTrailing = raw.replace(/,(\s*[}\]])/g, '$1'); |
| 2425 | try { |
| 2426 | return JSON.parse(noTrailing); |
| 2427 | } catch { |
| 2428 | /* fall through */ |
| 2429 | } |
| 2430 | // 2) Escape stray straight double-quotes inside synopsis/text string values |
| 2431 | // (e.g. text: "the "harness" idea"). Operate on the trailing-comma-cleaned |
| 2432 | // text; for each "<key>": "<value>" pair, re-escape any bare " in <value>. |
| 2433 | const repaired = noTrailing.replace( |
| 2434 | /("(?:synopsis|text)"\s*:\s*")([\s\S]*?)("\s*(?:,|\}|\]))/g, |
| 2435 | (_m, pre: string, val: string, post: string) => |
| 2436 | pre + val.replace(/\\?"/g, '\\"') + post, |
| 2437 | ); |
| 2438 | return JSON.parse(repaired); // if this still throws, caller reports it |
| 2439 | } |
| 2440 | |
| 2441 | /** A content type is multi-frame UNLESS it's an explicitly single-frame kind |
| 2442 | * (title card / cover / single still). Whitelisting "讲解/explainer/…" was too |
| 2443 | * narrow — e.g. "概念解说短片" (解说, not 讲解) fell through to single-frame. |
| 2444 | * Inverting the test makes new/renamed multi-frame types default correctly. */ |
| 2445 | function isMultiFrameType(pickedType: string): boolean { |
| 2446 | if (!pickedType) return false; |
| 2447 | const single = /单帧|单画面|标题卡|封面|logo|title.?card|single.?frame|cover|still/i.test(pickedType); |
| 2448 | return !single; |
| 2449 | } |
| 2450 | |
| 2451 | function buildStylePhasePrompt(pickedType: string): string { |
| 2452 | const p: string[] = []; |
| 2453 | p.push(`The user has shared their content for a "${pickedType}". Now ask them about visual style with ONE hv-options card. JSON shape EXACTLY as shown — keep "meta" verbatim:`); |
| 2454 | p.push('```hv-options'); |
| 2455 | p.push(JSON.stringify({ |
| 2456 | meta: { phase: 'style' }, |
| 2457 | question: '视觉风格怎么定?', |
| 2458 | options: [ |
| 2459 | { label: 'Cyberpunk glitch', hint: '霓虹 / 故障感 / 高对比' }, |
| 2460 | { label: 'Swiss minimalist', hint: '网格 / 无衬线 / 留白' }, |
| 2461 | { label: 'Warm-grain magazine', hint: '纸感 / 衬线 / 暖色' }, |
| 2462 | { label: 'Mono brutalist', hint: '黑白 / 块状 / 粗体' }, |
| 2463 | { label: '从设计模板选', hint: '上方挑一个现成模板' }, |
| 2464 | ], |
| 2465 | allow_freeform: true, |
| 2466 | }, null, 2)); |
| 2467 | p.push('```'); |
| 2468 | p.push(''); |
| 2469 | p.push(`Add ONE short sentence above the card in the user's language inviting them to pick or describe a vibe. Mention they can also upload a reference image via the 📎 button.`); |
| 2470 | p.push(''); |
| 2471 | p.push(`Do NOT write HTML this turn. Do NOT return an empty reply.`); |
| 2472 | return p.join('\n'); |
| 2473 | } |
| 2474 | |
| 2475 | function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { |
| 2476 | const { tmpl, exampleHtml, priorHtml, history, userText, attachments, openingTopic } = args; |
| 2477 | |
| 2478 | // When a template is selected, its own source HTML is the style ground truth — |
| 2479 | // NOT a prior render. Otherwise a project that was previously rendered in some |
| 2480 | // other look would keep feeding that stale look back in as "the style to draw |
| 2481 | // from", and the freshly-picked template gets ignored. Only fall back to |
| 2482 | // priorHtml (iterate-on-last-render) when no template is in play. |
| 2483 | const baseHtml = tmpl |
| 2484 | ? exampleHtml |
| 2485 | : (priorHtml && priorHtml !== exampleHtml ? priorHtml : exampleHtml); |
| 2486 | const trimmed = userText.trim(); |
| 2487 | // A fetched article / repo / uploaded doc carries inlined content — that IS |
| 2488 | // the topic, so we should not interrogate the user about what the video is |
| 2489 | // about. The source rides into every phase's prompt via `attachments`. |
| 2490 | const hasSourceMaterial = attachments.some((a) => !!a.inlineText); |
| 2491 | const { phase, inputs } = detectPhase(history, userText, !!tmpl, hasSourceMaterial, args.focusFrameId ?? ''); |
| 2492 | |
| 2493 | // ---- edit-menu: post-generation "what do you want to change?" card ---- |
| 2494 | if (phase === 'edit-menu') { |
| 2495 | const em: string[] = []; |
| 2496 | em.push(`The user wants to change the already-generated video but hasn't said what. Reply with ONE short line in their language asking what to change, then ONE fenced \`\`\`hv-options block. Use this EXACT JSON — keep "meta" verbatim:`); |
| 2497 | em.push('```hv-options'); |
| 2498 | em.push(JSON.stringify({ |
| 2499 | meta: { phase: 'edit-menu' }, |
| 2500 | question: '想改哪方面?', |
| 2501 | options: [ |
| 2502 | { label: '🎨 换风格', hint: '保留内容,换一套视觉风格' }, |
| 2503 | { label: '✏️ 改内容', hint: '改文案 / 主题 / 重写脚本' }, |
| 2504 | { label: '⏱️ 改时长', hint: '调整每帧时长 / 节奏' }, |
| 2505 | ], |
| 2506 | allow_freeform: true, |
| 2507 | }, null, 2)); |
| 2508 | em.push('```'); |
| 2509 | em.push(''); |
| 2510 | em.push(`Do NOT write HTML this turn. Do NOT return an empty reply. The hv-options block is REQUIRED.`); |
| 2511 | return em.join('\n'); |
| 2512 | } |
| 2513 | |
| 2514 | // ---- opener: hv-options card with meta.phase = "type" ---- |
| 2515 | if (phase === 'opener') { |
| 2516 | const opener: string[] = []; |
| 2517 | opener.push( |
| 2518 | `The user just opened a project and said "${trimmed}". You are an HTML-video creation assistant.`, |
| 2519 | ); |
| 2520 | opener.push(''); |
| 2521 | opener.push(`Reply with TWO things, in this exact order:`); |
| 2522 | opener.push(`1. ONE friendly opening sentence in the user's language (≤ 25 chars).`); |
| 2523 | opener.push(`2. A fenced \`\`\`hv-options block with the 4 content-type choices below. JSON shape EXACTLY as shown — do not change keys or omit "meta":`); |
| 2524 | opener.push('```hv-options'); |
| 2525 | opener.push(JSON.stringify({ |
| 2526 | meta: { phase: 'type' }, |
| 2527 | question: '想做哪种内容?', |
| 2528 | options: [ |
| 2529 | { label: '单帧标题卡', hint: 'logo / 封面 / 单画面 - 5-10s' }, |
| 2530 | { label: '多帧预告片', hint: '产品 / 活动 teaser, 3-6 帧' }, |
| 2531 | { label: '数据大字报', hint: '1-2 个核心数字, 社媒爆款风' }, |
| 2532 | { label: '概念解说短片', hint: '几帧讲清一个 idea / feature' }, |
| 2533 | ], |
| 2534 | allow_freeform: true, |
| 2535 | }, null, 2)); |
| 2536 | opener.push('```'); |
| 2537 | opener.push(''); |
| 2538 | if (tmpl) { |
| 2539 | opener.push( |
| 2540 | `Note: a template "${tmpl.name}" is currently selected (${tmpl.description}). Treat it as a visual style reference only — content type still drives the structure.`, |
| 2541 | ); |
| 2542 | opener.push(''); |
| 2543 | } |
| 2544 | opener.push(`Do NOT write HTML this turn. Do NOT return an empty reply. The hv-options block is REQUIRED.`); |
| 2545 | return opener.join('\n'); |
| 2546 | } |
| 2547 | |
| 2548 | // ---- content: free chat asking about topic / headline / data ---- |
| 2549 | if (phase === 'content') { |
| 2550 | const pickedType = inputs.pickedType ?? ''; |
| 2551 | const turns = inputs.contentTurns ?? []; |
| 2552 | const p: string[] = []; |
| 2553 | |
| 2554 | // Source material present → DON'T interrogate. The article/repo content is |
| 2555 | // the topic; acknowledge it and let the flow advance to style/format. |
| 2556 | if (hasSourceMaterial) { |
| 2557 | p.push(`The user is making a ${pickedType ? `"${pickedType}"` : 'video'} based on the source material below — do NOT ask them what it's about, the content is already provided.`); |
| 2558 | p.push(''); |
| 2559 | for (const a of attachments) p.push(...renderAttachment(a)); |
| 2560 | p.push(''); |
| 2561 | p.push(`In the user's language, write ONE short line that names the actual topic/title you read from the source and states the video will be built from it (e.g. "好,我读完了《…》这篇文章 — 这就基于它生成。下一步选风格。"). Do NOT ask the user to retype or summarize anything. End with this hidden marker on its own line:`); |
| 2562 | p.push('<!-- hv-phase:content-question -->'); |
| 2563 | p.push(''); |
| 2564 | p.push(`Plain text + the marker only. NO code blocks. NO questions. Do NOT return an empty reply.`); |
| 2565 | return p.join('\n'); |
| 2566 | } |
| 2567 | |
| 2568 | p.push(`The user is making a ${pickedType ? `"${pickedType}"` : 'video'}. Collect concrete content for it via natural conversation — DO NOT emit any code block, hv-options, hv-form, or hv-confirm. End your reply with this hidden marker on its own line so the server knows you're still in the content phase:`); |
| 2569 | p.push('<!-- hv-phase:content-question -->'); |
| 2570 | p.push(''); |
| 2571 | p.push(`Goal: surface what the video is ABOUT (topic, brand / project name, headline / tagline, key numbers or data points). The user can answer, partially answer, or say "随便发挥 / skip / 不知道" — accept whatever they give and move on.`); |
| 2572 | p.push(''); |
| 2573 | // The user's opening request already names the subject (e.g. "做一个 Open |
| 2574 | // Design 推广视频"). Lock onto it: don't let a vague follow-up answer like |
| 2575 | // "随机/随便/anything" silently become a literal NEW topic — that's how a |
| 2576 | // "promote Open Design" request turned into a probability explainer. |
| 2577 | { |
| 2578 | const openingTopic = history.find((m) => m.role === 'user')?.content?.trim().slice(0, 200); |
| 2579 | if (openingTopic) { |
| 2580 | p.push(`The user's ORIGINAL opening request was: "${openingTopic}". Treat this as the LOCKED subject of the video unless the user clearly asks to change it.`); |
| 2581 | p.push(`If the user's answer this turn CONTRADICTS or seems unrelated to that subject (e.g. they opened with a product/brand video but now answer with an off-topic word), do NOT silently switch topics. Ask ONE short clarifying question: keep the original subject (with the new word as a detail/example/angle), or genuinely change the subject? Treat vague answers like "随机 / 随便 / anything / 你定 / whatever" as "you decide the details, KEEP the original subject" — never as a literal new topic.`); |
| 2582 | p.push(''); |
| 2583 | } |
| 2584 | } |
| 2585 | if (turns.length === 0) { |
| 2586 | p.push(`This is the first content turn. Ask 1–3 short, sharp questions, in the user's language. Keep it under 60 words. Mention they can answer fully, partially, or just say "skip" / "随便".`); |
| 2587 | } else { |
| 2588 | p.push(`The user has already shared:`); |
| 2589 | for (const t of turns) p.push(` - ${t.slice(0, 200)}`); |
| 2590 | p.push(''); |
| 2591 | p.push(`Two options:`); |
| 2592 | p.push(`- If you still need more info: ask ONE clarifying question and end your reply with the marker on its own line: <!-- hv-phase:content-question -->`); |
| 2593 | p.push(`- If you have enough: write ONLY a one-line confirmation in the user's language (e.g. "好,我有思路了,下一步是风格。" / "Got it. Next: style."). Do NOT add the marker — the server will advance to style automatically.`); |
| 2594 | } |
| 2595 | p.push(''); |
| 2596 | p.push(`Reply in plain text. NO code blocks. Do NOT return an empty reply.`); |
| 2597 | return p.join('\n'); |
| 2598 | } |
| 2599 | |
| 2600 | // ---- style: hv-options card with style presets + "pick template" + freeform ---- |
| 2601 | if (phase === 'style') { |
| 2602 | return buildStylePhasePrompt(inputs.pickedType ?? ''); |
| 2603 | } |
| 2604 | |
| 2605 | // ---- need-template: user chose "from design template" but hasn't picked one |
| 2606 | if (phase === 'need-template') { |
| 2607 | const p: string[] = []; |
| 2608 | p.push(`The user chose "从设计模板选" (use a design template) but has NOT selected a template yet. Do NOT generate. Tell them — in their language, ONE short friendly line — to pick a template from the top-bar 模板 / Template dropdown, then offer this card so they can confirm once they've picked, or switch to a built-in style instead. JSON shape EXACTLY — keep "meta" verbatim:`); |
| 2609 | p.push('```hv-options'); |
| 2610 | p.push(JSON.stringify({ |
| 2611 | meta: { phase: 'need-template' }, |
| 2612 | question: '先在顶部「模板」里选一个模板,选好后点下面继续;或直接选一种内置风格:', |
| 2613 | options: [ |
| 2614 | { label: '我已选好模板,继续', hint: '用顶部选中的模板生成' }, |
| 2615 | { label: 'Cyberpunk glitch', hint: '霓虹 / 故障感 / 高对比' }, |
| 2616 | { label: 'Swiss minimalist', hint: '网格 / 无衬线 / 留白' }, |
| 2617 | { label: 'Warm-grain magazine',hint: '纸感 / 衬线 / 暖色' }, |
| 2618 | { label: 'Mono brutalist', hint: '黑白 / 块状 / 粗体' }, |
| 2619 | ], |
| 2620 | allow_freeform: true, |
| 2621 | }, null, 2)); |
| 2622 | p.push('```'); |
| 2623 | p.push(''); |
| 2624 | p.push(`Do NOT write HTML this turn. Do NOT return an empty reply.`); |
| 2625 | return p.join('\n'); |
| 2626 | } |
| 2627 | |
| 2628 | // ---- format / format-edit: hv-form with 3 segmented controls ---- |
| 2629 | if (phase === 'format' || phase === 'format-edit') { |
| 2630 | const isEdit = phase === 'format-edit'; |
| 2631 | const pre = inputs.collected ?? {}; |
| 2632 | const pickedType = isEdit |
| 2633 | ? lastCardPickByPhase(history, 'type') ?? '' |
| 2634 | : (inputs.pickedType ?? ''); |
| 2635 | const isMulti = !!pickedType && isMultiFrameType(pickedType); |
| 2636 | const defaults = { |
| 2637 | aspect: pre.aspect ?? '16:9 横屏', |
| 2638 | duration: pre.duration ?? (isMulti ? '15' : '5'), |
| 2639 | frame_count: pre.frame_count ?? (isMulti ? '4' : '1'), |
| 2640 | // Per-frame pacing default 4s — comfortable, avoids the "rushed" feel a |
| 2641 | // short total ÷ many frames produces. Total is derived from this × frames. |
| 2642 | per_frame: pre.per_frame ?? '4', |
| 2643 | }; |
| 2644 | const p: string[] = []; |
| 2645 | if (isEdit) { |
| 2646 | p.push(`The user wants to revise the format. Re-emit the SAME hv-form card with each \`default\` set to their last answer so they only need to change what they want.`); |
| 2647 | } else { |
| 2648 | p.push(`Now ask about format with ONE hv-form card — three segmented controls, no text inputs. JSON shape EXACTLY as shown — keep "meta" verbatim:`); |
| 2649 | } |
| 2650 | // The card is the ONLY acceptable way to ask this. Asking in prose makes |
| 2651 | // the user type a free-form answer with no submit marker, which the flow |
| 2652 | // then fails to recognise and re-asks (issue #2). |
| 2653 | p.push(`IMPORTANT: emit the hv-form card below — do NOT ask for size / duration / frames in plain prose, and do NOT list example answers for the user to type.`); |
| 2654 | p.push('```hv-form'); |
| 2655 | p.push(JSON.stringify({ |
| 2656 | meta: { phase: 'format' }, |
| 2657 | title: isEdit ? '改一下格式' : (isMulti ? '最后一步:尺寸 / 每帧时长 / 帧数' : '最后一步:选个尺寸 / 时长'), |
| 2658 | fields: [ |
| 2659 | { |
| 2660 | key: 'aspect', label: '画面尺寸', kind: 'buttons', required: true, |
| 2661 | default: defaults.aspect, |
| 2662 | options: [ |
| 2663 | { value: '16:9 横屏', label: '16:9 横屏' }, |
| 2664 | { value: '9:16 手机竖屏', label: '9:16 竖屏' }, |
| 2665 | { value: '1:1 方形', label: '1:1 方形' }, |
| 2666 | { value: '4:5 小红书', label: '4:5 小红书' }, |
| 2667 | ], |
| 2668 | }, |
| 2669 | // Multi-frame: pace by PER-FRAME duration (total = per_frame × frames, |
| 2670 | // shown live). Single-frame: just a total duration. |
| 2671 | ...(isMulti |
| 2672 | ? [ |
| 2673 | { |
| 2674 | key: 'per_frame', label: '每帧时长 (秒)', kind: 'buttons', required: true, |
| 2675 | default: defaults.per_frame, |
| 2676 | hint: '总时长 = 每帧时长 × 帧数', |
| 2677 | options: ['2', '3', '4', '5', '6', '8'].map((v) => ({ value: v, label: `${v}s` })), |
| 2678 | }, |
| 2679 | { |
| 2680 | key: 'frame_count', label: '帧数', kind: 'buttons', required: true, |
| 2681 | default: defaults.frame_count, |
| 2682 | options: ['2', '3', '4', '5', '6', '7', '8', '9', '10'].map((v) => ({ value: v, label: v })), |
| 2683 | }, |
| 2684 | // Opt-in: render data frames natively with Remotion (numbers roll, |
| 2685 | // bars grow) instead of static hyperframes HTML. Default OFF — |
| 2686 | // Remotion is a user-chosen enhancement, the AI never flips it. |
| 2687 | { |
| 2688 | key: 'remotion_enhance', label: '⚡ 数据帧用 Remotion', kind: 'buttons', required: false, |
| 2689 | default: '关', |
| 2690 | hint: '数据帧用原生 Remotion 渲染(数字滚动 / 柱子生长);其余帧仍走 Hyperframes', |
| 2691 | options: [ |
| 2692 | { value: '关', label: '关' }, |
| 2693 | { value: '开', label: '开 · Remotion' }, |
| 2694 | ], |
| 2695 | }, |
| 2696 | ] |
| 2697 | : [ |
| 2698 | { |
| 2699 | key: 'duration', label: '时长 (秒)', kind: 'buttons', required: true, |
| 2700 | default: defaults.duration, |
| 2701 | options: ['3', '5', '10', '15'].map((v) => ({ value: v, label: `${v}s` })), |
| 2702 | }, |
| 2703 | ]), |
| 2704 | ], |
| 2705 | allow_attachments: false, |
| 2706 | }, null, 2)); |
| 2707 | p.push('```'); |
| 2708 | p.push(''); |
| 2709 | p.push(`Do NOT write HTML this turn. Do NOT return an empty reply.`); |
| 2710 | return p.join('\n'); |
| 2711 | } |
| 2712 | |
| 2713 | // ---- confirm: emit hv-confirm summarising what was collected ---- |
| 2714 | if (phase === 'confirm') { |
| 2715 | const collected = inputs.collected ?? {}; |
| 2716 | const pickedType = lastCardPickByPhase(history, 'type') ?? ''; |
| 2717 | const pickedStyle = lastCardPickByPhase(history, 'style') ?? ''; |
| 2718 | const contentTurns = collectContentTurns(history); |
| 2719 | const summaryRows: { label: string; value: string }[] = []; |
| 2720 | if (pickedType) summaryRows.push({ label: '类型', value: pickedType }); |
| 2721 | if (contentTurns.length > 0) { |
| 2722 | summaryRows.push({ label: '内容', value: contentTurns.join(' · ').slice(0, 240) }); |
| 2723 | } |
| 2724 | if (pickedStyle) summaryRows.push({ label: '风格', value: pickedStyle }); |
| 2725 | if (tmpl) summaryRows.push({ label: '模板', value: tmpl.name }); |
| 2726 | const labelMap: Record<string, string> = { |
| 2727 | aspect: '尺寸', duration: '时长', frame_count: '帧数', per_frame: '每帧时长', |
| 2728 | }; |
| 2729 | // When pacing by per-frame, show per-frame + frames + derived total. |
| 2730 | const pf = Number(collected.per_frame ?? '') || 0; |
| 2731 | const keys = pf > 0 ? ['aspect', 'per_frame', 'frame_count'] : ['aspect', 'duration', 'frame_count']; |
| 2732 | for (const k of keys) { |
| 2733 | const v = collected[k]; |
| 2734 | if (v) summaryRows.push({ label: labelMap[k] ?? k, value: k === 'per_frame' ? `${v}s` : v }); |
| 2735 | } |
| 2736 | if (pf > 0) { |
| 2737 | const frames = Number(collected.frame_count ?? '4') || 4; |
| 2738 | summaryRows.push({ label: '总时长', value: `${pf * frames}s` }); |
| 2739 | } |
| 2740 | if (attachments.length > 0) { |
| 2741 | summaryRows.push({ label: '素材', value: attachments.map((a) => a.filename).join(', ') }); |
| 2742 | } |
| 2743 | |
| 2744 | const p: string[] = []; |
| 2745 | p.push(`The user has chosen the format. Emit ONE \`\`\`hv-confirm block (no other code blocks) summarising what you've got, in the user's language. Use this exact JSON — keep "meta":`); |
| 2746 | p.push(''); |
| 2747 | p.push('```hv-confirm'); |
| 2748 | p.push(JSON.stringify({ |
| 2749 | meta: { phase: 'confirm' }, |
| 2750 | title: '按这些信息生成?', |
| 2751 | summary: summaryRows, |
| 2752 | actions: ['generate', 'edit'], |
| 2753 | }, null, 2)); |
| 2754 | p.push('```'); |
| 2755 | p.push(''); |
| 2756 | // Soft gate: if the subject is too thin to make a meaningful video, nudge |
| 2757 | // the user to add a concrete topic/brand/number — but never block, the card |
| 2758 | // still ships with both actions so they can proceed as-is. |
| 2759 | const contentBlob = contentTurns.join(' ').trim(); |
| 2760 | const topicThin = |
| 2761 | attachments.length === 0 && |
| 2762 | (contentBlob.replace(/\s/g, '').length < 8 || |
| 2763 | /^(随机|随便|anything|random|whatever|都行|你定|skip|不知道)$/i.test(contentBlob)); |
| 2764 | if (topicThin) { |
| 2765 | p.push(`NOTE: the collected content ("${contentBlob || '(empty)'}") is very thin / vague. BEFORE the hv-confirm block, add ONE short friendly sentence in the user's language gently flagging that the topic is sparse and inviting them to add a concrete subject / brand / key number for a stronger video — but STILL emit the hv-confirm block exactly as above so they can generate anyway if they want.`); |
| 2766 | p.push(''); |
| 2767 | } |
| 2768 | p.push(`Do NOT write HTML this turn. Do NOT return an empty reply. The hv-confirm block is REQUIRED.`); |
| 2769 | return p.join('\n'); |
| 2770 | } |
| 2771 | |
| 2772 | // ---- generate: actually write the HTML / content-graph ---- |
| 2773 | if (phase === 'generate') { |
| 2774 | const collected = inputs.collected ?? {}; |
| 2775 | const pickedType = inputs.pickedType ?? ''; |
| 2776 | const pickedStyle = inputs.pickedStyle ?? ''; |
| 2777 | const contentTurns = inputs.contentTurns ?? []; |
| 2778 | const aspect = ((collected.aspect ?? '16:9').split(/\s+/)[0] ?? '16:9'); // strip "16:9 横屏" → "16:9" |
| 2779 | const [w, h] = aspect.includes(':') ? aspect.split(':').map(Number) : [16, 9]; |
| 2780 | const isMulti = isMultiFrameType(pickedType) |
| 2781 | || Number(collected.frame_count ?? '1') > 1 |
| 2782 | || Number(collected.per_frame ?? '0') > 0; |
| 2783 | |
| 2784 | // Pick a concrete pixel resolution that respects the aspect choice. |
| 2785 | let resolution = '1920×1080'; |
| 2786 | if (aspect === '9:16') resolution = '1080×1920'; |
| 2787 | else if (aspect === '1:1') resolution = '1080×1080'; |
| 2788 | else if (aspect === '4:5') resolution = '1080×1350'; |
| 2789 | |
| 2790 | const styleLabel = pickedStyle && /^从设计模板选|template/i.test(pickedStyle) |
| 2791 | ? (tmpl ? `(use the selected template "${tmpl.name}" — ${tmpl.description})` : '(let the model choose)') |
| 2792 | : pickedStyle; |
| 2793 | |
| 2794 | const p: string[] = []; |
| 2795 | p.push(`Generate the HTML video file(s) the user just confirmed.`); |
| 2796 | p.push(''); |
| 2797 | // Lock the subject to the user's opening request. The content turns below |
| 2798 | // can be as thin as "随机" — without this the video drifts onto that literal |
| 2799 | // word (a "promote Open Design" request became a randomness explainer). |
| 2800 | if (openingTopic) { |
| 2801 | p.push(`VIDEO SUBJECT (LOCKED): the user opened with "${openingTopic}". The video MUST be about THIS subject.`); |
| 2802 | p.push(`If a content line below is a vague placeholder like "随机 / 随便 / anything / 你定 / whatever", it means "YOU choose the concrete details (selling points, framing, copy) — but the SUBJECT stays "${openingTopic}"". NEVER treat "随机" as the literal topic; do NOT make a video about randomness.`); |
| 2803 | p.push(''); |
| 2804 | } |
| 2805 | p.push(`Inputs (use these LITERALLY — do NOT make up brand names or facts beyond what is stated):`); |
| 2806 | p.push(`- 类型 / type: ${pickedType || '(未指定)'}`); |
| 2807 | if (contentTurns.length > 0) { |
| 2808 | p.push(`- 内容 / content (what the user told us in the chat):`); |
| 2809 | for (const t of contentTurns) p.push(` · ${t.replace(/\n/g, ' ').slice(0, 280)}`); |
| 2810 | } else { |
| 2811 | p.push(`- 内容 / content: (the user did not specify; pick a sensible default that fits the type, but keep it generic — no fake brand names)`); |
| 2812 | } |
| 2813 | if (styleLabel) p.push(`- 风格 / style: ${styleLabel}`); |
| 2814 | p.push(`- 画面尺寸: ${aspect} (${resolution})`); |
| 2815 | p.push(`- 时长: ${collected.duration ?? '?'} 秒`); |
| 2816 | p.push(`- 帧数: ${collected.frame_count ?? (isMulti ? '4' : '1')}`); |
| 2817 | p.push(''); |
| 2818 | if (attachments.length > 0) { |
| 2819 | const { specs, content } = partitionAttachments(attachments); |
| 2820 | // A design.md / frame.md is a style+motion spec to OBEY, surfaced first. |
| 2821 | p.push(...renderDesignSpecBlock(specs)); |
| 2822 | if (content.length > 0 || specs.length === 0) { |
| 2823 | p.push(`Attachments:`); |
| 2824 | for (const a of (content.length ? content : attachments)) p.push(...renderAttachment(a)); |
| 2825 | p.push(`Use binary attachments (images, data files) as actual assets where appropriate (logo, screenshot, data file). The inlined text/article/repo content above is the SOURCE MATERIAL — base the video's actual content (facts, names, numbers, narrative) on it, don't just decorate with it.`); |
| 2826 | p.push(''); |
| 2827 | } |
| 2828 | } |
| 2829 | p.push(`Constraints: full-bleed ${resolution}, opens with an animation timeline, inline CSS + JS, single complete <!doctype html>...</html> document(s). CDN imports (Tailwind, GSAP) are fine. Tag every visible text node with data-hv-text set to a stable key (brand_name, headline, item_1, cta…). No prose outside code blocks.`); |
| 2830 | p.push(''); |
| 2831 | // Frame-count safety: claude --print can truncate / stall on very large |
| 2832 | // multi-frame batches. Cap at 10 (high frame counts get progressively |
| 2833 | // less reliable in a single pass), and tell the model so it can plan. |
| 2834 | const requestedFrames = Math.max(1, Math.min(10, Number(collected.frame_count ?? '4') || 4)); |
| 2835 | // ⚠️ FALLBACK ONLY. Real multi-frame generation goes through |
| 2836 | // runSplitMultiFrameGenerate (the server routes frame_count>1 there before |
| 2837 | // ever reaching this single-shot prompt). This branch only fires if that |
| 2838 | // routing is bypassed. If you change multi-frame grounding / template / |
| 2839 | // source-material rules, change runSplitMultiFrameGenerate — that's the |
| 2840 | // path users actually hit. Keep the two in sync. |
| 2841 | if (isMulti) { |
| 2842 | p.push(`Output (multi-frame storyboard) — emit IN THIS EXACT ORDER and SHAPE:`); |
| 2843 | p.push(`1. ONE \`\`\`json#content-graph block.`); |
| 2844 | p.push(`2. ONE \`\`\`html#<nodeId> block per node.`); |
| 2845 | p.push(''); |
| 2846 | p.push(`Aim for ${requestedFrames} frames. Each frame should be self-contained, full-bleed ${resolution}, with its own opening animation. Nothing between blocks.`); |
| 2847 | p.push(''); |
| 2848 | if (attachments.length > 0) { |
| 2849 | // The agent has, in practice, been handed the full article yet fallen |
| 2850 | // back to generic "first-principles / see-the-essence" filler. Force it |
| 2851 | // to ground every node in the source material's actual specifics. |
| 2852 | p.push(`GROUNDING (REQUIRED — the source material above is the script, not decoration):`); |
| 2853 | p.push(`- EVERY node's "text" MUST quote or paraphrase a SPECIFIC fact, name, number, product, or claim from the source material. Pull the real proper nouns (product names, companies, metrics, version numbers) verbatim.`); |
| 2854 | p.push(`- The "synopsis" MUST name the article's actual subject — not "AI/technology trends" or any vague category.`); |
| 2855 | p.push(`- BANNED: generic motivational filler with no tie to the source ("看清本质", "第一性原理", "复杂表象之下", "you really understand…", "the logic behind…"). If a line would fit ANY article, it is wrong — replace it with something that could ONLY come from THIS source.`); |
| 2856 | p.push(`- A reader who knows the article must recognize each frame as being about it; a reader who doesn't must learn its specific points.`); |
| 2857 | p.push(''); |
| 2858 | } |
| 2859 | // Skeleton for multi-frame — empirically claude --print returns 1 byte |
| 2860 | // without an example, ~10KB with one. Show the exact shape, even with |
| 2861 | // placeholder content; the model fills it in. |
| 2862 | p.push(`Skeleton (replace placeholders with the inputs above; expand styling per the chosen type / style):`); |
| 2863 | p.push('```json#content-graph'); |
| 2864 | p.push(JSON.stringify({ |
| 2865 | schemaVersion: 1, |
| 2866 | intent: 'explainer', |
| 2867 | synopsis: '<one-line description>', |
| 2868 | nodes: Array.from({ length: requestedFrames }, (_, i) => ({ |
| 2869 | id: `frame_${i + 1}`, |
| 2870 | kind: i === 0 ? 'text' : i === requestedFrames - 1 ? 'entity' : (i % 2 ? 'data' : 'text'), |
| 2871 | durationSec: Math.max(2, Math.floor(Number(collected.duration ?? '15') / requestedFrames)), |
| 2872 | })), |
| 2873 | edges: Array.from({ length: requestedFrames - 1 }, (_, i) => ({ |
| 2874 | from: `frame_${i + 1}`, |
| 2875 | to: `frame_${i + 2}`, |
| 2876 | kind: 'sequence', |
| 2877 | })), |
| 2878 | }, null, 2)); |
| 2879 | p.push('```'); |
| 2880 | p.push(''); |
| 2881 | p.push('```html#frame_1'); |
| 2882 | p.push(`<!doctype html> |
| 2883 | <html><head><meta charset="utf-8"><style> |
| 2884 | html,body{margin:0;height:100%;background:#000;color:#fff;overflow:hidden;font-family:system-ui,sans-serif} |
| 2885 | .stage{width:100vw;height:100vh;display:grid;place-items:center;text-align:center;padding:6vw} |
| 2886 | h1{font-size:8vw;letter-spacing:-.03em;animation:in 1s ease forwards;opacity:0;transform:translateY(24px)} |
| 2887 | @keyframes in{to{opacity:1;transform:none}} |
| 2888 | </style></head><body> |
| 2889 | <div class="stage"><h1 data-hv-text="headline">PLACEHOLDER</h1></div> |
| 2890 | </body></html>`); |
| 2891 | p.push('```'); |
| 2892 | p.push(''); |
| 2893 | p.push(`(continue with the same shape for the remaining frames — \`\`\`html#frame_2 … \`\`\`html#frame_${requestedFrames})`); |
| 2894 | if (baseHtml && baseHtml.length > 0) { |
| 2895 | p.push(''); |
| 2896 | p.push(tmpl |
| 2897 | ? `Template HTML — this is the REQUIRED visual style. Reuse its palette, layout, typography, and animation approach; change only the text/data to fit the source material. Do NOT switch to a different look (no dark "cosmic particle" default, etc.):` |
| 2898 | : `Prior preview HTML to draw style from:`); |
| 2899 | p.push('```html'); |
| 2900 | p.push(baseHtml.slice(0, 3000)); |
| 2901 | p.push('```'); |
| 2902 | } |
| 2903 | } else { |
| 2904 | p.push(`Output (single-frame): begin your reply with \`\`\`html and end with \`\`\`. Nothing outside the block.`); |
| 2905 | p.push(''); |
| 2906 | if (baseHtml && baseHtml.length > 0) { |
| 2907 | p.push(tmpl |
| 2908 | ? `Template HTML — this is the REQUIRED visual style. Reuse its palette, layout, typography, and animation approach; change only the text/data to fit the source material. Do NOT switch to a different look:` |
| 2909 | : `Prior preview HTML (iterate on its visual style if it fits, or replace if a different vibe is better):`); |
| 2910 | p.push('```html'); |
| 2911 | p.push(baseHtml.slice(0, 4000)); |
| 2912 | p.push('```'); |
| 2913 | } else { |
| 2914 | p.push(`Skeleton to extend (replace placeholder with the inputs above; expand styling per the chosen type / style):`); |
| 2915 | p.push('```html'); |
| 2916 | p.push(`<!doctype html> |
| 2917 | <html><head><meta charset="utf-8"><style> |
| 2918 | html,body{margin:0;height:100%;background:#000;color:#fff;overflow:hidden;font-family:system-ui,sans-serif} |
| 2919 | .stage{width:100vw;height:100vh;display:grid;place-items:center;text-align:center;padding:6vw} |
| 2920 | h1{font-size:8vw;letter-spacing:-.03em;animation:in 1.2s ease forwards;opacity:0;transform:translateY(24px)} |
| 2921 | @keyframes in{to{opacity:1;transform:none}} |
| 2922 | </style></head><body> |
| 2923 | <div class="stage"><h1 data-hv-text="headline">PLACEHOLDER</h1></div> |
| 2924 | </body></html>`); |
| 2925 | p.push('```'); |
| 2926 | } |
| 2927 | } |
| 2928 | p.push(''); |
| 2929 | if (tmpl) { |
| 2930 | p.push(`Template visual signature (REQUIRED): ${tmpl.name} — ${tmpl.description}. Match this look — it is the whole reason the template was chosen. Only a single explicit user style note may override it; "based on this article" is NOT such an override.`); |
| 2931 | p.push(''); |
| 2932 | } |
| 2933 | p.push(`Do NOT return an empty reply. Do NOT emit any of \`\`\`hv-options / \`\`\`hv-form / \`\`\`hv-confirm — those are over.`); |
| 2934 | // discard variable since some lints complain |
| 2935 | void w; void h; |
| 2936 | return p.join('\n'); |
| 2937 | } |
| 2938 | |
| 2939 | // ---- iterate: post-generation free-form revision ---- |
| 2940 | // claude --print is unreliable when fed 6KB+ of HTML and asked to emit |
| 2941 | // 6KB+ back — it silently no-ops in ~50% of attempts. Instead of feeding |
| 2942 | // the whole HTML, we extract the visible text + style summary and let |
| 2943 | // the model REWRITE rather than EDIT. Output is bounded by the same |
| 2944 | // skeleton trick used by generate-phase. |
| 2945 | const it: string[] = []; |
| 2946 | if (args.focusFrameId) { |
| 2947 | it.push(`The user has pinned frame "${args.focusFrameId}" and wants to revise ONLY that frame. Apply their request below — write a fresh complete HTML page that delivers the same content, in roughly the same visual style, but with the requested change.`); |
| 2948 | } else { |
| 2949 | it.push(`The user is iterating on an existing HTML video. Apply their request below — write a fresh complete HTML page that delivers the same content, in roughly the same visual style, but with the requested change.`); |
| 2950 | } |
| 2951 | it.push(''); |
| 2952 | it.push(`# User request`); |
| 2953 | it.push(userText); |
| 2954 | it.push(''); |
| 2955 | if (attachments.length > 0) { |
| 2956 | it.push(`# Attachments`); |
| 2957 | for (const a of attachments) it.push(...renderAttachment(a)); |
| 2958 | it.push(''); |
| 2959 | } |
| 2960 | if (baseHtml) { |
| 2961 | // IMPORTANT: do NOT inline the raw HTML. Empirically, including 6-8KB |
| 2962 | // of reference HTML in an iterate prompt makes `claude --print` return |
| 2963 | // 1 byte ~70% of the time (verified by hand). A summary of the |
| 2964 | // existing content + palette is enough to anchor a clean rewrite. |
| 2965 | const summary = summariseHtmlForIterate(baseHtml); |
| 2966 | it.push(`# Current frame — what's there now`); |
| 2967 | if (summary.headline) it.push(`Headline: ${summary.headline}`); |
| 2968 | if (summary.subheads.length) it.push(`Sub-text:\n${summary.subheads.map((s) => ` · ${s}`).join('\n')}`); |
| 2969 | if (summary.dataPoints.length) it.push(`Data points:\n${summary.dataPoints.map((s) => ` · ${s}`).join('\n')}`); |
| 2970 | if (summary.bgColors.length) it.push(`Palette: ${summary.bgColors.join(' / ')}`); |
| 2971 | if (summary.fontFamilies.length) it.push(`Fonts: ${summary.fontFamilies.join(', ')}`); |
| 2972 | it.push(''); |
| 2973 | } |
| 2974 | it.push(`Output: ONE complete HTML document. Begin your reply with \`\`\`html and end with \`\`\`. Inline all CSS / JS. Full-bleed 1920×1080. Tag visible text with data-hv-text (preserve existing keys when meaningful). No prose outside the block. Do NOT return an empty reply.`); |
| 2975 | it.push(''); |
| 2976 | it.push(`Skeleton to extend (replace with the real content + visual style):`); |
| 2977 | it.push('```html'); |
| 2978 | it.push(`<!doctype html> |
| 2979 | <html><head><meta charset="utf-8"><style> |
| 2980 | html,body{margin:0;height:100%;background:#000;color:#fff;overflow:hidden;font-family:system-ui,sans-serif} |
| 2981 | .stage{width:100vw;height:100vh;display:grid;place-items:center;text-align:center;padding:6vw} |
| 2982 | h1{font-size:8vw;letter-spacing:-.03em;animation:in 1s ease forwards;opacity:0;transform:translateY(24px)} |
| 2983 | @keyframes in{to{opacity:1;transform:none}} |
| 2984 | </style></head><body> |
| 2985 | <div class="stage"><h1 data-hv-text="headline">PLACEHOLDER</h1></div> |
| 2986 | </body></html>`); |
| 2987 | it.push('```'); |
| 2988 | return it.join('\n'); |
| 2989 | } |
| 2990 | |
| 2991 | /** Pull headline / subheads / data values / palette / fonts from a frame's HTML. */ |
| 2992 | function summariseHtmlForIterate(html: string): { |
| 2993 | headline: string; |
| 2994 | subheads: string[]; |
| 2995 | dataPoints: string[]; |
| 2996 | bgColors: string[]; |
| 2997 | fontFamilies: string[]; |
| 2998 | } { |
| 2999 | const subheads: string[] = []; |
| 3000 | const dataPoints: string[] = []; |
| 3001 | // Visible text in tagged elements |
| 3002 | const textRe = /data-hv-text="([^"]+)"[^>]*>([^<]{1,160})</gi; |
| 3003 | let m: RegExpExecArray | null; |
| 3004 | let headline = ''; |
| 3005 | while ((m = textRe.exec(html)) !== null) { |
| 3006 | const key = m[1] ?? ''; |
| 3007 | const val = (m[2] ?? '').trim(); |
| 3008 | if (!val) continue; |
| 3009 | if (/headline|title|hero/i.test(key) && !headline) headline = val; |
| 3010 | else if (/data|stat|value|number/i.test(key)) dataPoints.push(`${key}: ${val}`); |
| 3011 | else subheads.push(`${key}: ${val}`); |
| 3012 | } |
| 3013 | // Body / stage background colour (rough) |
| 3014 | const bgColors = Array.from( |
| 3015 | html.matchAll(/background[^:]*:\s*(#[0-9a-f]{3,8}|rgb[a]?\([^)]+\)|hsla?\([^)]+\))/gi), |
| 3016 | ).slice(0, 3).map((x) => x[1]!).filter(Boolean); |
| 3017 | // Font families (first occurrence in css) |
| 3018 | const fontFamilies = Array.from( |
| 3019 | new Set( |
| 3020 | Array.from(html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) |
| 3021 | .map((x) => (x[1] ?? '').trim().slice(0, 80)) |
| 3022 | .filter(Boolean), |
| 3023 | ), |
| 3024 | ).slice(0, 2); |
| 3025 | return { |
| 3026 | headline, |
| 3027 | subheads: subheads.slice(0, 6), |
| 3028 | dataPoints: dataPoints.slice(0, 6), |
| 3029 | bgColors, |
| 3030 | fontFamilies, |
| 3031 | }; |
| 3032 | } |
| 3033 | |
| 3034 | /** |
| 3035 | * Extract a full HTML document from agent output. |
| 3036 | * Tries (1) `\`\`\`html ... \`\`\`` block, (2) bare `<!doctype html>...</html>`. |
| 3037 | */ |
| 3038 | function extractHtmlDocument(text: string): string | null { |
| 3039 | // Plain ```html``` block (no node-id tag — single-frame fast path) |
| 3040 | const fence = /```html\s*\n([\s\S]*?)```/i.exec(text); |
| 3041 | if (fence && fence[1]) { |
| 3042 | const html = fence[1].trim(); |
| 3043 | if (/<\/html>/i.test(html)) return html; |
| 3044 | } |
| 3045 | const bare = /<!doctype html[\s\S]*?<\/html>/i.exec(text); |
| 3046 | if (bare) return bare[0]; |
| 3047 | return null; |
| 3048 | } |
| 3049 | |
| 3050 | /** |
| 3051 | * v0.8: extract a content-graph JSON block + N tagged html#<nodeId> blocks |
| 3052 | * from a single agent response. |
| 3053 | * |
| 3054 | * Expected agent output format for multi-frame: |
| 3055 | * ```json#content-graph |
| 3056 | * { "schemaVersion": 1, "intent": "explainer", "nodes": [...], "edges": [...] } |
| 3057 | * ``` |
| 3058 | * ```html#node_1 |
| 3059 | * <!doctype html>... |
| 3060 | * ``` |
| 3061 | * ```html#node_2 |
| 3062 | * <!doctype html>... |
| 3063 | * ``` |
| 3064 | * |
| 3065 | * Returns null when no content-graph block is found (caller falls back to |
| 3066 | * single-frame extraction). |
| 3067 | */ |
| 3068 | function extractContentGraphAndFrames( |
| 3069 | text: string, |
| 3070 | ): { graph: import('@html-video/content-graph').ContentGraph; frames: { nodeId: string; html: string }[] } | null { |
| 3071 | // Find a fenced JSON block tagged as content-graph. |
| 3072 | const graphMatch = /```json#content-graph\s*\n([\s\S]*?)```/i.exec(text); |
| 3073 | if (!graphMatch || !graphMatch[1]) return null; |
| 3074 | let graph: import('@html-video/content-graph').ContentGraph; |
| 3075 | try { |
| 3076 | graph = parseGraphJsonTolerant(graphMatch[1].trim()) as import('@html-video/content-graph').ContentGraph; |
| 3077 | } catch { |
| 3078 | return null; |
| 3079 | } |
| 3080 | if (!graph || !Array.isArray((graph as { nodes?: unknown[] }).nodes)) return null; |
| 3081 | |
| 3082 | // Find tagged html blocks: ```html#<nodeId> |
| 3083 | const frames: { nodeId: string; html: string }[] = []; |
| 3084 | const re = /```html#([a-z0-9_-]+)\s*\n([\s\S]*?)```/gi; |
| 3085 | let match: RegExpExecArray | null; |
| 3086 | while ((match = re.exec(text)) !== null) { |
| 3087 | const nodeId = match[1]; |
| 3088 | const html = match[2]?.trim() ?? ''; |
| 3089 | if (nodeId && /<\/html>/i.test(html)) { |
| 3090 | frames.push({ nodeId, html }); |
| 3091 | } |
| 3092 | } |
| 3093 | |
| 3094 | return { graph, frames }; |
| 3095 | } |
| 3096 | |
| 3097 | // --------------------------------------------------------------------------- |
| 3098 | // Split multi-frame generate |
| 3099 | // |
| 3100 | // `claude --print` is unreliable when asked to emit a content-graph PLUS |
| 3101 | // 4-6 full HTML pages in one shot — it tends to time out at 100s+ with 1 |
| 3102 | // byte of output. Each call individually is fine, so we orchestrate: |
| 3103 | // |
| 3104 | // 1. one short call → graph JSON |
| 3105 | // 2. one short call per node → frame HTML |
| 3106 | // |
| 3107 | // Each step writes its result to disk and pushes an SSE event so the UI |
| 3108 | // can show "frame N/M" progress. |
| 3109 | // --------------------------------------------------------------------------- |
| 3110 | interface SplitGenerateArgs { |
| 3111 | ctx: CliContext; |
| 3112 | projectId: string; |
| 3113 | projectDir: string; |
| 3114 | agentDef: import('@html-video/runtime').AgentDef; |
| 3115 | agentModel?: string | undefined; |
| 3116 | tmpl: import('@html-video/core').TemplateMetadata | null; |
| 3117 | priorHtml: string; |
| 3118 | inputs: PhaseInputs; |
| 3119 | attachments: Attachment[]; |
| 3120 | /** The user's original opening subject, locked across phases. */ |
| 3121 | openingTopic?: string; |
| 3122 | /** |
| 3123 | * Restyle mode: keep the EXISTING content-graph text verbatim and only |
| 3124 | * re-render each frame's HTML in the new style. Skips the Step-1 graph |
| 3125 | * re-plan. Used by the post-generation "换风格 / 改时长" sub-flows. |
| 3126 | */ |
| 3127 | restyleOnly?: boolean; |
| 3128 | /** Called for human-readable progress lines. */ |
| 3129 | onProgress: (msg: string) => void; |
| 3130 | /** Called for structured SSE events. */ |
| 3131 | onSse: (obj: unknown) => void; |
| 3132 | } |
| 3133 | |
| 3134 | // NOTE: the old classifyIterateIntent (LLM guesses rewrite-all/edit-visual/ |
| 3135 | // edit-frame from one sentence) was removed. The post-generation flow no longer |
| 3136 | // guesses: detectPhase routes a vague "改一下" to an explicit edit-menu card |
| 3137 | // (style / content / duration) and the user's pick drives restyle / |
| 3138 | // iterate-content / iterate-format. |
| 3139 | |
| 3140 | async function runSplitMultiFrameGenerate( |
| 3141 | args: SplitGenerateArgs, |
| 3142 | ): Promise<{ frameCount: number; intent: string }> { |
| 3143 | const { ctx, projectId, projectDir, agentDef, agentModel, tmpl, priorHtml, inputs, attachments, openingTopic, restyleOnly, onProgress, onSse } = args; |
| 3144 | const collected = inputs.collected ?? {}; |
| 3145 | const pickedType = inputs.pickedType ?? ''; |
| 3146 | const pickedStyle = inputs.pickedStyle ?? ''; |
| 3147 | const contentTurns = inputs.contentTurns ?? []; |
| 3148 | // When a template is selected, its OWN source HTML is the style ground truth — |
| 3149 | // every frame must reuse its palette/typography/layout/motion. Previously |
| 3150 | // split-generate only passed the template's one-line description, so a picked |
| 3151 | // template (e.g. Swiss Grid: light grey + black/gold serif) came out as a |
| 3152 | // generic dark theme. Read the real source once and force it into each frame. |
| 3153 | let templateHtml = ''; |
| 3154 | if (tmpl?.__dir && tmpl.source_entry) { |
| 3155 | try { |
| 3156 | const { readFileSync } = await import('node:fs'); |
| 3157 | const p = join(tmpl.__dir, tmpl.source_entry); |
| 3158 | if (existsSync(p)) templateHtml = readFileSync(p, 'utf8'); |
| 3159 | } catch { /* fall back to description-only */ } |
| 3160 | } |
| 3161 | const aspect = ((collected.aspect ?? '16:9').split(/\s+/)[0] ?? '16:9'); |
| 3162 | const frameCountReq = Math.max(2, Math.min(10, Number(collected.frame_count ?? '4') || 4)); |
| 3163 | // Opt-in (format card): render data frames natively with Remotion. When on, |
| 3164 | // the planner must give every data node structured items, and after each |
| 3165 | // data frame's HTML is written we enhance it in place (best-effort). |
| 3166 | const enhanceData = (collected.remotion_enhance ?? '').startsWith('开'); |
| 3167 | // Prefer per-frame pacing (total = per_frame × frames) — set by the format |
| 3168 | // card so a short total ÷ many frames can't produce a rushed clip. Fall back |
| 3169 | // to total ÷ frames for older projects that only stored `duration`. |
| 3170 | const perFrameInput = Number(collected.per_frame ?? '') || 0; |
| 3171 | const perFrameDurationSec = perFrameInput > 0 |
| 3172 | ? Math.max(2, perFrameInput) |
| 3173 | : Math.max(2, Math.floor((Number(collected.duration ?? '15') || 15) / frameCountReq)); |
| 3174 | const totalDurationSec = perFrameInput > 0 |
| 3175 | ? perFrameDurationSec * frameCountReq |
| 3176 | : (Number(collected.duration ?? '15') || 15); |
| 3177 | let resolution = '1920×1080'; |
| 3178 | if (aspect === '9:16') resolution = '1080×1920'; |
| 3179 | else if (aspect === '1:1') resolution = '1080×1080'; |
| 3180 | else if (aspect === '4:5') resolution = '1080×1350'; |
| 3181 | // Persist the chosen resolution on the project so EXPORT records at the right |
| 3182 | // aspect (it reads project.preferences.resolution; without this it defaulted |
| 3183 | // to 1920×1080 and squashed a 4:5 / 9:16 frame into a 16:9 canvas). |
| 3184 | { |
| 3185 | const [w, h] = resolution.split('×').map(Number); |
| 3186 | if (w && h) { |
| 3187 | const proj = await ctx.projects.load(projectId); |
| 3188 | proj.preferences = { ...proj.preferences, resolution: { width: w, height: h } }; |
| 3189 | await ctx.projects.save(proj); |
| 3190 | } |
| 3191 | } |
| 3192 | |
| 3193 | const styleLabel = pickedStyle && /^从设计模板选|template/i.test(pickedStyle) |
| 3194 | ? (tmpl ? `(use the selected template "${tmpl.name}" — ${tmpl.description})` : '(let the model choose)') |
| 3195 | : pickedStyle; |
| 3196 | |
| 3197 | // ---- Step 1: obtain the content graph ---- |
| 3198 | let graph: import('@html-video/content-graph').ContentGraph; |
| 3199 | if (restyleOnly) { |
| 3200 | // Restyle / re-time: keep the EXISTING storyboard text verbatim, skip the |
| 3201 | // re-plan entirely. Only Step 2 (per-frame HTML) re-runs, in the new style. |
| 3202 | const existing = await ctx.orchestrator.readContentGraph(projectId); |
| 3203 | if (!existing || !Array.isArray(existing.nodes) || existing.nodes.length === 0) { |
| 3204 | throw new Error('restyle requested but the project has no existing storyboard to reuse'); |
| 3205 | } |
| 3206 | graph = existing as import('@html-video/content-graph').ContentGraph; |
| 3207 | onProgress(`✓ 沿用现有文案:${graph.nodes.length} 帧`); |
| 3208 | onSse({ type: 'plan_ready', frame_count: graph.nodes.length, intent: graph.intent }); |
| 3209 | } else { |
| 3210 | onProgress(`📋 规划 ${frameCountReq} 帧的故事板…`); |
| 3211 | const graphPromptParts: string[] = []; |
| 3212 | graphPromptParts.push(`Plan a ${frameCountReq}-frame HTML video storyboard. Output ONLY a content-graph JSON in a fenced \`\`\`json#content-graph block — no HTML, no prose outside.`); |
| 3213 | graphPromptParts.push(''); |
| 3214 | graphPromptParts.push(`Inputs (use literally — do NOT invent brand names or facts beyond these):`); |
| 3215 | graphPromptParts.push(`- 类型 / type: ${pickedType || '(unspecified)'} (this is the FORMAT, NOT the subject — never make the video be "about" the type itself)`); |
| 3216 | // Lock the storyboard to the user's opening subject (unless a SOURCE MATERIAL |
| 3217 | // block below supersedes it). This is the path the user actually hits, and |
| 3218 | // where "随机" turned into a randomness explainer instead of the Open Design |
| 3219 | // promo they asked for. |
| 3220 | if (openingTopic && !attachments.some((a) => !!a.inlineText)) { |
| 3221 | graphPromptParts.push(`- 主题 / subject (LOCKED): the user opened with "${openingTopic}". The synopsis and EVERY node MUST be about this subject. If the content line below is a vague word like "随机 / 随便 / anything / 你定", it means "you choose the concrete angle and points — but keep the subject = "${openingTopic}"". NEVER make the video about randomness or the literal word.`); |
| 3222 | } |
| 3223 | if (contentTurns.length > 0) { |
| 3224 | graphPromptParts.push(`- 内容 / content:`); |
| 3225 | for (const t of contentTurns) graphPromptParts.push(` · ${t.replace(/\n/g, ' ').slice(0, 280)}`); |
| 3226 | } |
| 3227 | // Inline the fetched article / repo / uploaded text — THIS is the subject of |
| 3228 | // the video. Without it the planner only sees the type word and invents a |
| 3229 | // video "about 概念解说" instead of about the user's actual source. |
| 3230 | const { specs: designSpecs, content: contentAtts } = partitionAttachments(attachments); |
| 3231 | if (designSpecs.length > 0) graphPromptParts.push('', ...renderDesignSpecBlock(designSpecs)); |
| 3232 | const sourceTexts = contentAtts.filter((a) => !!a.inlineText); |
| 3233 | if (sourceTexts.length > 0) { |
| 3234 | graphPromptParts.push(''); |
| 3235 | graphPromptParts.push(`SOURCE MATERIAL — the video MUST be about THIS content (real facts, names, numbers from it). This is the subject, not the type:`); |
| 3236 | for (const a of sourceTexts) { |
| 3237 | graphPromptParts.push(`--- ${a.filename} ---`); |
| 3238 | graphPromptParts.push((a.inlineText ?? '').slice(0, 6000)); |
| 3239 | } |
| 3240 | } |
| 3241 | if (styleLabel) graphPromptParts.push(`- 风格 / style: ${styleLabel}`); |
| 3242 | graphPromptParts.push(`- 总时长: ${totalDurationSec}s split across ${frameCountReq} frames (~${perFrameDurationSec}s each)`); |
| 3243 | graphPromptParts.push(''); |
| 3244 | if (sourceTexts.length > 0) { |
| 3245 | graphPromptParts.push(`GROUNDING (REQUIRED): every node's text must come from the SOURCE MATERIAL above — quote its real product names, facts, numbers. The synopsis must name the source's actual subject. BANNED: generic filler about the content TYPE (e.g. "什么是概念解说", "信息密度×传播效率") that would fit any video. If a line could fit any topic, it's wrong.`); |
| 3246 | graphPromptParts.push(''); |
| 3247 | } |
| 3248 | graphPromptParts.push(`Schema (keep all keys; one node per frame; nodes[].id should be a short readable slug like "intro" / "stat_users" / "outro"):`); |
| 3249 | graphPromptParts.push('```json#content-graph'); |
| 3250 | graphPromptParts.push(JSON.stringify({ |
| 3251 | schemaVersion: 1, |
| 3252 | intent: 'explainer', |
| 3253 | synopsis: '<one-line description of the video>', |
| 3254 | nodes: Array.from({ length: frameCountReq }, (_, i) => { |
| 3255 | const kind = i === 0 ? 'text' : i === frameCountReq - 1 ? 'entity' : 'data'; |
| 3256 | const node: Record<string, unknown> = { |
| 3257 | id: `frame_${i + 1}`, |
| 3258 | kind, |
| 3259 | durationSec: perFrameDurationSec, |
| 3260 | text: '<headline / subtitle for this frame>', |
| 3261 | }; |
| 3262 | // Every data node carries structured items so it can be rendered natively |
| 3263 | // with Remotion (numbers roll, bars grow) — whether the user opted in now |
| 3264 | // or enhances the frame later from the strip. A data frame without numbers |
| 3265 | // is just a text frame. |
| 3266 | if (kind === 'data') { |
| 3267 | node.data = { |
| 3268 | title: '<short chart title>', |
| 3269 | unit: '<optional unit, e.g. K / % / ★>', |
| 3270 | items: [ |
| 3271 | { label: '<label>', value: 0 }, |
| 3272 | { label: '<label>', value: 0 }, |
| 3273 | ], |
| 3274 | }; |
| 3275 | } |
| 3276 | return node; |
| 3277 | }), |
| 3278 | edges: Array.from({ length: frameCountReq - 1 }, (_, i) => ({ |
| 3279 | from: `frame_${i + 1}`, |
| 3280 | to: `frame_${i + 2}`, |
| 3281 | kind: 'sequence', |
| 3282 | })), |
| 3283 | }, null, 2)); |
| 3284 | graphPromptParts.push('```'); |
| 3285 | graphPromptParts.push(''); |
| 3286 | graphPromptParts.push(`Replace the placeholder text in each node with concrete content from the inputs. Adjust intent to match (single-frame|explainer|data-viz|promo|comparison|other). Keep node ids unique. Do NOT return an empty reply. Do NOT emit any HTML this turn.`); |
| 3287 | graphPromptParts.push(`DATA FRAMES: every \`kind:"data"\` node MUST carry a \`data\` object \`{ title?, unit?, items: [{ label, value }] }\` with at least 2 items and numeric \`value\`s drawn from the inputs/source — real figures, not placeholders (they can be animated with rolling counters / growing bars). The node's \`text\` still holds the headline. If a frame genuinely has no quantitative data, make it a \`text\` node instead of \`data\`.`); |
| 3288 | graphPromptParts.push(`DATA FRAME QUALITY: (1) Items in ONE data frame must be COMPARABLE — the same unit and a similar order of magnitude. Do NOT mix wildly different scales in one chart (e.g. 61,000 GitHub stars next to 142 plugins) — one giant bar makes the rest invisible. If figures have different units or scales, split them across separate data frames, or pick the 2-4 that genuinely compare. (2) \`unit\` is OPTIONAL and only for a real shared unit (e.g. "%", "K", "★", "ms"). If the numbers are plain counts with no meaningful unit, OMIT \`unit\` entirely — never use filler like "count" / "个" / "次".`); |
| 3289 | graphPromptParts.push(`STRICT JSON: the block must be valid JSON. Inside string values do NOT use straight double-quotes ("…") — if you need to quote a term or title, use 「」 or 《》 or single quotes. No trailing commas. No comments.`); |
| 3290 | |
| 3291 | const graphPrompt = graphPromptParts.join('\n'); |
| 3292 | const graphText = await callAgentSimple(agentDef, graphPrompt, projectDir, agentModel); |
| 3293 | const graphMatch = /```json#content-graph\s*\n([\s\S]*?)```/i.exec(graphText) |
| 3294 | ?? /```json\s*\n([\s\S]*?)```/i.exec(graphText); |
| 3295 | if (!graphMatch || !graphMatch[1]) { |
| 3296 | throw new Error(`agent did not return a content-graph (got ${graphText.length} bytes, head: ${graphText.slice(0, 80)})`); |
| 3297 | } |
| 3298 | try { |
| 3299 | graph = parseGraphJsonTolerant(graphMatch[1].trim()) as import('@html-video/content-graph').ContentGraph; |
| 3300 | } catch (e) { |
| 3301 | throw new Error(`graph JSON failed to parse: ${e instanceof Error ? e.message : e}`); |
| 3302 | } |
| 3303 | if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { |
| 3304 | throw new Error('graph has no nodes'); |
| 3305 | } |
| 3306 | await ctx.orchestrator.writeContentGraph(projectId, graph); |
| 3307 | onProgress(`✓ 故事板规划完成:${graph.nodes.length} 帧 (${graph.intent})`); |
| 3308 | onSse({ type: 'plan_ready', frame_count: graph.nodes.length, intent: graph.intent }); |
| 3309 | } |
| 3310 | |
| 3311 | // ---- Step 2: one call per node, output a single ```html block ---- |
| 3312 | for (let i = 0; i < graph.nodes.length; i++) { |
| 3313 | const node = graph.nodes[i]!; |
| 3314 | const nodeId = node.id; |
| 3315 | onProgress(`🎬 生成第 ${i + 1}/${graph.nodes.length} 帧 (${nodeId})…`); |
| 3316 | onSse({ type: 'frame_started', node_id: nodeId, order: i, total: graph.nodes.length }); |
| 3317 | |
| 3318 | const frameContext = describeNode(node); |
| 3319 | const fp: string[] = []; |
| 3320 | fp.push(`Generate ONE complete HTML page for frame "${nodeId}" of a ${graph.nodes.length}-frame video. Output ONE \`\`\`html block, nothing else.`); |
| 3321 | fp.push(''); |
| 3322 | fp.push(`Frame ${i + 1} of ${graph.nodes.length}: ${frameContext}`); |
| 3323 | if (restyleOnly) { |
| 3324 | // Keep the exact words; only the visual style changes. |
| 3325 | fp.push(`RESTYLE: keep this frame's TEXT EXACTLY as given above — same headline, subtitle, numbers, wording. Do NOT rewrite, translate, or reword anything. Change ONLY the visual style (layout, colour, typography, motion) to: ${styleLabel || pickedStyle || '(the new style)'}.`); |
| 3326 | } |
| 3327 | if (openingTopic && !attachments.some((a) => !!a.inlineText)) { |
| 3328 | fp.push(`Subject (locked): "${openingTopic}". This frame is about this subject; "随机/随便" anywhere in the inputs means you pick details, not a new topic.`); |
| 3329 | } |
| 3330 | fp.push(`Duration: ${node.durationSec ?? perFrameDurationSec}s`); |
| 3331 | fp.push(`Type: ${pickedType}`); |
| 3332 | if (styleLabel) fp.push(`Style: ${styleLabel}`); |
| 3333 | fp.push(`Resolution: ${aspect} (${resolution})`); |
| 3334 | fp.push(''); |
| 3335 | if (contentTurns.length > 0) { |
| 3336 | fp.push(`Source material from the user (use literally; do NOT invent facts):`); |
| 3337 | for (const t of contentTurns) fp.push(` · ${t.replace(/\n/g, ' ').slice(0, 280)}`); |
| 3338 | fp.push(''); |
| 3339 | } |
| 3340 | // Fetched article/repo text — keep the per-frame HTML grounded in the real |
| 3341 | // source, not just the one-line graph node. (Graph step gets the full text; |
| 3342 | // give each frame a trimmed slice so it can pull accurate specifics.) |
| 3343 | const { specs: frameSpecs, content: frameContentAtts } = partitionAttachments(attachments); |
| 3344 | if (frameSpecs.length > 0) fp.push(...renderDesignSpecBlock(frameSpecs)); |
| 3345 | const frameSourceTexts = frameContentAtts.filter((a) => !!a.inlineText); |
| 3346 | if (frameSourceTexts.length > 0) { |
| 3347 | fp.push(`SOURCE MATERIAL (the video's real subject — use its actual facts/names/numbers, never generic filler about the content type):`); |
| 3348 | for (const a of frameSourceTexts) fp.push((a.inlineText ?? '').slice(0, 3000)); |
| 3349 | fp.push(''); |
| 3350 | } |
| 3351 | fp.push(`Output: begin with \`\`\`html and end with \`\`\`. Inline CSS + JS, full-bleed ${resolution}, opens with an animation timeline. Tag visible text with data-hv-text. CDN imports (Tailwind, GSAP) fine. No prose outside the block.`); |
| 3352 | fp.push(''); |
| 3353 | if (templateHtml) { |
| 3354 | // A template is selected → its HTML is the REQUIRED look for every frame. |
| 3355 | fp.push(`Template HTML — this is the REQUIRED visual style for THIS frame. Reuse its exact palette, background, typography, layout structure and animation approach; only swap in this frame's text/data. Do NOT invent a different theme (no generic dark background unless the template itself is dark):`); |
| 3356 | fp.push('```html'); |
| 3357 | fp.push(templateHtml.slice(0, 4000)); |
| 3358 | fp.push('```'); |
| 3359 | fp.push(''); |
| 3360 | fp.push(`Keep all ${graph.nodes.length} frames visually consistent with this template so they read as one video.`); |
| 3361 | } else { |
| 3362 | fp.push(`Skeleton to extend (replace placeholder, expand styling per type / style):`); |
| 3363 | fp.push('```html'); |
| 3364 | fp.push(`<!doctype html> |
| 3365 | <html><head><meta charset="utf-8"><style> |
| 3366 | html,body{margin:0;height:100%;background:#000;color:#fff;overflow:hidden;font-family:system-ui,sans-serif} |
| 3367 | .stage{width:100vw;height:100vh;display:grid;place-items:center;text-align:center;padding:6vw} |
| 3368 | h1{font-size:8vw;letter-spacing:-.03em;animation:in 1s ease forwards;opacity:0;transform:translateY(24px)} |
| 3369 | @keyframes in{to{opacity:1;transform:none}} |
| 3370 | </style></head><body> |
| 3371 | <div class="stage"><h1 data-hv-text="headline">PLACEHOLDER</h1></div> |
| 3372 | </body></html>`); |
| 3373 | fp.push('```'); |
| 3374 | if (priorHtml && priorHtml.length > 0) { |
| 3375 | fp.push(''); |
| 3376 | fp.push(`Visual style reference (mine for palette / typography / motion vocabulary, do not copy literally):`); |
| 3377 | fp.push('```html'); |
| 3378 | fp.push(priorHtml.slice(0, 2400)); |
| 3379 | fp.push('```'); |
| 3380 | } |
| 3381 | } |
| 3382 | if (i === 0 && attachments.length > 0) { |
| 3383 | fp.push(''); |
| 3384 | fp.push(`User attachments (binary = assets; inlined text = source material to base content on):`); |
| 3385 | for (const a of attachments) fp.push(...renderAttachment(a)); |
| 3386 | } |
| 3387 | fp.push(''); |
| 3388 | fp.push(`Do NOT return an empty reply. Output the full HTML.`); |
| 3389 | |
| 3390 | const framePrompt = fp.join('\n'); |
| 3391 | let frameText = await callAgentSimple(agentDef, framePrompt, projectDir, agentModel); |
| 3392 | let extracted = /```html\s*\n([\s\S]*?)```/i.exec(frameText)?.[1]?.trim() |
| 3393 | ?? /<!doctype html[\s\S]*?<\/html>/i.exec(frameText)?.[0]; |
| 3394 | |
| 3395 | // One retry on empty: shorter prompt, just the skeleton call. |
| 3396 | if (!extracted) { |
| 3397 | onProgress(` ↻ 第 ${i + 1} 帧首试为空,重试…`); |
| 3398 | const retryPrompt = `Output ONE complete HTML video frame in a fenced \`\`\`html block. Frame purpose: ${frameContext}. Style: ${styleLabel || 'tasteful default'}. Resolution: ${resolution}. ${contentTurns.length ? `Content: ${contentTurns.join(' / ').slice(0, 200)}` : ''} \n\nBegin your reply with \`\`\`html. Inline CSS, opens with animation, tag text with data-hv-text. No prose.`; |
| 3399 | frameText = await callAgentSimple(agentDef, retryPrompt, projectDir, agentModel); |
| 3400 | extracted = /```html\s*\n([\s\S]*?)```/i.exec(frameText)?.[1]?.trim() |
| 3401 | ?? /<!doctype html[\s\S]*?<\/html>/i.exec(frameText)?.[0]; |
| 3402 | } |
| 3403 | if (!extracted) { |
| 3404 | throw new Error(`frame "${nodeId}" generation returned empty (${frameText.length}B)`); |
| 3405 | } |
| 3406 | await ctx.orchestrator.writeFrameHtml(projectId, nodeId, extracted); |
| 3407 | // Native Remotion enhancement (opt-in via format card). The frame now has a |
| 3408 | // FrameRecord, so enhanceFrameNative can set engine/nativeTemplateId/data in |
| 3409 | // place. Best-effort: if the data node lacks usable {label,value} items it |
| 3410 | // throws — we keep the hyperframes HTML and warn rather than fail the run. |
| 3411 | // 'frame-data-rollup' is the only native template today (TODO: picker). |
| 3412 | if (enhanceData && node.kind === 'data') { |
| 3413 | try { |
| 3414 | // Two steps, same as the manual enhance endpoint: (1) set the frame's |
| 3415 | // engine/data, (2) actually RENDER the preview MP4. Without step 2 the |
| 3416 | // frame is flagged remotion but has no previewMp4Path, so the studio |
| 3417 | // tries to play a <video> that 404s → black thumbnail + preview. |
| 3418 | await ctx.orchestrator.enhanceFrameNative(projectId, nodeId, 'frame-data-rollup'); |
| 3419 | onProgress(` ⚡ 第 ${i + 1} 帧渲染 Remotion 动效 (数字滚动 / 柱子生长)…`); |
| 3420 | await ctx.orchestrator.renderFrameNativePreview({ projectId, graphNodeId: nodeId }); |
| 3421 | onSse({ type: 'frame_enhanced', node_id: nodeId, order: i }); |
| 3422 | } catch (e) { |
| 3423 | const msg = e instanceof Error ? e.message : String(e); |
| 3424 | process.stderr.write(`[studio:split-generate] proj=${projectId} frame=${nodeId} enhance skipped: ${msg}\n`); |
| 3425 | onProgress(` ⚠️ 第 ${i + 1} 帧无法用 Remotion 增强(回落静态 HTML):${msg}`); |
| 3426 | // Revert the engine flag so the frame falls back to its hyperframes HTML |
| 3427 | // (the <iframe> path) instead of showing a broken <video>. |
| 3428 | try { await ctx.orchestrator.unenhanceFrame(projectId, nodeId); } catch { /* ignore */ } |
| 3429 | } |
| 3430 | } |
| 3431 | onProgress(` ✓ 第 ${i + 1}/${graph.nodes.length} 帧完成 (${nodeId})`); |
| 3432 | onSse({ type: 'frame_done', node_id: nodeId, order: i, total: graph.nodes.length }); |
| 3433 | } |
| 3434 | |
| 3435 | return { frameCount: graph.nodes.length, intent: graph.intent }; |
| 3436 | } |
| 3437 | |
| 3438 | /** Describe a node's purpose for prompt context. */ |
| 3439 | function describeNode(node: import('@html-video/content-graph').Node): string { |
| 3440 | const bits: string[] = []; |
| 3441 | if (node.label) bits.push(node.label); |
| 3442 | if ((node as { text?: string }).text) bits.push(`text: ${(node as { text: string }).text.slice(0, 200)}`); |
| 3443 | if (node.kind === 'data' && (node as { data?: unknown }).data !== undefined) { |
| 3444 | bits.push(`data: ${JSON.stringify((node as { data: unknown }).data).slice(0, 200)}`); |
| 3445 | } |
| 3446 | if (node.kind === 'entity' && (node as { props?: unknown }).props !== undefined) { |
| 3447 | bits.push(`entity props: ${JSON.stringify((node as { props: unknown }).props).slice(0, 200)}`); |
| 3448 | } |
| 3449 | if (node.frameIntent) bits.push(`intent: ${node.frameIntent}`); |
| 3450 | if (bits.length === 0) bits.push(`(${node.kind} frame "${node.id}")`); |
| 3451 | return bits.join('; '); |
| 3452 | } |
| 3453 | |
| 3454 | /** Spawn the agent, collect all stdout text, return when done. */ |
| 3455 | async function callAgentSimple( |
| 3456 | def: import('@html-video/runtime').AgentDef, |
| 3457 | prompt: string, |
| 3458 | cwd: string, |
| 3459 | model?: string, |
| 3460 | ): Promise<string> { |
| 3461 | let buf = ''; |
| 3462 | const handle = spawnAgent({ |
| 3463 | def, |
| 3464 | prompt, |
| 3465 | context: { cwd, ...(model && { model }) }, |
| 3466 | onEvent: (ev) => { |
| 3467 | if (ev.type === 'text') buf += ev.chunk; |
| 3468 | }, |
| 3469 | }); |
| 3470 | await handle.done; |
| 3471 | return buf; |
| 3472 | } |
| 3473 |