diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py
index 9d2314d..3ed2788 100644
--- a/core/backend/apps/adminpanel/tests.py
+++ b/core/backend/apps/adminpanel/tests.py
@@ -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
diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py
index 600ffe2..4d5c479 100644
--- a/core/backend/apps/ai/script_agent.py
+++ b/core/backend/apps/ai/script_agent.py
@@ -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,
},
)
diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py
index c31222d..76a86a1 100644
--- a/core/backend/apps/ai/services.py
+++ b/core/backend/apps/ai/services.py
@@ -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}。"
diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py
index 219eb6b..a33e6db 100644
--- a/core/backend/apps/ai/test_script_duration_combo.py
+++ b/core/backend/apps/ai/test_script_duration_combo.py
@@ -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):
diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py
index ff0bc4b..e6315fd 100644
--- a/core/backend/apps/projects/serializers.py
+++ b/core/backend/apps/projects/serializers.py
@@ -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,
diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py
index fb82d48..cdebc9c 100644
--- a/core/backend/apps/projects/services/quick_create.py
+++ b/core/backend/apps/projects/services/quick_create.py
@@ -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
diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py
index d7059aa..984a453 100644
--- a/core/backend/apps/projects/test_quick_create.py
+++ b/core/backend/apps/projects/test_quick_create.py
@@ -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):
diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py
index 93516b5..440611d 100644
--- a/core/backend/apps/projects/views.py
+++ b/core/backend/apps/projects/views.py
@@ -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[^/.]+)")
+ 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[^/.]+)")
+ 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,
diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md
index 8f0c628..cc05107 100644
--- a/core/backend/skills/ecommerce-video-script/SKILL.md
+++ b/core/backend/skills/ecommerce-video-script/SKILL.md
@@ -95,6 +95,14 @@ description: >
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
- `visual_prompt` 由你自动生成,小白无需打字。
+- **商品图是真实外观的最高依据**:若任务消息附有商品视觉参考图,颜色、外形、材质、结构、配件和可见品牌标识必须以图为准;
+ 旁白、`type:"product"` 的 `visual_prompt` 与每镜 `visual` 不得凭商品名臆测或改色(例如不能把黑色耳机写成白色)。
+- **角色基础资产隔离**:`type:"character"` 的 `visual_prompt` 只能描述人物本身(年龄段、发型、穿着、气质、姿态),
+ **禁止**出现商品名、产品、包装、品牌、Logo、价签、桌子、电脑、杯子、手持物或具体生活场景。
+ 角色图只用于锁脸锁人;商品只能在 `type:"product"`、`product_exposure` 与秒级 `visual` 中出现,避免角色图擅自重绘商品。
+- **未成年人绝不生成角色资产或画面主体**:`type:"character"` 只能是明确的成年人,禁止婴儿、宝宝、幼儿、儿童、小朋友、未成年人及任何 `0–17 岁` 人物。
+ 婴幼儿/儿童用品也一样:用商品平铺、包装、材质、尺寸、功能细节、成人手部或成年照护者的局部演示表达;不得写儿童出镜、试穿、坐卧、拿着商品或作为镜头主体。
+ 旁白可以说明适用年龄与使用场景,但不能把儿童变成要生成的角色/画面。
- **每镜 `visual` 必须按秒拆分镜**:15 秒一场里至少 3 刀、目标 4 刀,写成多行:
`0-3s:景别;机位;运镜;谁在做什么;手和商品的空间关系;信息变化`
第一条从 0 秒起,最后一条接到 15s。每条都要有景别/机位/运镜,15 秒内至少切两次景别。
diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md
index 9f24f99..d2b008d 100644
--- a/core/backend/skills/ecommerce-video-script/references/methodology.md
+++ b/core/backend/skills/ecommerce-video-script/references/methodology.md
@@ -67,6 +67,11 @@
- **一个角色/场景/商品 = 一个 entity = 一份 `visual_prompt`**。多镜复用同一 id,**绝不为同一对象写两份 visual_prompt**(会导致下游生图人脸/包装漂移)。
- `visual_prompt` 由你**自动生成**(小白不打字):写清外观特征(角色:性别/年龄段/发型/穿着/气质;场景:地点/光线/风格;商品:品类/包装/颜色/摆放),用于直接喂图模型。
+- **角色与商品必须隔离出图**:角色的 `visual_prompt` 只能写人物外观与气质,生成的是纯色背景的单人角色参考图;
+ 禁止写商品、包装、品牌、Logo、价签、桌子、电脑、杯子、手持物或生活场景。商品由独立 product 基础资产锁定,
+ 只在故事板和视频镜头里与人物合成,避免人物图把真实包装改掉。
+- **儿童用品不生成儿童角色**:任何未成年人(婴儿、宝宝、幼儿、儿童、小朋友、0–17 岁)都不能作为 `character`、故事板或视频的画面主体。
+ 这类商品改用商品平铺、材质/包装/尺寸细节和成人手部或成年照护者局部演示;适用年龄只写在旁白和商品信息中,不把儿童写成要生图的人物。
- `type` 三选一:`character`(人) / `scene`(环境) / `product`(商品)。一份脚本通常至少 1 个 `product`、**至少 1 个 `scene`**。
- **场景与镜头一一对应(可复用)**:**每个 segment 的 `entity_refs` 必须恰好引用一个 `scene`**——它就是这一镜画面所处的环境。多镜同环境就复用同一个 scene id(如全程宿舍 → 只一个「宿舍书桌」scene,4 镜都引用它);真正换了地点才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(无明确环境则复用主场景)。这样下游「场景」基础资产能成图、且同环境背景一致。
- `ref_index` 是该 entity 在图集里的参考序号(从 1 递增,供下游三视图/参考图对齐)。
diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx
index ee829f2..933f7b0 100644
--- a/core/frontend/src/App.tsx
+++ b/core/frontend/src/App.tsx
@@ -635,6 +635,14 @@ export function App() {
}
}
+ async function deleteProjectAction(projectId: string) {
+ const ok = await action(() => api.deleteProject(projectId), "项目已删除");
+ if (ok !== null && projectId === activeProjectIdRef.current) {
+ setActiveProjectId("");
+ setProjectDetail(null);
+ }
+ }
+
async function runProductBatch(ids: string[], work: (id: string) => Promise, successText: string): Promise {
const uniqueIds = Array.from(new Set(ids));
if (!uniqueIds.length) return { succeededIds: [], failedIds: [] };
@@ -804,7 +812,7 @@ export function App() {
return null;
}
await refreshBilling();
- setNotice({ type: "success", text: okText });
+ if (okText) setNotice({ type: "success", text: okText });
return assets[0].id;
}
@@ -889,7 +897,7 @@ export function App() {
function renderPage() {
switch (page) {
case "dashboard":
- return ;
+ return ;
case "products":
return (
action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
- onDelete={async (projectId) => {
- const ok = await action(() => api.deleteProject(projectId), "项目已删除");
- // 删的是当前激活项目就清掉残留 id/详情,否则后续新建/进管线会拿着已删 id 去拉 → 404 / 卡 loading
- if (ok !== null && projectId === activeProjectIdRef.current) {
- setActiveProjectId("");
- setProjectDetail(null);
- }
- }}
+ onDelete={deleteProjectAction}
/>
);
case "projectWizard":
@@ -1082,7 +1083,7 @@ export function App() {
case "settingsNotify":
return setNotice({ type: "success", text })} onLogout={logout} />;
default:
- return ;
+ return ;
}
}
@@ -1123,6 +1124,7 @@ export function App() {
project={pipelineProject}
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
+ videoModels={modelConfigs.filter((m) => m.capability === "video" && m.status === "active")}
loading={loading}
navigate={navigate}
onBack={() => goBack("projects")}
@@ -1149,10 +1151,20 @@ export function App() {
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
onGenerateBaseAsset={async (kind, prompt, label, referenceAssetId) => {
- // 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成
+ // 异步:提交→轮询出图→刷新。角色立绘成功后立刻据该立绘出三视图,用户不必再进详情点一次。
// referenceAssetId:角色重跑时传当前立绘 → 后端走 image_edit 参考它,保持人物一致
- const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }), "基础资产已生成");
- return assetId ? { adopted_asset: assetId } : null;
+ const assetId = await submitAndPollAsset(
+ () => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }),
+ kind === "person" ? "" : "基础资产已生成",
+ );
+ if (!assetId) return null;
+ if (kind === "person") {
+ await submitAndPollAsset(
+ () => api.generateTriview(pipelineProject.id, { portrait_asset_id: assetId }),
+ "角色立绘与三视图已生成",
+ );
+ }
+ return { adopted_asset: assetId };
}}
onGenerateStoryboard={(prompt) =>
// 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」)
diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts
index 4068ba8..017e43b 100644
--- a/core/frontend/src/api.ts
+++ b/core/frontend/src/api.ts
@@ -372,6 +372,9 @@ export const api = {
cancelQuickCreate(jobId: string) {
return request(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" });
},
+ retryQuickCreate(jobId: string) {
+ return request(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" });
+ },
quickCreateHistory() {
return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/");
},
diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx
index a880fa7..30fb4f4 100644
--- a/core/frontend/src/components/overlays.tsx
+++ b/core/frontend/src/components/overlays.tsx
@@ -131,7 +131,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true }: {
open: boolean;
title: string;
- detail: string;
+ detail: ReactNode;
confirmText: string;
subtitle?: string;
icon?: ReactNode;
diff --git a/core/frontend/src/dashboard-page.css b/core/frontend/src/dashboard-page.css
index 3a07f64..d58e2e5 100644
--- a/core/frontend/src/dashboard-page.css
+++ b/core/frontend/src/dashboard-page.css
@@ -301,7 +301,7 @@
position: relative;
min-height: 152px;
display: grid;
- grid-template-columns: 160px minmax(0, 1fr) minmax(220px, 1.1fr) 140px;
+ grid-template-columns: 160px minmax(0, 1fr) minmax(220px, 1.1fr) auto;
align-items: center;
gap: 28px;
padding: 18px 34px 18px 32px;
@@ -383,6 +383,37 @@
.dashboard-page .segment.warn { background: #ff7219; }
.dashboard-page .segment.fail { background: var(--accent-crimson); }
+.dashboard-page .project-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+}
+.dashboard-page .project-del {
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 1px solid rgba(34, 42, 54, 0.10);
+ border-radius: 10px;
+ color: var(--black-alpha-56);
+ background: var(--control);
+ cursor: pointer;
+ font: inherit;
+ transition: color 180ms ease, background-color 180ms ease, border-color 180ms ease;
+}
+.dashboard-page .project-del svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+}
+.dashboard-page .project-del:hover {
+ color: var(--accent-crimson);
+ border-color: var(--crimson-bd);
+ background: var(--crimson-bg);
+}
+
.dashboard-page .continue-button {
height: 42px;
min-width: 88px;
@@ -421,8 +452,12 @@
grid-template-columns: 122px minmax(0, 1fr);
min-height: 0;
}
- .dashboard-page .stage,
- .dashboard-page .continue-button { display: none; }
+ .dashboard-page .stage { display: none; }
+ .dashboard-page .project-actions {
+ grid-column: 1 / -1;
+ justify-content: flex-end;
+ padding-top: 4px;
+ }
}
@media (max-width: 820px) {
.dashboard-page .creation-entry-pair { grid-template-columns: 1fr; }
diff --git a/core/frontend/src/model-display.ts b/core/frontend/src/model-display.ts
index 3529ac6..88cfa9d 100644
--- a/core/frontend/src/model-display.ts
+++ b/core/frontend/src/model-display.ts
@@ -25,7 +25,19 @@ export function publicModelDisplayName(
return publicModelRouteName(model.name, model.display_name?.trim() || model.name || fallback);
}
-/** Gemini 3.1 只给「提炼提示词」用,脚本助手下拉里不出现。 */
+/**
+ * 脚本助手只展示可读取商品视觉参考的文案模型。
+ * Gemini 3.1 只给「提炼提示词」用;DeepSeek / DP V4 Pro 当前不接收商品图片,
+ * 因此都不在脚本生成下拉中展示。
+ */
export function isHiddenFromScriptPicker(model: Pick) {
- return model.name === "gemini-3.1-pro-preview" || publicModelDisplayName(model) === "AirShelf Script";
+ const name = (model.name || "").trim().toLowerCase();
+ const label = publicModelDisplayName(model).trim().toLowerCase();
+ return (
+ name === "gemini-3.1-pro-preview"
+ || label === "airshelf script"
+ || name.includes("deepseek")
+ || label.includes("deepseek")
+ || /\bdp\s*v?4\s*pro\b/i.test(label)
+ );
}
diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css
index 1ef34b5..0374640 100644
--- a/core/frontend/src/pipeline-page.css
+++ b/core/frontend/src/pipeline-page.css
@@ -2089,8 +2089,19 @@
.rg-kind-person { color: var(--heat); background: var(--heat-8); }
.rg-kind-scene { color: var(--black-alpha-56); background: var(--black-alpha-5); }
.rg-actions { display: flex; gap: 10px; margin-top: 22px; align-items: stretch; }
-.rg-actions .btn-primary { flex: 1; }
-.rg-force { display: inline-flex; align-items: baseline; gap: 5px; }
+.rg-actions .btn-primary,
+.rg-actions .rg-force { flex: 1; }
+.rg-force {
+ height: auto;
+ min-height: 36px;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ white-space: normal;
+ text-align: center;
+ line-height: 1.25;
+}
.rg-warn { font-size: 10px; font-family: var(--font-mono); color: var(--black-alpha-48); }
/* 提取闸门单按钮:醒目主 CTA */
diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css
index 8d41997..4d0ee26 100644
--- a/core/frontend/src/quick-create-page.css
+++ b/core/frontend/src/quick-create-page.css
@@ -4,8 +4,6 @@
.quick-create-page .project-builder-title { display: flex; align-items: center; gap: 14px; }
.quick-create-page .project-builder-title h1 { margin: 0 0 5px; color: #17181a; font-size: 28px; line-height: 1.2; font-weight: 700; }
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 18px; }
-.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; margin-right: 5px; padding: 9px 15.6px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; line-height: 18px; }
-.quick-create-page .project-builder-status strong { color: var(--quick-blue); font-size: 13px; }
.quick-create-page .image-back-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; margin-top: 2px; border: 1px solid rgba(0,47,167,.46); border-radius: 12px; color: var(--quick-blue); background: rgba(255,255,255,.82); cursor: pointer; box-shadow: 0 5px 14px rgba(0,47,167,.07); transition: background-color 180ms ease, transform 180ms ease; }
.quick-create-page .image-back-button:hover { transform: translateX(-2px); border-color: var(--quick-blue); background: rgba(0,47,167,.055); }.quick-create-page .image-back-button svg { width: 19px; height: 19px; }
.quick-create-header { margin-bottom: 16px !important; }
@@ -60,7 +58,16 @@
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; }
.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; }
.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
-.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; color: #9aa1ab; font-size: 12px; }.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: -1; top: 23px; left: 50%; width: 100%; height: 2px; background: rgba(34,42,54,.12); }.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }.quick-create-page .quick-progress-dot { width: 46px; height: 46px; display: grid; place-items: center; border: 5px solid rgba(0,47,167,.10); border-radius: 50%; color: #fff; background: var(--quick-blue); }.quick-create-page .quick-progress-dot svg { width: 18px; height: 18px; stroke-width: 1.8; }.quick-create-page .quick-progress-node strong { margin-top: 9px; max-width: 8em; text-align: center; font-size: 12px; font-weight: 700; line-height: 1.35; }.quick-create-page .quick-progress-node.done,.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }.quick-create-page .quick-progress-node.active { color: #16171a; }.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: rgba(22,23,26,.12); background: #16171a; }
+.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }
+.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; gap: 10px; color: #111216; font-size: 11px; font-weight: 700; }
+.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: 0; top: 19px; left: calc(50% + 24px); width: calc(100% - 48px); height: 2px; background: #111216; }
+.quick-create-page .quick-progress-dot { position: relative; z-index: 1; width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid #111216; border-radius: 50%; color: #fff; background: #111216; box-shadow: 0 0 0 5px rgba(17,18,22,.05); }
+.quick-create-page .quick-progress-dot svg { width: 17px; height: 17px; stroke-width: 2; }
+.quick-create-page .quick-progress-node strong { margin-top: 0; max-width: none; text-align: center; font-size: 11px; font-weight: 700; line-height: 1.35; }
+.quick-create-page .quick-progress-node.done { color: var(--quick-blue); }
+.quick-create-page .quick-progress-node.done .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 5px rgba(0,47,167,.08); }
+.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }
+.quick-create-page .quick-progress-node.active .quick-progress-dot { box-shadow: 0 0 0 6px rgba(17,18,22,.09); }
.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; }
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
@@ -97,6 +104,7 @@
.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; }
.quick-create-page .quick-history-copy { min-width: 0; }
.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; }
+.quick-create-page .quick-history-badge.is-wait { color: var(--quick-muted); background: rgba(34,42,54,.06); }
.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; }
.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; }
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx
index 90863be..64e3b86 100644
--- a/core/frontend/src/routes/dashboard.tsx
+++ b/core/frontend/src/routes/dashboard.tsx
@@ -5,10 +5,12 @@ import {
FolderKanban,
Replace,
ScanSearch,
+ Trash2,
WandSparkles,
} from "lucide-react";
import type { BillingSummary, Product, Project } from "../types";
import type { NavigateFn, Page } from "./route-config";
+import { ConfirmModal } from "../components/overlays";
type DashTab = "all" | "wip" | "done";
type EntryTone = "primary" | "subtle";
@@ -100,6 +102,7 @@ export function Dashboard({
userName,
loading: _loading = false,
navigate,
+ onDelete,
}: {
products: Product[];
projects: Project[];
@@ -109,8 +112,10 @@ export function Dashboard({
userName?: string;
loading?: boolean;
navigate: NavigateFn;
+ onDelete: (projectId: string) => Promise | void;
}) {
const [tab, setTab] = useState("all");
+ const [deleteTarget, setDeleteTarget] = useState(null);
const projCount = projectTotal ?? projects.length;
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
@@ -253,13 +258,24 @@ export function Dashboard({
))}
-
+
+
+
+
);
})}
@@ -267,6 +283,20 @@ export function Dashboard({
)}
+
+ }
+ detail={`确定删除「${deleteTarget?.name || ""}」?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
+ confirmText="删除"
+ onCancel={() => setDeleteTarget(null)}
+ onConfirm={async () => {
+ if (!deleteTarget) return;
+ await onDelete(deleteTarget.id);
+ setDeleteTarget(null);
+ }}
+ />
);
}
diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx
index 32c320d..46b9263 100644
--- a/core/frontend/src/routes/free-create.tsx
+++ b/core/frontend/src/routes/free-create.tsx
@@ -440,20 +440,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
}
}, [refs, mode, model, ratio, resolution, duration, seed, doSubmit, notify]);
- const handleRetry = useCallback((task: FreeVideoTask) => {
- void doSubmit({
- prompt: task.prompt,
- mode: task.mode,
- model: task.model,
- aspect_ratio: task.aspect_ratio,
- resolution: task.resolution,
- duration: task.duration,
- seed: task.seed,
- references: task.references
- });
- }, [doSubmit]);
-
- // 再次生成:参数 + 素材 + 提示词(含 mention chip)全部回填输入条
+ // 失败重试 / 再次生成:不立刻再跑同一条任务,把提示词+参数+素材回填到底部操作区,用户改完再点生成
const handleReuse = useCallback((task: FreeVideoTask) => {
setDetailId(null);
setMode(task.mode);
@@ -463,7 +450,11 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
setDuration(task.duration);
setSeed(task.seed ?? -1);
setRefs(task.references.map((r) => ({ ...r, key: nextRefKey() })));
- window.setTimeout(() => promptRef.current?.setContent(task.prompt, task.references), 0);
+ window.setTimeout(() => {
+ promptRef.current?.setContent(task.prompt, task.references);
+ promptRef.current?.focus();
+ document.querySelector(".fc-composer")?.scrollIntoView({ behavior: "smooth", block: "end" });
+ }, 0);
notify("info", "已回填参数,可修改后重新生成");
}, [notify]);
@@ -665,7 +656,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
- onRetry={() => handleRetry(task)}
+ onRetry={() => handleReuse(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx
index 2150a65..dba8336 100644
--- a/core/frontend/src/routes/pipeline.tsx
+++ b/core/frontend/src/routes/pipeline.tsx
@@ -8,7 +8,8 @@ import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-displ
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
import type { Notice, Page } from "./route-config";
import { stageOrder, statusPill } from "./stage-config";
-import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
+import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
+import { DEFAULT_BILLING_RATES, estimateCost } from "../components/free-create/constants";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import {
@@ -517,6 +518,7 @@ export function PipelinePage(props: {
onNotify?: (type: "success" | "error", text: string) => void;
scriptModelName: string;
textModels?: ModelConfig[];
+ videoModels?: ModelConfig[];
onAdoptScript: (scriptId: string) => void | Promise;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise;
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise;
@@ -560,17 +562,22 @@ export function PipelinePage(props: {
}) {
const {
project, loading, navigate, products, assets, onNotify,
- textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
+ textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
// ── 团队价格系数(差异化调价):页面各处「N 积分/次」文案按团队系数动态显示,拉不到按标准价 1 ──
- const [priceMultiplier, setPriceMultiplier] = useState(1);
+ const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES);
useEffect(() => {
- void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
+ void api.billingConfig().then((cfg) => setBillingRates({
+ margin: Number(cfg.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
+ rate: Number(cfg.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
+ multiplier: Number(cfg.team_price_multiplier) || 1,
+ })).catch(() => undefined);
}, []);
+ const priceMultiplier = billingRates.multiplier;
// 与后端 apply_team_price 逐字对齐:挂牌整数积分 × 系数 → HALF_UP 最低 1(toFixed(6) 只吸浮点噪声)
const pts = (base: number) => (priceMultiplier === 1 ? base : Math.max(1, Math.round(Number((base * priceMultiplier).toFixed(6)))));
@@ -827,10 +834,13 @@ export function PipelinePage(props: {
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise {
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
addBusy(busyKey);
+ const triKey = kind === "person" ? `${busyKey}:tri` : "";
+ if (triKey) addBusy(triKey);
try {
return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult;
} finally {
delBusy(busyKey);
+ if (triKey) delBusy(triKey);
}
}
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
@@ -843,7 +853,7 @@ export function PipelinePage(props: {
delBusy(busyKey);
}
}
- // 流程步骤4 · 生成人物立绘;三视图只在角色详情里手动生成。
+ // 流程步骤4 · 生成人物立绘(App 层自动接力三视图)
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
return await genBaseAsset("person", prompt, label, busyKey);
}
@@ -855,16 +865,16 @@ export function PipelinePage(props: {
const extractPollRef = useRef(0); // 提取轮询定时器句柄
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
- // 提取完成后(mode≠only)按返回实体循环出参考图(立绘/场景图;三视图手动生成)
+ // 提取完成后(mode≠only)按返回实体循环出参考图(角色=立绘+三视图,场景=场景图)
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
for (const e of entities) {
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
if (e.type === "character") {
- if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘;三视图手动生成
- else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
+ if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk);
+ else await genBaseAsset("person", e.visual_prompt, e.name, bk);
} else {
- await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
+ await genBaseAsset("scene", e.visual_prompt, e.name, bk);
}
}
await onRefreshProject();
@@ -1121,6 +1131,27 @@ export function PipelinePage(props: {
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
+ const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
+ const [chargeConfirm, setChargeConfirm] = useState<"storyboard" | "video" | null>(null);
+ const sbChargeShots = shots.length || sbExpectedShots;
+ const sbChargePoints = sbChargeShots * pts(20);
+ const defaultVideoModel = (videoModels ?? []).find((m) => m.status === "active") || (videoModels ?? [])[0];
+ const videoChargeDurations = segments.length
+ ? segments.map((s) => s.target_duration_seconds || 15)
+ : shots.map((s) => shotSeconds(s));
+ const videoChargeShots = videoChargeDurations.length;
+ const videoChargePoints = videoChargeDurations.reduce(
+ (sum, duration) => sum + estimateCost(defaultVideoModel, { ratio: "9:16", resolution: "720p", duration, refs: [] }, billingRates).points,
+ 0,
+ );
+ const sbNextLabel = sbAnyImage || sbAnyGenerating
+ ? "进入故事板"
+ : `生成故事板 · ${sbChargeShots > 0 ? `${sbChargePoints} 积分` : `${pts(20)} 积分/镜`}`;
+ const videoNextLabel = videoAnyStarted
+ ? "进入视频"
+ : videoChargePoints > 0
+ ? `生成视频 · ${videoChargePoints} 积分`
+ : "生成视频";
// Stage 4 · 视频详情弹窗:选中段 + 查看的版本 + 可编辑的重跑提示词
const [vdSegId, setVdSegId] = useState(null);
const [vdVerId, setVdVerId] = useState(null);
@@ -1897,6 +1928,10 @@ export function PipelinePage(props: {
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
const sbSavedPrompt = (project.metadata as Record | undefined)?.storyboard_prompt as string | undefined;
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
+ const startStoryboardGeneration = () => {
+ setSbGenerating(true);
+ void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false));
+ };
const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
@@ -3649,8 +3684,19 @@ export function PipelinePage(props: {
确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣
-
@@ -3757,10 +3803,6 @@ export function PipelinePage(props: {
onChange={(v) => setStoryboardPrompt(v.trim())}
/>
-
guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}>
-
- {sbAnyImage ? "全部重跑" : "开始生成故事板"}
-
{sbActiveShot && (
guardGen(shots.filter((s) => s.sort_order === sbActiveShot.sort_order), () => rerunStoryboardShotOptimistic(sbActiveShot.id))}>
{activeBusy ? <>生成中…> : `↻ 重跑本场`}
@@ -3819,10 +3861,25 @@ export function PipelinePage(props: {
故事板还没出齐
- 请先点上方 开始生成故事板,每场都出片后再确认进入视频生成。
+ 请先等每场故事板都出片,再点 生成视频。
)}
- { if (!sbAllDone) { setSbConfirmHint(true); return; } goStage(4); }}>确认故事板,开始生成视频
+ {
+ if (!sbAllDone) { setSbConfirmHint(true); return; }
+ if (videoAnyStarted) {
+ goStage(4);
+ return;
+ }
+ guardVideoGen(shots, null, () => setChargeConfirm("video"));
+ }}
+ >
+ {videoNextLabel}
+
+
@@ -3832,7 +3889,6 @@ export function PipelinePage(props: {
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
{viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
- const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
const segMax = segSeconds.length ? Math.max(...segSeconds) : 0;
@@ -3864,7 +3920,6 @@ export function PipelinePage(props: {
{pct}%
- 0} onClick={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <>提交中…> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
void exportAllVideos()}>
@@ -4460,7 +4515,10 @@ export function PipelinePage(props: {
)}
{ setRefGate(null); goStage(2); }}>去基础资产页补齐
- { const go = refGate.proceed; setRefGate(null); go(); }}>仍要继续 可能跟角色对不上
+ { const go = refGate.proceed; setRefGate(null); go(); }}>
+ 仍要继续
+ 可能跟角色对不上
+
,
@@ -4549,7 +4607,7 @@ export function PipelinePage(props: {
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
- // 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出。
+ // 重跑立绘 → 追加新立绘并自动接力该版三视图。
// 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。
const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined;
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref);
@@ -4788,6 +4846,42 @@ export function PipelinePage(props: {
+ }
+ detail={chargeConfirm === "video"
+ ? (
+ <>
+ 将按故事板生成 {videoChargeShots || "多"} 段视频,预估扣除 {videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}
+ {videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
+ 确认后进入视频页并开始生成。
+ >
+ )
+ : (
+ <>
+ 将按脚本生成 {sbChargeShots || "多"} 场分镜图,预估扣除 {sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}
+ {sbChargeShots > 0 ? `(${pts(20)} 积分/镜 × ${sbChargeShots} 场)` : ""}。
+ 确认后进入故事板并开始生成。
+ >
+ )}
+ confirmText={chargeConfirm === "video"
+ ? (videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成")
+ : (sbChargePoints > 0 ? `确认生成 · ${sbChargePoints} 积分` : "确认生成")}
+ onCancel={() => setChargeConfirm(null)}
+ onConfirm={() => {
+ const kind = chargeConfirm;
+ setChargeConfirm(null);
+ if (kind === "storyboard") {
+ goStage(3);
+ startStoryboardGeneration();
+ } else if (kind === "video") {
+ goStage(4);
+ submitAllVideosOptimistic();
+ }
+ }}
+ />
);
}
diff --git a/core/frontend/src/routes/quick-create.tsx b/core/frontend/src/routes/quick-create.tsx
index 83e9fc2..9e6a606 100644
--- a/core/frontend/src/routes/quick-create.tsx
+++ b/core/frontend/src/routes/quick-create.tsx
@@ -2,17 +2,17 @@ import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
ArrowLeft,
+ Boxes,
Clapperboard,
Download,
ImagePlus,
+ LayoutPanelTop,
RefreshCw,
- ScanSearch,
ScrollText,
SlidersHorizontal,
Sparkles,
Play,
Upload,
- UsersRound,
WandSparkles,
X,
Columns2,
@@ -31,10 +31,10 @@ import type { NavigateFn } from "./route-config";
const QUICK_JOB_KEY = "airshelf:quick-create-job";
const PROGRESS_STEPS = [
- { label: "识别商品与卖点", icon: ScanSearch },
- { label: "推荐脚本方向", icon: ScrollText },
- { label: "匹配模特与场景", icon: UsersRound },
- { label: "生成故事板与视频", icon: Clapperboard },
+ { label: "脚本", icon: ScrollText },
+ { label: "资产", icon: Boxes },
+ { label: "故事板", icon: LayoutPanelTop },
+ { label: "视频", icon: Clapperboard },
];
const QUICK_RATIOS = [
{ value: "9:16", label: "9:16 竖屏" },
@@ -70,6 +70,14 @@ function historyTitle(item: QuickCreateJob) {
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
}
+function historyBadge(item: QuickCreateJob) {
+ if (item.status === "cancelled") return "已取消";
+ if (item.status === "succeeded" || item.result?.video_url || item.result?.video_segments?.some((clip) => clip.video_url)) {
+ return "已完成";
+ }
+ return "未完成";
+}
+
function savedJobId() {
try {
return localStorage.getItem(QUICK_JOB_KEY) || "";
@@ -298,6 +306,33 @@ export function QuickCreatePage({
setImages([]);
}
+ async function retryGeneration() {
+ if (job?.id && job.status === "failed") {
+ setSubmitting(true);
+ setServiceUnavailable(false);
+ setUnavailableMessage("");
+ cancelRequestedRef.current = false;
+ try {
+ const next = await api.retryQuickCreate(job.id);
+ setJob(next);
+ setJobId(next.id);
+ try {
+ localStorage.setItem(QUICK_JOB_KEY, next.id);
+ } catch {
+ /* ignore */
+ }
+ onNotify?.("success", "已从上次进度继续生成");
+ onProjectCreated?.();
+ } catch (error) {
+ onNotify?.("error", error instanceof Error ? error.message : "继续生成失败");
+ } finally {
+ setSubmitting(false);
+ }
+ return;
+ }
+ await startGeneration();
+ }
+
async function startGeneration() {
if (!name.trim() || (!images.length && !savedImages.length)) {
onNotify?.("info", "请先填写商品名称并上传商品图片");
@@ -401,11 +436,11 @@ export function QuickCreatePage({
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
const isComplete = job?.status === "succeeded";
const isCancelled = job?.status === "cancelled";
- const isFailed = job?.status === "failed" || isCancelled || serviceUnavailable;
+ const isFailed = !isGenerating && (job?.status === "failed" || isCancelled || serviceUnavailable);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
- const activePhase = submitting ? 0 : Math.max(0, Math.min(3, job?.phase_index ?? 0));
+ const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
const result = job?.result;
const videoClips = result?.video_segments?.length
? result.video_segments
@@ -434,7 +469,6 @@ export function QuickCreatePage({
极速成片
- AI 自动编排· 无需逐步配置
@@ -534,7 +568,7 @@ export function QuickCreatePage({
{PROGRESS_STEPS.map((step, index) => {
const Icon = step.icon;
const done = index < activePhase;
- const active = isGenerating && index === activePhase;
+ const active = isGenerating && index === activePhase && activePhase < 4;
return
{step.label}
;
})}
@@ -575,10 +609,10 @@ export function QuickCreatePage({
{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}
-
{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成过程中遇到问题,可以重试或重新开始。"}
+
{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的故事板不会重做。"}
{canRetry && !serviceUnavailable ? (
-
void startGeneration()}>重试
+
void retryGeneration()}>重试
) : null}
重新开始
@@ -605,6 +639,7 @@ export function QuickCreatePage({
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
+ const badge = historyBadge(item);
return (
{formatClock(duration)}
-
已完成
+
{badge}
{historyTitle(item)}
极速成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}
diff --git a/core/frontend/src/video-remix-page.css b/core/frontend/src/video-remix-page.css
index 1c373e2..ea4ac5f 100644
--- a/core/frontend/src/video-remix-page.css
+++ b/core/frontend/src/video-remix-page.css
@@ -1053,9 +1053,11 @@
.vr-page .remix-history-prompt textarea {
width: 100%;
- min-height: 108px;
+ min-height: 360px;
+ max-height: 560px;
display: block;
resize: none;
+ overflow-y: auto;
padding: 13px 14px;
border: 1px solid rgba(34, 42, 54, 0.12);
border-radius: 9px;