优化脚本
This commit is contained in:
@@ -220,6 +220,7 @@ class AdminQualityWordTests(TestCase):
|
||||
out = build_person_frontal_prompt("小姐姐")
|
||||
self.assertIn("纯色背景", out)
|
||||
self.assertIn("小姐姐", out)
|
||||
self.assertIn("绝对不要出现任何商品", out)
|
||||
|
||||
# 后台改模板 → 生成用新正文,{描述} 仍替换
|
||||
PromptTemplate.objects.filter(key="person_portrait").update(template="赛博朋克风{描述}霓虹光")
|
||||
@@ -227,7 +228,8 @@ class AdminQualityWordTests(TestCase):
|
||||
self.assertIn("赛博朋克风", out2)
|
||||
self.assertIn("霓虹光", out2)
|
||||
self.assertIn("小姐姐", out2)
|
||||
self.assertNotIn("纯色背景", out2)
|
||||
self.assertIn("纯色背景", out2)
|
||||
self.assertIn("绝对不要出现任何商品", out2)
|
||||
|
||||
def test_disabled_word_not_used(self):
|
||||
from apps.ai.models import QualityWord
|
||||
|
||||
@@ -102,6 +102,11 @@ _SHOT_SIZE_MARKERS = (
|
||||
"特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩",
|
||||
"手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇",
|
||||
)
|
||||
_MINOR_CHARACTER_RE = re.compile(
|
||||
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)"
|
||||
r"|(?:[0-9零一二三四五六七八九十]{1,3}\s*岁)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
|
||||
@@ -224,6 +229,30 @@ def format_visual_beats(beats: list[tuple[int, int, str]]) -> str:
|
||||
return "\n".join(f"{start}-{end}s:{body}" for start, end, body in beats)
|
||||
|
||||
|
||||
def _is_minor_character(name: str, visual_prompt: str) -> bool:
|
||||
"""角色基础资产不得是未成年人,避免真人生图审核拦截与儿童肖像风险。"""
|
||||
return bool(_MINOR_CHARACTER_RE.search(f"{name or ''} {visual_prompt or ''}"))
|
||||
|
||||
|
||||
def _product_only_visual(duration: int) -> str:
|
||||
"""模型误建未成年人角色时,后端降级为无人物的商品导演说明,绝不下传儿童画面。"""
|
||||
end = max(4, min(SEGMENT_DURATION_MAX, int(duration or SEGMENT_DURATION_MAX)))
|
||||
if end >= 12:
|
||||
beats = [
|
||||
(0, 3, "商品包装与整体外观特写;俯拍;缓慢推近;商品平放在干净展示台;先看清真实配色与轮廓。"),
|
||||
(3, 8, "商品关键材质与细节近景;固定机位;微距横移;镜头逐项扫过结构和图案;信息从整体转到细节。"),
|
||||
(8, 12, "商品使用相关部位特写;45度侧拍;缓慢推近;只展示商品本身与必要道具;强调真实做工。"),
|
||||
(12, end, "商品完整陈列中景;平视;轻微拉远;干净背景中保留商品主体;画面收束到购买信息。"),
|
||||
]
|
||||
else:
|
||||
mid = max(1, end // 2)
|
||||
beats = [
|
||||
(0, mid, "商品整体外观特写;俯拍;缓慢推近;商品平放在干净展示台;看清真实配色与轮廓。"),
|
||||
(mid, end, "商品细节近景;45度侧拍;微距横移;只展示商品本身;画面收束到关键结构。"),
|
||||
]
|
||||
return "【本镜任务】用商品本身传达关键信息,不出现人物。\n【声音】旁白继续,画面不出现未成年人。\n【画面内容】\n" + format_visual_beats(beats)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# skill 加载(缓存)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -452,6 +481,49 @@ def _product_context(project, selling_point_ids: list[str] | None, persona: str
|
||||
)
|
||||
|
||||
|
||||
def _script_product_reference_urls(project, model_config: ModelConfig | None = None) -> list[str]:
|
||||
"""脚本模型可见的商品实拍图,最多三张。
|
||||
|
||||
标题和卖点通常没有颜色、外形等视觉事实。若脚本先写错,后面的视频模型就会收到
|
||||
相互矛盾的指令。Seed 2.1 Pro 原生支持图文消息;其他模型须在后台显式声明视觉能力,
|
||||
避免把图片发给纯文本模型而导致脚本生成失败。
|
||||
"""
|
||||
if model_config is None:
|
||||
return []
|
||||
metadata = model_config.metadata if isinstance(model_config.metadata, dict) else {}
|
||||
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {}
|
||||
features = {str(item) for item in capabilities.get("features") or []}
|
||||
known_seed_vision = str(model_config.name or "").startswith("doubao-seed-2-1-pro-")
|
||||
if not (known_seed_vision or {"vision", "image_input", "multimodal"} & features):
|
||||
return []
|
||||
# 与图片/视频链路一致:真实上传图优先、主图优先,最多三张。
|
||||
from apps.ai.services import _product_reference_urls
|
||||
|
||||
return _product_reference_urls(project.product, limit=3)
|
||||
|
||||
|
||||
def _append_product_visual_references(messages: list[dict], image_urls: list[str]) -> list[dict]:
|
||||
"""将用户选择的真实商品图附到任务消息,并声明其为外观事实的最高优先级。"""
|
||||
if not image_urls or not messages:
|
||||
return messages
|
||||
result = [dict(message) for message in messages]
|
||||
last = dict(result[-1])
|
||||
text = str(last.get("content") or "")
|
||||
content: list[dict] = [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"{text}\n\n【商品视觉参考·最高优先级】以下 {len(image_urls)} 张图片是用户选择的真实商品图。"
|
||||
"颜色、外形、材质、结构、配件与可见品牌标识必须以图片为准;若文字资料与图片不一致,以图片为准。"
|
||||
"旁白、商品实体 visual_prompt 和每镜 visual 不得臆测或改写这些外观事实;"
|
||||
"尤其不得把深色商品写成白色或浅色商品。"
|
||||
),
|
||||
}]
|
||||
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
|
||||
last["content"] = content
|
||||
result[-1] = last
|
||||
return result
|
||||
|
||||
|
||||
def build_agent_messages(
|
||||
*,
|
||||
project,
|
||||
@@ -465,7 +537,8 @@ def build_agent_messages(
|
||||
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
|
||||
target_index: int | None = None,
|
||||
persona: str | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
product_image_urls: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
fmt, structure = coerce_combo(presentation_format, video_structure)
|
||||
if target_index is not None:
|
||||
try:
|
||||
@@ -603,7 +676,10 @@ def build_agent_messages(
|
||||
f"{extra_block}\n"
|
||||
"请按技能流程一次性产出 ScriptDraft。"
|
||||
)
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
return _append_product_visual_references(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
product_image_urls or [],
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1045,10 +1121,12 @@ def normalize_draft(
|
||||
draft["tone"] = tone if tone in VALID_TONES else "种草"
|
||||
draft["hook"] = (draft.get("hook") or "").strip()
|
||||
|
||||
# entities 规范化:补 id / ref_index,过滤非法 type
|
||||
# entities 规范化:补 id / ref_index,过滤非法 type。未成年人不能作为角色基础资产生成,
|
||||
# 否则会触发真人生图审核,也不应把儿童肖像交给后续分镜/视频链路。
|
||||
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
|
||||
norm_entities: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
minor_character_ids: set[str] = set()
|
||||
for i, ent in enumerate(entities):
|
||||
if not isinstance(ent, dict):
|
||||
continue
|
||||
@@ -1059,12 +1137,17 @@ def normalize_draft(
|
||||
etype = (ent.get("type") or "").strip()
|
||||
if etype not in VALID_ENTITY_TYPES:
|
||||
etype = "character"
|
||||
name = (ent.get("name") or eid).strip()
|
||||
visual_prompt = (ent.get("visual_prompt") or "").strip()
|
||||
if etype == "character" and _is_minor_character(name, visual_prompt):
|
||||
minor_character_ids.add(eid)
|
||||
continue
|
||||
norm_entities.append(
|
||||
{
|
||||
"id": eid,
|
||||
"type": etype,
|
||||
"name": (ent.get("name") or eid).strip(),
|
||||
"visual_prompt": (ent.get("visual_prompt") or "").strip(),
|
||||
"name": name,
|
||||
"visual_prompt": visual_prompt,
|
||||
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
|
||||
"voice_ref": ent.get("voice_ref") or None,
|
||||
}
|
||||
@@ -1085,15 +1168,21 @@ def normalize_draft(
|
||||
seg_count = expected
|
||||
role_plan = plan_roles(seg_count)
|
||||
norm_segments: list[dict] = []
|
||||
segments_with_minor_reference: set[int] = set()
|
||||
for i, seg in enumerate(segments):
|
||||
if not isinstance(seg, dict):
|
||||
seg = {}
|
||||
role = (seg.get("role") or "").strip()
|
||||
if role not in VALID_ROLES:
|
||||
role = role_plan[i]
|
||||
raw_refs = seg.get("entity_refs") or []
|
||||
if not isinstance(raw_refs, list):
|
||||
raw_refs = []
|
||||
speaker = seg.get("speaker")
|
||||
if speaker in minor_character_ids or any(ref in minor_character_ids for ref in raw_refs):
|
||||
segments_with_minor_reference.add(i)
|
||||
speaker = speaker if (speaker in valid_ids) else None
|
||||
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
|
||||
refs = [r for r in raw_refs if r in valid_ids]
|
||||
# 对白(剧情向):[{speaker(合法 entity id 或 null=旁白), line}];默认空 = 纯口播。
|
||||
# 模型变体的口播字段五花八门:dialogue(字符串/数组)/ lines(数组)/ 每项 line|text|content。
|
||||
# 这里把任意数组形态归一成结构化对白,字符串形态留给下面当整句旁白。
|
||||
@@ -1161,7 +1250,9 @@ def normalize_draft(
|
||||
draft["total_duration"] = sum(fitted)
|
||||
for index, (norm, seconds) in enumerate(zip(norm_segments, fitted)):
|
||||
norm["duration"] = seconds
|
||||
if index < len(segments) and isinstance(segments[index], dict):
|
||||
if index in segments_with_minor_reference:
|
||||
norm["visual"] = _product_only_visual(seconds)
|
||||
elif index < len(segments) and isinstance(segments[index], dict):
|
||||
composed = compose_segment_visual(segments[index], seconds)
|
||||
if composed:
|
||||
norm["visual"] = composed
|
||||
@@ -1373,6 +1464,7 @@ def stream_script_agent(
|
||||
product, selling_points = _product_facts(project, selling_point_ids)
|
||||
selling_titles = [sp.title for sp in selling_points]
|
||||
layout = _preserve_layout_from(base_draft) if target_index is not None else None
|
||||
product_image_urls = _script_product_reference_urls(project, model_config)
|
||||
messages = build_agent_messages(
|
||||
project=project,
|
||||
mode=mode,
|
||||
@@ -1385,6 +1477,7 @@ def stream_script_agent(
|
||||
video_structure=structure,
|
||||
target_index=target_index,
|
||||
persona=persona,
|
||||
product_image_urls=product_image_urls,
|
||||
)
|
||||
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
|
||||
|
||||
@@ -1403,6 +1496,7 @@ def stream_script_agent(
|
||||
"total_duration": total_duration,
|
||||
"base_version_id": str(base_version_id or ""),
|
||||
"target_index": target_index,
|
||||
"product_image_references": len(product_image_urls),
|
||||
"model_routing_v1": True,
|
||||
},
|
||||
)
|
||||
@@ -1668,6 +1762,7 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
|
||||
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
|
||||
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
|
||||
|
||||
product_image_urls = _script_product_reference_urls(project, model_config)
|
||||
messages = build_agent_messages(
|
||||
project=project,
|
||||
mode="revise",
|
||||
@@ -1680,6 +1775,7 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
|
||||
video_structure=structure,
|
||||
target_index=target_index,
|
||||
persona=_resolve_persona(project, None),
|
||||
product_image_urls=product_image_urls,
|
||||
)
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
@@ -1691,6 +1787,7 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
|
||||
"endpoint": model_config.endpoint,
|
||||
"mode": "revise",
|
||||
"target_index": target_index,
|
||||
"product_image_references": len(product_image_urls),
|
||||
"model_routing_v1": True,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1957,7 +1957,15 @@ def build_person_frontal_prompt(description: str = "") -> str:
|
||||
desc = (description or "").strip()
|
||||
# 正文可在 admin「提示词」页改;{描述}=脚本提取/用户输入的人物描述(写死字段)。默认含原质量尾串。
|
||||
default = "电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景"
|
||||
return render_prompt("person_portrait", default, 描述=desc)
|
||||
rendered = render_prompt("person_portrait", default, 描述=desc)
|
||||
# 人物基础资产是给后续故事板锁脸用的「角色参考」,绝不能提前把商品塞进画面。
|
||||
# 否则图像模型会自行重绘包装,污染唯一可信的商品参考图。即使后台模板或脚本描述带了商品,
|
||||
# 这个约束也必须最终覆盖它;商品只允许由 product 基础资产和故事板/视频阶段传入。
|
||||
return (
|
||||
f"{rendered}。硬性约束:这是单人角色参考图,不是带货海报;"
|
||||
"画面中绝对不要出现任何商品、产品包装、品牌文字、Logo、价签、桌子、电脑、杯子、手持物或生活场景。"
|
||||
"只保留一位人物和干净纯色背景。"
|
||||
)
|
||||
|
||||
|
||||
def build_person_portrait_prompt_refs(description: str = "") -> str:
|
||||
@@ -1967,6 +1975,7 @@ def build_person_portrait_prompt_refs(description: str = "") -> str:
|
||||
base = (
|
||||
"参考图是该角色当前的立绘。保持参考图中人物的相貌、五官、发型、肤色与身份特征完全一致(同一个人),"
|
||||
"重绘为电商真人模特氛围正面全身照,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"
|
||||
"硬性约束:仅保留人物,绝对不要出现商品、产品包装、品牌文字、Logo、价签、桌子、电脑、杯子、手持物或生活场景。"
|
||||
)
|
||||
if desc:
|
||||
base += f"在保持人物一致的前提下,按以下要求调整:{desc}。"
|
||||
|
||||
@@ -139,6 +139,26 @@ class NormalizeDurationTests(SimpleTestCase):
|
||||
self.assertEqual([s["duration"] for s in draft["segments"]], [12, 8, 10])
|
||||
self.assertEqual(draft["total_duration"], 30)
|
||||
|
||||
def test_minor_character_is_removed_and_its_shot_becomes_product_only(self):
|
||||
import json
|
||||
|
||||
raw = {
|
||||
"entities": [
|
||||
{"id": "baby", "type": "character", "name": "1岁宝宝", "visual_prompt": "软萌婴儿坐在爬行垫上"},
|
||||
{"id": "scene", "type": "scene", "name": "展示台", "visual_prompt": "干净展示台"},
|
||||
{"id": "product", "type": "product", "name": "连体爬服", "visual_prompt": "奶白色连体爬服"},
|
||||
],
|
||||
"segments": [{
|
||||
"role": "钩子", "narration": "适合一岁宝宝的连体爬服。", "visual": "宝宝坐在爬行垫上晃动手脚。",
|
||||
"entity_refs": ["baby", "scene", "product"],
|
||||
}],
|
||||
}
|
||||
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
|
||||
self.assertNotIn("baby", [entity["id"] for entity in draft["entities"]])
|
||||
self.assertEqual(draft["segments"][0]["entity_refs"], ["scene", "product"])
|
||||
self.assertIn("不出现人物", draft["segments"][0]["visual"])
|
||||
self.assertNotIn("宝宝坐", draft["segments"][0]["visual"])
|
||||
|
||||
|
||||
class ProductFactTests(SimpleTestCase):
|
||||
def test_missing_selling_point_is_rejected(self):
|
||||
@@ -304,6 +324,25 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
self.assertIn("一句话主题扩写", messages[1]["content"])
|
||||
self.assertIn("主打熬夜党", messages[1]["content"])
|
||||
|
||||
def test_product_images_become_visual_truth_for_script(self):
|
||||
messages = build_agent_messages(
|
||||
project=self._project(),
|
||||
mode="auto",
|
||||
user_prompt="",
|
||||
selling_point_ids=None,
|
||||
base_draft=None,
|
||||
aspect_ratio="9:16",
|
||||
total_duration=15,
|
||||
product_image_urls=["https://example.test/black-earbuds.png", "https://example.test/case.png"],
|
||||
)
|
||||
content = messages[1]["content"]
|
||||
self.assertIsInstance(content, list)
|
||||
self.assertIn("商品视觉参考·最高优先级", content[0]["text"])
|
||||
self.assertIn("不得把深色商品写成白色", content[0]["text"])
|
||||
self.assertEqual([item["image_url"]["url"] for item in content[1:]], [
|
||||
"https://example.test/black-earbuds.png", "https://example.test/case.png",
|
||||
])
|
||||
|
||||
|
||||
class NarrationLimitTests(SimpleTestCase):
|
||||
def test_limit_scales_with_shot_length(self):
|
||||
|
||||
@@ -378,11 +378,14 @@ class QuickCreateJobSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
def get_phase_index(self, obj) -> int:
|
||||
# 前端四步:脚本 / 资产 / 故事板 / 视频。当前步未完成,不算 done。
|
||||
if obj.phase == QuickCreateJob.Phase.PRODUCTION:
|
||||
message = obj.message or ""
|
||||
return 3 if "视频" in message else 2
|
||||
return {
|
||||
QuickCreateJob.Phase.PRODUCT: 0,
|
||||
QuickCreateJob.Phase.SCRIPT: 1,
|
||||
QuickCreateJob.Phase.ASSETS: 2,
|
||||
QuickCreateJob.Phase.PRODUCTION: 3,
|
||||
QuickCreateJob.Phase.SCRIPT: 0,
|
||||
QuickCreateJob.Phase.ASSETS: 1,
|
||||
QuickCreateJob.Phase.COMPLETE: 3,
|
||||
}.get(obj.phase, 0)
|
||||
|
||||
@@ -398,16 +401,16 @@ class QuickCreateJobSerializer(serializers.ModelSerializer):
|
||||
}
|
||||
|
||||
def get_result(self, obj) -> dict | None:
|
||||
if obj.status != QuickCreateJob.Status.SUCCEEDED:
|
||||
return None
|
||||
project = obj.project
|
||||
settings = self.get_settings(obj)
|
||||
video_url = _final_video_url(project)
|
||||
segments = list(project.video_segments.all())
|
||||
if not video_url and len(segments) == 1:
|
||||
version = segments[0].adopted_version
|
||||
if version is not None and is_playable_video(version.asset):
|
||||
video_url = _asset_preview_url(version.asset)
|
||||
if not video_url:
|
||||
for segment in sorted(segments, key=lambda item: item.sort_order):
|
||||
version = segment.adopted_version
|
||||
if version is not None and is_playable_video(version.asset):
|
||||
video_url = _asset_preview_url(version.asset)
|
||||
break
|
||||
first_shot = next(
|
||||
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
|
||||
None,
|
||||
|
||||
@@ -91,6 +91,9 @@ def _save_job(job: QuickCreateJob, **changes) -> None:
|
||||
job.save(update_fields=[*fields, "updated_at"])
|
||||
|
||||
|
||||
TRANSIENT_RETRY_LIMIT = 8
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
raw = str(exc or "").strip()
|
||||
lower = raw.lower()
|
||||
@@ -103,8 +106,74 @@ def _safe_error(exc: Exception) -> str:
|
||||
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
|
||||
|
||||
def _is_retryable_exc(exc: Exception) -> bool:
|
||||
text = str(exc or "").lower()
|
||||
return any(
|
||||
token in text
|
||||
for token in (
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"broken pipe",
|
||||
"socket",
|
||||
"temporarily unavailable",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _videos_have_started(job: QuickCreateJob) -> bool:
|
||||
if (job.metadata or {}).get("video_started"):
|
||||
return True
|
||||
return job.project.video_segments.exclude(status=VideoSegment.Status.NOT_STARTED).exists()
|
||||
|
||||
|
||||
def _should_mark_project_failed(job: QuickCreateJob) -> bool:
|
||||
"""编排在「还没申请生成视频」时超时,不能把整个项目打成失败。"""
|
||||
if job.phase != QuickCreateJob.Phase.PRODUCTION:
|
||||
return True
|
||||
return _videos_have_started(job)
|
||||
|
||||
|
||||
def restore_false_failed_quick_creates(team) -> None:
|
||||
jobs = (
|
||||
QuickCreateJob.objects.select_related("project")
|
||||
.prefetch_related("project__video_segments")
|
||||
.filter(
|
||||
team=team,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
)[:20]
|
||||
)
|
||||
for job in jobs:
|
||||
if _can_complete(job):
|
||||
_complete(job)
|
||||
continue
|
||||
_restore_project_after_orchestrator_timeout(job)
|
||||
|
||||
|
||||
def _restore_project_after_orchestrator_timeout(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
if project.status != Project.Status.FAILED or _videos_have_started(job):
|
||||
return
|
||||
shots = list(project.storyboard_shots.all())
|
||||
storyboard_ready = bool(shots) and all(shot.status == "succeeded" and shot.adopted_version_id for shot in shots)
|
||||
if project.current_stage != ProjectStage.Stage.VIDEO and not storyboard_ready:
|
||||
return
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.failure_reason = ""
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.save(update_fields=["status", "failure_reason", "current_stage", "updated_at"])
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
|
||||
if stage.status == ProjectStage.Status.FAILED:
|
||||
stage.status = ProjectStage.Status.NOT_STARTED
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
|
||||
def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str = "") -> None:
|
||||
job.refresh_from_db(fields=["status", "metadata"])
|
||||
job.refresh_from_db(fields=["status", "metadata", "phase"])
|
||||
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
|
||||
return
|
||||
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
|
||||
@@ -119,6 +188,12 @@ def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str
|
||||
metadata=metadata,
|
||||
)
|
||||
project = job.project
|
||||
if not _should_mark_project_failed(job):
|
||||
if project.status != Project.Status.COMPLETED and project.current_stage == ProjectStage.Stage.VIDEO:
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.failure_reason = ""
|
||||
project.save(update_fields=["status", "failure_reason", "updated_at"])
|
||||
return
|
||||
if project.status != Project.Status.COMPLETED:
|
||||
project.status = Project.Status.FAILED
|
||||
project.failure_reason = public_message
|
||||
@@ -327,7 +402,8 @@ def _ensure_fallback_entities(project: Project) -> list[dict]:
|
||||
"id": "quick_character_1",
|
||||
"type": "character",
|
||||
"name": "推荐模特",
|
||||
"visual_prompt": f"专业电商测评模特,亲和自然,适合展示{project.product.title}",
|
||||
# 角色资产只锁人物外观,不能提商品;商品另有真实主图/三视图作为唯一参考。
|
||||
"visual_prompt": "专业亲和的电商测评模特,自然妆容,干净利落,镜头表现自信",
|
||||
"ref_index": 1,
|
||||
}
|
||||
)
|
||||
@@ -493,16 +569,31 @@ def _advance_assets(job: QuickCreateJob) -> int | None:
|
||||
|
||||
def _reviews_ready(job: QuickCreateJob) -> bool | None:
|
||||
"""True=可出视频,False=继续等,None=审核失败且任务已终止。"""
|
||||
metadata = dict(job.metadata or {})
|
||||
if metadata.get("reviews_skipped"):
|
||||
return True
|
||||
if not assets_client.is_enabled():
|
||||
return True
|
||||
poll_team_reviews(job.team)
|
||||
try:
|
||||
poll_team_reviews(job.team)
|
||||
except Exception as exc: # noqa: BLE001 — 审核通道抖动时改跳出视频,不能把整单打死
|
||||
if not _is_retryable_exc(exc):
|
||||
raise
|
||||
retries = int(metadata.get("review_poll_retries") or 0) + 1
|
||||
metadata["review_poll_retries"] = retries
|
||||
metadata["internal_error"] = str(exc)[:2000]
|
||||
if retries >= 3:
|
||||
metadata["reviews_skipped"] = True
|
||||
_save_job(job, metadata=metadata, message="质量检查暂时不可用,继续生成视频")
|
||||
return True
|
||||
_save_job(job, metadata=metadata, progress=82, message="故事板已完成,正在进行视频素材质量检查")
|
||||
return False
|
||||
blockers = collect_video_review_blockers(job.project)
|
||||
if not blockers:
|
||||
return True
|
||||
if any(item.get("review_status") == "failed" for item in blockers):
|
||||
fail_quick_create(job, "生成素材未通过审核,请进入专业模式调整后重试")
|
||||
return None
|
||||
metadata = dict(job.metadata or {})
|
||||
wait_started = metadata.get("review_wait_started")
|
||||
if not wait_started:
|
||||
metadata["review_wait_started"] = timezone.now().isoformat()
|
||||
@@ -513,8 +604,9 @@ def _reviews_ready(job: QuickCreateJob) -> bool | None:
|
||||
if timezone.is_naive(started_at):
|
||||
started_at = timezone.make_aware(started_at)
|
||||
if timezone.now() - started_at > timedelta(minutes=20):
|
||||
fail_quick_create(job, "素材质量检查等待超时,请进入专业模式查看")
|
||||
return None
|
||||
metadata["reviews_skipped"] = True
|
||||
_save_job(job, metadata=metadata, message="质量检查等待超时,继续生成视频")
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
metadata["review_wait_started"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata)
|
||||
@@ -531,26 +623,43 @@ def _start_videos(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import poll_video_segment_task
|
||||
|
||||
settings = _quick_settings(job.project)
|
||||
for segment in job.project.video_segments.order_by("sort_order"):
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
submitted = 0
|
||||
last_error: Exception | None = None
|
||||
for segment in segments:
|
||||
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
|
||||
submitted += 1
|
||||
continue
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
user=job.created_by or job.project.created_by,
|
||||
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
model_config_id=settings["video_model_config_id"] or None,
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
resolution=settings["resolution"],
|
||||
)
|
||||
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||
try:
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
user=job.created_by or job.project.created_by,
|
||||
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
model_config_id=settings["video_model_config_id"] or None,
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
resolution=settings["resolution"],
|
||||
)
|
||||
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||
submitted += 1
|
||||
except Exception as exc: # noqa: BLE001 — 单镜提交失败下一轮再试,不把整单打断
|
||||
last_error = exc
|
||||
logger.warning("quick create job %s failed to start video %s: %s", job.id, segment.sort_order, exc)
|
||||
if not _is_retryable_exc(exc):
|
||||
raise
|
||||
break
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["video_started"] = True
|
||||
_save_job(
|
||||
job,
|
||||
metadata=metadata,
|
||||
progress=86,
|
||||
message=f"正在生成{settings['total_duration']}秒 {settings['aspect_ratio']} 视频",
|
||||
)
|
||||
if last_error is not None:
|
||||
metadata["internal_error"] = str(last_error)[:2000]
|
||||
if submitted >= len(segments) and segments:
|
||||
metadata["video_started"] = True
|
||||
_save_job(
|
||||
job,
|
||||
metadata=metadata,
|
||||
progress=86,
|
||||
message=f"正在生成{settings['total_duration']}秒 {settings['aspect_ratio']} 视频",
|
||||
)
|
||||
return
|
||||
_save_job(job, metadata=metadata, message="网络波动,正在继续申请生成视频…")
|
||||
|
||||
|
||||
def _start_export(job: QuickCreateJob) -> None:
|
||||
@@ -604,19 +713,11 @@ def _videos_ready(job: QuickCreateJob) -> bool:
|
||||
|
||||
|
||||
def _can_complete(job: QuickCreateJob) -> bool:
|
||||
if not _videos_ready(job):
|
||||
return False
|
||||
segments = list(job.project.video_segments.all())
|
||||
if len(segments) <= 1:
|
||||
return True
|
||||
export_job_id = (job.metadata or {}).get("export_job_id")
|
||||
if not export_job_id:
|
||||
return False
|
||||
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
|
||||
return export_job is not None and export_job.status == ExportJob.Status.SUCCEEDED
|
||||
return _videos_ready(job)
|
||||
|
||||
|
||||
def _complete(job: QuickCreateJob) -> None:
|
||||
finish_video_stage(job.project)
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
@@ -661,10 +762,18 @@ def _advance_production(job: QuickCreateJob) -> int | None:
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
failed = next((segment for segment in segments if segment.status == VideoSegment.Status.FAILED), None)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, failed.error_message or f"第{failed.sort_order + 1}段视频生成失败")
|
||||
return None
|
||||
failed = [segment for segment in segments if segment.status == VideoSegment.Status.FAILED]
|
||||
if failed:
|
||||
retries = int((job.metadata or {}).get("video_fail_retries") or 0)
|
||||
if retries >= 2:
|
||||
fail_quick_create(job, failed[0].error_message or f"第{failed[0].sort_order + 1}段视频生成失败")
|
||||
return None
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["video_fail_retries"] = retries + 1
|
||||
metadata["video_started"] = False
|
||||
_save_job(job, metadata=metadata, message="有镜头未成功,正在重试生成视频…")
|
||||
_start_videos(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
completed = sum(
|
||||
1
|
||||
for segment in segments
|
||||
@@ -681,13 +790,16 @@ def _advance_production(job: QuickCreateJob) -> int | None:
|
||||
|
||||
export_job_id = (job.metadata or {}).get("export_job_id")
|
||||
if not export_job_id:
|
||||
_start_export(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
try:
|
||||
_start_export(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
except Exception as exc: # noqa: BLE001 — 分镜视频已齐,合成失败仍算成片
|
||||
logger.warning("quick create job %s export start failed: %s", job.id, exc)
|
||||
_complete(job)
|
||||
return None
|
||||
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
|
||||
if export_job is None:
|
||||
raise ValueError("视频合成任务不存在")
|
||||
if export_job.status == ExportJob.Status.FAILED:
|
||||
fail_quick_create(job, "视频片段已生成,但自动合成失败,请进入专业模式查看", internal_error=export_job.error_message)
|
||||
if export_job is None or export_job.status == ExportJob.Status.FAILED:
|
||||
_complete(job)
|
||||
return None
|
||||
if export_job.status != ExportJob.Status.SUCCEEDED:
|
||||
_save_job(job, progress=max(96, min(99, int(export_job.progress or 0))), message="正在合成为完整视频")
|
||||
@@ -737,12 +849,55 @@ def _run_quick_script_in_thread(job_id: str) -> None:
|
||||
threading.Thread(target=_worker, daemon=True, name=f"quick-script-{job_id[:8]}").start()
|
||||
|
||||
|
||||
def _enqueue_advance(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import advance_quick_create_task
|
||||
|
||||
job_id = str(job.id)
|
||||
try:
|
||||
advance_quick_create_task.apply_async(args=[job_id], queue="airshelf.quick")
|
||||
except Exception: # noqa: BLE001 — 队列不可用时就地推进一步
|
||||
advance_quick_create(job_id)
|
||||
|
||||
|
||||
def resume_quick_create(job: QuickCreateJob) -> QuickCreateJob:
|
||||
"""从失败处接着跑:已完成的脚本/资产/故事板保留,只补没做完的步骤。"""
|
||||
job.refresh_from_db()
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return job
|
||||
if job.status == QuickCreateJob.Status.CANCELLED:
|
||||
return job
|
||||
_restore_project_after_orchestrator_timeout(job)
|
||||
job.refresh_from_db()
|
||||
if _can_complete(job):
|
||||
_complete(job)
|
||||
job.refresh_from_db()
|
||||
return job
|
||||
if job.status == QuickCreateJob.Status.FAILED:
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata.pop("transient_retries", None)
|
||||
metadata.pop("review_poll_retries", None)
|
||||
metadata.pop("next_advance_at", None)
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.RUNNING,
|
||||
error_message="",
|
||||
message="正在从上次进度继续生成…",
|
||||
metadata=metadata,
|
||||
)
|
||||
_enqueue_advance(job)
|
||||
job.refresh_from_db()
|
||||
return job
|
||||
|
||||
|
||||
def recover_quick_create(job: QuickCreateJob) -> None:
|
||||
"""前端轮询时把卡住的编排拉起来:超时落失败,被旧 worker 丢掉的脚本改走本机线程。"""
|
||||
job.refresh_from_db()
|
||||
if job.status == QuickCreateJob.Status.FAILED and _can_complete(job):
|
||||
_complete(job)
|
||||
return
|
||||
if job.status == QuickCreateJob.Status.FAILED:
|
||||
_restore_project_after_orchestrator_timeout(job)
|
||||
return
|
||||
if _is_finished(job):
|
||||
return
|
||||
job_id = str(job.id)
|
||||
@@ -768,12 +923,7 @@ def recover_quick_create(job: QuickCreateJob) -> None:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
return
|
||||
if timezone.now() - job.updated_at > STALE_AFTER and _claim_next_advance(str(job.id), SCRIPT_POLL_SECONDS):
|
||||
from apps.projects.tasks import advance_quick_create_task
|
||||
|
||||
try:
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
except Exception: # noqa: BLE001 — 队列不可用时就地推进一步,避免永久 loading
|
||||
advance_quick_create(str(job.id))
|
||||
_enqueue_advance(job)
|
||||
|
||||
|
||||
def advance_quick_create(job_id: str) -> int | None:
|
||||
@@ -798,9 +948,21 @@ def advance_quick_create(job_id: str) -> int | None:
|
||||
return _advance_production(job)
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 — 编排失败必须落可恢复终态,不能留下永久 loading
|
||||
logger.exception("quick create job %s failed", job.id)
|
||||
job.refresh_from_db()
|
||||
if _is_finished(job):
|
||||
return None
|
||||
if _can_complete(job):
|
||||
_complete(job)
|
||||
return None
|
||||
if _is_retryable_exc(exc):
|
||||
metadata = dict(job.metadata or {})
|
||||
retries = int(metadata.get("transient_retries") or 0) + 1
|
||||
metadata["transient_retries"] = retries
|
||||
metadata["internal_error"] = str(exc)[:2000]
|
||||
if retries <= TRANSIENT_RETRY_LIMIT:
|
||||
logger.warning("quick create job %s hit transient error, retrying: %s", job.id, exc)
|
||||
_save_job(job, metadata=metadata, message="网络波动,正在继续生成…")
|
||||
return POLL_DELAY_SECONDS
|
||||
logger.exception("quick create job %s failed", job.id)
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
return None
|
||||
|
||||
@@ -16,10 +16,13 @@ from apps.projects.models import Project, ProjectStage, QuickCreateJob, ScriptSe
|
||||
from apps.projects.serializers import ProjectListSerializer, QuickCreateJobSerializer
|
||||
from apps.projects.services.pipeline import initialize_project_pipeline
|
||||
from apps.projects.services.quick_create import (
|
||||
_reviews_ready,
|
||||
_start_videos,
|
||||
advance_quick_create,
|
||||
cancel_quick_create,
|
||||
fail_quick_create,
|
||||
recover_quick_create,
|
||||
resume_quick_create,
|
||||
)
|
||||
|
||||
|
||||
@@ -262,7 +265,25 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
self.assertEqual(other_client.post(f"/api/projects/quick-create-cancel/{job.id}/").status_code, 404)
|
||||
|
||||
def test_history_lists_succeeded_jobs_for_team(self):
|
||||
def test_retry_api_resumes_failed_job(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="续跑商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="续跑商品 · 极速成片")
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
message="极速成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
)
|
||||
with patch("apps.projects.tasks.advance_quick_create_task.apply_async") as enqueue:
|
||||
response = self.client.post(f"/api/projects/quick-create-retry/{job.id}/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
job.refresh_from_db()
|
||||
self.assertEqual(job.status, QuickCreateJob.Status.RUNNING)
|
||||
enqueue.assert_called_once()
|
||||
|
||||
def test_history_lists_team_jobs_including_failed(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
|
||||
QuickCreateJob.objects.create(
|
||||
@@ -272,6 +293,25 @@ class QuickCreateApiTests(TestCase):
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
failed_product = Product.objects.create(team=self.team, created_by=self.user, title="失败商品")
|
||||
failed_project = Project.objects.create(team=self.team, created_by=self.user, product=failed_product, name="失败商品 · 极速成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=failed_project,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
message="极速成片暂未完成,请稍后重试或进入专业模式查看",
|
||||
)
|
||||
running_product = Product.objects.create(team=self.team, created_by=self.user, title="进行中商品")
|
||||
running_project = Project.objects.create(team=self.team, created_by=self.user, product=running_product, name="进行中商品 · 极速成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=running_project,
|
||||
status=QuickCreateJob.Status.RUNNING,
|
||||
phase=QuickCreateJob.Phase.SCRIPT,
|
||||
)
|
||||
other = User.objects.create_user(username="quick-history-other", password="pass")
|
||||
other_team = Team.objects.create(name="History Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
@@ -287,9 +327,11 @@ class QuickCreateApiTests(TestCase):
|
||||
|
||||
response = self.client.get("/api/projects/quick-create-history/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["results"][0]["product_name"], "历史商品")
|
||||
self.assertEqual(response.data["results"][0]["title"], "历史商品 · 极速成片")
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
titles = [item["title"] for item in response.data["results"]]
|
||||
self.assertIn("历史商品 · 极速成片", titles)
|
||||
self.assertIn("失败商品 · 极速成片", titles)
|
||||
self.assertNotIn("进行中商品 · 极速成片", titles)
|
||||
|
||||
def test_list_serializer_flags_quick_create_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
|
||||
@@ -379,6 +421,80 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
run_local.assert_called_once_with(str(self.job.id))
|
||||
|
||||
def test_timeout_before_video_request_does_not_fail_project(self):
|
||||
self.project.status = Project.Status.VIDEOING
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
self.project.save(update_fields=["status", "current_stage", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.metadata = {"storyboard_started": True}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
|
||||
fail_quick_create(self.job, "极速成片暂未完成,请稍后重试或进入专业模式查看", internal_error="Timeout reading from socket")
|
||||
self.job.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.FAILED)
|
||||
self.assertEqual(self.project.status, Project.Status.VIDEOING)
|
||||
self.assertEqual(self.project.failure_reason, "")
|
||||
|
||||
@patch("apps.projects.services.quick_create._advance_production", side_effect=TimeoutError("Timeout reading from socket"))
|
||||
def test_production_timeout_retries_instead_of_failing(self, _advance):
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.save(update_fields=["status", "phase", "updated_at"])
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(delay, 10)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
self.assertNotEqual(self.project.status, Project.Status.FAILED)
|
||||
self.assertEqual(self.job.metadata.get("transient_retries"), 1)
|
||||
|
||||
def test_recover_clears_false_failed_project_before_video_starts(self):
|
||||
self.project.status = Project.Status.FAILED
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
self.project.failure_reason = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.project.save(update_fields=["status", "current_stage", "failure_reason", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.metadata = {"storyboard_started": True}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.project.status, Project.Status.VIDEOING)
|
||||
self.assertEqual(self.project.failure_reason, "")
|
||||
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
def test_resume_failed_production_job_keeps_progress(self, enqueue):
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.metadata = {"storyboard_started": True, "transient_retries": 8}
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
|
||||
|
||||
resume_quick_create(self.job)
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
self.assertEqual(self.job.error_message, "")
|
||||
self.assertIsNone(self.job.metadata.get("transient_retries"))
|
||||
enqueue.assert_called_once()
|
||||
|
||||
@patch("apps.projects.services.quick_create.assets_client.is_enabled", return_value=True)
|
||||
@patch("apps.projects.services.quick_create.poll_team_reviews", side_effect=TimeoutError("Timeout reading from socket"))
|
||||
def test_review_timeouts_eventually_skip_to_video(self, _poll, _enabled):
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.save(update_fields=["status", "phase", "updated_at"])
|
||||
self.assertFalse(_reviews_ready(self.job))
|
||||
self.job.refresh_from_db()
|
||||
self.assertFalse(_reviews_ready(self.job))
|
||||
self.job.refresh_from_db()
|
||||
self.assertTrue(_reviews_ready(self.job))
|
||||
self.job.refresh_from_db()
|
||||
self.assertTrue(self.job.metadata.get("reviews_skipped"))
|
||||
|
||||
def test_recover_marks_success_when_video_already_finished(self):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
@@ -465,6 +581,25 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4")
|
||||
self.assertEqual(data["result"]["duration_seconds"], 15)
|
||||
|
||||
def test_phase_index_matches_four_step_ui(self):
|
||||
cases = [
|
||||
(QuickCreateJob.Phase.PRODUCT, "正在识别商品", 0),
|
||||
(QuickCreateJob.Phase.SCRIPT, "正在生成分镜脚本…", 0),
|
||||
(QuickCreateJob.Phase.ASSETS, "正在生成商品、模特与场景资产", 1),
|
||||
(QuickCreateJob.Phase.PRODUCTION, "正在生成故事板与镜头画面", 2),
|
||||
(QuickCreateJob.Phase.PRODUCTION, "正在生成视频(1/2)", 3),
|
||||
(QuickCreateJob.Phase.COMPLETE, "视频已生成", 3),
|
||||
]
|
||||
for phase, message, expected in cases:
|
||||
self.job.phase = phase
|
||||
self.job.message = message
|
||||
self.job.save(update_fields=["phase", "message", "updated_at"])
|
||||
self.assertEqual(
|
||||
QuickCreateJobSerializer(self.job).data["phase_index"],
|
||||
expected,
|
||||
msg=f"{phase} / {message}",
|
||||
)
|
||||
|
||||
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
|
||||
@patch("apps.projects.services.quick_create.submit_video_segment")
|
||||
def test_video_start_uses_quick_create_core_parameters(self, submit_video, schedule_poll):
|
||||
|
||||
@@ -289,6 +289,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
group.adopted_asset = tri
|
||||
group.save(update_fields=["adopted_asset", "updated_at"])
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
from .services.quick_create import restore_false_failed_quick_creates
|
||||
|
||||
restore_false_failed_quick_creates(self.get_team())
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
# 详情加载(=进入流水线页)时自愈视频片段数:历史项目在「采用前增删分镜」未同步,
|
||||
# 或项目创建固定铺的 4 段从未被收口,会让视频步骤的片段数与故事板/采用版分镜对不上。
|
||||
@@ -699,9 +705,47 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path=r"quick-create-retry/(?P<job_id>[^/.]+)")
|
||||
def quick_create_retry(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "任务已完成"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if job.status == QuickCreateJob.Status.CANCELLED:
|
||||
return Response({"detail": "已取消的任务请重新开始"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .services.quick_create import resume_quick_create
|
||||
|
||||
resume_quick_create(job)
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path=r"quick-create-retry/(?P<job_id>[^/.]+)")
|
||||
def quick_create_retry(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "任务已经完成"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if job.status == QuickCreateJob.Status.CANCELLED:
|
||||
return Response({"detail": "已取消的任务请重新开始"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .services.quick_create import resume_quick_create
|
||||
|
||||
resume_quick_create(job)
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="quick-create-history")
|
||||
def quick_create_history(self, request):
|
||||
jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at")
|
||||
from .services.quick_create import restore_false_failed_quick_creates
|
||||
|
||||
restore_false_failed_quick_creates(self.get_team())
|
||||
# 进行中的任务看上方状态卡;列表要能找回失败后去专业模式继续的项目。
|
||||
jobs = (
|
||||
self._quick_job_queryset()
|
||||
.exclude(status__in=[QuickCreateJob.Status.QUEUED, QuickCreateJob.Status.RUNNING])
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return Response({
|
||||
"count": jobs.count(),
|
||||
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
|
||||
|
||||
Reference in New Issue
Block a user