fix(core): 资产详情大图随数据显示,缩略图已出大图不再卡转圈
- 三视图/立绘大图改为跟数据(url)走,出图完成即显示,不再被在途轮询的 busy 标志挡住——根治「小缩略图已出、大图一直转圈」 - 一并带上工作区其余改动(script_agent / services / tests / ai-tools / actor-library / App) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 — 仅标注此函数运行在独立线程
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -66,7 +66,7 @@ const crumbLabels: Partial<Record<Page, string>> = {
|
||||
/* 图片生成工作台·跨刷新持久化:把在跑的任务 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 <AssetFactoryPage navigate={navigate} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "platformCover":
|
||||
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "modelPhotoDemoA":
|
||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => 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) =>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
initialStudio?: boolean; // 打开即进「添加人物工作台」
|
||||
assets: Asset[];
|
||||
onClose: () => void;
|
||||
onPick?: (assetId: string) => void | Promise<unknown>;
|
||||
onPick?: (assetId: string, assetName?: string) => void | Promise<unknown>;
|
||||
onGenerate: (prompt: string) => Promise<{ assets: Asset[] } | null>;
|
||||
onUpload: (file: File) => Promise<Asset | null>;
|
||||
// 据选中立绘生成配套三视图(后端吃任意 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
|
||||
<div className="actorlib-bg" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="actorlib" role="dialog" aria-modal="true" aria-label="演员库">
|
||||
<div className="actorlib-h">
|
||||
<h2>{mode === "replace" ? "演员库 · 选择演员替换" : "演员库"}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即替换当前立绘" : "平台预设演员 / 我的演员"}</span>
|
||||
<h2>{mode === "replace" ? "演员库 · 选择演员" : "演员库"}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点演员卡即选用应用" : "平台预设演员 / 我的演员"}</span>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
@@ -230,15 +238,16 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
</button>
|
||||
</div>
|
||||
{list.length ? (
|
||||
<>
|
||||
<div className="actorlib-grid">
|
||||
{list.map((a) => {
|
||||
{pageList.map((a) => {
|
||||
const url = previewOf(a);
|
||||
return (
|
||||
<div className="actor-card" key={a.id}>
|
||||
<div className={`placeholder actor-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}
|
||||
role="button" tabIndex={0} title={mode === "replace" ? "选用此演员替换" : "查看大图"}
|
||||
onClick={() => { 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 && <span className="ph-frame">{a.name}</span>}
|
||||
{mode === "replace" && <span className="actor-pick">选用</span>}
|
||||
</div>
|
||||
@@ -247,6 +256,14 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="actorlib-pager" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, marginTop: 16 }}>
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>上一页</button>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{page} / {pageCount}</span>
|
||||
<button className="btn btn-ghost btn-sm" type="button" disabled={page >= pageCount} onClick={() => setPage((p) => Math.min(pageCount, p + 1))}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// {tab === "preset" ? "暂无平台预设演员" : "还没有自己的演员 · 点右上「添加演员」"}</span>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -424,7 +424,7 @@ export function PipelinePage(props: {
|
||||
scriptModelName: string;
|
||||
textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
||||
onDeleteShot: (segmentId: string) => Promise<unknown>;
|
||||
// 行35 · 单条分镜重跑(后端只重写该 segment);行28/34 · 把设定/标签合并进 project.metadata 持久化
|
||||
onRerunShot?: (segmentId: string, instruction?: string) => Promise<unknown>;
|
||||
@@ -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) => (
|
||||
<DraftShotCard key={draft.id} draft={draft}
|
||||
onCommit={(d) => { 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))} />
|
||||
))}
|
||||
<div className="shot-insert-gap">
|
||||
@@ -2465,7 +2467,7 @@ export function PipelinePage(props: {
|
||||
<h3>{KIND_LABEL[kind]} · {entities.length} 个</h3>
|
||||
<span className="spacer"></span>
|
||||
{/* 对齐设计稿:克制的小按钮(人物去演员库工作台;场景直接生成一版) */}
|
||||
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "browse", studio: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||
<button className="btn btn-sm" type="button" data-stop disabled={isBusy(customBusy)} onClick={() => { if (kind === "person") setActorLib({ mode: "replace", seedKind: "person", addNew: true }); else setSceneDrafts((d) => [...d, { id: `scene-draft-${Date.now()}`, title: "", prompt: genPrompt }]); }}>{isBusy(customBusy) ? "生成中…" : `+ 新增${KIND_LABEL[kind]}`}</button>
|
||||
</div>
|
||||
<div className="asset-grid-2">
|
||||
{/* 新增场景:本地占位草稿卡(可改标题 + 提示词),编辑完点「AI 生成」才出图;出图成功后移除草稿 */}
|
||||
@@ -3279,12 +3281,13 @@ export function PipelinePage(props: {
|
||||
{/* 左:大立绘 + 版本缩略 */}
|
||||
<div className="asset-detail-lead">
|
||||
<div className="ad-lead-wrap">
|
||||
<div className={`placeholder ad-lead-img${portraitUrl && !busyPortrait ? " has-mock-media" : ""}`} style={portraitUrl && !busyPortrait ? mediaStyle(portraitUrl) : undefined}>
|
||||
{busyPortrait
|
||||
{/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */}
|
||||
<div className={`placeholder ad-lead-img${portraitUrl ? " has-mock-media" : ""}`} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}>
|
||||
{!portraitUrl && (busyPortrait
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">立绘生成中…</span></div>
|
||||
: !portraitUrl && <span className="ph-frame">立绘</span>}
|
||||
: <span className="ph-frame">立绘</span>)}
|
||||
</div>
|
||||
{portraitUrl && !busyPortrait && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
|
||||
{portraitUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
|
||||
</div>
|
||||
{portraitVersions.length > 0 && (
|
||||
<div className="ad-thumbs">
|
||||
@@ -3311,12 +3314,14 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="asset-detail-tri-row">
|
||||
<div className="ad-tri-wrap">
|
||||
<div className={`placeholder${triUrl && !busyTri ? " has-mock-media" : ""}`} style={triUrl && !busyTri ? mediaStyle(triUrl) : undefined}>
|
||||
{busyTri
|
||||
{/* 大图随数据(triUrl)走,不被在途 poll 的 busyTri 挡住:出图完成(缩略图已有 url)即显示,
|
||||
避免「缩略图已出、大图还在转圈」——busyTri 只在「还没任何结果」时显示生成中占位 */}
|
||||
<div className={`placeholder${triUrl ? " has-mock-media" : ""}`} style={triUrl ? mediaStyle(triUrl) : undefined}>
|
||||
{!triUrl && (busyTri
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">三视图生成中…</span></div>
|
||||
: !triUrl && <span className="ph-frame">正 / 侧 / 背 · 三视图</span>}
|
||||
: <span className="ph-frame">正 / 侧 / 背 · 三视图</span>)}
|
||||
</div>
|
||||
{triUrl && !busyTri && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
|
||||
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
|
||||
</div>
|
||||
</div>
|
||||
{!triUrl && !busyTri && (
|
||||
|
||||
Reference in New Issue
Block a user