fix(assets,storyboard): 统一素材库 entity 口径、修复远端空结果遮蔽,补分镜输出契约与解析 (#510)

远端空页只在本地仍有未同步结果或本页全是 entity 时回退,避免把合法空搜索铺成过期缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-17 00:01:57 +08:00
7 changed files with 183 additions and 38 deletions

View File

@@ -99,8 +99,49 @@ export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): Storyb
])) as StoryboardColumn[];
}
// 分镜文本任务的模型侧输出契约,字段与 storyboardRowsFromTask 的解析结构一一对应。
// 后端只有带 metadata.promptTemplateOperation 的任务才会编译服务端分镜模板(含 JSON 契约),
// 章节分镜走的是自定义 prompt,缺这段约束时模型会按自然语言习惯返回 Markdown 表格,解析阶段拿不到 rows。
export function storyboardRowsOutputContract(requirement: string) {
return [
"【受保护输出契约】",
"- 只返回一个 JSON 对象,不要 Markdown、表格、代码块、注释或任何解释文字。",
`- 顶层固定为 {"title": string, "rows": object[]},rows 至少 1 项。${requirement}`,
"- rows 每项对应一个镜头,按剧情顺序排列;字段缺失时填空字符串或空数组,不要省略字段:",
" shotNumber(整数,从 1 递增)、durationSeconds(整数,1-60)、plotDescription(本镜可见的剧情与关键动作)、",
" dialogue(本镜实际念出的台词或简短旁白,超长必须拆镜)、characters(本镜出现的角色名数组)、",
" narrativeIntent、viewerPOV、performanceBlocking、shotSize(景别)、emotion、lightingAndAtmosphere、",
" audioEffects、camera、motion、timeBeats、imageGenerationPrompt(首帧画面提示词)、",
" videoMotionPrompt(运镜与结尾状态)、continuityOut、negativePrompt、",
" mustHave(数组,最多 3 项)、optionalDetails(数组)。",
"- 镜头数量以完整覆盖剧情因果、人物关系和关键台词为准,不要为压缩数量删减关键情节。",
].join("\n");
}
// 文本任务的 result_json 外层是 {mode, reasoning, text} 包装,分镜表在 text 字段的内层 JSON 字符串里;
// 历史模板路径可能直接落 {title, rows}。两种结构都要能解析,内层再做代码块剥离与首尾大括号截取兜底,
// 模型偶发违反契约(包 ```json 或混入解释文字)时仍能取出分镜表。
function parseStoryboardRowsPayload(raw: string): { title?: string; rows?: Array<Partial<StoryboardRow>> } {
const parsed = JSON.parse(raw) as { title?: string; rows?: Array<Partial<StoryboardRow>>; text?: unknown };
if (Array.isArray(parsed.rows)) return parsed;
if (typeof parsed.text === "string" && parsed.text.trim()) {
const inner = parsed.text.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
const start = inner.indexOf("{");
const end = inner.lastIndexOf("}");
if (start >= 0 && end > start) {
try {
const innerParsed = JSON.parse(inner.slice(start, end + 1)) as { title?: string; rows?: Array<Partial<StoryboardRow>> };
if (Array.isArray(innerParsed.rows)) return innerParsed;
} catch {
// 内层不是合法 JSON 时落到外层结构,由调用方的缺行校验统一报错。
}
}
}
return parsed;
}
export function storyboardRowsFromTask(task: GenerationTask) {
const result = JSON.parse(task.resultJson || "{}") as { title?: string; rows?: Array<Partial<StoryboardRow>> };
const result = parseStoryboardRowsPayload(task.resultJson || "{}");
if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行");
return {
title: result.title?.trim(),