返回 html-video
studio-server.ts
根目录 / packages / cli / src / studio-server.ts
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
3473 lines TYPESCRIPT