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:
zyc
2026-06-18 10:57:02 +08:00
co-authored by Claude Opus 4.8
parent fe38d3a512
commit 326c34ecd1
7 changed files with 200 additions and 47 deletions
+71 -18
View File
@@ -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)
+28
View File
@@ -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 — 仅标注此函数运行在独立线程
+41
View File
@@ -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({