From 1774383439993526c6607bf9c8552753ec4a4e9c Mon Sep 17 00:00:00 2001 From: "N.S.Devaraj" Date: Wed, 1 Jul 2026 18:07:55 +0530 Subject: [PATCH 01/10] update for including mp4 files apart from AI clips --- packages/cli/src/studio-server.ts | 364 ++++++++++++++++++------------ packages/core/src/project.ts | 167 +++++++++++++- packages/core/src/types/index.ts | 9 + 3 files changed, 390 insertions(+), 150 deletions(-) diff --git a/packages/cli/src/studio-server.ts b/packages/cli/src/studio-server.ts index 1ba43ae..ec395a7 100644 --- a/packages/cli/src/studio-server.ts +++ b/packages/cli/src/studio-server.ts @@ -359,6 +359,61 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< } } + + // --- Clip frames (user-imported MP4) --- + + // Upload an MP4 file and add it as a clip frame at the end of the timeline. + // The file is first ingested into the AssetStore (content-addressed), then + // a FrameRecord with clipAssetId is created. Duration is auto-detected via + // ffprobe. Multipart only — the studio sends the file directly. + const clipMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/clips$/); + if (clipMatch && clipMatch[1] && m === 'POST') { + const projectId = clipMatch[1]; + const ct = req.headers['content-type'] ?? ''; + if (!ct.startsWith('multipart/form-data')) { + return json(res, 400, { error: 'Clip upload requires multipart/form-data' }); + } + try { + const saved = await receiveMultipartFile(req, ct); + // Only accept video files (AssetStore.guessMime checks extension). + const vmime = AssetStore.guessMime(saved.filePath); + if (vmime.type !== 'video') { + return json(res, 400, { error: `File "${saved.filename}" is not a video (detected: ${vmime.mime})` }); + } + // 1) Ingest to asset store + const projectAfterAsset = await ctx.orchestrator.addFileAsset(projectId, saved.filePath); + // 2) Create clip frame from the newly added asset (it's the last one) + const lastAsset = projectAfterAsset.assets[projectAfterAsset.assets.length - 1]; + if (!lastAsset) { + return json(res, 500, { error: 'Asset ingestion succeeded but no asset was returned' }); + } + const { project, frame } = await ctx.orchestrator.addClipFrame(projectId, lastAsset.id); + return json(res, 200, { project, frame }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('HtmlVideoError')) { + return json(res, 400, { error: msg }); + } + return json(res, 500, { error: msg }); + } + } + + // Delete a user-imported clip frame. Only removes clip frames (clipAssetId set); + // agent-generated frames are protected. The underlying asset file is NOT deleted. + const clipDelMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/clips\/([^/]+)$/); + if (clipDelMatch && clipDelMatch[1] && clipDelMatch[2] && m === 'DELETE') { + try { + const project = await ctx.orchestrator.removeClipFrame(clipDelMatch[1], clipDelMatch[2]); + return json(res, 200, { ok: true, project }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('HtmlVideoError')) { + return json(res, 404, { error: msg }); + } + return json(res, 500, { error: msg }); + } + } + // Export MP4 — streams progress via SSE so the user sees per-frame // recording status during a multi-minute multi-frame export. const expMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/export$/); @@ -525,7 +580,13 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< const body = (await readBody(req)) as { agentId?: string; frameId?: string }; const graph = await ctx.orchestrator.readContentGraph(projectId); if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { - return json(res, 400, { error: 'No frames yet — generate the video first.' }); + const proj = await ctx.orchestrator.load(projectId); + const hasAnyFrames = (proj.frames?.length ?? 0) > 0; + return json(res, 400, { + error: hasAnyFrames + ? 'This project has imported clips but no AI-generated storyboard. Draft narration requires a generated video — create one first, then import clips.' + : 'No frames yet — generate the video first.', + }); } if (!body.agentId) return json(res, 400, { error: 'No agent selected.' }); const agentDef = findAgent(body.agentId); @@ -1030,8 +1091,8 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< if (rewriteInputs) { const n = (project.frames ?? []).length || Number(phaseInfo.inputs.collected?.frame_count ?? '3'); const notice = restyleOnly - ? `🎨 沿用文案,按新风格重做全部 ${n} 帧…\n` - : `🔄 基于新内容重做全部 ${n} 帧(已手动修改过的帧会被覆盖)…\n`; + ? `🎨 Keep copy, remake all ${n} frames in new style...\n` + : `🔄 Remake all ${n} frames based on new content (manually modified frames will be overwritten)...\n`; assistantText += notice; sseWrite({ type: 'text', chunk: notice }); } @@ -1100,7 +1161,7 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< // that only ships the user's request + a tiny instruction. This // catches the 6-8KB-prompt empty-reply mode. if (assistantText.trim().length < 32 && phaseInfo.phase === 'iterate' && priorHtml) { - sseWrite({ type: 'text', chunk: '\n↻ 第一次输出为空,重试中…\n' }); + sseWrite({ type: 'text', chunk: '\n↻ First output was empty, retrying...\n' }); // Retry without inlining the prior HTML — same observation as // the iterate prompt itself: claude --print silently no-ops // when fed multi-KB of HTML to rewrite. @@ -1267,7 +1328,13 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< const projectId = fitMatch[1]; const graph = await ctx.orchestrator.readContentGraph(projectId); if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) { - return json(res, 400, { error: 'No frames yet — generate the video first.' }); + const proj = await ctx.orchestrator.load(projectId); + const hasAnyFrames = (proj.frames?.length ?? 0) > 0; + return json(res, 400, { + error: hasAnyFrames + ? 'This project has imported clips but no AI-generated storyboard. Fit timing requires generated frames — create a video first, then import clips.' + : 'No frames yet — generate the video first.', + }); } const byFrame = ((await readBody(req)) as { narrationByFrame?: Record }).narrationByFrame ?? {}; const lenOf = (id: string) => (byFrame[id]?.trim().length ?? 0); @@ -1314,19 +1381,28 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< const frameMp4Match = sub.match(/^\/frame\/([a-z0-9_-]+)\.mp4$/i); if (frameMp4Match && frameMp4Match[1]) { const frame = (project.frames ?? []).find((f) => f.graphNodeId === frameMp4Match[1]); + // Enhanced native frame: serve its rendered preview MP4. if (frame?.previewMp4Path && existsSync(frame.previewMp4Path)) { return serveFile(frame.previewMp4Path, res); } + // Imported clip frame: serve the asset MP4 directly. + if (frame?.clipAssetId) { + const clipAsset = project.assets.find((a) => a.id === frame.clipAssetId); + if (clipAsset?.path && existsSync(clipAsset.path)) { + return serveFile(clipAsset.path, res); + } + } res.writeHead(404); return res.end('No preview MP4 for frame'); } - // v0.8: serve a specific frame HTML by graph node id + // v0.8: serve a specific frame HTML by graph node id. + // Clip frames have no HTML — they're pre-rendered MP4s served via the .mp4 route above. const frameMatch = sub.match(/^\/frame\/([a-z0-9_-]+)$/i); if (frameMatch && frameMatch[1]) { const nodeId = frameMatch[1]; const frame = (project.frames ?? []).find((f) => f.graphNodeId === nodeId); - if (frame && existsSync(frame.htmlPath)) { + if (frame && !frame.clipAssetId && existsSync(frame.htmlPath)) { return serveFile(frame.htmlPath, res); } res.writeHead(404); @@ -1642,7 +1718,7 @@ type MultipartPart = * Two encodings can appear: * - `filename*=UTF-8''%E4%B8%AD%E6%96%87.md` (RFC 5987, percent-encoded) — * decode the percent-escapes after stripping the charset prefix. - * - `filename="中文.md"` — the bytes are UTF-8, but the multipart body was + * - `filename="chinese.md"` — the bytes are UTF-8, but the multipart body was * read as a latin1 string, so each UTF-8 byte became one latin1 char. Round * -trip latin1→utf8 to restore the original. If the name was plain ASCII the * round-trip is a no-op. @@ -1832,7 +1908,7 @@ interface Attachment { * * opener → hv-options{meta.phase:"type"} → user picks content type * content → free chat: agent asks about topic / headline / data, user - * can answer in 1+ turns or say "skip" / "随便" + * can answer in 1+ turns or say "skip" / "random" * style → hv-options{meta.phase:"style"} → user picks style preset * (skipped automatically if a project template is already set) * format → hv-form{meta.phase:"format"} → 3 segmented controls @@ -1861,7 +1937,7 @@ type ConvPhase = /** Did the user pick the "choose from design templates" style option? */ function isFromTemplateStyle(style: string): boolean { - return /^从设计模板选|design template|pick.*template|from template/i.test(style.trim()); + return /^pick design template|design template|pick.*template|from template/i.test(style.trim()); } interface PhaseInputs { @@ -1908,7 +1984,7 @@ function detectPhase( // was asking for format params (whether it rendered the hv-form card or — as // the model sometimes does — just asked in prose), and this user turn parses // as a format answer, treat it like a card submit and advance to confirm. - // This stops the loop where a typed "16:9 横屏 / 5s / 10" goes unrecognised + // This stops the loop where a typed "16:9 Landscape / 5s / 10" goes unrecognised // and the flow re-asks the same params in a different shape. if (!hadGenerationYet(history) && lastAssistantAskedFormat(history)) { const parsed = parseFormatReply(trimmed); @@ -1922,9 +1998,9 @@ function detectPhase( // Post-generation iteration. Previously ANY message after a generation was // forced to phase 'iterate', which only ever did a vague single-frame rewrite - // of preview.html — so "换个风格" / "改内容" looked like nothing happened - // (the user's recurring "后面的指令好像都没用了"). Instead, run a small - // card-driven sub-flow: a vague "改一下" pops an edit-menu (change style / + // of preview.html — so "change style" / "change content" looked like nothing happened + // (the user's recurring "subsequent instructions seem useless"). Instead, run a small + // card-driven sub-flow: a vague "change it a bit" pops an edit-menu (change style / // content / duration); picking an option re-uses the existing style / content // / format cards; the final regeneration is based on the existing storyboard. if (hadGenerationYet(history)) { @@ -1933,15 +2009,15 @@ function detectPhase( if (last?.metaPhase === 'edit-menu') { // Route the menu choice. Match by label keywords (works for clicks, which // send the option label, and for free text). - if (/风格|style|视觉|配色|换个?样子/i.test(trimmed)) { + if (/style|visuals|colors|change look/i.test(trimmed)) { inputs.pickedType = lastCardPickByPhase(history, 'type'); return { phase: 'style', inputs, postGen: true }; } - if (/时长|时间|duration|长度|快|慢|秒|节奏/i.test(trimmed)) { + if (/duration|time|length|fast|slow|seconds|rhythm/i.test(trimmed)) { inputs.pickedType = lastCardPickByPhase(history, 'type'); return { phase: 'format', inputs, postGen: true }; } - // default / "内容 / content / 文案 / 主题 / 重写" + // default / "content / copy / theme / rewrite" inputs.pickedType = lastCardPickByPhase(history, 'type'); inputs.contentTurns = collectContentTurns(history); return { phase: 'content', inputs, postGen: true }; @@ -1963,26 +2039,26 @@ function detectPhase( } // A fresh post-generation instruction. The DEFAULT is the card-driven // sub-flow, not a single-frame rewrite — a whitelist of trigger phrases was - // the bug (e.g. "换个模板重新生成一下" didn't match and silently fell back to + // the bug (e.g. "regenerate with a different template" didn't match and silently fell back to // a no-op preview rewrite). So: // - pinned frame → single-frame iterate (the user explicitly scoped it). // - clearly names style / content / duration → jump straight there. - // - everything else (incl. vague "改一下" / "换个模板" / "重新生成") → pop + // - everything else (incl. vague "change it a bit" / "change template" / "regenerate") → pop // the edit-menu and ask, rather than guess or no-op. const pinned = !!focusFrameId; if (pinned) { return { phase: 'iterate', inputs: { collected: lastFormSubmission(history) } }; } // Direct shortcuts when the instruction is unambiguous about WHAT to change. - if (/风格|样式|配色|视觉|主题色|模板|template|style|换个?样子|赛博|极简|杂志|brutal|cyber|swiss/i.test(trimmed)) { + if (/style|colors|visuals|theme color|template|change look|cyber|minimal|magazine|brutal|swiss/i.test(trimmed)) { inputs.pickedType = lastCardPickByPhase(history, 'type'); return { phase: 'style', inputs, postGen: true }; } - if (/时长|时间|duration|时间长度|节奏|快一点|慢一点|更短|更长|多少秒/i.test(trimmed)) { + if (/duration|time|length|rhythm|faster|slower|shorter|longer|how many seconds/i.test(trimmed)) { inputs.pickedType = lastCardPickByPhase(history, 'type'); return { phase: 'format', inputs, postGen: true }; } - if (/文案|内容|主题|改成|换成|重写|讲|介绍|加.{0,4}(信息|数据|卖点)|text|content|rewrite/i.test(trimmed)) { + if (/copy|content|theme|change to|rewrite|talk about|introduce|add.{0,4}(info|data|selling point)|text/i.test(trimmed)) { inputs.pickedType = lastCardPickByPhase(history, 'type'); inputs.contentTurns = [...collectContentTurns(history), trimmed].filter((s) => !isControlPhrase(s)); return { phase: 'iterate-content', inputs, postGen: true }; @@ -2022,7 +2098,7 @@ function detectPhase( inputs.pickedType = lastCardPickByPhase(history, 'type'); inputs.pickedStyle = trimmed; inputs.contentTurns = collectContentTurns(history); - // "从设计模板选" but no template actually picked → don't silently fall back + // "Pick from design template" but no template actually picked → don't silently fall back // to a default look; ask the user to pick one (top-bar) or choose a style. if (isFromTemplateStyle(trimmed) && !hasTemplate) { return { phase: 'need-template', inputs }; @@ -2035,13 +2111,13 @@ function detectPhase( inputs.pickedType = lastCardPickByPhase(history, 'type'); inputs.contentTurns = collectContentTurns(history); // Picked a built-in style instead → use it. - if (!isFromTemplateStyle(trimmed) && !/^我已选好模板|继续|done|ready|next$/i.test(trimmed)) { + if (!isFromTemplateStyle(trimmed) && !/^I have picked a template|continue|done|ready|next$/i.test(trimmed)) { inputs.pickedStyle = trimmed; return { phase: 'format', inputs }; } // Said "I've picked one / continue": proceed only if a template is now set. if (hasTemplate) { - inputs.pickedStyle = '从设计模板选'; + inputs.pickedStyle = 'Pick from design template'; return { phase: 'format', inputs }; } return { phase: 'need-template', inputs }; // still none → ask again @@ -2052,17 +2128,17 @@ function detectPhase( if (prev.kind === 'content-question') { // User is replying to content question. Could be (a) more content, or // (b) a "skip / I'm done" signal. - const isSkip = /^(skip|跳过|够了|够|done|next|下一步|ok|好|不知道)$/i.test(trimmed) + const isSkip = /^(skip|enough|done|next|ok|good|don't know)$/i.test(trimmed) || trimmed.length <= 3; // "Free rein" answers — the user is handing the subject's details to the - // agent ("随便生成 / 随便发挥 / 你定 / 都行 / 随机"). These should advance the + // agent ("generate randomly / random / you decide / whatever"). These should advance the // flow (and pop the style card) just like a skip, instead of being treated - // as more content to collect — which left the user stuck re-typing "风格选择". + // as more content to collect — which left the user stuck re-typing "style selection". // Substring match (not anchored) with a length guard so it doesn't swallow a - // real sentence that merely contains "随便". + // real sentence that merely contains "random". const isFreeRein = trimmed.length <= 16 && - /(随便|随机|随意|你定|你来定|你决定|都行|都可以|看着办|自由发挥|发挥|无所谓|任意|随你)/.test(trimmed); + /(random|you decide|whatever|freely|any)/.test(trimmed); // With source material attached there's nothing to collect — advance as // soon as the user says anything (the article already is the content). if (isSkip || isFreeRein || hasSourceMaterial || hasEnoughContent(history, trimmed)) { @@ -2145,12 +2221,12 @@ function lastCardPickByPhase(history: ChatMessage[], phase: string): string | un /** All free-text user replies during the content phase (between type-pick and style/format). */ /** A short user turn that just nudges the flow forward ("continue", "go", - * "下一步", "开始生成") rather than supplying video content. Such turns must + * "next step", "start generating") rather than supplying video content. Such turns must * not be collected as content — otherwise they end up as on-screen text. */ function isControlPhrase(t: string): boolean { const s = t.trim().toLowerCase().replace(/[。.!!~\s]+$/u, ''); if (s.length > 12) return false; // real content is longer; keep it - return /^(继续|继续(刚刚|上次|之前)的?任务|接着|接着(来|做|生成)|下一步|开始(生成)?|生成(吧)?|go|continue|next|start|ok|好的?|行|走|动手|可以|确认)$/u.test(s); + return /^(continue|continue task|next|start generating|generate|go|start|ok|good|alright|do it|can|confirm)$/u.test(s); } function collectContentTurns(history: ChatMessage[]): string[] { @@ -2177,9 +2253,9 @@ function collectContentTurns(history: ChatMessage[]): string[] { const t = m.content.trim(); if (!t) continue; if (t.startsWith('[hv-')) continue; // skip marker messages - // Skip control phrases ("continue / next / go / 开始生成 …"). These are the + // Skip control phrases ("continue / next / go / start generating ..."). These are the // user nudging the flow forward, NOT video content — otherwise e.g. - // "继续刚刚的任务" gets baked in as the opening frame's headline. + // "continue the previous task" gets baked in as the opening frame's headline. if (isControlPhrase(t)) continue; // Skip the "trimmed answer" that picks the type — it's the first user turn // immediately after the type card; keep only later ones. @@ -2196,9 +2272,9 @@ function collectContentTurns(history: ChatMessage[]): string[] { /** * The video's LOCKED subject, in the user's own words. The opening message - * ("帮我生成一个关于 Open Design 的介绍视频") names the subject, but it never + * ("generate an intro video about Open Design for me") names the subject, but it never * reached the generate / storyboard prompts: collectContentTurns() only keeps - * turns after the type-pick card, so a later vague answer like "随机" became the + * turns after the type-pick card, so a later vague answer like "random" became the * entire content input and the video came out about randomness instead of Open * Design. This recovers the opening subject so every downstream prompt can lock * onto it. @@ -2213,7 +2289,7 @@ function resolveOpeningTopic(project: { intent?: string }, history: ChatMessage[ if (fromIntent) return fromIntent.slice(0, 200); const firstUser = history.find((m) => m.role === 'user')?.content ?? ''; const clean = (firstUser.split('\n\n📎')[0] ?? '').trim(); - // Don't lock onto a bare control phrase ("继续" / "ok") if that's somehow first. + // Don't lock onto a bare control phrase ("continue" / "ok") if that's somehow first. if (!clean || isControlPhrase(clean)) return ''; return clean.slice(0, 200); } @@ -2257,7 +2333,7 @@ function hadGenerationYet(history: ChatMessage[]): boolean { return history.some( (m) => m.role === 'assistant' && - /```json#content-graph|故事板规划完成|storyboard (generated|regenerated|restyled)|帧完成|frame .* (done|完成)/i.test(m.content), + /```json#content-graph|storyboard planned|storyboard (generated|regenerated|restyled)|frame .* (done|completed)/i.test(m.content), ); } @@ -2280,8 +2356,8 @@ function lastAssistantAskedFormat(history: ChatMessage[]): boolean { } // Prose fallback: the turn talks about size/duration/frames without a card. // Require at least two of the three concepts so an unrelated mention of - // "时长" elsewhere doesn't trigger it. - const hits = [/尺寸|横屏|竖屏|方形|aspect|比例/i, /时?长|秒|duration|\bs\b/i, /帧|frames?/i] + // "duration" elsewhere doesn't trigger it. + const hits = [/size|landscape|portrait|square|aspect|ratio/i, /duration|seconds|\bs\b/i, /frames?/i] .filter((re) => re.test(c)).length; return hits >= 2; } @@ -2294,7 +2370,7 @@ function lastAssistantAskedFormat(history: ChatMessage[]): boolean { * The format step is supposed to render an `hv-form` card (segmented buttons) * whose submit carries an explicit `[hv-form:submit]` marker. But the model * sometimes ignores that instruction and instead asks for the params in prose - * ("9:16 竖屏 / 3s / 6 …"); the user then types the answer free-form, with no + * ("9:16 Portrait / 3s / 6 ..."); the user then types the answer free-form, with no * marker. Without this parser the state machine can't tell the params were * already given, so it loops — re-asking the same thing in a different shape * (issue #2). We extract aspect / duration / frame_count heuristically so a @@ -2311,27 +2387,27 @@ export function parseFormatReply(text: string): Record | undefin // --- aspect: explicit ratio (16:9 / 9:16 / 1:1 / 4:5) or a keyword --- const ratio = /\b(16\s*[::]\s*9|9\s*[::]\s*16|1\s*[::]\s*1|4\s*[::]\s*5)\b/.exec(t); const ratioNorm = ratio?.[1]?.replace(/\s/g, '').replace(':', ':'); - if (ratioNorm === '16:9' || /横屏|landscape|宽屏/i.test(t)) out.aspect = '16:9 横屏'; - else if (ratioNorm === '9:16' || /竖屏|手机|portrait|vertical/i.test(t)) out.aspect = '9:16 手机竖屏'; - else if (ratioNorm === '1:1' || /方形|square/i.test(t)) out.aspect = '1:1 方形'; - else if (ratioNorm === '4:5' || /小红书|xiaohongshu|rednote/i.test(t)) out.aspect = '4:5 小红书'; + if (ratioNorm === '16:9' || /landscape|widescreen/i.test(t)) out.aspect = '16:9 Landscape'; + else if (ratioNorm === '9:16' || /portrait|mobile|vertical/i.test(t)) out.aspect = '9:16 Portrait'; + else if (ratioNorm === '1:1' || /square/i.test(t)) out.aspect = '1:1 Square'; + else if (ratioNorm === '4:5' || /xiaohongshu|rednote/i.test(t)) out.aspect = '4:5 Xiaohongshu'; - // --- duration: a number directly tied to seconds (5s / 5秒 / 5 sec) --- - const dur = /(\d{1,3})\s*(?:s\b|秒|sec)/i.exec(t); + // --- duration: a number directly tied to seconds (5s / 5 sec) --- + const dur = /(\d{1,3})\s*(?:s\b|sec)/i.exec(t); if (dur?.[1]) out.duration = dur[1]; - // --- frame_count: a number tied to 帧/frame, or the lone trailing number in + // --- frame_count: a number tied to frame, or the lone trailing number in // a "a / b / c" triple where a=ratio, b=duration. --- - const fr = /(\d{1,2})\s*(?:帧|frames?)\b/i.exec(t); + const fr = /(\d{1,2})\s*(?:frames?)\b/i.exec(t); if (fr?.[1]) out.frame_count = fr[1]; else { - // "16:9 横屏 / 5s / 10" — after stripping ratio+duration tokens, a bare + // "16:9 Landscape / 5s / 10" — after stripping ratio+duration tokens, a bare // small integer left over is the frame count. const parts = t.split(/[/、,,]+/).map((s) => s.trim()).filter(Boolean); if (parts.length >= 2) { const last = parts[parts.length - 1]!; - const bare = /^(\d{1,2})\s*帧?$/.exec(last); - if (bare?.[1] && !/[::s秒]/.test(last)) out.frame_count = bare[1]; + const bare = /^(\d{1,2})\s*frames?$/.exec(last); + if (bare?.[1] && !/[:s]/.test(last)) out.frame_count = bare[1]; } } @@ -2439,12 +2515,12 @@ function parseGraphJsonTolerant(raw: string): unknown { } /** A content type is multi-frame UNLESS it's an explicitly single-frame kind - * (title card / cover / single still). Whitelisting "讲解/explainer/…" was too - * narrow — e.g. "概念解说短片" (解说, not 讲解) fell through to single-frame. + * (title card / cover / single still). Whitelisting "explainer/..." was too + * narrow — e.g. "concept explainer short video" (explainer, not explanation) fell through to single-frame. * Inverting the test makes new/renamed multi-frame types default correctly. */ function isMultiFrameType(pickedType: string): boolean { if (!pickedType) return false; - const single = /单帧|单画面|标题卡|封面|logo|title.?card|single.?frame|cover|still/i.test(pickedType); + const single = /single frame|title card|cover|logo|title.?card|single.?frame|cover|still/i.test(pickedType); return !single; } @@ -2454,13 +2530,13 @@ function buildStylePhasePrompt(pickedType: string): string { p.push('```hv-options'); p.push(JSON.stringify({ meta: { phase: 'style' }, - question: '视觉风格怎么定?', + question: 'How to set the visual style?', options: [ - { label: 'Cyberpunk glitch', hint: '霓虹 / 故障感 / 高对比' }, - { label: 'Swiss minimalist', hint: '网格 / 无衬线 / 留白' }, - { label: 'Warm-grain magazine', hint: '纸感 / 衬线 / 暖色' }, - { label: 'Mono brutalist', hint: '黑白 / 块状 / 粗体' }, - { label: '从设计模板选', hint: '上方挑一个现成模板' }, + { label: 'Cyberpunk glitch', hint: 'Neon / Glitch / High contrast' }, + { label: 'Swiss minimalist', hint: 'Grid / Sans-serif / Whitespace' }, + { label: 'Warm-grain magazine', hint: 'Paper / Serif / Warm' }, + { label: 'Mono brutalist', hint: 'Black & white / Blocky / Bold' }, + { label: 'Pick from design template', hint: 'Pick an existing template above' }, ], allow_freeform: true, }, null, 2)); @@ -2497,11 +2573,11 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { em.push('```hv-options'); em.push(JSON.stringify({ meta: { phase: 'edit-menu' }, - question: '想改哪方面?', + question: 'What do you want to change?', options: [ - { label: '🎨 换风格', hint: '保留内容,换一套视觉风格' }, - { label: '✏️ 改内容', hint: '改文案 / 主题 / 重写脚本' }, - { label: '⏱️ 改时长', hint: '调整每帧时长 / 节奏' }, + { label: '🎨 Change style', hint: 'Keep content, apply a new visual style' }, + { label: '✏️ Change content', hint: 'Change copy / theme / rewrite script' }, + { label: '⏱️ Change duration', hint: 'Adjust per-frame duration / rhythm' }, ], allow_freeform: true, }, null, 2)); @@ -2524,12 +2600,12 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { opener.push('```hv-options'); opener.push(JSON.stringify({ meta: { phase: 'type' }, - question: '想做哪种内容?', + question: 'What kind of content do you want?', options: [ - { label: '单帧标题卡', hint: 'logo / 封面 / 单画面 - 5-10s' }, - { label: '多帧预告片', hint: '产品 / 活动 teaser, 3-6 帧' }, - { label: '数据大字报', hint: '1-2 个核心数字, 社媒爆款风' }, - { label: '概念解说短片', hint: '几帧讲清一个 idea / feature' }, + { label: 'Single frame title card', hint: 'logo / cover / single frame - 5-10s' }, + { label: 'Multi-frame trailer', hint: 'Product / Event teaser, 3-6 frames' }, + { label: 'Data poster', hint: '1-2 key numbers, social media hit style' }, + { label: 'Concept explainer short video', hint: 'Explain an idea / feature in a few frames' }, ], allow_freeform: true, }, null, 2)); @@ -2558,7 +2634,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { p.push(''); for (const a of attachments) p.push(...renderAttachment(a)); p.push(''); - 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:`); + 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. "Alright, I have read the article '...' — generating based on it. Next, pick a style."). Do NOT ask the user to retype or summarize anything. End with this hidden marker on its own line:`); p.push(''); p.push(''); p.push(`Plain text + the marker only. NO code blocks. NO questions. Do NOT return an empty reply.`); @@ -2568,29 +2644,29 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { 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:`); p.push(''); p.push(''); - 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.`); + 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 "random / skip / don't know" — accept whatever they give and move on.`); p.push(''); - // The user's opening request already names the subject (e.g. "做一个 Open - // Design 推广视频"). Lock onto it: don't let a vague follow-up answer like - // "随机/随便/anything" silently become a literal NEW topic — that's how a + // The user's opening request already names the subject (e.g. "make an Open + // Design promo video"). Lock onto it: don't let a vague follow-up answer like + // "random / anything" silently become a literal NEW topic — that's how a // "promote Open Design" request turned into a probability explainer. { const openingTopic = history.find((m) => m.role === 'user')?.content?.trim().slice(0, 200); if (openingTopic) { 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.`); - 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.`); + 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 "random / anything / you decide / whatever" as "you decide the details, KEEP the original subject" — never as a literal new topic.`); p.push(''); } } if (turns.length === 0) { - 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" / "随便".`); + 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" / "random".`); } else { p.push(`The user has already shared:`); for (const t of turns) p.push(` - ${t.slice(0, 200)}`); p.push(''); p.push(`Two options:`); p.push(`- If you still need more info: ask ONE clarifying question and end your reply with the marker on its own line: `); - 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.`); + 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.`); } p.push(''); p.push(`Reply in plain text. NO code blocks. Do NOT return an empty reply.`); @@ -2605,17 +2681,17 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { // ---- need-template: user chose "from design template" but hasn't picked one if (phase === 'need-template') { const p: string[] = []; - 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:`); + p.push(`The user chose "Pick from design template" (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 / 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:`); p.push('```hv-options'); p.push(JSON.stringify({ meta: { phase: 'need-template' }, - question: '先在顶部「模板」里选一个模板,选好后点下面继续;或直接选一种内置风格:', + question: 'First pick a template from the "Template" dropdown at the top, then click continue below; or directly pick a built-in style:', options: [ - { label: '我已选好模板,继续', hint: '用顶部选中的模板生成' }, - { label: 'Cyberpunk glitch', hint: '霓虹 / 故障感 / 高对比' }, - { label: 'Swiss minimalist', hint: '网格 / 无衬线 / 留白' }, - { label: 'Warm-grain magazine',hint: '纸感 / 衬线 / 暖色' }, - { label: 'Mono brutalist', hint: '黑白 / 块状 / 粗体' }, + { label: 'I have picked a template, continue', hint: 'Generate using the template selected at the top' }, + { label: 'Cyberpunk glitch', hint: 'Neon / Glitch / High contrast' }, + { label: 'Swiss minimalist', hint: 'Grid / Sans-serif / Whitespace' }, + { label: 'Warm-grain magazine',hint: 'Paper / Serif / Warm' }, + { label: 'Mono brutalist', hint: 'Black & white / Blocky / Bold' }, ], allow_freeform: true, }, null, 2)); @@ -2634,7 +2710,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { : (inputs.pickedType ?? ''); const isMulti = !!pickedType && isMultiFrameType(pickedType); const defaults = { - aspect: pre.aspect ?? '16:9 横屏', + aspect: pre.aspect ?? '16:9 Landscape', duration: pre.duration ?? (isMulti ? '15' : '5'), frame_count: pre.frame_count ?? (isMulti ? '4' : '1'), // Per-frame pacing default 4s — comfortable, avoids the "rushed" feel a @@ -2654,16 +2730,16 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { p.push('```hv-form'); p.push(JSON.stringify({ meta: { phase: 'format' }, - title: isEdit ? '改一下格式' : (isMulti ? '最后一步:尺寸 / 每帧时长 / 帧数' : '最后一步:选个尺寸 / 时长'), + title: isEdit ? 'Change format' : (isMulti ? 'Last step: Aspect / Per-frame duration / Frame count' : 'Last step: Pick aspect / duration'), fields: [ { - key: 'aspect', label: '画面尺寸', kind: 'buttons', required: true, + key: 'aspect', label: 'Aspect ratio', kind: 'buttons', required: true, default: defaults.aspect, options: [ - { value: '16:9 横屏', label: '16:9 横屏' }, - { value: '9:16 手机竖屏', label: '9:16 竖屏' }, - { value: '1:1 方形', label: '1:1 方形' }, - { value: '4:5 小红书', label: '4:5 小红书' }, + { value: '16:9 Landscape', label: '16:9 Landscape' }, + { value: '9:16 Portrait', label: '9:16 Portrait' }, + { value: '1:1 Square', label: '1:1 Square' }, + { value: '4:5 Xiaohongshu', label: '4:5 Xiaohongshu' }, ], }, // Multi-frame: pace by PER-FRAME duration (total = per_frame × frames, @@ -2671,13 +2747,13 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { ...(isMulti ? [ { - key: 'per_frame', label: '每帧时长 (秒)', kind: 'buttons', required: true, + key: 'per_frame', label: 'Per-frame duration (seconds)', kind: 'buttons', required: true, default: defaults.per_frame, - hint: '总时长 = 每帧时长 × 帧数', + hint: 'Total duration = Per-frame duration x Frame count', options: ['2', '3', '4', '5', '6', '8'].map((v) => ({ value: v, label: `${v}s` })), }, { - key: 'frame_count', label: '帧数', kind: 'buttons', required: true, + key: 'frame_count', label: 'Frame count', kind: 'buttons', required: true, default: defaults.frame_count, options: ['2', '3', '4', '5', '6', '7', '8', '9', '10'].map((v) => ({ value: v, label: v })), }, @@ -2685,18 +2761,18 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { // bars grow) instead of static hyperframes HTML. Default OFF — // Remotion is a user-chosen enhancement, the AI never flips it. { - key: 'remotion_enhance', label: '⚡ 数据帧用 Remotion', kind: 'buttons', required: false, - default: '关', - hint: '数据帧用原生 Remotion 渲染(数字滚动 / 柱子生长);其余帧仍走 Hyperframes', + key: 'remotion_enhance', label: '⚡ Data frames with Remotion', kind: 'buttons', required: false, + default: 'Off', + hint: 'Render data frames with native Remotion (number scrolling / bar growth); other frames still use Hyperframes', options: [ - { value: '关', label: '关' }, - { value: '开', label: '开 · Remotion' }, + { value: 'Off', label: 'Off' }, + { value: 'On', label: 'On · Remotion' }, ], }, ] : [ { - key: 'duration', label: '时长 (秒)', kind: 'buttons', required: true, + key: 'duration', label: 'Duration (seconds)', kind: 'buttons', required: true, default: defaults.duration, options: ['3', '5', '10', '15'].map((v) => ({ value: v, label: `${v}s` })), }, @@ -2717,14 +2793,14 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { const pickedStyle = lastCardPickByPhase(history, 'style') ?? ''; const contentTurns = collectContentTurns(history); const summaryRows: { label: string; value: string }[] = []; - if (pickedType) summaryRows.push({ label: '类型', value: pickedType }); + if (pickedType) summaryRows.push({ label: 'Type', value: pickedType }); if (contentTurns.length > 0) { - summaryRows.push({ label: '内容', value: contentTurns.join(' · ').slice(0, 240) }); + summaryRows.push({ label: 'Content', value: contentTurns.join(' · ').slice(0, 240) }); } - if (pickedStyle) summaryRows.push({ label: '风格', value: pickedStyle }); - if (tmpl) summaryRows.push({ label: '模板', value: tmpl.name }); + if (pickedStyle) summaryRows.push({ label: 'Style', value: pickedStyle }); + if (tmpl) summaryRows.push({ label: 'Template', value: tmpl.name }); const labelMap: Record = { - aspect: '尺寸', duration: '时长', frame_count: '帧数', per_frame: '每帧时长', + aspect: 'Aspect ratio', duration: 'duration', frame_count: 'Frame count', per_frame: 'Per-frame duration', }; // When pacing by per-frame, show per-frame + frames + derived total. const pf = Number(collected.per_frame ?? '') || 0; @@ -2735,10 +2811,10 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { } if (pf > 0) { const frames = Number(collected.frame_count ?? '4') || 4; - summaryRows.push({ label: '总时长', value: `${pf * frames}s` }); + summaryRows.push({ label: 'Total duration', value: `${pf * frames}s` }); } if (attachments.length > 0) { - summaryRows.push({ label: '素材', value: attachments.map((a) => a.filename).join(', ') }); + summaryRows.push({ label: 'Assets', value: attachments.map((a) => a.filename).join(', ') }); } const p: string[] = []; @@ -2747,7 +2823,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { p.push('```hv-confirm'); p.push(JSON.stringify({ meta: { phase: 'confirm' }, - title: '按这些信息生成?', + title: 'Generate with this info?', summary: summaryRows, actions: ['generate', 'edit'], }, null, 2)); @@ -2760,7 +2836,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { const topicThin = attachments.length === 0 && (contentBlob.replace(/\s/g, '').length < 8 || - /^(随机|随便|anything|random|whatever|都行|你定|skip|不知道)$/i.test(contentBlob)); + /^(random|anything|whatever|you decide|skip|don't know)$/i.test(contentBlob)); if (topicThin) { 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.`); p.push(''); @@ -2775,7 +2851,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { const pickedType = inputs.pickedType ?? ''; const pickedStyle = inputs.pickedStyle ?? ''; const contentTurns = inputs.contentTurns ?? []; - const aspect = ((collected.aspect ?? '16:9').split(/\s+/)[0] ?? '16:9'); // strip "16:9 横屏" → "16:9" + const aspect = ((collected.aspect ?? '16:9').split(/\s+/)[0] ?? '16:9'); // strip "16:9 Landscape" → "16:9" const [w, h] = aspect.includes(':') ? aspect.split(':').map(Number) : [16, 9]; const isMulti = isMultiFrameType(pickedType) || Number(collected.frame_count ?? '1') > 1 @@ -2787,7 +2863,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { else if (aspect === '1:1') resolution = '1080×1080'; else if (aspect === '4:5') resolution = '1080×1350'; - const styleLabel = pickedStyle && /^从设计模板选|template/i.test(pickedStyle) + const styleLabel = pickedStyle && /^Pick from design template|template/i.test(pickedStyle) ? (tmpl ? `(use the selected template "${tmpl.name}" — ${tmpl.description})` : '(let the model choose)') : pickedStyle; @@ -2795,25 +2871,25 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { p.push(`Generate the HTML video file(s) the user just confirmed.`); p.push(''); // Lock the subject to the user's opening request. The content turns below - // can be as thin as "随机" — without this the video drifts onto that literal + // can be as thin as "random" — without this the video drifts onto that literal // word (a "promote Open Design" request became a randomness explainer). if (openingTopic) { p.push(`VIDEO SUBJECT (LOCKED): the user opened with "${openingTopic}". The video MUST be about THIS subject.`); - 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.`); + p.push(`If a content line below is a vague placeholder like "random / anything / you decide / whatever", it means "YOU choose the concrete details (selling points, framing, copy) — but the SUBJECT stays "${openingTopic}"". NEVER treat "random" as the literal topic; do NOT make a video about randomness.`); p.push(''); } p.push(`Inputs (use these LITERALLY — do NOT make up brand names or facts beyond what is stated):`); - p.push(`- 类型 / type: ${pickedType || '(未指定)'}`); + p.push(`- Type: ${pickedType || '(unspecified)'}`); if (contentTurns.length > 0) { - p.push(`- 内容 / content (what the user told us in the chat):`); + p.push(`- Content (what the user told us in the chat):`); for (const t of contentTurns) p.push(` · ${t.replace(/\n/g, ' ').slice(0, 280)}`); } else { - p.push(`- 内容 / content: (the user did not specify; pick a sensible default that fits the type, but keep it generic — no fake brand names)`); + p.push(`- Content: (the user did not specify; pick a sensible default that fits the type, but keep it generic — no fake brand names)`); } - if (styleLabel) p.push(`- 风格 / style: ${styleLabel}`); - p.push(`- 画面尺寸: ${aspect} (${resolution})`); - p.push(`- 时长: ${collected.duration ?? '?'} 秒`); - p.push(`- 帧数: ${collected.frame_count ?? (isMulti ? '4' : '1')}`); + if (styleLabel) p.push(`- Style: ${styleLabel}`); + p.push(`- Aspect ratio: ${aspect} (${resolution})`); + p.push(`- Duration: ${collected.duration ?? '?'} seconds`); + p.push(`- Frame count: ${collected.frame_count ?? (isMulti ? '4' : '1')}`); p.push(''); if (attachments.length > 0) { const { specs, content } = partitionAttachments(attachments); @@ -2852,7 +2928,7 @@ function buildHtmlGenerationPrompt(args: BuildPromptArgs): string { p.push(`GROUNDING (REQUIRED — the source material above is the script, not decoration):`); 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.`); p.push(`- The "synopsis" MUST name the article's actual subject — not "AI/technology trends" or any vague category.`); - 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.`); + p.push(`- BANNED: generic motivational filler with no tie to the source ("see the essence", "first principles", "beneath the complex surface", "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.`); 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.`); p.push(''); } @@ -3122,7 +3198,7 @@ interface SplitGenerateArgs { /** * Restyle mode: keep the EXISTING content-graph text verbatim and only * re-render each frame's HTML in the new style. Skips the Step-1 graph - * re-plan. Used by the post-generation "换风格 / 改时长" sub-flows. + * re-plan. Used by the post-generation "change style / change duration" sub-flows. */ restyleOnly?: boolean; /** Called for human-readable progress lines. */ @@ -3133,7 +3209,7 @@ interface SplitGenerateArgs { // NOTE: the old classifyIterateIntent (LLM guesses rewrite-all/edit-visual/ // edit-frame from one sentence) was removed. The post-generation flow no longer -// guesses: detectPhase routes a vague "改一下" to an explicit edit-menu card +// guesses: detectPhase routes a vague "change it a bit" to an explicit edit-menu card // (style / content / duration) and the user's pick drives restyle / // iterate-content / iterate-format. @@ -3163,7 +3239,7 @@ async function runSplitMultiFrameGenerate( // Opt-in (format card): render data frames natively with Remotion. When on, // the planner must give every data node structured items, and after each // data frame's HTML is written we enhance it in place (best-effort). - const enhanceData = (collected.remotion_enhance ?? '').startsWith('开'); + const enhanceData = (collected.remotion_enhance ?? '').startsWith('On'); // Prefer per-frame pacing (total = per_frame × frames) — set by the format // card so a short total ÷ many frames can't produce a rushed clip. Fall back // to total ÷ frames for older projects that only stored `duration`. @@ -3190,7 +3266,7 @@ async function runSplitMultiFrameGenerate( } } - const styleLabel = pickedStyle && /^从设计模板选|template/i.test(pickedStyle) + const styleLabel = pickedStyle && /^Pick from design template|template/i.test(pickedStyle) ? (tmpl ? `(use the selected template "${tmpl.name}" — ${tmpl.description})` : '(let the model choose)') : pickedStyle; @@ -3204,29 +3280,29 @@ async function runSplitMultiFrameGenerate( throw new Error('restyle requested but the project has no existing storyboard to reuse'); } graph = existing as import('@html-video/content-graph').ContentGraph; - onProgress(`✓ 沿用现有文案:${graph.nodes.length} 帧`); + onProgress(`✓ Kept existing copy: ${graph.nodes.length} frames`); onSse({ type: 'plan_ready', frame_count: graph.nodes.length, intent: graph.intent }); } else { - onProgress(`📋 规划 ${frameCountReq} 帧的故事板…`); + onProgress(`📋 Planning storyboard for ${frameCountReq} frames...`); const graphPromptParts: string[] = []; 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.`); graphPromptParts.push(''); graphPromptParts.push(`Inputs (use literally — do NOT invent brand names or facts beyond these):`); - graphPromptParts.push(`- 类型 / type: ${pickedType || '(unspecified)'} (this is the FORMAT, NOT the subject — never make the video be "about" the type itself)`); + graphPromptParts.push(`- Type: ${pickedType || '(unspecified)'} (this is the FORMAT, NOT the subject — never make the video be "about" the type itself)`); // Lock the storyboard to the user's opening subject (unless a SOURCE MATERIAL // block below supersedes it). This is the path the user actually hits, and - // where "随机" turned into a randomness explainer instead of the Open Design + // where "random" turned into a randomness explainer instead of the Open Design // promo they asked for. if (openingTopic && !attachments.some((a) => !!a.inlineText)) { - 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.`); + graphPromptParts.push(`- theme / 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 "random / anything / you decide", it means "you choose the concrete angle and points — but keep the subject = "${openingTopic}"". NEVER make the video about randomness or the literal word.`); } if (contentTurns.length > 0) { - graphPromptParts.push(`- 内容 / content:`); + graphPromptParts.push(`- Content:`); for (const t of contentTurns) graphPromptParts.push(` · ${t.replace(/\n/g, ' ').slice(0, 280)}`); } // Inline the fetched article / repo / uploaded text — THIS is the subject of // the video. Without it the planner only sees the type word and invents a - // video "about 概念解说" instead of about the user's actual source. + // video "about concept explainer" instead of about the user's actual source. const { specs: designSpecs, content: contentAtts } = partitionAttachments(attachments); if (designSpecs.length > 0) graphPromptParts.push('', ...renderDesignSpecBlock(designSpecs)); const sourceTexts = contentAtts.filter((a) => !!a.inlineText); @@ -3238,11 +3314,11 @@ async function runSplitMultiFrameGenerate( graphPromptParts.push((a.inlineText ?? '').slice(0, 6000)); } } - if (styleLabel) graphPromptParts.push(`- 风格 / style: ${styleLabel}`); - graphPromptParts.push(`- 总时长: ${totalDurationSec}s split across ${frameCountReq} frames (~${perFrameDurationSec}s each)`); + if (styleLabel) graphPromptParts.push(`- Style: ${styleLabel}`); + graphPromptParts.push(`- Total duration: ${totalDurationSec}s split across ${frameCountReq} frames (~${perFrameDurationSec}s each)`); graphPromptParts.push(''); if (sourceTexts.length > 0) { - 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.`); + 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. "what is a concept explainer", "information density x transmission efficiency") that would fit any video. If a line could fit any topic, it's wrong.`); graphPromptParts.push(''); } graphPromptParts.push(`Schema (keep all keys; one node per frame; nodes[].id should be a short readable slug like "intro" / "stat_users" / "outro"):`); @@ -3285,7 +3361,7 @@ async function runSplitMultiFrameGenerate( graphPromptParts.push(''); 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.`); 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\`.`); - 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" / "个" / "次".`); + 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".`); 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.`); const graphPrompt = graphPromptParts.join('\n'); @@ -3304,7 +3380,7 @@ async function runSplitMultiFrameGenerate( throw new Error('graph has no nodes'); } await ctx.orchestrator.writeContentGraph(projectId, graph); - onProgress(`✓ 故事板规划完成:${graph.nodes.length} 帧 (${graph.intent})`); + onProgress(`✓ Storyboard planned: ${graph.nodes.length} frames (${graph.intent})`); onSse({ type: 'plan_ready', frame_count: graph.nodes.length, intent: graph.intent }); } @@ -3312,7 +3388,7 @@ async function runSplitMultiFrameGenerate( for (let i = 0; i < graph.nodes.length; i++) { const node = graph.nodes[i]!; const nodeId = node.id; - onProgress(`🎬 生成第 ${i + 1}/${graph.nodes.length} 帧 (${nodeId})…`); + onProgress(`🎬 Generating frame ${i + 1}/${graph.nodes.length} (${nodeId})...`); onSse({ type: 'frame_started', node_id: nodeId, order: i, total: graph.nodes.length }); const frameContext = describeNode(node); @@ -3325,7 +3401,7 @@ async function runSplitMultiFrameGenerate( 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)'}.`); } if (openingTopic && !attachments.some((a) => !!a.inlineText)) { - fp.push(`Subject (locked): "${openingTopic}". This frame is about this subject; "随机/随便" anywhere in the inputs means you pick details, not a new topic.`); + fp.push(`Subject (locked): "${openingTopic}". This frame is about this subject; "random" anywhere in the inputs means you pick details, not a new topic.`); } fp.push(`Duration: ${node.durationSec ?? perFrameDurationSec}s`); fp.push(`Type: ${pickedType}`); @@ -3394,7 +3470,7 @@ h1{font-size:8vw;letter-spacing:-.03em;animation:in 1s ease forwards;opacity:0;t // One retry on empty: shorter prompt, just the skeleton call. if (!extracted) { - onProgress(` ↻ 第 ${i + 1} 帧首试为空,重试…`); + onProgress(` ↻ Frame ${i + 1} first attempt empty, retrying...`); 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.`; frameText = await callAgentSimple(agentDef, retryPrompt, projectDir, agentModel); extracted = /```html\s*\n([\s\S]*?)```/i.exec(frameText)?.[1]?.trim() @@ -3416,19 +3492,19 @@ h1{font-size:8vw;letter-spacing:-.03em;animation:in 1s ease forwards;opacity:0;t // frame is flagged remotion but has no previewMp4Path, so the studio // tries to play a