diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 4e3447b..baca9dc 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -72,6 +72,12 @@ _OUTPUT_PROTOCOL = """ 1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展; 2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象; 3. json 代码块之后**不要再写任何文字**。 + +### 字段名锚定(硬性 · 下游靠它取数,跑偏即数据全空) +- 分镜数组的键名**必须**叫 `segments`(禁止用 scenes / script / shots / 分镜 等同义词)。 +- 每镜口播键名**必须**叫 `narration`(禁止用 voiceover / audio / line)。 +- 每镜画面键名**必须**叫 `visual`,且为**一句话字符串**(禁止用 scene / screen / 画面,也禁止写成 {setting,camera,...} 对象)。 +- 即使你额外附带了 scenes / shots 等创作结构,也**必须同时**给出标准 `segments` 数组,并把口播填进 `narration`、画面填进 `visual`,否则视为不合格。 """ @@ -215,31 +221,81 @@ def _nearest_duration(value) -> int: # 与其逐一追变体,不如「优先键命中 → 否则按关键词模糊匹配」通用解析。SKIP 掉明显的非内容键, # 避免误抓(shotNo/duration/bgMusic/note 等)。 _PICK_SKIP_KEYS = { - "shotno", "shotsize", "shotsizetype", "duration", "starttime", "endtime", "timerange", - "bgmusic", "bg_music", "music", "sound", "sfx", "note", "notes", "tips", "index", - "role", "speaker", "entity_refs", "product_exposure", "id", "no", "transition", + "shotno", "shotsize", "shotsizetype", "duration", "duration_seconds", "starttime", "endtime", + "timerange", "time_range", "bgmusic", "bg_music", "music", "sound", "sfx", "note", "notes", + "tips", "index", "role", "speaker", "entity_refs", "product_exposure", "id", "no", "transition", + # 标题/编号类:含 scene 字样会误命中画面 fuzzy,显式跳过(注意:不跳裸 scene,它常=画面) + "scene_id", "sceneid", "scene_no", "sceneno", "scene_number", "scenenumber", + "scene_title", "scenetitle", "title", "scene_title_type", } -_VISUAL_EXACT = ("visual", "visual_prompt", "visual_description", "scene", "screen_description", "screendescription", "screen", "picture", "shot_description") -_VISUAL_FUZZY = ("scene", "screen", "visual", "picture", "画面", "镜头描述", "分镜画面", "描述") -_NARRATION_EXACT = ("narration", "voiceover", "voice_over", "line", "caption", "subtitle", "speech", "口播", "旁白") -_NARRATION_FUZZY = ("narrat", "voice", "旁白", "口播", "台词", "dialog", "caption", "subtitle", "字幕", "speech", "monolog") +_VISUAL_EXACT = ("visual", "visual_prompt", "visual_description", "screen_description", "screendescription", "screen", "picture", "shot_description", "scene", "画面") +_VISUAL_FUZZY = ("visual", "screen", "picture", "scene", "画面", "镜头描述", "分镜画面") +_NARRATION_EXACT = ("narration", "voiceover", "voice_over", "vo", "line", "caption", "subtitle", "speech", "口播", "旁白") +_NARRATION_FUZZY = ("narrat", "voice", "旁白", "口播", "台词", "dialog", "caption", "subtitle", "字幕", "speech", "monolog", "audio") + + +def _flatten_text(val) -> str: + """把 字符串/字典/列表 里的文本拍平成一句。模型常把 visual 写成 {setting,camera,key_shots} + 对象、把口播写成数组,这里统一抽成纯文本,避免结构化值被当空丢弃(全空根因之一)。""" + if isinstance(val, str): + return val.strip() + if isinstance(val, dict): + return " · ".join(p for p in (_flatten_text(v) for v in val.values()) if p) + if isinstance(val, (list, tuple)): + return " ".join(p for p in (_flatten_text(v) for v in val) if p) + return "" def _pick_field(seg: dict, exact: tuple[str, ...], fuzzy: tuple[str, ...]) -> str: - """从一镜里取某类文本字段:先按优先键精确命中,再按关键词在剩余键里模糊匹配(跳过非内容键)。""" + """从一镜里取某类文本字段:先按优先键精确命中,再按关键词在剩余键里模糊匹配(跳过非内容键)。 + 值允许是 字符串/对象/数组(嵌套结构拍平成一句),不再只认裸字符串。""" for key in exact: - val = seg.get(key) - if isinstance(val, str) and val.strip(): - return val.strip() + if key in seg: + txt = _flatten_text(seg.get(key)) + if txt: + return txt for key, val in seg.items(): kl = str(key).lower() - if kl in _PICK_SKIP_KEYS or not (isinstance(val, str) and val.strip()): + if kl in _PICK_SKIP_KEYS: continue if any(f in kl for f in fuzzy): - return val.strip() + txt = _flatten_text(val) + if txt: + return txt return "" +# 模型给分镜数组的键名五花八门(segments/scenes/script/shots…),且常同时给一个**空的** +# segments 骨架 + 真内容放在 scenes 里。所以不能「见 segments 是 list 就用」,要在所有候选里 +# 挑「能解析出最多非空旁白/画面」的那个。segments(契约本名)排第一,同分时优先。 +_SEGMENT_ARRAY_KEYS = ( + "segments", "shots", "scenes", "script", "shot_list", "shotlist", "shotList", + "scene_list", "scenes_list", "storyboard", "分镜", "镜头", +) + + +def _resolve_segments(draft: dict) -> list: + """在所有候选数组键里挑内容最丰富的分镜数组(按非空旁白/画面条数打分)。""" + best: list = [] + best_score = -1 + for key in _SEGMENT_ARRAY_KEYS: + arr = draft.get(key) + if not isinstance(arr, list) or not arr: + continue + dict_items = [s for s in arr if isinstance(s, dict)] + if not dict_items: + continue + score = sum( + 1 + for s in dict_items + if _pick_field(s, _NARRATION_EXACT, _NARRATION_FUZZY) + or _pick_field(s, _VISUAL_EXACT, _VISUAL_FUZZY) + ) + if score > best_score: # 严格大于 → 同分保留更靠前的键(segments 优先) + best, best_score = arr, score + return best + + def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict: """把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。""" blob = _extract_json(raw_text) @@ -262,11 +318,8 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> draft.setdefault("total_duration", basic.get("totalDuration") or basic.get("total_duration")) draft.setdefault("aspect_ratio", basic.get("aspectRatio") or basic.get("aspect_ratio")) draft.setdefault("theme", basic.get("theme")) - if not isinstance(draft.get("segments"), list): - for alias in ("shots", "scenes_list", "shotList"): - if isinstance(draft.get(alias), list): - draft["segments"] = draft[alias] - break + # 在所有候选键里挑内容最丰富的分镜数组(空 segments 骨架会被更丰富的 scenes/shots 顶替) + draft["segments"] = _resolve_segments(draft) draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip() dur = _nearest_duration(draft.get("total_duration") or total_duration) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 425af2f..8efe2ed 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -819,6 +819,34 @@ def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dic return "\n".join(line for line in lines if line) +def _is_transient_error(exc: Exception) -> bool: + """网络抖动/超时类瞬时错误(可重试),区别于内容审核拦截、参数非法等确定性失败。 + 中转站(tokenssr 等)偶发 Read timeout / 连接重置会无谓掐掉单帧,这类才重试。""" + msg = str(exc).lower() + return any( + k in msg + for k in ("timed out", "timeout", "connection", "reset by peer", "temporarily", + "bad gateway", "502", "503", "504", "remotedisconnected", "max retries") + ) + + +def _call_image_with_retry(fn, *, attempts: int = 3, base_delay: float = 2.0): + """对一次出图网络调用做有界重试:仅瞬时错误重试(指数退避),确定性失败立即抛出。 + 出图无副作用(失败=没拿到图),重试安全;成功一次即返回。""" + import time + + last: Exception | None = None + for i in range(attempts): + try: + return fn() + except Exception as exc: # noqa: BLE001 + last = exc + if i == attempts - 1 or not _is_transient_error(exc): + raise + time.sleep(base_delay * (i + 1)) + raise last # 理论不可达(循环内已 return/raise) + + def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: """后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。""" import threading # noqa: F401 — 仅标注此函数运行在独立线程 diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 35f66ae..0873906 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -80,6 +80,47 @@ class NormalizeDraftTests(SimpleTestCase): self.assertEqual(draft["segments"][0]["narration"], "一句完整口播") self.assertEqual(draft["segments"][0]["dialogue"], []) # 字符串不进结构化对白 + def test_empty_segments_skeleton_yields_to_richer_scenes(self): + """真实回归(全空根因):模型把内容放 scenes/voiceover/visual,却另给一个**空 segments 骨架**。 + 必须挑内容最丰富的数组(scenes),而不是见 segments 是 list 就用→落 4 个空镜。""" + raw = json.dumps({ + "scenes": [ + {"sceneIndex": 1, "sceneTitle": "通勤", "voiceover": "手机一卡效率掉线", "visual": "地铁口拿出亮银色机身"}, + {"sceneIndex": 2, "sceneTitle": "办公", "voiceover": "A19多任务很跟手", "visual": "办公桌俯拍切换应用"}, + ], + "segments": [{"index": 0, "role": "钩子", "narration": "", "visual": ""}, {"index": 1, "role": "痛点", "narration": "", "visual": ""}], + "total_duration": 30, + }, ensure_ascii=False) + draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=30) + self.assertEqual(draft["segments"][0]["narration"], "手机一卡效率掉线") + self.assertEqual(draft["segments"][0]["visual"], "地铁口拿出亮银色机身") + + def test_script_key_with_visual_object_is_flattened(self): + """真实回归:数组键叫 script、visual 写成 {setting,camera,key_shots} 对象。 + 必须认出 script 数组并把 visual 对象拍平成一句,而非留空。""" + raw = json.dumps({ + "script": [ + {"scene_id": 1, "scene_title": "通勤", "voiceover": "选手机看重流畅好看", + "visual": {"setting": "地铁口", "camera": "竖屏手持跟拍", "key_shots": ["拿出亮银色机身"]}}, + ], + "total_duration": 15, + }, ensure_ascii=False) + draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=15) + seg = draft["segments"][0] + self.assertEqual(seg["narration"], "选手机看重流畅好看") + self.assertIn("地铁口", seg["visual"]) + self.assertIn("竖屏手持跟拍", seg["visual"]) + + def test_audio_field_fills_narration(self): + """真实回归:shots 用 audio 放口播(GPT 变体),旁白曾因 audio 不在词典而留空。""" + raw = json.dumps({ + "shots": [{"scene": 1, "visual": "咖啡厅办公把玩机身", "audio": "这款亮银色是我的高光决定"}], + "total_duration": 15, + }, ensure_ascii=False) + draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=15) + self.assertEqual(draft["segments"][0]["narration"], "这款亮银色是我的高光决定") + self.assertEqual(draft["segments"][0]["visual"], "咖啡厅办公把玩机身") + def test_canonical_flat_schema_still_works(self): """契约内的扁平 schema(segments/narration/visual)不受兼容改动影响。""" raw = json.dumps({ diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 7e6314f..a659d3c 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -66,7 +66,7 @@ const crumbLabels: Partial> = { /* 图片生成工作台·跨刷新持久化:把在跑的任务 id + 已出结果按 mode 存本地, 刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */ const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`; -type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number }; +type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string }; function loadImgwb(mode?: string): ImgwbSaved | null { try { const raw = localStorage.getItem(imgwbKey(mode)); @@ -410,7 +410,9 @@ export function App() { const ids = tasks.map((t) => t.id); if (ids.length === 0) throw new Error("未能提交生成任务"); // 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢) - saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count }); + // 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名) + const batchProduct = products.find((p) => p.id === payload.product_id); + saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count, productId: payload.product_id, productTitle: batchProduct?.title }); return pollImageTasks(payload.mode, ids); }, "图片已生成"); } @@ -686,11 +688,11 @@ export function App() { case "assetFactory": return ; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": @@ -750,7 +752,7 @@ export function App() { avatarChar={avatarChar} logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")} onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")} - onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")} + onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加")} onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")} onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑")} onSaveProjectMeta={(meta) => diff --git a/core/frontend/src/components/actor-library.tsx b/core/frontend/src/components/actor-library.tsx index 0579860..98fc600 100644 --- a/core/frontend/src/components/actor-library.tsx +++ b/core/frontend/src/components/actor-library.tsx @@ -18,7 +18,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic initialStudio?: boolean; // 打开即进「添加人物工作台」 assets: Asset[]; onClose: () => void; - onPick?: (assetId: string) => void | Promise; + onPick?: (assetId: string, assetName?: string) => void | Promise; onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>; onUpload: (file: File) => Promise; // 据选中立绘生成配套三视图(后端吃任意 person 资产 id,不依赖该资产已在某 base group) @@ -71,6 +71,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic const people = useMemo(() => source.filter((a) => a.category === "person" && previewOf(a)), [source]); const list = people.filter((a) => (tab === "preset" ? isPreset(a) : !isPreset(a))); + // 演员库分页(客户端):一页 10 个(5 列 × 2 行)。切 tab / 列表变化时回第 1 页,避免停在越界页。 + const PAGE_SIZE = 10; + const [page, setPage] = useState(1); + const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE)); + useEffect(() => { setPage(1); }, [tab, open]); + useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]); + const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + // 选某张候选立绘 → 进右侧栏(预填名字,清三视图标志) function pickCandidate(a: Asset) { setPicked(a); @@ -126,8 +134,8 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
{ if (e.target === e.currentTarget) onClose(); }}>
-

