From 2f70d3e8a0a3cb98d9512ca58cfc981d33bcf26a Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Tue, 25 Aug 2026 18:46:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=9E=81=E9=80=9F=E6=88=90?= =?UTF-8?q?=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/script_agent.py | 40 ++++- core/backend/apps/ai/services.py | 99 ++++++++---- .../apps/ai/test_script_duration_combo.py | 32 ++++ .../apps/ai/test_video_segment_routing.py | 14 ++ .../apps/projects/services/quick_create.py | 103 ++++++++++-- .../apps/projects/test_quick_create.py | 80 +++++++++- core/backend/apps/projects/views.py | 21 ++- .../skills/ecommerce-video-script/SKILL.md | 2 + .../references/playbooks/combo-matrix.md | 3 +- .../references/playbooks/structure-review.md | 7 +- core/frontend/src/api.ts | 2 +- core/frontend/src/pipeline-page.css | 19 +++ core/frontend/src/quick-create-page.css | 9 +- core/frontend/src/routes/pipeline.tsx | 63 +++++--- core/frontend/src/routes/quick-create.tsx | 147 ++++++++++-------- 15 files changed, 495 insertions(+), 146 deletions(-) diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 4d5c479..f65a448 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -81,7 +81,7 @@ PERSONA_BRIEFS: dict[str, str] = { "urban": "25–32岁都市白领,工位或下班回家,说话像跟同事吐槽,不要主播腔", "bestie": "闺蜜分享口吻,带点兴奋,爱用「你懂的」「我跟你讲」", "ceo": "利落、判断句、少形容词,像拍板不是带货", - "reviewer": "先讲怎么试的再给结论,允许提一个小缺点才可信", + "reviewer": "先讲怎么试的再给结论;可信度来自过程、适用边界或意外发现,不编造小缺点", "mom": "带娃/家务间隙,讲省事,孩子或家人能沾边", "genz": "宿舍或通勤,短句,像给朋友发语音条", } @@ -103,10 +103,18 @@ _SHOT_SIZE_MARKERS = ( "手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇", ) _MINOR_CHARACTER_RE = re.compile( - r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)" - r"|(?:[0-9零一二三四五六七八九十]{1,3}\s*岁)", + r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)", re.IGNORECASE, ) +_NUMERIC_AGE_RE = re.compile(r"(? str: def _is_minor_character(name: str, visual_prompt: str) -> bool: """角色基础资产不得是未成年人,避免真人生图审核拦截与儿童肖像风险。""" - return bool(_MINOR_CHARACTER_RE.search(f"{name or ''} {visual_prompt or ''}")) + text = f"{name or ''} {visual_prompt or ''}" + if _MINOR_CHARACTER_RE.search(text) or _CHINESE_MINOR_AGE_RE.search(text): + return True + return any(int(age) < 18 for age in _NUMERIC_AGE_RE.findall(text)) def _product_only_visual(duration: int) -> str: @@ -253,6 +264,14 @@ def _product_only_visual(duration: int) -> str: return "【本镜任务】用商品本身传达关键信息,不出现人物。\n【声音】旁白继续,画面不出现未成年人。\n【画面内容】\n" + format_visual_beats(beats) +def _replace_canned_defect_phrase(text: str) -> str: + """不改模型给出的事实,只替换会让测评显得千篇一律的“唯一缺点”话术。""" + if not text or not _CANNED_DEFECT_RE.search(text): + return text + variant = _NATURAL_TURN_PHRASES[sum(map(ord, text)) % len(_NATURAL_TURN_PHRASES)] + return _CANNED_DEFECT_RE.sub(variant, text) + + # --------------------------------------------------------------------------- # # skill 加载(缓存) # --------------------------------------------------------------------------- # @@ -613,6 +632,11 @@ def build_agent_messages( f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写," f"不要串成别的结构的套话。\n" ) + authenticity_line = ( + "【真实感转折】禁止使用「唯一缺点/唯一不足/唯一的小遗憾」这类模板句,也不要为了显得真实而编造缺点。" + "每版从以下角度自然选一个推进:测试过程里的意外发现、适用人群的边界、不同使用场景的反差、" + "一个可观察的细节、或使用习惯建议;必须由商品资料或画面可观察事实支持,不能每镜重复同一种。\n" + ) head = ( f"【画幅】{aspect_ratio}\n" f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n" @@ -623,6 +647,7 @@ def build_agent_messages( f"{beats_line}" f"{structure_line}" f"{combo_line}" + f"{authenticity_line}" f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}" ) if mode == "revise" and base_draft and target_index is not None: @@ -1196,7 +1221,7 @@ def normalize_draft( if not line: continue sp = d.get("speaker") - dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line}) + dialogue.append({"speaker": sp if sp in valid_ids else None, "line": _replace_canned_defect_phrase(line)}) # 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕…) narration = "" if isinstance(raw_dialogue, str) and raw_dialogue.strip(): @@ -1205,8 +1230,9 @@ def normalize_draft( narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音 if not narration: narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY) + narration = _replace_canned_defect_phrase(narration) # 画面:优先收成秒级分镜;beats 数组会折进 visual,下游故事板/视频直接读这一段。 - visual = compose_segment_visual(seg) + visual = _replace_canned_defect_phrase(compose_segment_visual(seg)) norm_segments.append( { "index": i, @@ -1255,7 +1281,7 @@ def normalize_draft( elif index < len(segments) and isinstance(segments[index], dict): composed = compose_segment_visual(segments[index], seconds) if composed: - norm["visual"] = composed + norm["visual"] = _replace_canned_defect_phrase(composed) draft["segments"] = norm_segments draft["segment_count"] = len(norm_segments) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 76a86a1..9590ea3 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -2217,10 +2217,20 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask: ref_url = _asset_preview_url(portrait_asset) # 人物三视图提示词:正文可在 admin「提示词」页改(无占位符) tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT) + portrait_label = "" + for group in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON): + meta = group.metadata or {} + if meta.get("triview_of"): + continue + candidates = [str(value) for value in (group.candidate_assets or [])] + if str(group.adopted_asset_id or "") == asset_key or asset_key in candidates: + portrait_label = str(meta.get("label") or "") + break payload = { "model": model_config.name, "prompt": tri_prompt, "kind": "person", + "label": portrait_label or str(portrait_asset.name or ""), "triview_of": asset_key, "reference_image": ref_url, "model_routing_v1": True, @@ -3234,6 +3244,26 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" = return blockers +_VIDEO_INFLIGHT_STATUSES = { + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, +} + + +def video_segment_has_inflight_task(video_segment: VideoSegment) -> bool: + """同一镜头是否已有在途视频任务。防止极速成片并发推进把同一段提交两次。""" + segment_id = str(video_segment.id) + tasks = AITask.objects.filter( + project_id=video_segment.project_id, + task_type=AITask.Type.VIDEO_SEGMENT, + status__in=_VIDEO_INFLIGHT_STATUSES, + ).only("request_payload") + return any(str((task.request_payload or {}).get("video_segment_id") or "") == segment_id for task in tasks) + + def submit_video_segment( *, video_segment: VideoSegment, @@ -3261,7 +3291,17 @@ def submit_video_segment( model_config = get_default_model(ModelConfig.Capability.VIDEO) if model_config is None: raise ValueError("no active video model configured") - project = video_segment.project + + with transaction.atomic(): + video_segment = VideoSegment.objects.select_for_update().select_related("project").get(pk=video_segment.pk) + project = video_segment.project + if video_segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}: + return None + if video_segment_has_inflight_task(video_segment): + return None + if video_segment.status != VideoSegment.Status.QUEUED: + video_segment.status = VideoSegment.Status.QUEUED + video_segment.save(update_fields=["status", "updated_at"]) # 衔接:按 sort_order 把视频段绑到对应脚本镜,并织出跟住该镜的提示词。 scene = None @@ -3289,31 +3329,38 @@ def submit_video_segment( references=[], team=project.team, ) - task = create_ai_task( - project=project, - user=user, - task_type=AITask.Type.VIDEO_SEGMENT, - model_config=model_config, - quote=quote, - reserve_amount=video_reserve_amount(quote.points), - request_payload={ - "model": model_config.name, - "endpoint": model_config.endpoint, - "prompt": final_prompt, - "duration": video_segment.target_duration_seconds, - "ratio": aspect_ratio, - "resolution": resolution, - "estimated_tokens": est_tokens, - # 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律) - "price_multiplier": quote.meta.get("price_multiplier", "1"), - "video_segment_id": str(video_segment.id), - "reference_images": reference_images, - "model_routing_v1": True, - }, - ) - # 提交尝试的实际平台成本由 AIModelAttempt 累加;成片后再用实际模型的 usage true-up 覆盖。 - task.base_cost = Decimal("0") - task.save(update_fields=["base_cost", "updated_at"]) + with transaction.atomic(): + video_segment = VideoSegment.objects.select_for_update().select_related("project").get(pk=video_segment.pk) + project = video_segment.project + if video_segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}: + return None + if video_segment_has_inflight_task(video_segment): + return None + task = create_ai_task( + project=project, + user=user, + task_type=AITask.Type.VIDEO_SEGMENT, + model_config=model_config, + quote=quote, + reserve_amount=video_reserve_amount(quote.points), + request_payload={ + "model": model_config.name, + "endpoint": model_config.endpoint, + "prompt": final_prompt, + "duration": video_segment.target_duration_seconds, + "ratio": aspect_ratio, + "resolution": resolution, + "estimated_tokens": est_tokens, + # 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律) + "price_multiplier": quote.meta.get("price_multiplier", "1"), + "video_segment_id": str(video_segment.id), + "reference_images": reference_images, + "model_routing_v1": True, + }, + ) + # 提交尝试的实际平台成本由 AIModelAttempt 累加;成片后再用实际模型的 usage true-up 覆盖。 + task.base_cost = Decimal("0") + task.save(update_fields=["base_cost", "updated_at"]) try: routed = execute_routed_video_submit( task=task, diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index a33e6db..7341655 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -159,6 +159,36 @@ class NormalizeDurationTests(SimpleTestCase): self.assertIn("不出现人物", draft["segments"][0]["visual"]) self.assertNotIn("宝宝坐", draft["segments"][0]["visual"]) + def test_adult_student_is_not_mistaken_for_a_minor(self): + import json + + raw = { + "entities": [ + {"id": "student", "type": "character", "name": "20岁大学生", "visual_prompt": "20岁成年学生,宿舍使用场景"}, + {"id": "scene", "type": "scene", "name": "宿舍", "visual_prompt": "宿舍桌面"}, + ], + "segments": [{"role": "钩子", "visual": "大学生在宿舍展示商品。", "entity_refs": ["student", "scene"]}], + } + draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15) + self.assertIn("student", [entity["id"] for entity in draft["entities"]]) + self.assertIn("student", draft["segments"][0]["entity_refs"]) + self.assertNotIn("不出现人物", draft["segments"][0]["visual"]) + + def test_canned_only_defect_phrase_is_replaced_without_dropping_content(self): + import json + + raw = { + "segments": [{ + "role": "卖点", "narration": "唯一缺点是瓶口需要慢一点倒,但质地很舒服。", + "visual": "近景展示唯一小缺点是瓶口开口较窄。", + }], + } + draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15) + joined = draft["segments"][0]["narration"] + draft["segments"][0]["visual"] + self.assertNotIn("唯一缺点", joined) + self.assertNotIn("唯一小缺点", joined) + self.assertIn("瓶口", joined) + class ProductFactTests(SimpleTestCase): def test_missing_selling_point_is_rejected(self): @@ -268,6 +298,8 @@ class PromptAssemblyTests(SimpleTestCase): self.assertIn("跟同事吐槽", user) self.assertIn("禁止一句 20 字收工", user) self.assertIn("秒级分镜", user) + self.assertIn("禁止使用「唯一缺点", user) + self.assertIn("测试过程里的意外发现", user) def test_video_digest_prompt_maps_camera_and_sound(self): digest = ( diff --git a/core/backend/apps/ai/test_video_segment_routing.py b/core/backend/apps/ai/test_video_segment_routing.py index c1e38a7..d009f9e 100644 --- a/core/backend/apps/ai/test_video_segment_routing.py +++ b/core/backend/apps/ai/test_video_segment_routing.py @@ -141,6 +141,20 @@ class VideoSegmentRoutingTests(TestCase): self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0) self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0) + def test_second_submit_does_not_create_another_task(self): + primary = self.model(self.provider("video-primary-once", 20), "video-primary-once", outbound=True, default=True) + provider = self._provider_for(primary) + provider.create_video_task.return_value = {"id": "remote-once", "status": "queued"} + + first = self._submit() + second = submit_video_segment(video_segment=self.segment, user=self.user, prompt="test") + + self.assertIsNone(second) + self.assertEqual(AITask.objects.filter(task_type=AITask.Type.VIDEO_SEGMENT, team=self.team).count(), 1) + self.assertEqual(provider.create_video_task.call_count, 1) + first.refresh_from_db() + self.assertEqual(first.status, AITask.Status.SUBMITTED) + @patch("apps.ai.services._store_generated_media") def test_retry_then_dynamic_fallback_polls_actual_model_and_charges_once(self, store_media): primary = self.model(self.provider("video-primary-fail", 20), "video-primary-fail", outbound=True, price=46, default=True) diff --git a/core/backend/apps/projects/services/quick_create.py b/core/backend/apps/projects/services/quick_create.py index cdebc9c..116e27c 100644 --- a/core/backend/apps/projects/services/quick_create.py +++ b/core/backend/apps/projects/services/quick_create.py @@ -20,10 +20,10 @@ from apps.ai.services import ( create_export_job, generate_base_asset, generate_person_triview, - get_default_model, poll_storyboard, submit_storyboard, submit_video_segment, + video_segment_has_inflight_task, ) from apps.assets import assets_client from apps.assets.models import Asset @@ -43,11 +43,13 @@ from apps.projects.services.pipeline import ( adopt_script_version, finish_storyboard_stage, finish_video_stage, + sync_video_segments_to_script, ) logger = logging.getLogger(__name__) POLL_DELAY_SECONDS = 10 +QUICK_SCRIPT_MODEL_NAME = "doubao-seed-2-1-pro-260628" SCRIPT_POLL_SECONDS = 5 SCRIPT_TIMEOUT = timedelta(minutes=4) SCRIPT_STOLEN_AFTER = timedelta(seconds=25) @@ -70,6 +72,24 @@ def _is_finished(job: QuickCreateJob) -> bool: return job.status in _FINISHED_STATUSES +def get_quick_script_model() -> ModelConfig | None: + """极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。 + + 不回退到默认文本模型,避免默认配置切到 DeepSeek 后两条创作链路的成片质量不一致。 + """ + return ( + ModelConfig.objects.select_related("provider") + .filter( + name=QUICK_SCRIPT_MODEL_NAME, + capability=ModelConfig.Capability.TEXT, + status=ModelConfig.Status.ACTIVE, + provider__status="active", + ) + .order_by("created_at") + .first() + ) + + def _quick_settings(project: Project) -> dict: wizard = dict((project.metadata or {}).get("wizard") or {}) return { @@ -97,16 +117,22 @@ TRANSIENT_RETRY_LIMIT = 8 def _safe_error(exc: Exception) -> str: raw = str(exc or "").strip() lower = raw.lower() + if QUICK_SCRIPT_MODEL_NAME in lower: + return "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置" if "insufficient credit" in lower or "额度不足" in raw: return "可用积分不足,极速成片已暂停" if "no active" in lower or "not configured" in lower or "没有可用" in raw: return "当前缺少可用的生成模型,请联系管理员配置" if "review" in lower or "审核" in raw: return "生成素材未通过审核,请进入专业模式调整后重试" + if _is_retryable_exc(exc): + return "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续" return "极速成片暂未完成,请稍后重试或进入专业模式查看" def _is_retryable_exc(exc: Exception) -> bool: + if isinstance(exc, TimeoutError): + return True text = str(exc or "").lower() return any( token in text @@ -123,6 +149,10 @@ def _is_retryable_exc(exc: Exception) -> bool: ) +def _transient_internal(job: QuickCreateJob) -> bool: + return _is_retryable_exc(Exception(str((job.metadata or {}).get("internal_error") or ""))) + + def _videos_have_started(job: QuickCreateJob) -> bool: if (job.metadata or {}).get("video_started"): return True @@ -131,9 +161,12 @@ def _videos_have_started(job: QuickCreateJob) -> bool: def _should_mark_project_failed(job: QuickCreateJob) -> bool: """编排在「还没申请生成视频」时超时,不能把整个项目打成失败。""" - if job.phase != QuickCreateJob.Phase.PRODUCTION: - return True - return _videos_have_started(job) + if job.phase == QuickCreateJob.Phase.PRODUCTION: + return _videos_have_started(job) + # 脚本已经落库时,资产/脚本阶段的网络抖动不要把整个项目打成失败。 + if job.phase in {QuickCreateJob.Phase.SCRIPT, QuickCreateJob.Phase.ASSETS} and _adopted_script(job.project) is not None: + return False + return True def restore_false_failed_quick_creates(team) -> None: @@ -241,9 +274,9 @@ def _consume_script_agent(job: QuickCreateJob) -> None: user = job.created_by or project.created_by if user is None: raise ValueError("极速成片任务缺少创建人") - model_config = get_default_model(ModelConfig.Capability.TEXT) + model_config = get_quick_script_model() if model_config is None: - raise ValueError("no active text model configured") + raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured") error_detail = "" stream = stream_script_agent( @@ -452,11 +485,8 @@ def _start_base_assets(job: QuickCreateJob) -> None: with transaction.atomic(): job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id) metadata = dict(job.metadata or {}) - if metadata.get("base_asset_task_ids") or metadata.get("assets_started"): + if metadata.get("base_asset_task_ids"): return - metadata["assets_started"] = True - job.metadata = metadata - job.save(update_fields=["metadata", "updated_at"]) entities = _ensure_fallback_entities(job.project) specs = [ (BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title), @@ -477,6 +507,7 @@ def _start_base_assets(job: QuickCreateJob) -> None: task_ids.append(str(task.id)) metadata = dict(job.metadata or {}) metadata["base_asset_task_ids"] = task_ids + metadata["assets_started"] = True _save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52) stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS) stage.status = ProjectStage.Status.RUNNING @@ -622,14 +653,33 @@ def _reviews_ready(job: QuickCreateJob) -> bool | None: def _start_videos(job: QuickCreateJob) -> None: from apps.projects.tasks import poll_video_segment_task + adopted = _adopted_script(job.project) + if adopted is not None: + sync_video_segments_to_script(job.project, adopted) + settings = _quick_settings(job.project) - segments = list(job.project.video_segments.order_by("sort_order")) + claimed_ids: list = [] + with transaction.atomic(): + segments = list( + VideoSegment.objects.select_for_update() + .filter(project=job.project) + .order_by("sort_order") + ) + for segment in segments: + if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}: + continue + if video_segment_has_inflight_task(segment): + continue + if segment.status == VideoSegment.Status.QUEUED and timezone.now() - segment.updated_at < timedelta(minutes=2): + continue + segment.status = VideoSegment.Status.QUEUED + segment.save(update_fields=["status", "updated_at"]) + claimed_ids.append(segment.id) + 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 + for segment_id in claimed_ids: + segment = VideoSegment.objects.get(id=segment_id) try: submit_video_segment( video_segment=segment, @@ -644,13 +694,22 @@ def _start_videos(job: QuickCreateJob) -> None: 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 segment.status == VideoSegment.Status.QUEUED and not video_segment_has_inflight_task(segment): + segment.status = VideoSegment.Status.NOT_STARTED + segment.save(update_fields=["status", "updated_at"]) if not _is_retryable_exc(exc): raise break metadata = dict(job.metadata or {}) if last_error is not None: metadata["internal_error"] = str(last_error)[:2000] - if submitted >= len(segments) and segments: + segments = list(job.project.video_segments.order_by("sort_order")) + started = all( + segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED} + or video_segment_has_inflight_task(segment) + for segment in segments + ) + if started and segments: metadata["video_started"] = True _save_job( job, @@ -757,6 +816,7 @@ def _advance_production(job: QuickCreateJob) -> int | None: if not review_state: return POLL_DELAY_SECONDS + job.refresh_from_db(fields=["metadata"]) if not (job.metadata or {}).get("video_started"): _start_videos(job) return POLL_DELAY_SECONDS @@ -897,6 +957,17 @@ def recover_quick_create(job: QuickCreateJob) -> None: return if job.status == QuickCreateJob.Status.FAILED: _restore_project_after_orchestrator_timeout(job) + job.refresh_from_db() + retries = int((job.metadata or {}).get("transient_retries") or 0) + if ( + job.status == QuickCreateJob.Status.FAILED + and job.phase in {QuickCreateJob.Phase.SCRIPT, QuickCreateJob.Phase.ASSETS} + and _adopted_script(job.project) is not None + and _transient_internal(job) + and retries < TRANSIENT_RETRY_LIMIT + ): + _save_job(job, status=QuickCreateJob.Status.RUNNING, error_message="", message="网络波动,正在继续生成…") + _enqueue_advance(job) return if _is_finished(job): return diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 984a453..48975e7 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -45,15 +45,15 @@ class QuickCreateApiTests(TestCase): category=Asset.Category.PRODUCT_IMAGE, ) - @patch("apps.projects.views.get_default_model", return_value=object()) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) + @patch("apps.projects.views.get_default_model") @patch("apps.projects.views.advance_quick_create_task.apply_async") @patch("apps.projects.views.require_worker_task") @patch("apps.projects.views._store_uploaded_asset") - def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model): + def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model): store_asset.side_effect = self._uploaded_asset video_model_id = uuid.uuid4() get_model.side_effect = [ - object(), object(), SimpleNamespace( id=video_model_id, @@ -95,18 +95,20 @@ class QuickCreateApiTests(TestCase): self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)]) self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick") require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task") - self.assertEqual(get_model.call_count, 3) + self.assertEqual(get_model.call_count, 2) + get_quick_model.assert_called_once() self.assertEqual(len(response.data["product_images"]), 2) self.assertEqual( {item["asset_id"] for item in response.data["product_images"]}, {str(image.asset_id) for image in product.images.order_by("sort_order")}, ) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) @patch("apps.projects.views.get_default_model", return_value=object()) @patch("apps.projects.views.advance_quick_create_task.apply_async") @patch("apps.projects.views.require_worker_task") @patch("apps.projects.views._store_uploaded_asset") - def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model): + def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model): source_asset = Asset.objects.create( team=self.team, created_by=self.user, @@ -117,7 +119,7 @@ class QuickCreateApiTests(TestCase): ) source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset) ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True) - get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})] + get_model.side_effect = [object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})] response = self.client.post( "/api/projects/quick-create/", @@ -138,11 +140,14 @@ class QuickCreateApiTests(TestCase): self.assertEqual(product.images.first().asset_id, source_asset.id) self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id)) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) + @patch("apps.projects.views.get_default_model", return_value=object()) @patch("apps.projects.views.require_worker_task") - def test_submit_rejects_foreign_source_product_images(self, require_worker_task): + def test_submit_rejects_foreign_source_product_images(self, require_worker_task, get_model, get_quick_model): other = User.objects.create_user(username="quick-image-other", password="pass") other_team = Team.objects.create(name="Image Other Team", owner=other) foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图") + get_model.side_effect = [object(), SimpleNamespace(metadata={})] response = self.client.post( "/api/projects/quick-create/", { @@ -193,9 +198,10 @@ class QuickCreateApiTests(TestCase): self.assertEqual(response.status_code, 400) self.assertFalse(Product.objects.filter(title="错误文件").exists()) + @patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object()) @patch("apps.projects.views.get_default_model", return_value=object()) @patch("apps.projects.views.require_worker_task") - def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model): + def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model, get_quick_model): provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active") model = ModelConfig.objects.create( provider=provider, @@ -520,6 +526,51 @@ class QuickCreateCoordinatorTests(TestCase): self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED) self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE) + @patch("apps.projects.services.quick_create.generate_base_asset", side_effect=TimeoutError("Timeout reading from socket")) + def test_asset_start_timeout_retries_without_locking_the_job(self, _generate): + script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场") + self.job.status = QuickCreateJob.Status.RUNNING + self.job.phase = QuickCreateJob.Phase.ASSETS + 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.assertFalse(self.job.metadata.get("assets_started")) + self.assertFalse(self.job.metadata.get("base_asset_task_ids")) + self.assertNotEqual(self.project.status, Project.Status.FAILED) + + @patch("apps.projects.tasks.advance_quick_create_task.apply_async") + def test_recover_resumes_asset_timeout_after_script_exists(self, enqueue): + script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场") + self.job.status = QuickCreateJob.Status.FAILED + self.job.phase = QuickCreateJob.Phase.ASSETS + self.job.error_message = "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续" + self.job.metadata = {"internal_error": "Timeout reading from socket", "transient_retries": 1} + self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"]) + + recover_quick_create(self.job) + self.job.refresh_from_db() + self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING) + enqueue.assert_called_once() + + def test_timeout_after_script_does_not_fail_project(self): + script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场") + self.job.status = QuickCreateJob.Status.RUNNING + self.job.phase = QuickCreateJob.Phase.ASSETS + self.job.save(update_fields=["status", "phase", "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.assertNotEqual(self.project.status, Project.Status.FAILED) + @patch("apps.projects.services.quick_create._reviews_ready", return_value=True) def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews): self.project.video_segments.exclude(sort_order=0).delete() @@ -623,3 +674,16 @@ class QuickCreateCoordinatorTests(TestCase): self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111") self.job.refresh_from_db() self.assertIn("30秒 16:9", self.job.message) + + @patch("apps.projects.tasks.poll_video_segment_task.apply_async") + @patch("apps.projects.services.quick_create.submit_video_segment") + def test_start_videos_does_not_resubmit_when_called_twice(self, submit_video, _schedule_poll): + self.project.video_segments.exclude(sort_order__lt=2).delete() + self.assertEqual(self.project.video_segments.count(), 2) + + _start_videos(self.job) + _start_videos(self.job) + + self.assertEqual(submit_video.call_count, 2) + statuses = list(self.project.video_segments.order_by("sort_order").values_list("status", flat=True)) + self.assertEqual(statuses, [VideoSegment.Status.QUEUED, VideoSegment.Status.QUEUED]) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 440611d..9a41898 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -499,7 +499,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): if total_duration not in {15, 30, 45, 60}: return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST) - text_model = get_default_model(ModelConfig.Capability.TEXT) + # 极速成片和专业创作使用同一款豆包脚本模型;绝不因默认模型变化而退回 DeepSeek。 + from .services.quick_create import get_quick_script_model + + text_model = get_quick_script_model() image_model = get_default_model(ModelConfig.Capability.IMAGE) requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip() video_model = None @@ -523,9 +526,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): missing = [ label - for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频")) + for model, label in ((image_model, "图像"), (video_model, "视频")) if model is None ] + if text_model is None: + return Response( + {"detail": "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) if missing: return Response( {"detail": f"当前缺少可用的{'、'.join(missing)}模型,请联系管理员配置"}, @@ -988,7 +996,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): """本项目在途的基础资产出图任务(立绘/商品图/三视图)。前端进基础资产趴时据此重建 「生成中」占位卡 loading —— 出图在 worker 跑,刷新页面后内存态 genBusy 丢了,用它从后端认领恢复。""" project = self.get_object() - inflight = [AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING] + inflight = [ + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, + AITask.Status.POSTPROCESSING, + ] image_types = [AITask.Type.PRODUCT_IMAGE, AITask.Type.PERSON_IMAGE, AITask.Type.SCENE_IMAGE] tasks = AITask.objects.filter(project=project, task_type__in=image_types, status__in=inflight) pending = [] @@ -999,6 +1013,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): "kind": payload.get("kind") or "", "label": payload.get("label") or "", "is_triview": bool(payload.get("triview_of")), + "triview_of": payload.get("triview_of") or "", "status": task.status, }) return Response({"pending": pending}) diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index cc05107..a2c9fc7 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -97,6 +97,8 @@ description: > - `visual_prompt` 由你自动生成,小白无需打字。 - **商品图是真实外观的最高依据**:若任务消息附有商品视觉参考图,颜色、外形、材质、结构、配件和可见品牌标识必须以图为准; 旁白、`type:"product"` 的 `visual_prompt` 与每镜 `visual` 不得凭商品名臆测或改色(例如不能把黑色耳机写成白色)。 +- **真实感不靠“唯一缺点”**:禁止输出「唯一缺点/唯一不足/唯一的小遗憾」等模板句,也不得为显得客观而编造商品缺点。 + 用测试过程、适用边界、使用场景反差、可观察细节或使用习惯建议制造自然转折;每版优先换一个角度,避免重复套路。 - **角色基础资产隔离**:`type:"character"` 的 `visual_prompt` 只能描述人物本身(年龄段、发型、穿着、气质、姿态), **禁止**出现商品名、产品、包装、品牌、Logo、价签、桌子、电脑、杯子、手持物或具体生活场景。 角色图只用于锁脸锁人;商品只能在 `type:"product"`、`product_exposure` 与秒级 `visual` 中出现,避免角色图擅自重绘商品。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md index 52d0653..0dd9042 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md @@ -42,7 +42,8 @@ 带货转化率最高的组合,尤其适合中高客单价。 - 人的**身份权威性**要在前 3 秒立住(从业年限 / 专业背景 / 测过多少款) - 读数特写时人可以出画外,但声音要连着不断 -- 必须说一个缺点,且要从「专业判断」的角度说,不是随口一句 +- 可信度不靠硬塞缺点:从测试过程、适用边界、场景反差、可观察细节或使用习惯中任选一个做自然转折。 + 商品资料没有明确不足时,绝不编造;禁止使用「唯一缺点/唯一不足/唯一的小遗憾」模板句。 ### 口播 × 场景种草 最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md index 99e28c3..d8da323 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-review.md @@ -53,8 +53,9 @@ ## 四、诚实分寸 -- **必须说一个缺点。** 缺点要真实但不致命(价格偏高、颜色少、需要适应期) -- 说清**适合谁、不适合谁** —— 这既是诚实,也是精准筛选目标客户 +- 可信度来自可复现的测试过程、明确的适用边界或可观察细节,**不要求硬编一个缺点**。 + 商品资料确实提供了限制条件时才可以如实说明;否则改为说清**适合谁、什么场景更合适、怎样用更顺手**。 + 禁止使用「唯一缺点/唯一不足/唯一的小遗憾」这类模板句。 - 不许伪造读数、不许剪掉不利结果 - 不许用绝对化用语和医疗功效词(这条结构最容易踩,因为在「讲效果」) @@ -91,7 +92,7 @@ - ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用 - ❌ **只有结论没有过程** —— 那不是测评,是广告 -- ❌ **全是优点没有缺点** —— 观众判定为软广 +- ❌ **用“唯一缺点”硬凑真实感** —— 这是模板广告;用测试过程、边界或细节建立可信度 - ❌ **证据全是主观感受** —— 至少要有一个客观读数 - ❌ **伪造或含糊读数** —— 涉嫌虚假宣传 - ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 017e43b..a1842de 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -356,7 +356,7 @@ export const api = { }, // 本项目在途的基础资产出图任务(刷新后据此重建「生成中」占位卡 loading) pendingAssets(id: string) { - return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; status: string }> }>( + return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; triview_of?: string; status: string }> }>( `/api/projects/${id}/pending-assets/` ); }, diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 0374640..2ecfc81 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -920,7 +920,22 @@ background-position: center; background-repeat: no-repeat; } + .as-gen[data-asset-kind="person"] .as-gen-preview.ready { + background-image: none; + background-position: center top; + } + .as-gen-photo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + pointer-events: none; + display: block; + } .as-gen-preview.generating { display: grid; place-items: center; } + .as-gen-preview.ready.generating { display: block; } .as-gen-meta { display: flex; align-items: center; @@ -1601,6 +1616,7 @@ /* ── 流程步骤4 · 基础资产卡生成中 loading + 转圈 ── */ .asset-card-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--background-base); z-index: 2; } + .asset-card-loading.veil { background: color-mix(in srgb, var(--surface) 62%, transparent); } .asset-card-loading .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); letter-spacing: .04em; } .asset-spinner { width: 26px; height: 26px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; } .asset-spinner.sm { width: 13px; height: 13px; border-width: 2px; display: inline-block; vertical-align: -2px; } @@ -1729,6 +1745,9 @@ box-sizing: border-box; border-radius: var(--r-md); } +.asset-detail-lead .placeholder.ad-lead-img.has-mock-media { + background-position: center top; +} @supports not (aspect-ratio: 9 / 16) { .asset-detail-lead .placeholder.ad-lead-img { height: min(420px, 48vh); } } diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css index 4d0ee26..37c68f9 100644 --- a/core/frontend/src/quick-create-page.css +++ b/core/frontend/src/quick-create-page.css @@ -67,7 +67,9 @@ .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-progress-node.active { color: var(--quick-blue); } +.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 6px rgba(0,47,167,.11); animation: quick-progress-pulse 1.45s ease-out infinite; } +.quick-create-page .quick-progress-node.active .quick-progress-dot::before { content: ""; position: absolute; inset: -7px; border-radius: 50%; background: conic-gradient(transparent 0 46%,rgba(0,47,167,.18) 56%,var(--quick-blue) 100%); -webkit-mask: radial-gradient(circle,transparent 0 68%,#000 71%); mask: radial-gradient(circle,transparent 0 68%,#000 71%); animation: quick-progress-orbit 1.2s linear infinite; } .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; } @@ -91,6 +93,8 @@ .quick-create-page .quick-download-action { border-radius: 8px; } .quick-create-page .quick-state-failed { justify-items: center; gap: 14px; text-align: center; }.quick-create-page .quick-state-failed h2,.quick-create-page .quick-state-failed p { margin: 0; }.quick-create-page .quick-state-failed p { max-width: 430px; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-failed-icon { width: 82px; height: 82px; display: grid; place-items: center; border-radius: 50%; color: #c83d4d; background: rgba(200,61,77,.08); }.quick-create-page .quick-failed-icon svg { width: 36px; height: 36px; }.quick-create-page .quick-failed-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 8px; }.quick-create-page .quick-failed-actions button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-failed-actions svg { width: 17px; height: 17px; } @keyframes quick-spinner-rotate { to { transform: rotate(360deg); } } +@keyframes quick-progress-pulse { 50% { box-shadow: 0 0 0 11px rgba(0,47,167,.04); } } +@keyframes quick-progress-orbit { to { transform: rotate(360deg); } } .quick-create-page .quick-history { margin-top: 28px; } .quick-create-page .quick-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; } .quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; } @@ -98,9 +102,12 @@ .quick-create-page .quick-history-list { display: grid; gap: 10px; } .quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); } .quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; } +.quick-create-page .quick-history-thumb:disabled { cursor: default; } .quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; } .quick-create-page .quick-history-thumb-empty { width: 100%; height: 100%; display: grid; place-items: center; color: var(--quick-blue); } .quick-create-page .quick-history-thumb-empty svg { width: 18px; height: 18px; } +.quick-create-page .quick-history-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); pointer-events: none; } +.quick-create-page .quick-history-play svg { width: 22px; height: 22px; } .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; } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index dba8336..685f248 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -821,26 +821,46 @@ export function PipelinePage(props: { // 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物) const [genBusy, setGenBusy] = useState>(new Set()); // 刷新后从后端「认领」在途出图任务:出图在 worker 跑、内存态 genBusy 丢了,据此重建占位卡 loading - const [pendingGen, setPendingGen] = useState>([]); + const [pendingGen, setPendingGen] = useState>([]); const pendingHas = (kind: string, label: string) => pendingGen.some((p) => p.kind === kind && !p.is_triview && (p.label || "") === (label || "")); + const pendingTriOf = (assetId?: string | null) => + Boolean(assetId && pendingGen.some((p) => p.is_triview && p.triview_of === assetId)); + const pendingTriFor = (kind: "person" | "scene", entity: AssetEntity) => + pendingTriOf(entity.group.adopted_asset) + || pendingGen.some((p) => p.is_triview && p.kind === kind && (p.label || "") === (entity.label || entity.name || "")); + // 立绘出图后 seed 卡会换成 ent 卡,busyKey 必须两边都记,否则三视图还在跑、卡片已经换身份,转圈会丢。 + const entityGenKeys = (kind: "person" | "scene", ...names: Array) => { + const keys: string[] = []; + const seen = new Set(); + for (const name of names) { + const tag = (name || "").trim(); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + keys.push(`ent:${kind}:${tag}`, `seed:${kind}:${tag}`); + } + return keys; + }; // 在途出图集合的稳定指纹:集合变化(开工/出片完成)才变 → 给审核轮询当「重启信号」, // 让真人出片后自动送审的盾能被盯到变绿(否则审核轮询可能在送审前就停了)。仅集合变才变,不会每轮抖动。 - const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}`).join("|"), [pendingGen]); + const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}:${p.triview_of || ""}`).join("|"), [pendingGen]); const isBusy = (k: string) => genBusy.has(k); const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; }); const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; }); type GenResult = { id?: string; adopted_asset?: string | null } | null; 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); + const aliasKeys = kind === "product" + ? [busyKey] + : [...new Set([busyKey, ...entityGenKeys(kind, label)])]; + if (aliasKeys.some((k) => genBusy.has(k))) return null; // 同一按钮防连点(不同按钮可并发) + aliasKeys.forEach(addBusy); + const triKeys = kind === "person" ? aliasKeys.map((k) => `${k}:tri`) : []; + triKeys.forEach(addBusy); try { return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult; } finally { - delBusy(busyKey); - if (triKey) delBusy(triKey); + aliasKeys.forEach(delBusy); + triKeys.forEach(delBusy); } } // 据某一版立绘资产生成它配套的三视图(loading 用 busyKey) @@ -2831,7 +2851,7 @@ export function PipelinePage(props: { }).catch(() => undefined); } prevPendingIdsRef.current = list.map((pending) => pending.id); - setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview }))); + setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of }))); // 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。 if (list.length === 0 && genBusy.size === 0) { stopped = true; return; } } catch { @@ -3615,9 +3635,13 @@ export function PipelinePage(props: { const mainUrl = groupMainUrl(grp); const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? ""; const entBK = `ent:${kind}:${entity.key}`; - const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name)); + const genKeys = entityGenKeys(kind, entity.key, entity.name, entity.label); + const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name)); + const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity)); + const busy = portraitBusy || triBusy; + const loadingText = kind === "person" && mainUrl ? "三视图生成中…" : "生成中…"; const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : ""; - const previewState = busy ? " generating" : mainUrl ? " ready" : " pending"; + const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`; return (
{grp.id && (delArmed === grp.id ? ( @@ -3632,14 +3656,15 @@ export function PipelinePage(props: { ))}
openAssetDetail(kind, entity)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }} > - {busy ? 生成中… : null} + {kind === "person" && mainUrl ? : null} + {busy ? {loadingText} : null}

openAssetDetail(kind, entity)}>{entity.name}

@@ -3666,7 +3691,7 @@ export function PipelinePage(props: { {kind === "scene" ? "场景库替换" : "模特库替换"}
@@ -4602,9 +4627,10 @@ export function PipelinePage(props: { return false; })(); const pBK = `addet-portrait:${entity.key}`; + const genKeysPortrait = entityGenKeys(adDetail.kind, entity.key, entity.name, entity.label); // 没立绘时(尤其 seed 首次生成),立绘在 worker 跑 ~30s,本地 isBusy 早已落回 → 用 pendingHas 兜底转圈 - const busyPortrait = isBusy(pBK) || (!portraitUrl && pendingHas(adDetail.kind, entity.name)); - const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`); + const busyPortrait = isBusy(pBK) || genKeysPortrait.some((k) => isBusy(k)) || (!portraitUrl && pendingHas(adDetail.kind, entity.name)); + const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`) || genKeysPortrait.some((k) => isBusy(`${k}:tri`)) || pendingTriOf(viewPortraitAsset) || pendingTriFor(adDetail.kind, entity); async function regenPortrait() { const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`; // 重跑立绘 → 追加新立绘并自动接力该版三视图。 @@ -4674,15 +4700,16 @@ export function PipelinePage(props: { {!triUrl && (busyTri ?
三视图生成中…
: 正 / 侧 / 背 · 三视图)} + {triUrl && busyTri &&