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({
|
||||
|
||||
Reference in New Issue
Block a user