{mode === "replace" ? "演员库 · 选择演员替换" : "演员库"}

- // {mode === "replace" ? "点演员卡即替换当前立绘" : "平台预设演员 / 我的演员"} +

{mode === "replace" ? "演员库 · 选择演员" : "演员库"}

+ // {mode === "replace" ? "点演员卡即选用应用" : "平台预设演员 / 我的演员"} @@ -230,15 +238,16 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
{list.length ? ( + <>
- {list.map((a) => { + {pageList.map((a) => { const url = previewOf(a); return (
{ if (mode === "replace") { void onPick?.(a.id); } else if (url) { setPreview({ src: url, name: a.name }); } }} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id); } else if (url) { setPreview({ src: url, name: a.name }); } } }}> + role="button" tabIndex={0} title={mode === "replace" ? "选用此演员" : "查看大图"} + onClick={() => { if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } } }}> {!url && {a.name}} {mode === "replace" && 选用}
@@ -247,6 +256,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic ); })}
+ {pageCount > 1 && ( +
+ + {page} / {pageCount} + +
+ )} + ) : (
// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index c515af6..ccef281 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -497,7 +497,8 @@ export function ImageWorkbenchPage({ navigate, onGenerate, onResume, - initialProductId + initialProductId, + onProductChange }: { mode: WorkMode; products: Product[]; @@ -508,9 +509,13 @@ export function ImageWorkbenchPage({ onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; + /** 选中商品上抛给 App,持久化进 activeProductId;否则切走再回来选择会被重置回默认第一个商品 */ + onProductChange?: (productId: string) => void; }) { const meta = MODE_META[mode]; const [productId, setProductId] = useState(initialProductId || products[0]?.id || ""); + // 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个 + useEffect(() => { if (productId) onProductChange?.(productId); }, [productId, onProductChange]); const product = products.find((item) => item.id === productId) || products[0]; const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品")); const [ratio, setRatio] = useState(meta.ratio); @@ -742,7 +747,7 @@ export function ImageWorkbenchPage({ try { const raw = localStorage.getItem(`airshelf:imgwb:${mode}`); if (!raw) return; - const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number }; + const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string }; if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; const pending = saved.pending || []; if (pending.length && onResume) { @@ -752,7 +757,9 @@ export function ImageWorkbenchPage({ if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev; return [ ...prev, - { id: batchId, prompt: meta.promptTemplate(product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: product?.id, productTitle: product?.title } + // 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变, + // 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。 + { id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle } ]; }); onResume(mode, pending) diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index bee0147..8025d30 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -424,7 +424,7 @@ export function PipelinePage(props: { scriptModelName: string; textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise; onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise; - onAddShot: (afterSegmentId: string) => Promise; + onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise; onDeleteShot: (segmentId: string) => Promise; // 行35 · 单条分镜重跑(后端只重写该 segment);行28/34 · 把设定/标签合并进 project.metadata 持久化 onRerunShot?: (segmentId: string, instruction?: string) => Promise; @@ -650,7 +650,7 @@ export function PipelinePage(props: { } // 流程步骤4 · 演员库覆盖层:replace 带要替换的目标(已生成卡=groupId;seed 占位卡还没组=kind+label); // browse 带 studio 直接进「添加人物工作台」 - const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string } | null>(null); + const [actorLib, setActorLib] = useState<{ mode: "browse" | "replace"; studio?: boolean; groupId?: string; seedKind?: "person" | "scene"; seedLabel?: string; addNew?: boolean } | null>(null); function openActorReplace(entity: AssetEntity) { setActorLib({ mode: "replace", groupId: entity.group.id }); } @@ -658,9 +658,11 @@ export function PipelinePage(props: { function openActorReplaceSeed(kind: "person" | "scene", tag: string) { setActorLib({ mode: "replace", seedKind: kind, seedLabel: tag }); } - async function pickActor(assetId: string) { + async function pickActor(assetId: string, assetName?: string) { if (actorLib?.mode === "replace") { if (actorLib.groupId) await onAttachBaseAsset({ group_id: actorLib.groupId }, assetId); + // 新增人物(无既有 group / 无脚本标签):用所选演员名当 label 建一个新人物组挂上 + else if (actorLib.addNew && actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: (assetName || "").trim() }, assetId); else if (actorLib.seedKind) await onAttachBaseAsset({ kind: actorLib.seedKind, label: actorLib.seedLabel }, assetId); } setActorLib(null); @@ -2153,7 +2155,7 @@ export function PipelinePage(props: { {/* 行34 · 该卡之后挂着的本地草稿分镜(点「添加分镜」插入,可编辑,有内容失焦才落库) */} {draftShots.filter((d) => d.afterId === shot.id).map((draft) => ( { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id); }} + onCommit={(d) => { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id, { narration: d.narration, visual_prompt: d.visual }); }} onCancel={(id) => setDraftShots((list) => list.filter((x) => x.id !== id))} /> ))}
@@ -2465,7 +2467,7 @@ export function PipelinePage(props: {

{KIND_LABEL[kind]} · {entities.length} 个

{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */} - +
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */} @@ -3279,12 +3281,13 @@ export function PipelinePage(props: { {/* 左:大立绘 + 版本缩略 */}
-
- {busyPortrait + {/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */} +
+ {!portraitUrl && (busyPortrait ?
立绘生成中…
- : !portraitUrl && 立绘} + : 立绘)}
- {portraitUrl && !busyPortrait && } + {portraitUrl && }
{portraitVersions.length > 0 && (
@@ -3311,12 +3314,14 @@ export function PipelinePage(props: {
-
- {busyTri + {/* 大图随数据(triUrl)走,不被在途 poll 的 busyTri 挡住:出图完成(缩略图已有 url)即显示, + 避免「缩略图已出、大图还在转圈」——busyTri 只在「还没任何结果」时显示生成中占位 */} +
+ {!triUrl && (busyTri ?
三视图生成中…
- : !triUrl && 正 / 侧 / 背 · 三视图} + : 正 / 侧 / 背 · 三视图)}
- {triUrl && !busyTri && } + {triUrl && }
{!triUrl && !busyTri && (