import json import logging import math import re import subprocess import tempfile import time import uuid from datetime import timedelta from decimal import Decimal from io import BytesIO from pathlib import Path import requests from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.utils import timezone from apps.ai.models import AITask, ModelConfig from apps.ai.generation_errors import ( TASK_OPERATIONS, ProviderOutcomeUnknownError, classify_generation_error, public_error_for_task, ) from apps.ai.model_routing import ModelRequirements, capability_metadata, model_allows_fallback from apps.ai.providers import ( OpenAICompatibleProvider, TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, ) from apps.ai.routing_executor import AttemptMetadata, execute_model_call from apps.assets.models import Asset, AssetFile from apps.assets.storage import TosStorage from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit from apps.projects.models import ( BaseAssetGroup, ExportJob, ProjectStage, ScriptSegment, ScriptVersion, Timeline, VideoSegment, VideoSegmentVersion, ) logger = logging.getLogger(__name__) # 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律 # 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。 # 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。 OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark", "doubao"} SEED_21_PRO_NAME = "doubao-seed-2-1-pro-260628" def is_retired_text_model(model) -> bool: """DeepSeek / DP V4 Pro 已停用,解析层一律跳过。""" blob = f"{getattr(model, 'name', '')} {getattr(model, 'display_name', '')}".lower() return "deepseek" in blob or "dp v4" in blob or "dp-v4" in blob def get_seed_text_model() -> ModelConfig | None: qs = ( ModelConfig.objects.select_related("provider") .filter( capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE, provider__status="active", ) ) return ( qs.filter(name=SEED_21_PRO_NAME).first() or qs.filter(name__startswith="doubao-seed-2-1-pro").first() or qs.filter(name__startswith="doubao-seed-2-0-pro").first() ) def resolve_text_model(requested: ModelConfig | None = None, requested_id=None) -> ModelConfig | None: """文本模型入口:用户选了 DeepSeek 也改走 Seed 2.1 Pro。""" model = requested if model is None and requested_id: model = ( ModelConfig.objects.select_related("provider") .filter(id=requested_id, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE) .first() ) if model is not None and not is_retired_text_model(model): return model return get_default_model(ModelConfig.Capability.TEXT) def get_default_model(capability: str) -> ModelConfig: qs = ( ModelConfig.objects.select_related("provider") .filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active") ) if capability == ModelConfig.Capability.TEXT: for model in qs.filter(is_default=True).order_by("created_at"): if not is_retired_text_model(model): return model seed = get_seed_text_model() if seed is not None: return seed for model in qs.order_by("created_at"): if not is_retired_text_model(model): return model return None # 优先平台超管钦定的默认模型;未钦定则回落「最早创建的 active」(原行为,零回归) return qs.filter(is_default=True).order_by("created_at").first() or qs.order_by("created_at").first() def resolve_image_model(key: str | None) -> "ModelConfig | None": """前端「生图模型选择」→ ModelConfig。用户显式选的可以是 disabled 模型(故不按 status 过滤)。 · "volcano" → 火山官方 Seedream(取最新一版) · "gpt-image"→ gpt-image-2(优先 active provider 的那个) · "provider:name" 或裸 name → 精确匹配 解析不到返回 None,调用方回落 get_default_model。""" if not key: return None qs = ModelConfig.objects.select_related("provider").filter(capability=ModelConfig.Capability.IMAGE) if key == "volcano": # 火山 Seedream:优先版本号最高的(seedream-5 > seedream-4),按 name 倒序 vqs = qs.filter(provider__name__in=OFFICIAL_DIRECT_PROVIDERS) return vqs.filter(name__icontains="seedream").order_by("-name").first() or vqs.order_by("-name").first() if key in ("gpt-image", "gpt-image-2"): return (qs.filter(name__icontains="gpt-image", provider__status="active").first() or qs.filter(name__icontains="gpt-image").first()) if ":" in key: pname, mname = key.split(":", 1) return qs.filter(provider__name=pname, name=mname).first() return qs.filter(name=key).first() def public_model_name(model_config: ModelConfig) -> str: """普通用户公开名称保持稳定;Fallback 的真实模型只在管理员尝试链中展示。""" if model_config.provider.name in OFFICIAL_DIRECT_PROVIDERS: return model_config.display_name if model_config.capability == ModelConfig.Capability.TEXT: return "AirShelf Script" if model_config.capability == ModelConfig.Capability.IMAGE: return "AirShelf Image" return model_config.display_name def resolve_provider_credentials(provider) -> tuple[str | None, str | None]: """解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。 两者都不写死;换站只改 DB 这一行,或改 .env 对应项。""" name = provider.name base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(name) api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(name) # 官转等 yunqi_gemini_* 变体共用同一把 YunQi Gemini key,避免新 provider 没写进 .env 就落到火山。 if not api_key and name.startswith("yunqi_gemini"): api_key = (settings.PROVIDER_KEYS.get("yunqi_gemini") or "").strip() if not base_url and name.startswith("yunqi_gemini"): base_url = (settings.PROVIDER_BASE_URLS.get("yunqi_gemini") or "").strip() return (base_url or None), (api_key or None) def build_provider(model_config: ModelConfig): """按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。 两条路都走 resolve_provider_credentials,统一 DB→.env 优先级(官方直连的 None 再由 __post_init__ 回退 settings.VOLCANO)。""" provider = model_config.provider base_url, api_key = resolve_provider_credentials(provider) if provider.name in OFFICIAL_DIRECT_PROVIDERS: # 临时:视频(Seedance)借 AirDrama 账号的 ARK key,使真人素材库 asset:// 引用与 Seedance 同账号、可解析。 # 仅覆盖 VIDEO,图像/文本仍用自有 key。VIDEO_ARK_API_KEY 留空则不覆盖(回落默认)。待自有账号开通素材库后清掉。 video_key = (getattr(settings, "VIDEO_ARK_API_KEY", "") or "").strip() if video_key and model_config.capability == ModelConfig.Capability.VIDEO: api_key = video_key return VolcanoArkProvider(base_url=base_url, api_key=api_key) # api_version:某些中转站(yunqi)的 images/edits 需 Azure 风格 ?api-version=...。 # DB(ModelProvider.metadata.api_version)优先 → settings(.env)回退,与凭证同款可插拔。 api_version = (provider.metadata or {}).get("api_version") or settings.PROVIDER_API_VERSIONS.get(provider.name) return OpenAICompatibleProvider(base_url=base_url, api_key=api_key, api_version=api_version or None) def get_image_provider(model_config: ModelConfig): return build_provider(model_config) def execute_routed_image_request( *, task: AITask, primary_model: ModelConfig, prompt: str, reference_images: list[str], aspect_ratio: str | None = None, edit_size: str | None = None, direct_size: str | None = None, generate_size: str | None = None, request_summary: dict | None = None, ): """图片入口共用的模型调用薄层:只处理能力声明、Provider 调用和尝试成本审计。 业务入口仍负责提示词、参考图顺序、尺寸、任务/资产/账务终态;这里不创建任务、不结算积分, 因而图片创作、上身图、套图和项目基础资产可以复用而不互相耦合。 """ from apps.billing.pricing import quote_flat references = list(reference_images or []) reference_count = len(references) reference_mode = "none" if reference_count == 1: reference_mode = "single" elif reference_count > 1: reference_mode = "multiple" operation = "image_edit" if reference_count else "image_generate" requirements = ModelRequirements( capability=ModelConfig.Capability.IMAGE, operation=operation, reference_mode=reference_mode, reference_images=reference_count, aspect_ratio=aspect_ratio or None, ) def invoke_image(actual_model: ModelConfig, timeout: float): actual_provider = get_image_provider(actual_model) if references and hasattr(actual_provider, "image_edit"): kwargs = { "model": actual_model.name, "prompt": prompt, "images": references, "timeout": timeout, } if edit_size: kwargs["size"] = edit_size actual_response = actual_provider.image_edit(**kwargs) elif references: kwargs = { "model": actual_model.name, "endpoint": actual_model.endpoint, "prompt": prompt, "image": references, "timeout": timeout, } if direct_size or edit_size: kwargs["size"] = direct_size or edit_size actual_response = actual_provider.image_generation(**kwargs) else: kwargs = { "model": actual_model.name, "endpoint": actual_model.endpoint, "prompt": prompt, "timeout": timeout, } if generate_size: kwargs["size"] = generate_size actual_response = actual_provider.image_generation(**kwargs) try: actual_media = actual_provider.extract_first_media_url(actual_response) except Exception as exc: # Provider 已返回并可能产生上游费用;响应解析失败仍需审计本次真实尝试成本。 candidate_quote = quote_flat(actual_model, team=task.team) exc.outcome_unknown = True exc.attempt_metadata = AttemptMetadata( usage=actual_response.get("usage") if isinstance(actual_response, dict) else {}, platform_cost=candidate_quote.base_cost_yuan, response_summary={"media_missing": True}, ) raise return actual_response, actual_media def image_result_metadata(result, actual_model: ModelConfig): actual_response, _ = result candidate_quote = quote_flat(actual_model, team=task.team) return AttemptMetadata( usage=actual_response.get("usage") if isinstance(actual_response, dict) else {}, platform_cost=candidate_quote.base_cost_yuan, response_summary={"media_found": True}, ) summary = { "operation": operation, "prompt_length": len(prompt), "aspect_ratio": aspect_ratio or None, "reference_images": reference_count, } summary.update(request_summary or {}) return execute_model_call( task=task, primary_model=primary_model, requirements=requirements, public_model_name=public_model_name(primary_model), invoke=invoke_image, request_summary=summary, result_metadata=image_result_metadata, ) def get_text_provider(model_config: ModelConfig): return build_provider(model_config) def get_video_provider(model_config: ModelConfig): return build_provider(model_config) def get_audio_provider(model_config: ModelConfig): """豆包/火山现有 TTS 走专用直连;其余启用模型统一走 OpenAI 兼容 ``audio/speech``。""" if model_config.provider.name in OFFICIAL_DIRECT_PROVIDERS: return VolcanoTtsProvider() return build_provider(model_config) # estimate_cost() 已退役:全平台定价统一走 apps/billing/pricing.py 计价引擎(积分制)。 # flat 类型默认价由 create_ai_task 内 quote_flat 提供;视频/配音各入口自带 quote。 def parse_segment_fields(block: str) -> tuple[str, str]: """从一镜文本里拆出(旁白, 画面)。 模型按 build_script_prompt 的格式输出「旁白:…/画面:…」标签行时精确拆分; 自带脚本/旧格式没有标签则两个字段都用整段(保持旧行为),字幕/故事板各自兜底。 """ narration_lines: list[str] = [] visual_lines: list[str] = [] current: list[str] | None = None for raw in (block or "").splitlines(): line = raw.strip() if not line: continue matched = re.match(r"^(旁白|口播|台词|文案)\s*[::]\s*(.*)$", line) if matched: current = narration_lines if matched.group(2): current.append(matched.group(2)) continue matched = re.match(r"^(画面|镜头描述|视觉|画面描述)\s*[::]\s*(.*)$", line) if matched: current = visual_lines if matched.group(2): current.append(matched.group(2)) continue if re.match(r"^(镜头|分镜|场)\s*\d+", line): continue # 「镜头N」标题行不计入任何字段 if current is not None: current.append(line) narration = " ".join(narration_lines).strip() visual = " ".join(visual_lines).strip() if not narration and not visual: return block.strip(), block.strip() return narration or visual, visual or narration def build_cast_scene_extract_prompt(content: str) -> list[dict[str, str]]: """轻量抽取提示词:从镜头脚本里提炼人物 / 场景标签,并给每个标签一句可直接生图的画面提示词。""" system = ( "你是短视频脚本分析助手。请从给定的镜头脚本中提取出现的『人物』和『场景』," "并为每个人物 / 场景写一句可直接用于文生图的画面提示词(中文,30 字内,描述外形 / 着装 / 环境 / 光线)。" "人物提示词用 9:16 竖屏(出镜角色全身);场景提示词用 16:9 横屏(环境 / 背景空镜)。" "人物指出镜的角色(例:女主、同事、闺蜜);场景指画面发生的地点或环境(例:卫生间、地铁、办公室)。" "去重,人物与场景各最多 6 个。只输出一个 JSON 对象,不要 markdown 代码块,不要任何额外文字,格式如下:\n" '{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆容,米色针织衫,柔和室内光,9:16竖屏"}],' '"scenes":[{"name":"卫生间","prompt":"现代简约浴室,暖色灯光,干净台面,16:9横屏"}]}' ) return [{"role": "system", "content": system}, {"role": "user", "content": f"镜头脚本如下:\n{content}".strip()}] def _coerce_tag_entries(items: object, limit: int = 6) -> tuple[list[str], dict[str, str]]: """把模型回的 [{"name","prompt"}] 列表整理成 (标签列表, {标签: 提示词}),去重保序、容错。""" names: list[str] = [] prompts: dict[str, str] = {} if not isinstance(items, list): return names, prompts for item in items: if isinstance(item, dict): name = str(item.get("name") or "").strip() prompt = str(item.get("prompt") or "").strip() else: name, prompt = str(item or "").strip(), "" if not name or name in ("无", "暂无", "未提及") or name in names: continue names.append(name) if prompt: prompts[name] = prompt if len(names) >= limit: break return names, prompts def _parse_cast_scene_response(text: str) -> dict: """解析旧版脚本后人物/场景提取结果,并把结构校验纳入单次路由尝试。""" match = re.search(r"\{.*\}", text or "", re.DOTALL) # 容忍 markdown / 前后解释文字 if not match: raise ValueError("人物与场景提取结果不是有效 JSON") data = json.loads(match.group(0)) if not isinstance(data, dict): raise ValueError("人物与场景提取结果必须是 JSON 对象") cast, cast_prompts = _coerce_tag_entries(data.get("cast")) scenes, scene_prompts = _coerce_tag_entries(data.get("scenes")) return {"cast": cast, "scenes": scenes, "cast_prompts": cast_prompts, "scene_prompts": scene_prompts} def extract_cast_and_scenes(*, project, user, content: str) -> dict: """轻量调一次文本模型,从脚本里抽取人物 / 场景标签及每个标签的建议生图提示词。 这是存量兼容入口;当前 Script Agent 已在同一次结构化出稿中携带 entities,不会额外调用 本函数。若旧调用方或未来流程重新启用它,仍走统一重试/Fallback、尝试日志和一次性计费 闭环。全程 best-effort:无可用模型、预扣失败、调用或解析失败都返回空,绝不阻断主流程。 返回 {cast, scenes, cast_prompts, scene_prompts}。 """ empty = {"cast": [], "scenes": [], "cast_prompts": {}, "scene_prompts": {}} model_config = get_default_model(ModelConfig.Capability.TEXT) if model_config is None or not (content or "").strip(): return empty messages = build_cast_scene_extract_prompt(content) try: task = create_ai_task( project=project, user=user, task_type=AITask.Type.SCRIPT_OPTIMIZATION, model_config=model_config, request_payload={ "model": model_config.name, "endpoint": model_config.endpoint, "messages": messages, "model_routing_v1": True, }, ) except Exception: return empty # 余额不足等预扣失败:跳过提取,不挡出稿 reservation = task.credit_reservation # 每条真实尝试的成本由统一执行器按实际模型累加;用户积分仍只按逻辑任务结算一次。 task.base_cost = Decimal("0") task.save(update_fields=["base_cost", "updated_at"]) try: task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) routed = execute_routed_text_request( task=task, primary_model=model_config, messages=messages, streaming=False, structured_output=True, business_operation="entity_extract", temperature=0.3, validate_text=_parse_cast_scene_response, request_summary={"source": "legacy_post_script_extract"}, ) _text, response, parsed = routed.value with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) return parsed except Exception as exc: with transaction.atomic(): task.status = AITask.Status.FAILED task.error_message = str(exc) task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) return empty def _skills_root() -> "Path": """skills 根目录。优先 BASE_DIR/skills(= core/backend/skills,随后端打进 Docker 镜像); 回落仓库根 BASE_DIR.parent.parent/skills(本地/旧布局)。 ⚠️ 历史坑:镜像由 `./core/backend` 构建,skills 旧在仓库根 → 不在构建上下文 → 镜像里没有 → `_load_skill_system_prompt` 返回空串 → 提取拿不到「只输出 JSON」铁律 → 模型吐散文 → 解析失败。 现 skills 已挪进 core/backend 随镜像打包;此处仍保留双路径兜底。""" from pathlib import Path from django.conf import settings base = Path(settings.BASE_DIR) for cand in (base / "skills", base.parent.parent / "skills"): if cand.is_dir(): return cand return base / "skills" def _load_skill_system_prompt(name: str) -> str: """读取 skills//SKILL.md + references/*.md 拼成系统提示词(领域知识)。缺文件返回空串,不致命。""" skill_dir = _skills_root() / name parts: list[str] = [] main = skill_dir / "SKILL.md" if main.exists(): parts.append(main.read_text(encoding="utf-8")) ref_dir = skill_dir / "references" if ref_dir.exists(): for ref in sorted(ref_dir.glob("*.md")): parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}") return "\n".join(parts) def _normalize_extracted_entities(items: object) -> list[dict]: """整理提取出的 entities:只留 character/scene(绝不收 product),保留模型给的 id(供 segment refs 对应), 按 id 去重,角色/场景各最多 6 个,补 ref_index。""" out: list[dict] = [] if not isinstance(items, list): return out seen_ids: set[str] = set() n_char = n_scene = 0 for it in items: if not isinstance(it, dict): continue typ = str(it.get("type") or "").strip() if typ not in ("character", "scene"): # 丢弃 product / 未知类型 continue name = str(it.get("name") or "").strip() eid = str(it.get("id") or "").strip() if not name or not eid or eid in seen_ids: continue if typ == "character": n_char += 1 if n_char > 6: continue else: n_scene += 1 if n_scene > 6: continue seen_ids.add(eid) out.append( { "id": eid, "type": typ, "name": name, "visual_prompt": str(it.get("visual_prompt") or "").strip(), "ref_index": len(out) + 1, } ) return out def _normalize_extracted_segment_refs(items: object, valid_ids: set[str]) -> list[dict]: """整理每镜 refs:只留指向存活实体(角色/场景)的 id,丢掉被剔除的(如商品/无效 id)。""" out: list[dict] = [] if not isinstance(items, list): return out for it in items: if not isinstance(it, dict): continue idx = it.get("index") if not isinstance(idx, int): continue raw = it.get("entity_refs") or it.get("refs") or [] refs = [r for r in raw if isinstance(r, str) and r in valid_ids] out.append({"index": idx, "entity_refs": refs}) return out def _parse_extracted_entities_response(text: str) -> tuple[list[dict], list[dict]]: """解析并校验实体提取结构;放进单次模型尝试内部,格式漂移可按统一策略重试/切换。""" match = re.search(r"\{.*\}", text or "", re.DOTALL) if not match: raise ValueError("提取结果解析失败(模型未返回有效 JSON),请重试") try: data = json.loads(match.group(0)) except json.JSONDecodeError as exc: raise ValueError("提取结果解析失败,请重试") from exc entities = _normalize_extracted_entities(data.get("entities")) if not entities: raise ValueError("没有从脚本里识别到角色 / 场景,可调整脚本后重试") seg_refs = _normalize_extracted_segment_refs(data.get("segments"), {e["id"] for e in entities}) return entities, seg_refs def _resolve_extract_model_config(): """提取实体用平台后台钦定的默认文本模型(ModelConfig.is_default)。 未设默认时与 get_default_model 一致:回落最早创建的 active 文本模型。""" return get_default_model(ModelConfig.Capability.TEXT) def _collect_extract_text( provider, model_config, messages, *, temperature: float = 0.3, timeout: float = 300, on_event=None, extra_body=None, ) -> tuple[str, dict]: """走与「脚本生成」同一条已在生产验证稳定的流式通道把模型输出收全。 豆包 seed-pro / GPT / Gemini 等思考模型,思考期只发 reasoning_content、正文期才发 content, 流式分支(chat_completion_stream)已分别转发为 `reasoning` / `delta` 事件。这里**只收正文 delta**; 仅当正文为空(整轮被思考占满的极端兜底)才回退已收到的 reasoning。如此从根上避免非流式只读 content 拿到空串 → 解析不到 JSON 的老问题。返回 (text, 存档用精简 payload)。 """ content_parts: list[str] = [] reasoning_parts: list[str] = [] for ev in provider.chat_completion_stream( model=model_config.name, endpoint=model_config.endpoint, messages=messages, temperature=temperature, # 结构化抽取要稳:低温降低 JSON 漂移 timeout=timeout, extra_body=extra_body, ): if on_event is not None: on_event(ev) etype = ev.get("type") if etype == "delta": content_parts.append(ev.get("text") or "") elif etype == "reasoning": reasoning_parts.append(ev.get("text") or "") content = "".join(content_parts).strip() reasoning = "".join(reasoning_parts).strip() text = content or reasoning payload = { "streamed": True, "model": model_config.name, "content": content, "reasoning_chars": len(reasoning), "reasoning_preview": reasoning[:500], # 失败时看一眼模型在想啥/有没有跑题 "system_chars": sum(len(m.get("content") or "") for m in messages if m.get("role") == "system"), } return text, payload def execute_routed_text_request( *, task: AITask, primary_model: ModelConfig, messages: list[dict], streaming: bool, structured_output: bool, business_operation: str, temperature: float = 0.3, validate_text=None, request_summary: dict | None = None, stream_event_callback=None, abort_check=None, allow_retry: bool = True, allow_fallback: bool = True, extra_body=None, ): """文本入口共用的模型调用薄层:能力声明、Provider 调用、输出校验和尝试成本审计。 ``validate_text`` 在一次真实调用内部执行;模型返回空文或无效结构时会进入同一重试/Fallback 策略,而不是先把 Provider 调用判成功、随后在业务层直接失败。任务终态、积分和业务落库仍由 调用方负责。 """ from apps.billing.pricing import quote_flat features = set() if streaming: features.add("streaming") if structured_output: features.add("structured_output") requirements = ModelRequirements( capability=ModelConfig.Capability.TEXT, operation="chat", features=frozenset(features), ) stream_call_number = 0 def invoke_text(actual_model: ModelConfig, timeout: float): nonlocal stream_call_number stream_call_number += 1 if abort_check is not None: abort_check() provider = get_text_provider(actual_model) if streaming: text, response = _collect_extract_text( provider, actual_model, messages, temperature=temperature, timeout=timeout, extra_body=extra_body, on_event=( (lambda event: stream_event_callback(event, stream_call_number)) if stream_event_callback is not None else None ), ) else: response = provider.chat_completion( model=actual_model.name, endpoint=actual_model.endpoint, messages=messages, timeout=timeout, ) text = provider.extract_text(response) if abort_check is not None: abort_check() try: validated = validate_text(text) if validate_text is not None else None except Exception as exc: # Provider 已完成生成并可能产生上游费用;结构校验失败也必须把该次真实成本记入尝试。 candidate_quote = quote_flat(actual_model, team=task.team) exc.attempt_metadata = AttemptMetadata( usage=response.get("usage") if isinstance(response, dict) else {}, platform_cost=candidate_quote.base_cost_yuan, response_summary={ "streamed": streaming, "structured_output": structured_output, "content_chars": len(text or ""), "validation_failed": True, }, ) raise return text, response, validated def text_result_metadata(result, actual_model: ModelConfig): text, response, _ = result candidate_quote = quote_flat(actual_model, team=task.team) return AttemptMetadata( usage=response.get("usage") if isinstance(response, dict) else {}, platform_cost=candidate_quote.base_cost_yuan, response_summary={ "streamed": streaming, "structured_output": structured_output, "content_chars": len(text or ""), }, ) summary = { "operation": "chat", "business_operation": business_operation, "message_count": len(messages), "input_chars": sum(len(str(message.get("content") or "")) for message in messages), "streaming": streaming, "structured_output": structured_output, } summary.update(request_summary or {}) return execute_model_call( task=task, primary_model=primary_model, requirements=requirements, public_model_name=public_model_name(primary_model), invoke=invoke_text, request_summary=summary, result_metadata=text_result_metadata, error_classifier=lambda exc, model: classify_generation_error( exc, operation=business_operation, provider_name=model.provider.name, internal_kind="processing_failed" if isinstance(exc, _RoutedTextStreamCancelled) else "", reference_id=str(task.id), ), allow_retry=allow_retry, allow_fallback=allow_fallback, ) class _RoutedTextStreamCancelled(RuntimeError): """HTTP 客户端已断开;终止后台流读取,不再继续重试或切换模型。""" def stream_routed_text_request(**kwargs): """把统一文本执行器转换为可实时转发 Provider 事件的生成器。 路由与尝试日志仍完全复用 ``execute_routed_text_request``。执行器运行在一个短生命周期 后台线程中,当前生成器从队列逐个转发首轮事件;若首轮失败后重试/Fallback,为避免普通 用户看到重复半截文本,后续轮次静默收全,只返回最终结构化结果。生成器返回值是 ``RoutingExecutionResult``,调用方可用 ``yield from`` 或捕获 ``StopIteration.value`` 获取。 """ from queue import SimpleQueue from threading import Event, Thread from django.db import close_old_connections queue = SimpleQueue() cancelled = Event() def abort_check(): if cancelled.is_set(): raise _RoutedTextStreamCancelled("stream aborted (client disconnected)") def forward_event(event, call_number): abort_check() if call_number == 1: queue.put(("event", event)) def worker(): close_old_connections() try: result = execute_routed_text_request( **kwargs, stream_event_callback=forward_event, abort_check=abort_check, ) except BaseException as exc: # noqa: BLE001 — 跨线程原样交回请求生成器处理 queue.put(("error", exc)) else: queue.put(("done", result)) finally: close_old_connections() thread = Thread(target=worker, name=f"ai-text-stream-{kwargs['task'].id}", daemon=True) thread.start() try: while True: kind, value = queue.get() if kind == "event": yield value elif kind == "error": raise value else: return value finally: cancelled.set() def execute_routed_audio_request( *, task: AITask, primary_model: ModelConfig, text: str, public_voice: str, speed_ratio: float, user_id: str, request_summary: dict | None = None, ): """配音单句调用薄层:动态音色映射、OpenAI 兼容候选、尝试日志和实际成本。""" from apps.billing.pricing import quote_voiceover char_count = len(text) requirements = ModelRequirements( capability=ModelConfig.Capability.AUDIO, operation="tts", language="zh-CN", public_voice=public_voice, char_count=char_count, speed_ratio=float(speed_ratio or 1.0), output_format="mp3", ) def invoke_audio(actual_model: ModelConfig, timeout: float): provider = get_audio_provider(actual_model) if hasattr(provider, "configured") and not provider.configured: raise TtsNotConfigured("语音合成供应商凭证未配置") voice_map = capability_metadata(actual_model).get("voice_map") actual_voice = ( voice_map.get(public_voice) if isinstance(voice_map, dict) and voice_map.get(public_voice) else public_voice ) kwargs = { "text": text, "voice_type": actual_voice, "speed_ratio": speed_ratio, "uid": user_id, "timeout": timeout, } if actual_model.provider.name not in OFFICIAL_DIRECT_PROVIDERS: kwargs.update( { "model": actual_model.name, "endpoint": actual_model.endpoint or "audio/speech", "output_format": "mp3", } ) audio, duration_ms = provider.synthesize(**kwargs) if not isinstance(audio, (bytes, bytearray)) or not audio: raise ValueError("语音合成未返回有效音频") return bytes(audio), max(0, int(duration_ms or 0)) def audio_result_metadata(result, actual_model: ModelConfig): audio, duration_ms = result candidate_quote = quote_voiceover(actual_model, char_count=char_count, team=task.team) return AttemptMetadata( usage={"characters": char_count}, platform_cost=candidate_quote.base_cost_yuan, response_summary={ "audio_bytes": len(audio), "duration_ms": duration_ms, "output_format": "mp3", }, ) summary = { "operation": "tts", "public_voice": public_voice, "char_count": char_count, "speed_ratio": float(speed_ratio or 1.0), "language": "zh-CN", "output_format": "mp3", } summary.update(request_summary or {}) return execute_model_call( task=task, primary_model=primary_model, requirements=requirements, public_model_name=public_model_name(primary_model), invoke=invoke_audio, request_summary=summary, result_metadata=audio_result_metadata, error_classifier=lambda exc, model: classify_generation_error( exc, operation="voiceover_generate", provider_name=model.provider.name, reference_id=str(task.id), ), ) class VideoSubmissionStateUnknown(ProviderOutcomeUnknownError): """视频提交可能已到达供应商但未拿到可靠任务 ID;禁止自动重提。""" # 本系统出的任何视频都不带字幕。这条是最高优先级的成片硬性禁令,写得穷尽一点 —— # 实测只写「不要字幕」时,模型仍会自作主张加花字 / 价格贴片 / 购物浮层。 # ★ 这条的写法本身就是功能的一部分,改之前先读这段: # 视频扩散模型对「否定」很弱、对「名词」很强。旧版禁令把「字幕」念 7 次、「花字」念 3 次, # 等于反复把这个视觉概念喂给模型 —— 实测连续两条成片都因此带上了字幕。 # 所以新版反过来:**以正面陈述为主**(画面应该长什么样),负面名词全片只出现一次, # 并把「台词只出声、不上屏」单独讲明白(这是最大的诱因,见 _segment_script_text 的人声区)。 # 维护提醒:往这条里加词 = 提高负面名词词频 = 更容易出字幕。要加先想清楚。 NO_EMBEDDED_CAPTIONS_REQUIREMENT = ( "【画面洁净 · 最高优先级】成片是一条纯实拍素材:画面上只有真实拍到的人、商品和环境," "没有任何后期叠加层。全片从开场首帧到结尾都保持这个状态,开头也不加标题页。" "画面里能看到的文字只有一种 —— 参考图中商品包装、标签上本来就印着的那些," "保持与参考图一致,不放大、不重排、不新增。" "人物说的话通过口型和声音表达,不写到画面上;不要字幕,也不要任何贴片、浮层或界面元素。" ) # 结尾再补一句极短的正面复述:末位权重高,但只用「叠加」这类中性词,不再重复负面名词。 NO_EMBEDDED_CAPTIONS_TAIL = "画面全程保持纯实拍,无任何叠加层。" # ★ 实测:同一条片子出四段,只有**开场那一段**带字幕,后三段干净。 # 原因不在我们的提示词结构(四段模板完全一致),而在模型的先验:在「电商口播开场」这个语境下, # 它学到的强关联是「钩子 = 屏幕上打一行大字」;使用过程 / 特写 / CTA 段几乎不触发这个先验。 # 所以给开场段单独一句**正面**指令,把这个先验掰到「钩子靠画面和表演」上 —— # 注意全句不含任何负面名词(字幕/花字…),否则又会掉进「越说越画」的坑。 OPENING_SHOT_DIRECTIVE = ( "【开场】这是全片的第一段:第一帧就是实拍画面本身,人物已经在做第一条秒级分镜里的那件事," "镜头直接开拍,像从一段连续素材里截出来的。开场的吸引力全部来自人物的表情、动作和环境," "屏幕保持干净。" ) # 提示词正文里残留的「字幕:…」「花字:…」会和上面的禁令打架 —— 模型看到具体字幕内容就会画出来。 # 来源:脚本【声音】栏、视频提炼稿逐字照抄的画面花字、用户自己写的提示词。这里在送出前统一抹掉。 _CAPTION_WORDS = r"(?:字幕|花字|艺术字|贴片|水印|角标|标题栏|弹幕)" _CAPTION_FIELD_LINE_RE = re.compile(rf"^\s*[-•*]?\s*{_CAPTION_WORDS}\s*[::].*$") _CAPTION_FIELD_INLINE_RE = re.compile(rf"[,,;;、]?\s*{_CAPTION_WORDS}\s*[::][^;;。\n]*") # 无冒号的写法同样要清:「右下角加字幕」「配上花字」「画面无字幕」等。 # 历史项目早就把这些落进了 ScriptSegment.visual_prompt,光改 skill 管不到存量数据。 # 按**子句**整段删(以 ;;,,。 断句),否则会留下「右下角」「画面无」这种断头残渣让模型犯迷糊。 _CAPTION_CLAUSE_RE = re.compile(rf"[,,;;、]?\s*[^,,;;。\n]*{_CAPTION_WORDS}[^,,;;。\n]*[。]?") _SPEECH_LINE_RE = re.compile( r"^(\s*)(口播|旁白|台词|对白|解说)\s*[::]\s*(.+)$" ) _SPEECH_AUDIO_ONLY_PREFIX = "人声(仅音频,由人物口型与配音表达,不出现在画面上):" def rewrite_speech_as_audio_only(prompt: str) -> str: """把「口播:/旁白:/台词:」改写成「人声(仅音频…)」抬头。 全能创作 / 自由创作的 video_prompt 常按秒级分镜写「口播:…」。 裸摆口播原文时,出片模型极易把这段可见文本烧成画面字幕 —— 专业创作 已用同一抬头(_segment_script_text);这里对所有入口统一改写。 已带「仅音频」抬头的行不重复包一层。 """ out: list[str] = [] for line in (prompt or "").split("\n"): if "仅音频" in line and "人声" in line: out.append(line) continue m = _SPEECH_LINE_RE.match(line) if m: indent, _kind, speech = m.group(1), m.group(2), m.group(3).strip() if speech: out.append(f"{indent}{_SPEECH_AUDIO_ONLY_PREFIX}{speech}") continue out.append(line) return "\n".join(out) def strip_caption_directives(prompt: str) -> str: """把正文里所有会让模型联想到「画面文字」的字样抹掉 —— 无论它是要求加字幕还是声明没有字幕。 模型不区分肯定否定,看到「字幕」这个词本身就更容易画出来,所以一律清掉, 真正的规则只由 NO_EMBEDDED_CAPTIONS_REQUIREMENT 一处统一表述。 来源:脚本【声音】栏、视频提炼稿逐字照抄的原片花字、用户自己写的提示词、存量项目的历史脚本。 只清这些字样,不动其余正文;整行被清空则丢掉该行。""" out: list[str] = [] for line in (prompt or "").split("\n"): if _CAPTION_FIELD_LINE_RE.match(line): continue cleaned = _CAPTION_CLAUSE_RE.sub("", _CAPTION_FIELD_INLINE_RE.sub("", line)) if cleaned.strip() or not line.strip(): out.append(cleaned) return "\n".join(out) def enforce_no_embedded_captions(prompt: str) -> str: """所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写 / 全能创作)最终汇入这里: 先把「口播:」改写成仅音频抬头,再洗掉字幕字段,最后挂最高优先级无字幕禁令。 首镜最容易被模型自动加标题,所以不能再把禁令放在长提示词末尾。""" # 顺序要紧:先摘掉可能已存在的规则本身,再洗正文,最后统一拼回去。 # 反过来的话,清洗器会把规则里「不要字幕…」那半句也当成字幕字样吃掉 → 摘不干净 → 规则拼两遍 # (重复 = 负面词频翻倍 = 更容易出字幕,正是本次要根治的毛病)。 base = (prompt or "").replace(NO_EMBEDDED_CAPTIONS_REQUIREMENT, "").replace(NO_EMBEDDED_CAPTIONS_TAIL, "") base = base.replace(OPENING_SHOT_DIRECTIVE, "") base = rewrite_speech_as_audio_only(base) base = strip_caption_directives(base).strip() # 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。 # 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。 return f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n{base}\n{NO_EMBEDDED_CAPTIONS_TAIL}".strip() def execute_routed_video_submit( *, task: AITask, primary_model: ModelConfig, prompt: str, duration: int, ratio: str, resolution: str, reference_images: list[str] | None = None, content_items: list[dict] | None = None, pricing_references: list[dict] | None = None, generate_audio: bool = True, seed: int | None = None, search_mode: str = "off", request_summary: dict | None = None, ): """异步视频只路由“提交”阶段;拿到 Provider 任务 ID 后固定该实际模型轮询。""" from apps.billing.pricing import quote_video_estimate prompt = enforce_no_embedded_captions(prompt) references = list(reference_images or []) routed_content_items = list(content_items) if content_items is not None else None pricing_refs = list(pricing_references or []) item_types = [str((item or {}).get("type") or "") for item in routed_content_items or []] image_count = len(references) + item_types.count("image_url") video_count = item_types.count("video_url") audio_count = item_types.count("audio_url") requirements = ModelRequirements( capability=ModelConfig.Capability.VIDEO, operation="video_generate", features=frozenset({"generate_audio"}) if generate_audio else frozenset(), reference_images=image_count, reference_videos=video_count, reference_audios=audio_count, aspect_ratio=ratio, resolution=resolution, duration=duration, ) def candidate_quote(actual_model: ModelConfig): _tokens, quote = quote_video_estimate( actual_model, aspect_ratio=ratio, resolution=resolution, duration=duration, references=pricing_refs, team=task.team, ) return quote def invoke_video(actual_model: ModelConfig, timeout: float): provider = get_video_provider(actual_model) try: response = provider.create_video_task( model=actual_model.name, endpoint=actual_model.endpoint, prompt=prompt, duration=duration, ratio=ratio, resolution=resolution, reference_images=references or None, generate_audio=generate_audio, content_items=routed_content_items, seed=seed, search_mode=search_mode, timeout=timeout, ) except requests.ReadTimeout as exc: # 请求可能已被供应商接收;盲目重提会生成两条视频任务并产生双份上游成本。 raise VideoSubmissionStateUnknown("视频提交响应超时,远端创建状态未知") from exc provider_task_id = str(response.get("id") or response.get("task_id") or "") if not provider_task_id: exc = VideoSubmissionStateUnknown("视频提交响应缺少任务 ID,远端创建状态未知") quote = candidate_quote(actual_model) exc.attempt_metadata = AttemptMetadata( usage=response.get("usage") if isinstance(response, dict) else {}, platform_cost=quote.base_cost_yuan, response_summary={"provider_task_id_missing": True}, ) raise exc return response, provider_task_id def video_result_metadata(result, actual_model: ModelConfig): response, provider_task_id = result quote = candidate_quote(actual_model) return AttemptMetadata( provider_task_id=provider_task_id, usage=response.get("usage") if isinstance(response, dict) else {}, platform_cost=quote.base_cost_yuan, response_summary={ "remote_status": str(response.get("status") or "") if isinstance(response, dict) else "", "provider_task_id_received": True, }, ) summary = { "operation": "video_generate", "prompt_length": len(prompt), "duration": duration, "aspect_ratio": ratio, "resolution": resolution, "reference_images": image_count, "reference_videos": video_count, "reference_audios": audio_count, "generate_audio": generate_audio, } summary.update(request_summary or {}) return execute_model_call( task=task, primary_model=primary_model, requirements=requirements, public_model_name=public_model_name(primary_model), invoke=invoke_video, request_summary=summary, result_metadata=video_result_metadata, error_classifier=lambda exc, model: classify_generation_error( exc, operation="video_generate", provider_name=model.provider.name, internal_kind="processing_failed" if isinstance(exc, VideoSubmissionStateUnknown) else "", reference_id=str(task.id), ), ) # 在途状态:据此判「已有提取在跑」(提交侧防重复扣费 + 前端刷新后重建 loading) _EXTRACT_INFLIGHT = ( AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, ) # 写死的提取输出契约兜底(对齐脚本 agent 的 _OUTPUT_PROTOCOL):随代码进镜像、永远在。 # **只管"格式骨架"**(JSON 形状 / id 规则 / 不提商品 / 只输出 JSON)—— 这是流水线硬底线,skill 丢了也不会塌。 # **不碰任何"创作细则"**(穿搭/空手/字数/visual_prompt 怎么写),那些归 skill 正文独占,避免两处重复→漂移→稀释质量。 _EXTRACT_OUTPUT_CONTRACT = """ --- ## 输出契约(硬性 · 仅格式底线) **只输出且仅输出一个 JSON 对象**(UTF-8,无注释,无 ```json 代码块外的任何散文/解释/markdown),形状如下: { "entities": [ {"id": "c1", "type": "character", "name": "角色称呼", "visual_prompt": "一句生图描述"}, {"id": "s1", "type": "scene", "name": "地点名", "visual_prompt": "一句生图描述"} ], "segments": [{"index": 0, "entity_refs": ["c1", "s1"]}] } 硬性规则(**仅格式;角色/场景怎么写、穿搭/visual_prompt 细则一律以上文技能正文为准**): ① 只认 character / scene 两类,**绝不输出 product 商品实体**;② 角色 id 用 c1/c2…、场景 id 用 s1/s2…,严格去重; ③ segments 必须覆盖输入每一镜(index 从 0 开始),列出该镜出现的角色/场景 id;④ 整个回复就是那个 JSON,无前后缀。 """ def get_inflight_extraction(project): """本项目正在跑的实体提取任务(最近一条优先);无则 None。""" return ( AITask.objects.filter( project=project, task_type=AITask.Type.ENTITY_EXTRACTION, status__in=_EXTRACT_INFLIGHT ) .order_by("-created_at") .first() ) def submit_extract_entities(*, project, user) -> AITask: """从已定稿脚本**本地**拆出角色/场景(不调模型、不扣积分)。 脚本生成时已带结构化 entities;这里只做规范化 + 最少 1 角色/1 场景兜底 + 落库。 仍返回一条 SUCCEEDED 的 ENTITY_EXTRACTION 任务,方便前端轮询/审计口径不变。 """ from apps.ai.entity_local import materialize_script_entities from apps.projects.models import ScriptVersion script = ( ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first() or ScriptVersion.objects.filter(project=project).order_by("-created_at").first() ) if script is None: raise ValueError("请先生成并定稿脚本,再提取角色 / 场景") if not script.segments.exists(): raise ValueError("脚本没有分镜,无法提取") entities = materialize_script_entities(project=project, script=script) model_config = _resolve_extract_model_config() if model_config is None: # AITask.model_config 非空;本地提取不调模型,但仍需一条配置挂审计任务 raise ValueError("没有可用的文本模型") task = AITask.objects.create( team=project.team, created_by=user, project=project, task_type=AITask.Type.ENTITY_EXTRACTION, status=AITask.Status.SUCCEEDED, model_config=model_config, idempotency_key=f"entity_extraction:local:{project.id}:{uuid.uuid4()}", request_payload={ "mode": "local", "script_id": str(script.id), "entity_count": len(entities), }, response_payload={"entities": entities, "mode": "local"}, estimated_cost=Decimal("0"), actual_cost=Decimal("0"), base_cost=Decimal("0"), completed_at=timezone.now(), ) return task def run_extract_entities_task(*, task_id: str) -> None: """Celery worker 内执行实体提取慢活:流式调模型 → 解析 JSON → 落库(覆盖 project.metadata 的 cast/scenes/*_prompts/script_entities + 回填每镜 entity_refs + entities_extracted 标记)并扣费; 失败退费并把可读错误记进 task.error_message(前端轮询 extract-status 读取)。 幂等:只处理 RESERVED 任务,重复投递不会二次出活、二次扣费。""" from apps.projects.models import ScriptVersion from apps.ai.script_agent import _map_entities_to_project_metadata task = AITask.objects.select_related("team", "created_by", "project", "model_config").filter(id=task_id).first() if task is None or task.status != AITask.Status.RESERVED: return project = task.project payload = task.request_payload or {} messages = payload.get("messages") or [] model_config = task.model_config reservation = task.credit_reservation response: dict = {} # 模型返回的精简存档;失败时也落库(便于事后定位"模型到底吐了啥") try: task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) if payload.get("model_routing_v1"): routed = execute_routed_text_request( task=task, primary_model=model_config, messages=messages, streaming=True, structured_output=True, business_operation="entity_extract", temperature=0.3, validate_text=_parse_extracted_entities_response, request_summary={"script_id": str(payload.get("script_id") or "")}, ) _text, response, parsed = routed.value entities, seg_refs = parsed else: # 存量未迁移任务继续沿用旧调用,避免部署切换期间改变已排队任务行为。 provider = build_provider(model_config) text, response = _collect_extract_text(provider, model_config, messages) entities, seg_refs = _parse_extracted_entities_response(text) script_id = payload.get("script_id") script = ScriptVersion.objects.filter(id=script_id).first() if script_id else None if script is None: # 极端兜底:payload 里 script 没了就回落项目当前定稿稿 script = ( ScriptVersion.objects.filter(project=project, is_adopted=True).order_by("-created_at").first() or ScriptVersion.objects.filter(project=project).order_by("-created_at").first() ) segments = list(script.segments.order_by("sort_order")) if script is not None else [] with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) # 落库:覆盖 project.metadata + 回填每镜 entity_refs(提取为实体唯一权威来源)。 # entities_extracted 标记:前端资产页提取闸门据此显隐(没走过 → 盖蒙版露按钮;走过 → 露卡片)。 _map_entities_to_project_metadata(project, entities) md_flag = dict(project.metadata or {}) md_flag["entities_extracted"] = True project.metadata = md_flag project.save(update_fields=["metadata", "updated_at"]) refs_by_index = {r["index"]: r["entity_refs"] for r in seg_refs} for i, seg in enumerate(segments): new_refs = refs_by_index.get(i, []) if (seg.entity_refs or []) != new_refs: seg.entity_refs = new_refs seg.save(update_fields=["entity_refs", "updated_at"]) except Exception as exc: # noqa: BLE001 — 失败退费并把错误记进 AITask 供前端轮询;不向上抛(避免 celery 重试二次扣费) # ValueError 是我们给用户写好的可读话术(解析失败 / 没识别到角色等);其余(网络/模型异常)给通用话术。 with transaction.atomic(): task.status = AITask.Status.FAILED task.error_message = str(exc)[:2000] task.response_payload = response # 即便失败也存下模型输出片段(content/reasoning 长度等),供事后定位 task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)[:200]) def split_script_into_segments(content: str, count: int = 4) -> list[str]: """把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。 原实现按行 `[:4]`,ARK 返回整段散文时常变成「第1镜有词、2/3/4镜全空」, 导致后续故事板帧 / 视频段拿到空提示词,前后内容断裂。这里改为: 优先按空行/标号块切,块数够就把全部块均匀分桶;块不够再按句子切;仍不够则补齐。 """ def _bucketize(items: list[str], joiner: str) -> list[str]: buckets: list[list[str]] = [[] for _ in range(count)] per = len(items) / count for index, item in enumerate(items): buckets[min(count - 1, int(index / per))].append(item) return [joiner.join(bucket).strip() for bucket in buckets] text = (content or "").strip() if not text: return [""] * count # 1) 优先按空行分段;只有一段时退回按行分 blocks = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()] if len(blocks) < 2: blocks = [line.strip() for line in text.splitlines() if line.strip()] if len(blocks) >= count: return _bucketize(blocks, "\n") # 2) 段落不足:按中英文句末标点切句,再均匀分桶 sentences = [s.strip() for s in re.split(r"(?<=[。!?!?.;;\n])", text) if s.strip()] if len(sentences) >= count: return _bucketize(sentences, " ") # 3) 仍不足:用已有块/句补齐到 count,绝不留空镜 base = blocks or sentences or [text] filled = list(base) while len(filled) < count: filled.append(base[-1]) return filled[:count] @transaction.atomic def create_ai_task( *, project, user, task_type: str, model_config: ModelConfig, request_payload: dict, quote: "Quote | None" = None, reserve_amount: Decimal | None = None, ) -> AITask: """建任务 + 预留积分(统一计价枢纽)。 quote 不传 = flat 计价(unit_price 积分/次,文本/图像走这里);视频/配音入口自带 quote。 reserve_amount 仅视频类传(= 积分×buffer,应对真实 tokens 超预估;ledger 禁超预留扣费)。 estimated_cost 记用户价(积分),base_cost 记平台成本(¥,未配置=0)。 """ from apps.billing.pricing import quote_flat quote = quote or quote_flat(model_config, team=project.team) # 汇率快照:margin_yuan 报表用「计价当时」的 points_per_yuan,汇率调整不追溯历史任务(review 确认) if quote.meta.get("rate"): request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]} task = AITask.objects.create( team=project.team, created_by=user, project=project, task_type=task_type, status=AITask.Status.CREATED, model_config=model_config, idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}", request_payload=request_payload, estimated_cost=quote.points, base_cost=quote.base_cost_yuan, ) reserve_credit(team=project.team, user=user, task=task, amount=reserve_amount or quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) return task def regenerate_script_segment(*, project, user, segment, instruction: str = "") -> ScriptVersion: """单镜重跑(「场次刷新」按钮):复用脚本 agent 的精准改一镜——读全脚本上下文、只动该镜、保留其余镜,落新 ScriptVersion。 旧的「散文 prompt + 正则解析」整条已废,统一走 agent(结构化 + entity_refs 不丢)。""" from apps.ai.script_agent import regenerate_segment_via_agent model_config = get_default_model(ModelConfig.Capability.TEXT) if model_config is None: raise ValueError("no active text model configured") return regenerate_segment_via_agent( project=project, user=user, model_config=model_config, segment=segment, instruction=instruction ) def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None": """用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。""" if not video_bytes: return None try: with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp: tmp_dir = Path(tmp) video_path = tmp_dir / "in.mp4" poster_path = tmp_dir / "poster.jpg" video_path.write_bytes(video_bytes) proc = subprocess.run( ["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)], capture_output=True, timeout=60, ) if proc.returncode != 0 or not poster_path.exists(): return None poster_bytes = poster_path.read_bytes() if not poster_bytes: return None object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg" return TosStorage().upload_fileobj( fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg" ) except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断 return None def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset: fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) suffix = ".png" if "video" in content_type: suffix = ".mp4" elif "jpeg" in content_type: suffix = ".jpg" elif "webp" in content_type: suffix = ".webp" # 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 抛 # "I/O operation on closed file" 被下面的 except 吞掉 → 视频封面一直静默抽不出来(自由创作联调实测)。 raw_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b"" asset_id = uuid.uuid4() object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}" stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) asset = Asset.objects.create( id=asset_id, team=team, created_by=user, name=name, asset_type=asset_type, source=Asset.Source.AI_GENERATED, category=category, origin_task=task, ) AssetFile.objects.create( asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True, ) # 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图 if "video" in content_type: poster = _generate_video_poster(video_bytes=raw_bytes, team=team, project=project, asset_id=asset_id) if poster: AssetFile.objects.create( asset=asset, object_key=poster.object_key, bucket=poster.bucket, content_type=poster.content_type, size_bytes=poster.size_bytes, is_primary=False, ) return asset def _find_entity_group(project, kind: str, label: str, group_id: str | None): """复用同一实体的基础资产组(版本=candidate_assets,采用=adopted_asset)。 优先 group_id;商品=该项目唯一商品组;人物/场景按 label 命中非三视图组;都没有则 None(新建)。""" if group_id: return project.base_asset_groups.filter(id=group_id).first() candidates = list(project.base_asset_groups.filter(kind=kind).order_by("created_at")) candidates = [g for g in candidates if not (g.metadata or {}).get("triview_of")] # 三视图组不算实体本体 if kind == BaseAssetGroup.Kind.PRODUCT: return candidates[0] if candidates else None lbl = (label or "").strip() if lbl: return next((g for g in candidates if (g.metadata or {}).get("label") == lbl), None) return None def _ratio_to_image_size(ratio: str) -> str: """前端比例 → gpt-image 支持的尺寸(宽高均需被 16 整除)。 ⚠️ 该网关只接受 1024x1024 / 1024x1536 / 1536x1024 三种固定 size,无法直接产出精确 3:4 / 9:16 / 4:5, 这里只能取最接近的「竖/横/方」近似;要拿到精确像素比例需在出图后做主体保护裁切/补边(见优化文档 §7.1 normalize_output_image,属待部署的后处理项)。修复点:4:5 原会 fallback 成 1024x1024(方图)→ 比例错误, 现归到竖图 1024x1536。""" known = { "1:1": "1024x1024", "3:4": "1024x1536", # 近似竖图(网关无精确 3:4) "4:5": "1024x1536", # 近似竖图(原 fallback 成方图是比例 bug) "9:16": "1024x1536", # 近似竖图(网关无精确 9:16) "4:3": "1536x1024", "16:9": "1536x864", # 真 16:9(原来误用 1536x1024 = 3:2) "21:9": "1536x1024", # 超宽近似横图(网关无精确 21:9) } normalized = (ratio or "").strip() if normalized in known: return known[normalized] # 手动宽高比不能被静默当成方图。GPT 网关不接受任意精确尺寸时,至少保持横/竖方向; # 原始比例仍写进有效提示词,供应商获得的是它支持的最近画布。 try: width, height = (float(part.strip()) for part in normalized.split(":", 1)) if width > 0 and height > 0: if width < height: return "1024x1536" if width > height: return "1536x1024" except (TypeError, ValueError): pass return "1024x1024" def project_output_spec(project) -> dict: """专业创作 / 极速成片共用的成片规格:画幅、分辨率、视频模型。缺省 9:16 + 720p。""" wizard = dict((project.metadata or {}).get("wizard") or {}) return { "aspect_ratio": str(wizard.get("aspect_ratio") or "9:16").strip() or "9:16", "resolution": str(wizard.get("resolution") or "720p").strip().lower() or "720p", "video_model_config_id": str(wizard.get("video_model_config_id") or "").strip() or None, } def _sync_timeline_output_spec(project, *, aspect_ratio: str, resolution: str) -> None: from apps.ai.video_pricing import get_resolution try: width, height = get_resolution(aspect_ratio, resolution) except Exception: # noqa: BLE001 — 规格不合法时不挡提交,时间线保持原值 return pixels = f"{width}x{height}" timeline, created = Timeline.objects.get_or_create( project=project, defaults={ "name": f"{project.name} Timeline", "duration_seconds": 60, "aspect_ratio": aspect_ratio, "resolution": pixels, }, ) if created: return if timeline.aspect_ratio == aspect_ratio and timeline.resolution == pixels: return timeline.aspect_ratio = aspect_ratio timeline.resolution = pixels timeline.save(update_fields=["aspect_ratio", "resolution", "updated_at"]) def _ratio_to_volcano_size(ratio: str) -> str: """前端比例 → 火山 Seedream 尺寸(~2K 面积,各边夹在 [1024,4096] 且取 16 的倍数)。 预设比例直接给好尺寸;自定义 W:H 按 2K 面积换算;解析不到回落 '2K'。""" presets = { "1:1": "2048x2048", "3:4": "1728x2304", "4:3": "2304x1728", "9:16": "1440x2560", "16:9": "2560x1440", # 4:5 必须 ≥ Seedream 5.0 的最小面积 3,686,400 px:旧值 1664x2080=3.46M 会被 ARK 退 400 # (InvalidParameter: image size must be at least 3686400 pixels)。1728x2160 精确 4:5 且 3.73M 达标。 "4:5": "1728x2160", } r = (ratio or "").strip() if r in presets: return presets[r] if ":" in r: try: w_str, h_str = r.split(":", 1) w, h = float(w_str), float(h_str) if w > 0 and h > 0: scale = math.sqrt((2048 * 2048) / (w * h)) side = lambda v: min(max(int(round(v * scale / 16) * 16), 1024), 4096) # noqa: E731 return f"{side(w)}x{side(h)}" except (ValueError, ZeroDivisionError): pass return "2K" def _product_cover_url(product) -> str: """商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''。""" if product is None: return "" if product.cover_asset_id: url = _asset_preview_url(product.cover_asset) if url: return url image = product.images.filter(is_primary=True).first() or product.images.order_by("sort_order", "created_at").first() if image is not None: return _asset_preview_url(image.asset) return "" def _product_reference_urls(product, limit: int = 3) -> list[str]: """模特上身图的商品参考图(可多张):**真实上传图优先,排除 AI 生成图**——避免拿生成图当真相 再喂回模型造成误差累积。按主图/排序取前 limit 张真实上传图;一张都没有时回落 cover(即便是 AI 图, 至少保证能走 image_edit 而不是纯文生图)。""" if product is None: return [] from apps.assets.models import Asset urls: list[str] = [] seen: set[str] = set() rels = list(product.images.select_related("asset").all()) rels.sort(key=lambda im: (not im.is_primary, im.sort_order)) for im in rels: a = im.asset if a is None or getattr(a, "source", "") == Asset.Source.AI_GENERATED: continue u = _asset_preview_url(a) if u and u not in seen: seen.add(u) urls.append(u) if len(urls) >= limit: return urls if not urls: # 无任何真实上传图 → 回落 cover(可能是 AI 图,但好过纯文生图) cover = _product_cover_url(product) if cover: urls.append(cover) return urls def quality_words(stage: str, slot: str = "quality") -> list[str]: """平台单层质量词配置(QualityWord)。无配置/表不存在 → 返回 [],调用方回落写死值,保证零回归。""" try: from apps.ai.models import QualityWord return list( QualityWord.objects.filter(stage=stage, slot=slot, enabled=True) .order_by("sort", "created_at") .values_list("text", flat=True) ) except Exception: # noqa: BLE001 — 配置读取失败绝不阻断生成 return [] def quality_suffix(stage: str, fallback: str, slot: str = "quality", sep: str = ",") -> str: """取该阶段配置的质量词拼成尾串;无配置回落到 fallback(各 builder 的原写死值)。""" words = quality_words(stage, slot) return sep.join(words) if words else fallback _PROMPT_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}", re.UNICODE) # gpt-image 要求宽高都能被 16 整除;1536x864 = 真 16:9(长边封顶 1536),1536x1024 = 3:2 _RATIO_TO_SIZE = {"1:1": "1024x1024", "portrait": "1024x1536", "landscape": "1536x1024", "16:9": "1536x864"} def _prompt_template_row(key: str): """读 admin 可编辑的提示词模板行(启用的);缺表/缺行/异常一律返回 None(回落写死默认,零回归)。""" try: from apps.ai.models import PromptTemplate return PromptTemplate.objects.filter(key=key, enabled=True).first() except Exception: # noqa: BLE001 — 表还没迁移/查询异常都不该挡生成 return None def render_prompt(key: str, default_text: str, **fields) -> str: """按 admin 可编辑模板渲染提示词。无模板/停用/正文空 → 回落 default_text(各 builder 的写死值)。 安全替换 {占位符}:已知字段换值,未知占位符**原样保留**(用户改错也不会让生成崩)。占位符名支持中文。""" row = _prompt_template_row(key) tpl = row.template if (row and (row.template or "").strip()) else default_text return _PROMPT_PLACEHOLDER_RE.sub(lambda m: str(fields.get(m.group(1), m.group(0))), tpl) def prompt_ratio_size(key: str, default_size: str) -> str: """模板配的比例 → gpt-image 尺寸(1024x1024 / 1024x1536 / 1536x1024);未配则用 default_size。""" row = _prompt_template_row(key) if row and row.ratio: return _RATIO_TO_SIZE.get(row.ratio, default_size) return default_size def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str: """商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图。正文可在 admin「提示词」页改; {商品}=主图商品名(写死字段,不可删),{补充}=调用方附加文本。""" name = (getattr(product, "title", "") or "商品").strip() default = ( "参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质," "生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。" "三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}" ) return render_prompt("product_triview", default, 商品=name, 补充=(base_prompt or "").strip()) # 模特上身图:按品类分「穿戴 / 非穿戴」注入不同的商品-模特关系;每张按序号变化动作/场景/镜头。 _TRYON_WEARABLE_HINTS = ("服", "衣", "裤", "裙", "鞋", "帽", "袜", "围巾", "外套", "卫衣", "内衣", "文胸", "胸罩", "泳", "bra") _TRYON_VARIATIONS = [ {"action": "模特正面自然展示该商品", "scene": "干净的室内空间", "shot": "半身近景,商品清晰可见"}, {"action": "模特正在穿着 / 使用该商品", "scene": "生活化的真实居家场景", "shot": "侧面角度,突出穿着或使用方式"}, {"action": "模特手持或局部展示商品细节", "scene": "明亮的时尚生活场景", "shot": "中近景,商品占比较高"}, {"action": "模特与商品自然互动", "scene": "温暖时尚的生活场景", "shot": "半身,强调使用情境"}, ] _TRYON_NEGATIVE = ( "不要换人,不要改商品设计,不要改商品颜色与结构,不要生成错误或乱码的 Logo 与文字," "不要把商品改成相似款,不要多余的商品堆叠,不要多余文字,不要水印,不要边框," "不要低清模糊,不要过度磨皮,不要畸变,不要扭曲身体,不要夸张滤镜" ) def _is_wearable_product(product) -> bool: """据类目/标题判断是否「穿戴类」(服饰鞋帽内衣) → 真实穿到身上;否则非穿戴 → 手持/佩戴/使用。""" blob = f"{getattr(product, 'category', '') or ''} {getattr(product, 'title', '') or ''}".lower() return any(h in blob for h in _TRYON_WEARABLE_HINTS) def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "", index: int = 0, n_product: int = 1) -> str: """模特上身图 image_edit 提示词(refs 版): 参考图1~N=商品真实图(多角度,锁外形/品牌/配色),参考图N+1=选中模特(锁人脸/身形/气质)。 按品类分穿戴/非穿戴(穿戴=真实穿身上、替换原衣;非穿戴=手持/佩戴/使用,不动原衣); `index` 让每张图动作/场景/镜头不同;`n_product` 让参考图序号自适应。""" name = (getattr(product, "title", "") or "商品").strip() n_product = max(1, int(n_product or 1)) # 参考图序号自适应:N 张商品图 → 参考图1~N=商品, 参考图N+1=模特 if n_product <= 1: intro = f"参考图1是「{name}」的真实商品图,是该商品外观的唯一依据。" prod_ref = "参考图1" model_idx = 2 else: rng = f"1-{n_product}" if n_product > 2 else "1、2" intro = f"参考图{rng}是「{name}」同一件真实商品的不同角度图,是该商品外观的唯一依据,请综合这些角度还原商品。" prod_ref = f"参考图{rng}" model_idx = n_product + 1 if _is_wearable_product(product): relation = ( f"真实穿着{prod_ref}中的这件商品,替换掉模特原本的衣服,让商品自然合身地穿在身上," "而不是放在一旁展示,保持商品的版型、领口、袖型、长度、纹样不变" ) else: relation = ( f"自然地手持 / 在合适位置佩戴 / 正在使用{prod_ref}中的这件商品(如为耳机则佩戴在耳朵上)," "不要改动模特原本的服装,不要把商品强行穿到身上" ) var = _TRYON_VARIATIONS[index % len(_TRYON_VARIATIONS)] lines = [intro] if has_model: lines.append(f"参考图{model_idx}是出镜模特。请生成参考图{model_idx}中这位模特{relation}的电商详情页效果图。") lines.append(f"模特的五官、发型、肤色、身形、年龄与气质必须与参考图{model_idx}(模特图)高度一致,不要换人,不要自行生成另一位模特。") else: lines.append(f"请生成一位真人模特{relation}的电商详情页效果图。") lines.append(f"商品的外形、配色、材质、品牌文字与 Logo、图案必须与{prod_ref}(商品图)严格一致,不要重新设计、不要改样、不要生成相似款。") lines.append(f"本张画面:{var['action']};场景:{var['scene']};镜头:{var['shot']}。") lines.append(quality_suffix("model_tryon", "自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")) if base_prompt and base_prompt.strip(): lines.append(base_prompt.strip()) lines.append("请规避:" + _TRYON_NEGATIVE) return " ".join(lines) def _model_tryon_prompt_v2_rollout_source(*, team_id, payload: dict) -> str | None: """返回 V2.2 启用来源;未命中时失败关闭并继续使用旧提示词。""" if payload.get("tryon_prompt_v2_override") is True: return "internal_ab" if getattr(settings, "MODEL_TRYON_PROMPT_V2_ENABLED", False): return "global" raw_allowlist = getattr(settings, "MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS", ()) or () if isinstance(raw_allowlist, str): raw_allowlist = raw_allowlist.split(",") allowed_team_ids = { str(value).strip().lower() for value in raw_allowlist if str(value).strip() } team_key = str(team_id or "").strip().lower() return "canary" if team_key and team_key in allowed_team_ids else None def _build_model_tryon_prompt_v2( *, product, payload: dict, has_model: bool, index: int, n_product: int, rollout_source: str, ): """使用任务创建时的分类快照构建单张有效提示词,并返回可追溯信息。""" from apps.ai.tryon_prompt import ( ClassificationResult, ProductContext, TrouserFacts, build_tryon_prompt_plan, classify_product_details, default_ratio_for_kind, ) batch_count = int(payload.get("tryon_batch_count") or 0) if batch_count not in (1, 2, 4): raise ValueError("unsupported_tryon_batch_count") if index < 0 or index >= batch_count: raise ValueError("tryon_index_out_of_range") context = ProductContext.create( title=getattr(product, "title", "") or "商品", category=getattr(product, "category", "") or "", description=getattr(product, "description", "") or "", selling_points=tuple(product.selling_points.values_list("title", flat=True)[:8]), ) classification = ClassificationResult.from_payload(payload.get("tryon_classification")) if classification is None: classification = classify_product_details(context, str(payload.get("prompt") or "")) trouser_facts = TrouserFacts.from_payload(payload.get("tryon_trouser_facts")) requested_ratio = str(payload.get("ratio") or "").strip() resolved_ratio = requested_ratio or default_ratio_for_kind(classification.kind) plan = build_tryon_prompt_plan( context=context, user_prompt=str(payload.get("prompt") or ""), count=batch_count, ratio=resolved_ratio, product_reference_count=n_product, has_model_portrait=has_model, has_model_triview=False, classification=classification, trouser_facts=trouser_facts, ) prompt = plan.prompts[index] trace = { "version": "v2.6", "applied": True, "rollout_source": rollout_source, "effective_prompt": prompt, "shot_index": index, "batch_count": batch_count, "requested_ratio": requested_ratio or None, "resolved_ratio": resolved_ratio, "ratio_source": "user" if requested_ratio else "default", "reference_roles": { "product_numbers": list(plan.references.product_numbers), "model_portrait_number": plan.references.model_portrait_number, "model_triview_number": plan.references.model_triview_number, }, "trouser_facts": plan.trouser_facts.as_payload() if plan.trouser_facts is not None else None, } return prompt, trace, resolved_ratio # 平台套图同批多张要「同款商品、不同版式」,否则 N 张文案/构图雷同(PMC#24)。 # 锁死商品一致性,只让排版/构图/视角/配色基调按张变化。 # 注:仅纯文生图回落路径(无参考图)仍用这个简表;refs 版改用下面的 slot 体系。 _COVER_VARIATIONS = [ "本张:正面居中主图版式,商品占画面主体,纯净背景,经典电商主图构图", "本张:换一种排版——商品偏置 + 卖点文案分区,场景化背景,杂志感构图", "本张:特写细节版式,放大商品材质/做工,近景视角,突出质感", "本张:生活场景套图版式,商品置于真实使用情境,环境光,氛围感构图", "本张:多角度组合版式,商品换一个朝向/视角,几何分区背景,现代简约风", "本张:促销封面版式,留出标题/价签区,高对比配色,强视觉冲击构图", ] # 图片创作「自由模式」专用变体:用户没要广告,只是想要同一想法的不同张。 # 只换视角/景别/光线/机位,严禁加任何文字、卖点、标题、价签(那是套图 _COVER_VARIATIONS 的活)。 # 复用套图变体会让第 2 张起凭空长出护肤广告文案(PMC 自由模式反馈)。 _FREE_VARIATIONS = [ "换一个视角与构图", "换一种镜头景别(近景 / 中景 / 远景任选其一)与光线氛围", "换一个拍摄角度和背景环境,主体保持不变", "换一种构图与色调,画面更有层次", "调整主体在画面中的位置与景深,换个机位", ] # 平台套图优化版(对照「平台套图线上提示词优化版.md」):平台差异 + slot 版式 + 低信息密度上限。 # 平台 id 用规范化 key(前端 dy/tb… 已在 PLATFORM_ID_MAP 里映射成这些 canonical key)。 _PLATFORM_NAMES = { "taobao": "淘宝", "tmall": "天猫", "jd": "京东", "pdd": "拼多多", "douyin": "抖音电商", "xhs": "小红书", "kuaishou": "快手", "wechat": "视频号", "amazon": "亚马逊", "1688": "1688", } # §4 平台块:每块只描述平台调性 / 版式倾向,统一服从「头图低信息密度」,不写比例(比例由 size 控制)。 _PLATFORM_COVER_BLOCKS = { "taobao": ( "平台:淘宝。画面像淘宝搜索货架和商品主图,移动端缩略图下商品一眼可识别;" "常见左上小品牌区、中央或偏右商品主体、底部克制活动条 / 轻卖点条;" "背景干净但不单调,可用浅灰棚拍 / 浅色墙面 / 试衣间 / 窗边挂拍 / 干净台面;" "禁止详情页 / 长图 / 平台 UI / 二维码 / 直播间界面 / 虚假价格与未提供折扣。" ), "tmall": ( "平台:天猫。画面像品牌旗舰店商品首图,品牌感 / 质感 / 留白 / 材质光影更重要;" "促销感弱于淘宝,不要大红大黄低价感,不要拼多多式强利益区。" ), "jd": ( "平台:京东。画面清晰 / 可信 / 理性 / 标准,像京东商品主图;白底或浅灰商品图、克制品质短标题、包装 / 配件 / 材质细节;" "信息区极少,只允许 1 个短标题或 1-2 个短标签;不要参数表 / 功能说明长区 / 详情页式模块,不要小红书滤镜,不要强生活方式过度氛围。" ), "pdd": ( "平台:拼多多。主体大 / 信息直接 / 利益区明显,像商品主图或活动头图;" "可有高对比活动区和大标题但只讲一个利益点,不能编造价格 / 优惠 / 折扣 / 销量 / 平台补贴;" "背景明亮,商品边缘清楚,不要复杂品牌大片感。" ), "douyin": ( "平台:抖音电商。像信息流商品封面 / 短视频电商封面,近景 / 强裁切 / 真实使用瞬间 / 动作感强;" "常见背景:衣橱 / 梳妆台 / 卧室 / 开箱 / 手部整理 / 拿取 / 穿搭准备;文案更短、商品主体更大;" "不要抖音 UI / 播放按钮 / 直播间贴片 / 字幕条,也不要套淘宝底部活动条。" ), "xhs": ( "平台:小红书。像生活方式笔记封面,自然光 / 真实体验 / 种草氛围;" "常见背景:卧室 / 衣橱 / 梳妆台 / 桌面 / 浴室 / 旅行收纳 / 通勤准备;标题像用户体验表达,少硬广 / 少参数表 / 少促销条。" ), "kuaishou": ( "平台:快手。像快手小店商品图 / 直播前置封面素材,真实 / 直接 / 可信;" "可有桌面展示 / 手持展示 / 打包台 / 家中真实环境 / 开箱场景;不要直播 UI / 主播贴片 / 平台水印。" ), "wechat": ( "平台:视频号 / 微信小店。画面克制可信,适合社交分享和商品卡;" "背景可用干净家居 / 礼赠 / 办公 / 生活空间 / 柔和自然光;不要微信聊天界面 / 二维码 / 公众号 UI。" ), "amazon": ( "平台:亚马逊。第 1 张为 MAIN 合规图:纯白背景、只展示售卖商品本体、无文字 / 无道具 / 无边框 / 无水印,商品占画面主要区域;" "第 2 张以后可为 Lifestyle / Feature / Detail / Package 辅图;不要 Amazon 徽章 / 评分 / 排名 / 优惠 / 平台 UI。" ), "1688": ( "平台:1688。像批发采购图,商品清楚,偏规格 / 材质 / 工艺 / 包装 / 供货信息;" "背景可用白底 / 浅灰 / 工厂台面 / 仓储 / 包装台 / 材料工艺背景;头图仍保持低信息密度,只允许 1 个短标题或 1-2 个短标签;" "不要做成规格表详情页,不要虚构工厂资质 / 库存 / 起订量 / 认证与价格。" ), } # §3 / §3.1 slot 版式骨架(低信息密度版):默认 4 张按 hero→scene→selling→detail 取,8/12 张再补 multi/promo。 _COVER_SLOTS = { "hero": "正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案", "scene": "场景主视觉:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题", "selling": "轻卖点封面:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数", "detail": "质感特写:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线),近景视角,突出质感,无文案或 1 个短标签", "multi": "多角度组合:商品换一个朝向 / 视角,几何分区干净背景,现代简约风,无文案", "promo": "促销封面:主体大、利益点单一,底部或角落留 1 条活动短语,高对比配色,不铺满文字、不编造价格", } _COVER_SLOT_ORDER = ["hero", "scene", "selling", "detail", "multi", "promo"] # §3.1 / §8 头图低信息密度上限:所有平台 / 所有模型都必须遵守,防止漂成详情页。 _COVER_LOW_DENSITY = ( "这是一张商品头图,不是详情页:画面第一优先级是商品主体 / 上身效果 / 使用场景," "商品或模特主体占画面 60%-80%,文字与装饰不得抢主体;如需文案最多 1 个短标题(≤8 字)加 1-2 个极短标签;" "严禁参数表 / 规格表 / 长句卖点 / 三段式说明 / 密集图标 / 大量箭头 / 2x3 或 3x3 信息宫格 / 左右对比详情页 / 多屏排版。" ) # §5 背景反差:避免商品与背景同色相融(无法逐图取色时给通用避让规则)。 _COVER_BG_CONTRAST = ( "背景必须与商品主色形成清楚反差,不能让商品与背景同色相融;" "浅色商品避免奶白 / 浅米 / 浅粉等近似大面积底,优先冷灰 / 蓝灰 / 浅绿 / 自然木色或明确投影、边缘光;" "深色商品避免黑 / 深灰 / 深棕大底,优先暖白 / 浅灰 / 浅木色 / 日光窗边;" "除亚马逊 MAIN 或明确白底图外,不要整组都做成纯白 / 浅灰底。" ) # §6.1 内衣真人上身强约束:防止被第二件衣服遮挡、防性感化漂移。 _UNDERWEAR_ON_MODEL = ( "这是真人试穿商品头图:成人模特直接穿着参考商品中的内衣,内衣是唯一服饰重点," "罩杯 / 肩带 / 下围 / 杯型 / 面料 / 颜色与关键版型须完整可见且与参考商品一致;" "不要出现 T 恤 / 衬衫 / 吊带背心 / 运动上衣 / 连衣裙 / 制服 / 外套等遮挡内衣的第二件衣服," "不要把内衣穿在其他衣物外面或在其下面加衣;最多允许轻薄开衫 / 薄纱松搭在肩臂外侧但不得盖住罩杯 / 肩带 / 下围;" "正规电商服饰目录拍摄,成人、克制、商品展示向,非性感化、非挑逗姿势,关键结构不被裁切。" ) # §9 通用负面提示词(平台套图专用,压缩版)。 _COVER_NEGATIVE = ( "请规避:详情页 / 详情长图 / 多屏排版 / 店铺页 / 平台 UI / 直播间界面 / 二维码 / 联系方式 / 水印 / 平台 Logo / 虚假角标;" "不要重新设计商品或改其包装 / Logo / 配色 / 材质 / 文字 / 图案 / 比例,不要生成相似款或改品类,不要擅自加套装 / 配件 / 赠品;" "不要出现商品数据外的品牌名 / 英文 Logo / 系列名 / 角落签名 / 模型名 / 伪水印;" "不要编造价格 / 优惠券 / 满减 / 折扣 / 限时活动 / 销量 / 排名 / 认证 / 功效承诺 / 绝对化用语;" "不要错别字 / 乱码 / 小字堆叠 / 大段文案;不要让文字遮挡主体;不要让商品与背景同色相融。" ) # 内衣 / 贴身衣物品类提示(比通用穿戴更窄,用于触发内衣强约束分支)。 _UNDERWEAR_HINTS = ("内衣", "文胸", "胸罩", "bra", "内裤", "泳", "比基尼", "bikini", "睡衣", "塑身", "束身") def _is_underwear_product(product) -> bool: blob = f"{getattr(product, 'category', '') or ''} {getattr(product, 'title', '') or ''}".lower() return any(h in blob for h in _UNDERWEAR_HINTS) def build_platform_cover_prompt_refs( product, *, has_model: bool = False, base_prompt: str = "", platform_id: str = "", index: int = 0, count: int = 4, # noqa: ARG001 — 透传保留,后续可据张数扩展 slot 选择 product_ref_count: int = 1, ) -> str: """平台套图 image_edit 提示词(refs 优化版,对照「平台套图线上提示词优化版.md」): 参考图1~N=同一件真实商品(锁外形/品牌/配色/Logo/比例),有模特时参考图N+1=出镜模特。 按 `platform_id` 注入平台块、按 `index` 选 slot 版式,统一服从「头图低信息密度」与「背景反差」, 内衣 + 有模特时叠加强约束。只出头图 / 主图 / 封面候选,不再出详情。""" name = (getattr(product, "title", "") or "商品").strip() n = max(1, int(product_ref_count or 1)) if n <= 1: ref_word = "参考图1" intro = f"参考图1是「{name}」的真实商品参考图。" else: rng = f"1-{n}" if n > 2 else "1、2" ref_word = f"参考图{rng}" intro = f"参考图{rng}是「{name}」同一件真实商品的不同角度参考图。" lines = [intro] lines.append( f"请严格锁定{ref_word}中商品的外形、颜色、材质、结构、Logo、品牌文字与比例," "严禁重新设计 / 改样 / 改品类;这些参考图只锁商品本体,不锁原图里的床品 / 桌面 / 墙面 / 绿植 / 道具与拍摄光线。" ) # 平台块(canonical key 命中则用 §4 平台块,否则回落平台名 / 通用) block = _PLATFORM_COVER_BLOCKS.get(platform_id) pname = _PLATFORM_NAMES.get(platform_id, "") if block: lines.append(block) elif pname: lines.append(f"请生成一张适合「{pname}」平台的商品头图 / 主图 / 封面候选,统一视觉风格。") else: lines.append("请生成一张电商平台商品头图 / 主图 / 封面候选,统一视觉风格。") # 头图低信息密度上限 lines.append(_COVER_LOW_DENSITY) # 模特身份 + 内衣强约束 if has_model: lines.append( f"参考图{n + 1}是出镜模特,请让这位模特真实展示该商品;" "模特的五官 / 发型 / 肤色 / 身形须与该图高度一致,不要换人。" ) if _is_underwear_product(product): lines.append(_UNDERWEAR_ON_MODEL) # 本张 slot 版式 slot_key = _COVER_SLOT_ORDER[index % len(_COVER_SLOT_ORDER)] lines.append("本张版式:" + _COVER_SLOTS[slot_key] + "。") # 背景反差 lines.append(_COVER_BG_CONTRAST) if base_prompt and base_prompt.strip(): lines.append( "用户补充(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性、平台与版式规则):" + base_prompt.strip() ) lines.append(_COVER_NEGATIVE) return " ".join(lines) def build_free_reference_prompt(base_prompt: str, n_refs: int = 1, index: int = 0) -> str: """图片创作自由模式 · 带用户上传参考图时的提示词。 纯把用户原话丢给图生图,模型只会松散借个色调、不会真的保留参考图里的主体(背心/商品/人物), 这是「没参考我上传的素材」的根因 → 参考图仍钉成「画面主体的唯一依据」。 但一致性只锁「主体身份」(人物的五官/发型/体型,商品的品类/外形/Logo),不再无差别钉死款式/配色/材质: 用户要求本身常常就是要改变某个属性(如「参考这个角色,生成现代服装的穿着」),旧模板的 「严格保留款式、不要换款」与之直接矛盾 → 同批图模型每张随机听一边,一半换装一半原封不动。 改为:用户明确要求改变的部分以用户要求为最高优先级;用户没提的部分才默认与参考图一致。""" base = (base_prompt or "").strip() if n_refs <= 1: ref_intro = "参考图是用户提供的素材,是本次画面主体(商品 / 人物 / 物体)的唯一依据。" ref_word = "参考图" else: rng = f"1-{n_refs}" if n_refs > 2 else "1、2" ref_intro = f"参考图{rng}是用户提供的素材(同一主体的不同角度 / 多个主体),是本次画面主体的唯一依据。" ref_word = f"参考图{rng}" lines = [ ref_intro, f"画面主体必须取自{ref_word},主体身份须与参考图高度一致:人物须是同一个人(五官、发型、肤色、体型不变)," "商品 / 物体须是同一件(品类、外形、品牌文字与 Logo 不变);不要换人、不要换成相似但不同的物体。", ] if base: lines.append( f"用户要求:{base}。用户要求是最高优先级:用户明确要求改变的部分(如服装、场景、动作、风格等)" "必须按要求大胆改变、不要保留参考图原样;用户没有要求改变的部分,保持与参考图一致。" ) else: lines.append("用户未提出改变要求:请严格保留主体的外形、款式、配色、材质、纹样与图案,生成干净、专业的电商视觉画面。") # 同批多张要不同构图/视角,否则雷同(PMC#24);主体身份一致性已在上面钉死,这里只变表现。 # 自由模式用 _FREE_VARIATIONS(只换视角/光线/机位),不能用套图 _COVER_VARIATIONS: # 后者含"卖点文案分区/留出标题价签区",会让带参考图的第2张起也凭空长出广告文案。 if index > 0: lines.append(_FREE_VARIATIONS[index % len(_FREE_VARIATIONS)] + ",在保持主体身份一致、遵循用户要求的前提下换一种表现,不要添加任何文字、卖点、标题或价签。") lines.append("画面真实、构图协调、细节清晰。") return " ".join(lines) def build_person_frontal_prompt(description: str = "") -> str: """人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。 用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。""" desc = (description or "").strip() # 正文可在 admin「提示词」页改;{描述}=脚本提取/用户输入的人物描述(写死字段)。默认含原质量尾串。 default = "电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景" rendered = render_prompt("person_portrait", default, 描述=desc) # 人物基础资产是给后续故事板锁脸用的「角色参考」,绝不能提前把商品塞进画面。 # 否则图像模型会自行重绘包装,污染唯一可信的商品参考图。即使后台模板或脚本描述带了商品, # 这个约束也必须最终覆盖它;商品只允许由 product 基础资产和故事板/视频阶段传入。 return ( f"{rendered}。硬性约束:这是单人角色参考图,不是带货海报;" "画面中绝对不要出现任何商品、产品包装、品牌文字、Logo、价签、桌子、电脑、杯子、手持物或生活场景。" "只保留一位人物和干净纯色背景。" ) def build_person_portrait_prompt_refs(description: str = "") -> str: """角色立绘「重跑」refs 版:参考图=该角色当前立绘。保持同一人物的相貌/发型/身份不变, 只据提示词微调并重绘为正面全身、纯色背景的电商真人模特立绘 —— 避免重跑重抽成另一个人。""" desc = (description or "").strip() base = ( "参考图是该角色当前的立绘。保持参考图中人物的相貌、五官、发型、肤色与身份特征完全一致(同一个人)," "重绘为电商真人模特氛围正面全身照,自然妆容,柔和影棚光,真实质感,单人,纯色背景。" "硬性约束:仅保留人物,绝对不要出现商品、产品包装、品牌文字、Logo、价签、桌子、电脑、杯子、手持物或生活场景。" ) if desc: base += f"在保持人物一致的前提下,按以下要求调整:{desc}。" return base # --------------------------------------------------------------------------- # # 出图重试(中转站偶发抖动 → 一次失败就「生成不出来」的根因兜底) # --------------------------------------------------------------------------- # _IMAGE_GEN_ATTEMPTS = 3 def _is_transient_image_error(exc: Exception) -> bool: """判定出图失败是否「瞬时」(值得重试)。瞬时:网络抖动 / 5xx / 429 限流 / 中转站吐空无 media url。 永久(不重试,立即退费报错):4xx 内容违规 / 参数错(如 invalid_image_file)、配置缺失等。""" if isinstance(exc, (requests.ConnectionError, requests.Timeout)): return True if isinstance(exc, requests.HTTPError): code = getattr(getattr(exc, "response", None), "status_code", 0) or 0 return code >= 500 or code == 429 # extract_first_media_url 在响应没有 url 时抛 ValueError —— 多为中转站偶发空返回,重试常能拿到 if isinstance(exc, ValueError) and "media url" in str(exc).lower(): return True return False def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS): """跑一次出图(provider 调用 + 取 media url),瞬时错误按退避重试,永久错误立即抛。 make() 须返回 (response, media)。退避 2s / 4s,总耗时上限 ~6s + 出图本身,worker 内执行对用户无感。""" for i in range(attempts): try: return make() except Exception as exc: # noqa: BLE001 if i == attempts - 1 or not _is_transient_image_error(exc): raise time.sleep(2 * (i + 1)) def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None, reference_asset_id: str | None = None, auto_triview: bool = False) -> AITask: """提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级), 慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。 这样 Web 层(gunicorn)不被 ~30s+ 的出图请求占住 → 健康探针不饿死 → 不再"生成几张就整站 502/卡死"。 返回 RESERVED 的 AITask,前端拿 id 轮询 /api/ai/generate-image/?ids=… 取结果;出图后刷新项目即见新组。""" from apps.ai.tasks import generate_base_asset_task model_config = get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") # 商品三视图:有真实商品主图 → 走 image_edit 以主图为参考,锁定包装(品牌字/配色/外形/Logo)一致; # 无主图或当前模型不支持 image_edit → 回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。 product_ref_url = _product_cover_url(project.product) if kind == BaseAssetGroup.Kind.PRODUCT else "" # 角色「重跑」:若该角色已有当前立绘(reference_asset_id),以它为参考图走 image_edit, # 保持同一人物相貌一致(仅据立绘+提示词微调),不再重抽成另一个随机人。 person_ref_url = "" if kind == BaseAssetGroup.Kind.PERSON and reference_asset_id: ref_asset = Asset.objects.filter(id=reference_asset_id, team=project.team, is_deleted=False).first() if ref_asset is not None: person_ref_url = _asset_preview_url(ref_asset) ref_url = product_ref_url or person_ref_url # 是否需要参考图由业务素材决定,不再用主 Provider 是否暴露 image_edit 来预判。 # 直连 Seedream 可通过 image_generation(image=...) 使用同一参考图;候选资格由统一能力契约过滤。 use_edit = bool(ref_url) if use_edit and product_ref_url: gen_prompt = build_product_triview_prompt_refs(project.product, prompt) elif use_edit and person_ref_url: gen_prompt = build_person_portrait_prompt_refs(prompt) # 角色重跑:参考当前立绘 + 提示词,保持人物一致 elif kind == BaseAssetGroup.Kind.PERSON: gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板 else: # 场景图 = 空镜。不论 admin「提示词」页的 scene 模板写没写,都强制叠加「无真人」硬约束,并显式压过 # 场景描述里可能出现的人物词(如「主播在客厅」)—— 否则模型会照着画出真人,下游用这张场景图生成视频时 # 被火山以「输入图像可能包含真实人物」拒绝(ZWQ#13)。已叠加过(含「空镜」)则不重复。 gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip()) if "空镜" not in gen_prompt: gen_prompt = ( f"{gen_prompt}。" "重要约束:这是一张空镜场景图,画面里绝对不要出现任何人物 / 真人 / 模特 / 人脸 / 人手 / 人影;" "即使上文描述里提到了人,也只画对应的环境、空间与陈设,把人物完全省略。干净构图,9:16 竖屏。" ) payload = { "model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt, "kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "", "use_edit": use_edit, "reference_image": ref_url, "model_routing_v1": True, # 角色从这里生成时,立绘落库后由 worker 自动接力生成绑定它的三视图。 # 非角色一律忽略该参数,避免商品/场景误入人物三视图链路。 "auto_triview": bool(auto_triview and kind == BaseAssetGroup.Kind.PERSON), } task = create_ai_task( project=project, user=user, task_type={ BaseAssetGroup.Kind.PRODUCT: AITask.Type.PRODUCT_IMAGE, BaseAssetGroup.Kind.PERSON: AITask.Type.PERSON_IMAGE, BaseAssetGroup.Kind.SCENE: AITask.Type.SCENE_IMAGE, }[kind], model_config=model_config, request_payload=payload, ) # 真实平台成本由每条 AIModelAttempt 按实际调用模型累加,避免 Fallback 后仍记默认模型旧成本。 task.base_cost = Decimal("0") task.save(update_fields=["base_cost", "updated_at"]) generate_base_asset_task.delay(str(task.id)) return task def run_base_asset_task(*, task_id: str) -> None: """Celery worker 内执行基础资产的慢出图:调模型 → 成功落库扣费并归组 / 失败退费。 幂等:只处理 RESERVED 任务,重复投递不会二次出图、二次扣费。""" task = AITask.objects.select_related("team", "created_by", "project", "model_config").filter(id=task_id).first() if task is None or task.status != AITask.Status.RESERVED: return project = task.project user = task.created_by payload = task.request_payload or {} kind = payload.get("kind") prompt = str(payload.get("prompt") or "") label = str(payload.get("label") or "") group_id = payload.get("group_id") or None use_edit = bool(payload.get("use_edit")) ref_url = str(payload.get("reference_image") or "") model_config = task.model_config use_model_routing = bool(payload.get("model_routing_v1")) provider = None if use_model_routing else get_image_provider(model_config) reservation = task.credit_reservation try: if use_edit and ref_url: # 商品三视图默认横;角色立绘重跑默认竖,与原调用尺寸保持一致。 if kind == BaseAssetGroup.Kind.PERSON: edit_size = prompt_ratio_size("person_portrait", "1024x1536") else: edit_size = prompt_ratio_size("product_triview", "1536x1024") generate_size = None else: edit_size = None # 场景默认横、人物立绘默认竖;比例仍由现有提示词配置决定。 if kind == BaseAssetGroup.Kind.SCENE: generate_size = prompt_ratio_size("scene", "1536x1024") else: generate_size = prompt_ratio_size("person_portrait", "1024x1536") def _make(): if use_edit and ref_url: resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=edit_size) else: resp = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=generate_size) return resp, provider.extract_first_media_url(resp) if use_model_routing: routed = execute_routed_image_request( task=task, primary_model=model_config, prompt=prompt, reference_images=[ref_url] if use_edit and ref_url else [], edit_size=edit_size, direct_size=edit_size, generate_size=generate_size, request_summary={"base_asset_kind": kind}, ) response, media = routed.value else: # 存量未迁移任务继续沿用旧局部重试,避免部署切换期间改变已排队任务行为。 response, media = _run_image_with_retry(_make) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) category = { BaseAssetGroup.Kind.PRODUCT: Asset.Category.PRODUCT_IMAGE, BaseAssetGroup.Kind.PERSON: Asset.Category.PERSON, BaseAssetGroup.Kind.SCENE: Asset.Category.SCENE, }[kind] # 商品基础资产 = 商品三视图(prompt 即「生成同一件商品的三视图」):名字带「三视图」并打 # metadata.view=three_view + product_id,让商品库的三视图查询(products is_triview / ?product 过滤) # 能认出并回填——否则视频项目生成的商品三视图同步不回商品库,商品库永远显示「尚未生成」(ZWQ#5)。 is_product = kind == BaseAssetGroup.Kind.PRODUCT asset_name = f"{project.name}-商品三视图" if is_product else f"{project.name}-{kind}" asset = _store_generated_media( team=project.team, user=user, project=project, task=task, media=media, name=asset_name, category=category, asset_type=Asset.Type.IMAGE, ) if is_product: meta = dict(asset.metadata or {}) meta["view"] = "three_view" if project.product_id: meta["product_id"] = str(project.product_id) asset.metadata = meta asset.save(update_fields=["metadata", "updated_at"]) # 复用同实体的组:追加候选 + 采用最新(版本=candidate_assets,采用=adopted_asset);无则新建 group = _find_entity_group(project, kind, label, group_id) if group is None: group_meta = {"label": label.strip()} if label and label.strip() else {} group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta) elif prompt: group.prompt = prompt group.candidate_assets.add(asset) group.adopted_asset = asset group.save(update_fields=["adopted_asset", "prompt", "updated_at"]) # 含人脸资产(角色 / 场景 / 商品):事务提交后静默送火山审核(best-effort,网络调用放 on_commit 避免占着事务)。 # 场景 / 商品也送审,因为它们可能出现真人(模特出镜/上身),拿到 remote_id 后视频路才能换 asset:// 引用, # 否则传原始直链会被火山判「疑似真人」拒。submit_asset_for_review 自身按 REVIEW_CATEGORIES 兜底,非送审类不会真送。 from apps.assets.review import submit_asset_for_review transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) # 三视图是专业创作可选的增强资产。只有调用方明确要求时才接力, # 极速成片只需人物立绘即可进入故事板,不能为三视图额外等待或失败。 if kind == BaseAssetGroup.Kind.PERSON and payload.get("auto_triview"): def _kickoff_person_triview(portrait=asset): try: generate_person_triview( project=project, user=user, portrait_asset=portrait ) except Exception: logger.exception( "auto person triview kickoff failed for portrait %s", getattr(portrait, "id", ""), ) transaction.on_commit(_kickoff_person_triview) except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) task.status = AITask.Status.FAILED task.error_message = str(exc) task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) notify_generation_failure( task=task, project=project, recipient=user, stage_label="基础资产生成", raw=str(exc), hint=friendly_generation_error(str(exc)), ) def _triview_reference_url(*, asset_id: str, fallback: str = "") -> str: """三视图 worker 现取立绘可访问 URL。提交时写进 payload 的 TOS 签名链接会过期, 立绘落库瞬间也可能还没签出 URL;执行时再取一次,避免「立绘成了、三视图没参考图」。""" if not asset_id: return str(fallback or "") portrait = Asset.objects.filter(id=asset_id, is_deleted=False).first() live = _asset_preview_url(portrait) return live or str(fallback or "") def generate_person_triview(*, project, user, portrait_asset) -> AITask: """流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(**异步**:image_edit 慢,交给 worker)。 Web 请求只建 RESERVED 任务 + 预留额度后秒回;worker 内跑 image_edit 并把三视图归组(run_triview_task)。 三视图与立绘 1:1 绑定:metadata.triview_of=<立绘 asset id>;同一立绘多次=同组追加候选(版本)。""" from apps.ai.tasks import generate_triview_task from apps.ai.model_library import THREE_VIEW_PROMPT if portrait_asset is None: raise ValueError("该立绘尚未生成,无法据它生成三视图") asset_key = str(portrait_asset.id) model_config = get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") ref_url = _triview_reference_url(asset_id=asset_key) # 人物三视图提示词:正文可在 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 # candidate_assets 是 Django 的 ManyRelatedManager,不能直接遍历; # 资产生成完成后这里会立即触发人物三视图,直接遍历会抛 # “ManyRelatedManager object is not iterable”,进而让极速成片 # 在所有基础资产已成功时被错误终止。 candidates = [str(value) for value in group.candidate_assets.all()] 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, } task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload) # 真实平台成本由每条 AIModelAttempt 按实际模型累加,避免 Fallback 后仍记默认模型旧成本。 task.base_cost = Decimal("0") task.save(update_fields=["base_cost", "updated_at"]) generate_triview_task.delay(str(task.id)) return task _MODEL_TRIVIEW_INFLIGHT = ( AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING, ) def quote_model_triview(*, model): """模特三视图价格:图像模型标准价(当前 20 积分)× 团队价格系数。""" from apps.billing.pricing import quote_flat model_config = get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") return model_config, quote_flat(model_config, team=model.team) def generate_model_triview(*, model, user) -> tuple[AITask, bool]: """为团队级 Model 提交三视图任务;project 保持空,同一模特只允许一个在途任务。""" from apps.ai.model_library import THREE_VIEW_PROMPT from apps.ai.tasks import generate_model_triview_task from apps.assets.models import Model if model.is_official: raise ValueError("官方模特不可生成三视图") if model.portrait_asset_id is None: raise ValueError("请先设置模特形象图") model_config, quote = quote_model_triview(model=model) ref_url = _asset_preview_url(model.portrait_asset) if not ref_url: raise ValueError("模特形象图不可用,请先重新上传") prompt = render_prompt("person_triview", THREE_VIEW_PROMPT) with transaction.atomic(): locked = Model.objects.select_for_update().select_related("portrait_asset", "team").get(id=model.id) existing = ( AITask.objects.filter( team=locked.team, task_type=AITask.Type.MODEL_TRIVIEW, status__in=_MODEL_TRIVIEW_INFLIGHT, request_payload__model_id=str(locked.id), ) .order_by("-created_at") .first() ) if existing is not None: return existing, False payload = { "model": model_config.name, "prompt": prompt, "kind": "model_triview", "model_id": str(locked.id), "portrait_asset_id": str(locked.portrait_asset_id), "reference_image": ref_url, "price_points": str(quote.points), "model_routing_v1": True, } task = AITask.objects.create( team=locked.team, created_by=user, project=None, task_type=AITask.Type.MODEL_TRIVIEW, status=AITask.Status.CREATED, model_config=model_config, idempotency_key=f"model_triview:{locked.id}:{uuid.uuid4()}", request_payload=payload, estimated_cost=quote.points, # 路由任务的平台成本由每条 AIModelAttempt 按实际模型累加。 base_cost=Decimal("0"), ) reserve_credit(team=locked.team, user=user, task=task, amount=quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) generate_model_triview_task.delay(str(task.id)) return task, True def run_model_triview_task(*, task_id: str) -> None: """worker 执行团队模特三视图:成功扣费并切换 Model.triview_asset;失败释放预留。""" from apps.assets.models import Model with transaction.atomic(): task = ( AITask.objects.select_for_update() .select_related("team", "created_by", "model_config") .filter(id=task_id) .first() ) if task is None or task.status != AITask.Status.RESERVED: return task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) payload = task.request_payload or {} model_id = str(payload.get("model_id") or "") portrait_asset_id = str(payload.get("portrait_asset_id") or "") ref_url = _triview_reference_url( asset_id=portrait_asset_id, fallback=str(payload.get("reference_image") or ""), ) prompt = str(payload.get("prompt") or "") use_model_routing = bool(payload.get("model_routing_v1")) provider = None if use_model_routing else get_image_provider(task.model_config) reservation = task.credit_reservation try: if not model_id or not portrait_asset_id or not ref_url: raise ValueError("模特三视图任务参数不完整") size = prompt_ratio_size("person_triview", "1536x864") def _make(): response = provider.image_edit( model=task.model_config.name, prompt=prompt, images=[ref_url], size=size, ) return response, provider.extract_first_media_url(response) if use_model_routing: routed = execute_routed_image_request( task=task, primary_model=task.model_config, prompt=prompt, reference_images=[ref_url], aspect_ratio="16:9", edit_size=size, direct_size=size, request_summary={"model_triview": True}, ) response, media = routed.value else: response, media = _run_image_with_retry(_make) with transaction.atomic(): locked_model = ( Model.objects.select_for_update() .filter(id=model_id, team=task.team, is_deleted=False, purged_at__isnull=True) .first() ) if locked_model is None: raise ValueError("模特不存在或已删除") if str(locked_model.portrait_asset_id or "") != portrait_asset_id: raise ValueError("模特形象图已变化,请重新生成三视图") fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) suffix = ".jpg" if "jpeg" in content_type else ".webp" if "webp" in content_type else ".png" asset_id = uuid.uuid4() stored = TosStorage().upload_fileobj( fileobj=fileobj, object_key=f"teams/{task.team_id}/models/{locked_model.id}/triviews/{asset_id}{suffix}", content_type=content_type, ) triview = Asset.objects.create( id=asset_id, team=task.team, created_by=task.created_by, name=f"{locked_model.name}·三视图", asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.TRI_VIEW, in_library=False, origin_task=task, metadata={ "kind": "model", "view": "three_view", "model_id": str(locked_model.id), "triview_of": portrait_asset_id, }, ) AssetFile.objects.create( asset=triview, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True, ) versions = [str(value) for value in (locked_model.metadata or {}).get("triview_versions", []) if value] for value in (locked_model.triview_asset_id, triview.id): if value and str(value) not in versions: versions.append(str(value)) metadata = dict(locked_model.metadata or {}) metadata["triview_versions"] = versions locked_model.triview_asset = triview locked_model.metadata = metadata locked_model.save(update_fields=["triview_asset", "metadata", "updated_at"]) task.status = AITask.Status.SUCCEEDED task.response_payload = response task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) from apps.assets.review import submit_asset_for_review transaction.on_commit(lambda asset=triview: submit_asset_for_review(asset)) except Exception as exc: # noqa: BLE001 with transaction.atomic(): locked_task = AITask.objects.select_for_update().get(id=task.id) if locked_task.status == AITask.Status.SUCCEEDED: return locked_task.status = AITask.Status.FAILED locked_task.error_message = str(exc) locked_task.completed_at = timezone.now() locked_task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) def run_triview_task(*, task_id: str) -> None: """Celery worker 内执行三视图慢出图(image_edit 以立绘为参考):成功落库扣费并归到立绘的三视图组 / 失败退费。 幂等:只处理 RESERVED 任务,重复投递不会二次出图、二次扣费。""" from apps.ai.model_library import THREE_VIEW_PROMPT task = AITask.objects.select_related("team", "created_by", "project", "model_config").filter(id=task_id).first() if task is None or task.status != AITask.Status.RESERVED: return project = task.project user = task.created_by payload = task.request_payload or {} asset_key = str(payload.get("triview_of") or "") ref_url = _triview_reference_url( asset_id=asset_key, fallback=str(payload.get("reference_image") or ""), ) prompt = str(payload.get("prompt") or THREE_VIEW_PROMPT) model_config = task.model_config use_model_routing = bool(payload.get("model_routing_v1")) provider = None if use_model_routing else get_image_provider(model_config) reservation = task.credit_reservation try: if not asset_key or not ref_url: raise ValueError("立绘参考图不可用,无法生成三视图") # 构图意图仍是 16:9;gpt-image 不认 1536x864,OpenAICompatibleProvider 会收成 1536x1024。 tri_size = prompt_ratio_size("person_triview", "1536x864") def _make(): resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size) return resp, provider.extract_first_media_url(resp) if use_model_routing: routed = execute_routed_image_request( task=task, primary_model=model_config, prompt=prompt, reference_images=[ref_url], aspect_ratio="16:9", edit_size=tri_size, direct_size=tri_size, request_summary={"project_person_triview": True}, ) response, media = routed.value else: # 存量未迁移任务继续沿用旧局部重试,避免部署切换期间改变已排队任务行为。 response, media = _run_image_with_retry(_make) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) asset = _store_generated_media( team=project.team, user=user, project=project, task=task, media=media, name=f"{project.name}-三视图", category=Asset.Category.TRI_VIEW, asset_type=Asset.Type.IMAGE, ) # 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新 group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at") if (g.metadata or {}).get("triview_of") == asset_key), None) if group is None: group = BaseAssetGroup.objects.create( project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=prompt, metadata={"label": "·三视图", "triview_of": asset_key}, ) group.candidate_assets.add(asset) group.adopted_asset = asset group.save(update_fields=["adopted_asset", "updated_at"]) # 合规(期3):三视图含人脸,视频生成前必须过火山审核 → 事务提交后静默送审(best-effort) from apps.assets.review import submit_asset_for_review transaction.on_commit(lambda a=asset: submit_asset_for_review(a)) except Exception as exc: # noqa: BLE001 — 失败退费 + 错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) task.status = AITask.Status.FAILED task.error_message = str(exc) task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) notify_generation_failure( task=task, project=project, recipient=user, stage_label="商品三视图生成", raw=str(exc), hint=friendly_generation_error(str(exc)), ) def _scene_context(project) -> str: """从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。""" product = project.product parts = [f"商品:{product.title}"] if product.brand: parts.append(f"品牌:{product.brand}") if product.category: parts.append(f"类目:{product.category}") if getattr(product, "target_audience", ""): parts.append(f"人群:{product.target_audience}") adopted_kinds = set( project.base_asset_groups.filter(adopted_asset__isnull=False).values_list("kind", flat=True) ) if BaseAssetGroup.Kind.PERSON in adopted_kinds: parts.append("真人出镜,保持人物一致") if BaseAssetGroup.Kind.SCENE in adopted_kinds: parts.append("统一场景与色调") return " · ".join(parts) def _segment_script_text(segment, entities=None, *, with_dialogue: bool = False, with_exposure: bool = True) -> str: """本镜脚本文本(画面 + 人声 + 商品露出),拼进视频提示词的【脚本】。 with_dialogue:有结构化对白时按「说话人:台词」原样放(说话人 id 用 entities 解析成名字),回退扁平 narration。 with_exposure:是否带「商品露出:…」一行。 ★ 人声一律带「仅音频…不出现在画面上」的抬头:裸摆一段台词时,出片模型会把这段可见文本 当成需要渲染的画面文字 —— 这是成片带字幕最常见的来源,比缺少禁令更致命。""" parts = [] visual = (segment.visual_prompt or "").strip() if visual: label = "秒级分镜(按时间切镜,商品用法必须真实,禁止诡异动作/错误容器)" if ("s:" in visual or "s:" in visual or "秒" in visual[:12]) else "画面" parts.append(f"{label}:\n{visual}") dialogue = getattr(segment, "dialogue", None) if with_dialogue else None name_by_id = {e.get("id"): (e.get("name") or "").strip() for e in (entities or []) if isinstance(e, dict)} lines = [] if isinstance(dialogue, list): for d in dialogue: if not isinstance(d, dict): continue line = (d.get("line") or "").strip() if not line: continue spk = name_by_id.get(d.get("speaker")) or "" lines.append(f"{spk}:{line}" if spk else line) if lines: # 有对白 → 用带说话人的台词 # 「仅音频」这个抬头是防字幕的关键:裸摆一段台词,模型会当成要显示的文本渲染上屏 parts.append("人声(仅音频,由人物口型与配音表达,不出现在画面上):\n" + "\n".join(lines)) else: # 无对白 → 回退扁平口播/旁白 narration = (segment.narration or "").strip() if narration: parts.append(f"人声(仅音频,由人物口型与配音表达,不出现在画面上):{narration}") if with_exposure and getattr(segment, "product_exposure", ""): parts.append(f"商品露出:{segment.product_exposure.strip()}") return "\n".join(parts) def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str: """单段视频提示词(用户钦定 @图N 格式): 【设定】@图N 点名 角色/场景/商品;【风格】全片统一的风格锚点;【脚本】本镜导演说明书(秒级分镜 + 台词)。 ★ 去掉故事板中间层后,导演信息不再经由一张分镜图传递,而是**全靠这份提示词直达出片模型**, 因此这里必须自足:参考图一致性约束 + 秒级执行纪律 + 物理常识 + 成片硬性规则,一条都不能少。 refs 顺序与传给 seedance 的 reference_images 一致(@图N 对齐不错位)。""" setup_parts = [] for i, r in enumerate(refs or []): n = i + 1 setup_parts.append(f"@图{n}是{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})") # 视频脚本:用带说话人的台词(原样照脚本),不带「商品露出」那行(露出靠模板尾句统一要求) entities = (project.metadata or {}).get("script_entities", []) script_text = _segment_script_text(scene, entities, with_dialogue=True, with_exposure=False) if scene is not None else "" extra = (user_prompt or "").strip() if extra: script_text = (script_text + "\n" + extra).strip() if script_text else extra # 正文可在 admin「提示词·视频」页改。占位符:{开场}=仅第一段有的开场指令、{设定}=@图N点名、 # {风格}=全片风格锚点、{脚本}=本段脚本、{时长}。 # 时长/比例靠 API 参数传(duration/ratio),不写进正文;尾句给风格 + 音效/字幕要求。 default = ( "{开场}{设定}{风格}【脚本】{脚本}\n" "【执行纪律】严格按脚本里的秒级分镜拍:每一个时间段都要拍到,景别、机位、运镜、动作按写的来;" "不要把整段并成一个静止长镜头,也不要加脚本里没有的转场、人物或道具。" "\n【一致性】参考图是本片唯一的视觉依据:角色保持同一张脸、同一发型、同一套服装;" "商品保持参考图的外形、配色、材质、比例与包装上的真实文字标识,不得改造、换色、换包装或凭空加配件;" "场景保持同一空间、同一陈设、同一光线方向与色温。多镜之间人物与商品必须看起来是同一次拍摄。" "\n【物理常识】手从画面内自然入画,禁止悬浮肢体、反关节、商品凭空出现或消失;" "商品用法必须是真人会做的(茶/咖啡用热水、有蒸汽与茶汤渐染;护肤品挤出并涂抹;食品打开并入口)," "禁止诡异姿势、错误容器或违背常识的操作。" "\n【画质】真人实拍质感,自然光影,肤色与材质真实,焦点始终落在脚本指定的主体上,画面稳定不糊。" "\n电商带货短视频,商品露出清晰,节奏有转化感。不要背景音乐,但是要有音效,逼真的音效。" ) # 开场段(全片第一段)额外挂一条正面开场指令 —— 字幕基本只在这一段冒出来 opening = OPENING_SHOT_DIRECTIVE + "\n" if video_segment.sort_order == 0 else "" rendered = render_prompt( "video_segment", default, 开场=opening, 设定=("【设定】" + ",".join(setup_parts) + "。\n" if setup_parts else ""), 风格=(f"【风格】{_scene_context(project)}。\n" if _scene_context(project) else ""), 分镜="", # 兼容:旧模板行里可能还留着 {分镜}(故事板时代的占位符),渲染成空串而不是原样漏出 脚本=(script_text or f"第 {video_segment.sort_order + 1} 段"), 时长=video_segment.target_duration_seconds, ) return enforce_no_embedded_captions("\n".join(line for line in rendered.split("\n") if line.strip())) _ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"} def _product_reference_image(project, groups: list | None = None) -> dict | None: """商品参考图:优先已采用的商品三视图(product 基础资产组)→ 否则商品真实主图。无图返回 None。 (商品是预创建的真实商品,不从脚本提取;每镜参考图都无条件带上它。)""" if groups is None: groups = list( project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset") ) pg = next( (g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT and g.adopted_asset_id), None, ) if pg is not None: url = _asset_preview_url(pg.adopted_asset) if url: return {"url": url, "label": "商品", "type": "product", "review_status": pg.adopted_asset.review_status, "review_remote_id": pg.adopted_asset.review_remote_id} cover = _product_cover_url(project.product) if cover: return {"url": cover, "label": "商品", "type": "product"} return None def _segment_reference_images(project, segment) -> list[dict]: """按本镜 entity_refs 取参考图(角色 / 场景 已采用基础资产)+ **无条件带上商品参考图**, 供出片模型 @图N 锁脸 / 锁商品 / 锁场景。返回 [{url,label,type}],最多 4 张(角色/场景 ≤3 + 商品 1)。 商品不靠 entity_refs(预创建真实商品、不从脚本提),统一用商品三视图 / 主图带上,从根上保证 商品参考永不缺失。依赖提取步落进 metadata 的 script_entities。""" entities = { e.get("id"): e for e in (project.metadata or {}).get("script_entities", []) if isinstance(e, dict) } kind_by_type = { "character": BaseAssetGroup.Kind.PERSON, "scene": BaseAssetGroup.Kind.SCENE, } groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset")) # 下游只取「已采用」的角色/场景组(C/D):metadata.adopt 显性优先,否则按是否在脚本里推导; # 商品组永远保留(不受采用态影响)。顺带把不在脚本里的(含三视图组 label「·三视图」)排除, # 避免兜底误抓未采用/三视图组。 _entity_names = {(e.get("name") or "").strip() for e in entities.values() if (e.get("name") or "").strip()} def _group_adopted(g) -> bool: if g.kind == BaseAssetGroup.Kind.PRODUCT: return True a = (g.metadata or {}).get("adopt") if a == "adopted": return True if a == "unadopted": return False return (g.metadata or {}).get("label", "").strip() in _entity_names groups = [g for g in groups if _group_adopted(g)] out: list[dict] = [] used: set = set() for rid in (segment.entity_refs or []): ent = entities.get(rid) if not ent: continue kind = kind_by_type.get(ent.get("type")) if kind is None: # 商品 / 未知类型不在此处理,商品统一在末尾无条件带上 continue name = (ent.get("name") or "").strip() match = next( (g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used), None, ) or next((g for g in groups if g.kind == kind and g.id not in used), None) if match: used.add(match.id) url = _asset_preview_url(match.adopted_asset) if url: # 带上审核态/素材库 ID/资产 ID:视频路按需换成 asset:// 引用、过审闸据此定位送审;图像生成路只读 url 忽略它们 out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type"), "asset_id": match.adopted_asset.id, "review_status": match.adopted_asset.review_status, "review_remote_id": match.adopted_asset.review_remote_id}) if len(out) >= 3: # 给商品留一个位置(总计最多 4 张) break # 商品参考图永远带上:优先已采用商品三视图组 → 否则真实主图 product_ref = _product_reference_image(project, groups) if product_ref is not None and product_ref["url"] not in {r["url"] for r in out}: out.append(product_ref) # 规范 @图N 顺序:角色 → 商品 → 场景(与下游 image_edit 传图顺序一致,标注不错位) _ord = {"character": 0, "product": 1, "scene": 2} out.sort(key=lambda r: _ord.get(r.get("type"), 9)) return out[:4] def _extract_moderation_categories(raw: str) -> list[str]: """从原始报错里抽出被审核命中的类别(如 safety_violations=[sexual] / "categories":["sexual"]), 译成中文标签。抽不到返回 []。供友好提示点名真实类别,而非泛化的「疑似敏感内容」。""" cats: list[str] = [] seen: set[str] = set() # 抓 safety_violations / categories / category 后面的值块,到 ] / } / 换行 / 句末为止。 # 兼容 [sexual] / ["sexual","violence"] / sexual,violence / : "sexual" 等多种写法。 for m in re.finditer( r"(?:safety_violations|categories|violation_categor(?:y|ies)|category)\s*[=:]\s*\[?\s*([^\]\}\n]+)", raw or "", ): for tok in re.split(r"[\s,;\"']+", m.group(1)): key = tok.strip().strip("\"'[]").lower() if key and key not in seen: seen.add(key) cats.append(_MODERATION_CATEGORY_CN.get(key, key)) return cats def friendly_generation_error(raw: str) -> str: """把模型/中转站的原始报错翻成给用户的中文友好提示(前端直接展示)。原始报错仍记进 AITask 供排查。 覆盖:内容审核拦截 / 超时 / 限流 / 凭证 / 参考图被拒 等;命不中给通用兜底。""" s = (raw or "").lower() if any(k in s for k in ("moderation_blocked", "safety system", "safety_violation", "content_policy", "content policy")): cats = _extract_moderation_categories(raw) cat_note = f"(命中类别:{('、'.join(cats))})" if cats else "(疑似敏感内容)" return f"画面或文案被内容审核拦截{cat_note}。请调整脚本措辞(如避免「胸罩 / 内衣 / 抚摸 / 贴身」等直白表述,改用「产品 / 包装展示」),或更换参考图后重试。" if any(k in s for k in ("timed out", "timeout", "read timed out")): return "生成超时,可能是网络波动或模型繁忙,请稍后重试。" if any(k in s for k in ("429", "too many requests", "rate limit", "forbidden", "403")): return "请求过于频繁,模型暂时限流,请稍候片刻再重试。" if any(k in s for k in ("api_key", "unauthorized", "401")): return "图像服务凭证异常,请联系管理员处理。" if any(k in s for k in ("invalid_image", "invalid image", "image_file")): return "参考图未被模型接受(可能格式/尺寸问题),请更换参考图后重试。" if any(k in s for k in ("400", "bad request")): return "生成请求被模型拒绝,请调整提示词或更换参考图后重试。" return "生成失败,请重试;若多次失败请联系技术支持。" def notify_generation_failure( *, task, project, recipient, stage_label: str, raw: str, hint: str = "" ) -> None: """生成失败时落一条普通用户可见的安全通知。 原始错误只留在 AITask、日志和管理员任务详情,绝不能写进通知正文或 metadata。 best-effort:同一任务用 dedupe_key 去重,通知本身出错绝不反过来弄挂主失败流程。 """ from apps.ops.models import Notification raw_clean = (raw or "").strip() public_error = public_error_for_task(task) if public_error is None: public_error = classify_generation_error( RuntimeError(raw_clean or hint or "generation failed"), operation=TASK_OPERATIONS.get(getattr(task, "task_type", ""), "image_generate"), reference_id=str(getattr(task, "id", "") or "") or None, ) friendly = public_error.fallback_message # hint 可能来自旧调用方;仅作为空回退,不能覆盖统一安全文案。 if not friendly: friendly = (hint or "生成失败").strip() body = friendly try: Notification.objects.update_or_create( team=task.team, dedupe_key=f"task:{task.id}:failed", defaults=dict( recipient=recipient, project=project, notification_type=Notification.Type.TASK, priority=Notification.Priority.ERR, title=f"{stage_label}失败", brief=friendly[:300], body=body, source="AI 生成", stage=stage_label, owner_label=getattr(recipient, "username", "") or "成员", cost_label="-", related_url=f"pipeline.html?project_id={project.id}" if project is not None else "", metadata={ "task_id": str(task.id), "task_type": getattr(task, "task_type", ""), "generation_error": public_error.as_dict(), }, ), ) except Exception: # noqa: BLE001 — 通知是附带能力,绝不能反过来弄挂主失败处理 logger.exception("notify_generation_failure failed for task %s", getattr(task, "id", "?")) def _asset_preview_url(asset) -> str: """资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。""" if asset is None: return "" primary = asset.files.filter(is_primary=True).first() or asset.files.first() if primary is None: return "" if primary.preview_url: return primary.preview_url try: return TosStorage().presigned_get_url(object_key=primary.object_key) except Exception: return "" def asset_stable_url(asset) -> tuple[str, str]: """资产的「长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个: 预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。 顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。""" if asset is None: return "", "" files = list(asset.files.all()) if not files: return "", "" primary = next((f for f in files if f.is_primary), None) or files[0] # 封面只对视频/音频有意义:图片资产的第二张文件是它自己的兄弟图,不是封面。 poster = None if "image" not in (primary.content_type or ""): poster = next( (f for f in files if f is not primary and "image" in (f.content_type or "")), None ) def _url(f) -> str: if f is None: return "" if f.preview_url: return f.preview_url if not f.object_key: return "" try: return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None) except Exception: # noqa: BLE001 pass try: return TosStorage().presigned_get_url(object_key=f.object_key) except Exception: # noqa: BLE001 return "" return _url(primary), _url(poster) def _seedance_ref_url(raw_url: str, review_status: str = "", review_remote_id: str = "") -> str: """Seedance 参考图 URL:只要资产已进素材库(有 remote_id)就用 asset:// 素材库引用 —— 火山认的是 「在不在素材库」,与审核态无关(实测 processing 也能引用);未进库才回落原始直链。 火山对写实人脸的原始直链会以 InputImageSensitiveContentDetected 直接拒,必须走同账号素材库引用。 人物立绘 / 分镜图(含脸)进库走 asset://;场景 / 商品(无脸、未送审)保持直链。 注意不能只放过 active:故事板刚重生成时分镜图还是 processing,卡 active 会回落直链 → 视频被火山拒。""" if review_remote_id: return f"asset://{review_remote_id}" return raw_url def _video_reference_images(project, video_segment) -> list[dict]: """视频参考图(带类型,供 @图N):角色 / 场景 / 商品 基础资产。 顺序:角色 → 场景 → 商品(与提示词里 @图1角色@图2场景@图3商品 一致)。 ★ 故事板已从流程中去掉,不再有「分镜图」这一张:导演信息全部走提示词 (build_video_segment_prompt),参考图只负责锁脸 / 锁商品 / 锁场景。 返回 [{url,label,type}];url 对过审人脸资产为 asset:// 素材库引用。都取不到时兜底商品图。""" out: list[dict] = [] scene = None adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() if adopted_script is not None: scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first() if scene is not None: refs = list(_segment_reference_images(project, scene)) # 角色/商品/场景 实体图 _vord = {"character": 0, "scene": 1, "product": 2} refs.sort(key=lambda r: _vord.get(r.get("type"), 9)) out = refs if not out: product_group = ( project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False) .order_by("-created_at").first() ) if product_group is not None: url = _asset_preview_url(product_group.adopted_asset) if url: out.append({"url": url, "label": "商品", "type": "product"}) # 写实人脸(已过审)换素材库引用,避免火山「疑似真人」400 拒 → 退文生 → 人物/场景全错 for r in out: r["url"] = _seedance_ref_url(r["url"], r.get("review_status", ""), r.get("review_remote_id", "")) return out def collect_video_review_blockers(project, only_segment: "VideoSegment | None" = None) -> list[dict]: """点「生成视频」前的过审闸:列出本次将生成的段里,含真人脸的参考图(人物立绘)中 **尚未过审(review_status != 'active')** 的项。火山视频对含真人脸的图,只接受素材库已过审的引用, 否则报 InputImageSensitiveContentDetected。这里在调火山前先拦,弹窗指明是哪一镜的哪个人物未过审。 ★ 故事板去掉后不再有「分镜图」这一类待审资产,闸口只剩人物立绘。 only_segment 给定 → 只校验该段(单段重跑);为 None → 校验全部「未出片」段(整批生成)。 返回 [{video_segment_id, sort_order, scene_no, kind:'person', name, asset_id, review_status}]; 空列表 = 全部已过审,可放行。""" segs = [only_segment] if only_segment is not None else list(project.video_segments.order_by("sort_order")) adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() blockers: list[dict] = [] for seg in segs: # 整批生成只校验还没出片的段;单段重跑则无论状态都校验(用户主动要重出这一段) if only_segment is None and seg.status == VideoSegment.Status.SUCCEEDED: continue scene_no = seg.sort_order + 1 scene = adopted_script.segments.filter(sort_order=seg.sort_order).first() if adopted_script else None # 人物立绘(与视频实际取图同一套逻辑,保证「拦的」就是「会传给火山的」) if scene is not None: for ref in _segment_reference_images(project, scene): if ref.get("type") != "character": continue if (ref.get("review_status") or "") != "active": blockers.append({ "video_segment_id": str(seg.id), "sort_order": seg.sort_order, "scene_no": scene_no, "kind": "person", "name": ref.get("label") or "人物", "asset_id": str(ref.get("asset_id") or ""), "review_status": ref.get("review_status") or "", }) 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, user, prompt: str, model_config_id=None, aspect_ratio: str | None = None, resolution: str | None = None, ) -> VideoSegmentVersion | None: spec = project_output_spec(video_segment.project) aspect_ratio = str(aspect_ratio or spec["aspect_ratio"] or "9:16") resolution = str(resolution or spec["resolution"] or "720p").lower() if not model_config_id: model_config_id = spec["video_model_config_id"] model_config = None if model_config_id: model_config = ( ModelConfig.objects.select_related("provider") .filter( id=model_config_id, capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE, provider__status="active", ) .first() ) if model_config is None: raise ValueError("selected video model is unavailable") else: model_config = get_default_model(ModelConfig.Capability.VIDEO) if model_config is None: raise ValueError("no active video model configured") 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 adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() if adopted_script is not None: scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first() if scene is not None and video_segment.script_segment_id != scene.id: video_segment.script_segment = scene video_segment.save(update_fields=["script_segment", "updated_at"]) # 参考图(带类型):角色/场景/商品 基础资产 + 本镜故事板帧;@图N 提示词与传图顺序严格对齐。 refs = _video_reference_images(project, video_segment) reference_images = [r["url"] for r in refs] final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt) # 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估, # 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。 # 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。 from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount est_tokens, quote = quote_video_estimate( model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=video_segment.target_duration_seconds, references=[], team=project.team, ) 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, rule=quote.meta.get("rule")), 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, **video_quote_payload(quote), "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, primary_model=model_config, prompt=final_prompt, duration=video_segment.target_duration_seconds, ratio=aspect_ratio, resolution=resolution, reference_images=reference_images, request_summary={"video_segment_id": str(video_segment.id)}, ) response, provider_task_id = routed.value task.provider_task_id = provider_task_id task.response_payload = response payload = dict(task.request_payload or {}) payload["actual_model_config_id"] = str(routed.actual_model.id) task.request_payload = payload task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save( update_fields=[ "provider_task_id", "response_payload", "request_payload", "status", "submitted_at", "updated_at", ] ) video_segment.status = VideoSegment.Status.RUNNING video_segment.save(update_fields=["status", "updated_at"]) _sync_timeline_output_spec(project, aspect_ratio=aspect_ratio, resolution=resolution) return None except Exception as exc: public_error = classify_generation_error( exc, operation="video_generate", reference_id=str(task.id) ) task.status = AITask.Status.FAILED task.error_message = str(exc)[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=task.credit_reservation, reason=str(exc)) video_segment.status = VideoSegment.Status.FAILED video_segment.error_message = public_error.fallback_message video_segment.save(update_fields=["status", "error_message", "updated_at"]) notify_generation_failure( task=task, project=project, recipient=user, stage_label=f"视频·段 {video_segment.sort_order + 1}", raw=str(exc), hint=public_error.fallback_message, ) raise def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVersion | None: # 幂等:已完成的段直接回采用版;已失败的段不再 poll。避免对已成功 task 再 poll → 二次建版 / 二次扣费。 if video_segment.status == VideoSegment.Status.SUCCEEDED: return video_segment.adopted_version or video_segment.versions.order_by("-created_at").first() if video_segment.status == VideoSegment.Status.FAILED: return None # ★ 先找「在途任务」再回退旧版本的任务。旧实现反过来:重跑时段上已有(旧)版本, # 取到旧版本挂的已成功任务 → 短路返回旧版,在途的新任务永远没人轮询, # 段永远卡「生成中」、新视频取不回来(实测重跑卡 40 分钟,ARK 侧其实早已生成完)。 ai_task = video_segment.project.ai_tasks.filter( task_type=AITask.Type.VIDEO_SEGMENT, request_payload__video_segment_id=str(video_segment.id), status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING], ).order_by("-created_at").first() if ai_task is None: latest_version = video_segment.versions.order_by("-created_at").first() ai_task = latest_version.task if latest_version else None if ai_task is None: raise ValueError("no active video generation task") # task 已终态(可能被并发的 worker / 另一次 poll 处理过):直接回已有版,不再调 ARK。 if ai_task.status == AITask.Status.SUCCEEDED: return video_segment.versions.filter(task=ai_task).order_by("-created_at").first() if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED): return None # Fallback 只发生在提交阶段;拿到远端任务 ID 后必须固定到实际提交成功的模型和 Provider 轮询。 submit_attempt = ( ai_task.model_attempts.filter(status="succeeded", operation="video_generate") .select_related("model_config__provider") .order_by("-sequence") .first() ) actual_model = submit_attempt.model_config if submit_attempt and submit_attempt.model_config else ai_task.model_config from apps.ai.routing_policy import load_model_routing_policy video_policy = load_model_routing_policy().video provider = get_video_provider(actual_model) response = provider.poll_video_task( endpoint=actual_model.endpoint, provider_task_id=ai_task.provider_task_id, timeout=video_policy.poll_request_timeout, ) remote_status = response.get("status") if remote_status in {"queued", "running", "processing"}: # 仍在生成:只在状态首次进入 POLLING 时落一次库。旧实现每次 poll(5s 一次)都把完整 # response JSON 回写远程 MySQL——纯浪费写带宽,终态时反正会存完整 payload。 if ai_task.status != AITask.Status.POLLING: ai_task.status = AITask.Status.POLLING ai_task.save(update_fields=["status", "updated_at"]) return None if remote_status in {"failed", "expired", "cancelled"}: ai_task.status = AITask.Status.FAILED ai_task.response_payload = response error_info = response.get("error") or {} ai_task.error_message = error_info.get("message", "video generation failed") public_error = classify_generation_error( RuntimeError(ai_task.error_message), operation="video_generate", provider_code=str(error_info.get("code") or ""), reference_id=str(ai_task.id), ) ai_task.completed_at = timezone.now() ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"]) release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message) video_segment.status = VideoSegment.Status.FAILED video_segment.error_message = public_error.fallback_message video_segment.save(update_fields=["status", "error_message", "updated_at"]) notify_generation_failure( task=ai_task, project=video_segment.project, recipient=user, stage_label=f"视频·段 {video_segment.sort_order + 1}", raw=ai_task.error_message, hint=public_error.fallback_message, ) return None media = provider.extract_first_media_url(response) asset = _store_generated_media( team=video_segment.project.team, user=user, project=video_segment.project, task=ai_task, media=media, name=f"{video_segment.project.name}-segment-{video_segment.sort_order + 1}", category=Asset.Category.VIDEO_CLIP, asset_type=Asset.Type.VIDEO, ) # 终态化必须持锁原子做:两个并发 poll(前端 5s 静默轮询 × 提交后轮询/worker)同时走到这里时, # 旧实现会同 task 建两个版本 + charge_reserved_credit 双扣费(实测 03:08:25 同秒双版本)。 # select_for_update 锁 task 行,后到者看到 SUCCEEDED 直接回已有版,不再建版/扣费。 with transaction.atomic(): locked_task = AITask.objects.select_for_update().get(id=ai_task.id) if locked_task.status == AITask.Status.SUCCEEDED: existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first() if existing is not None: return existing # 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径): # 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。 # usage 缺失(异常响应)回落预估价,不阻断出片。 reservation = locked_task.credit_reservation payload = dict(locked_task.request_payload or {}) try: usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0) except (TypeError, ValueError): usage_tokens = 0 settle = settle_video_from_payload(actual_model, payload=payload, tokens=usage_tokens) if settle.meta.get("rule") == "missing_usage": actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost else: actual_points, base_cost = settle.points, settle.base_cost_yuan if settle.meta.get("rate"): payload["points_per_yuan_snapshot"] = settle.meta["rate"] locked_task.request_payload = payload if actual_points > reservation.amount: logger.warning( "video segment task %s actual %s exceeds reserved %s, clamped", locked_task.id, actual_points, reservation.amount, ) actual_points = reservation.amount locked_task.status = AITask.Status.SUCCEEDED locked_task.response_payload = response locked_task.actual_cost = actual_points locked_task.base_cost = base_cost locked_task.completed_at = timezone.now() locked_task.save(update_fields=["status", "request_payload", "response_payload", "actual_cost", "base_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=actual_points) version = VideoSegmentVersion.objects.create( video_segment=video_segment, task=locked_task, asset=asset, prompt=locked_task.request_payload.get("prompt", ""), is_adopted=True, ) video_segment.versions.exclude(id=version.id).update(is_adopted=False) video_segment.adopted_version = version video_segment.status = VideoSegment.Status.SUCCEEDED video_segment.error_message = "" video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"]) return version def create_export_job(*, timeline, user) -> ExportJob: return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED) # 图片趴三类(模特库+资产模型重构): # · model + product → model_tryon(模特上身图);model 无 product → model_portrait(新建模特候选) # · cover → platform_kit(平台套图);image → free_create(自由创作) _STANDALONE_CATEGORY = { "model": Asset.Category.MODEL_PORTRAIT, "cover": Asset.Category.PLATFORM_KIT, "image": Asset.Category.FREE_CREATE, } _STANDALONE_TASK_TYPE = { "model": AITask.Type.PERSON_IMAGE, "cover": AITask.Type.PRODUCT_IMAGE, "image": AITask.Type.PRODUCT_IMAGE, } # 脚本 agent 的 SSE 流是**在进程内跑的**(专业创作走 web 进程,一键成片走 worker 线程), # 进程一没(部署滚动更新、OOM、Pod 驱逐),那条流就没了,但 AITask 行还停在 SUBMITTED, # 预扣的积分也一直冻着 —— 任务监控里就是「进行中」挂几个小时不动。 # 图片有 _reap_stale_standalone_image_tasks、视频有 video_timeout_recovery,脚本此前是**裸奔**的。 # 单次脚本生成实测在秒~分钟级,20 分钟仍没终态一律判僵尸(quick_create 的 SCRIPT_TIMEOUT 是 30 分钟, # 这里留足余量,先于它把行收干净,免得编排端一直等一个永远不会动的任务)。 SCRIPT_TASK_STALE_AFTER = timedelta(minutes=20) _SCRIPT_TASK_TYPES = (AITask.Type.SCRIPT_GENERATION, AITask.Type.SCRIPT_OPTIMIZATION) _SCRIPT_ACTIVE_STATUSES = ( AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING, ) def reap_stale_script_tasks(*, team=None, project=None) -> int: """回收被进程重启丢下的脚本任务:置 FAILED + 退还预扣积分。返回回收条数。 没有 celery beat,沿用本项目既有的「顺手回收」策略:每次新提交脚本、以及一键成片每轮推进时 各扫一次。这样存量僵尸行会在下一次生成时自愈,不需要人工进库改数据。""" if team is None and project is None: return 0 cutoff = timezone.now() - SCRIPT_TASK_STALE_AFTER qs = AITask.objects.filter( task_type__in=_SCRIPT_TASK_TYPES, status__in=_SCRIPT_ACTIVE_STATUSES, updated_at__lt=cutoff, ) qs = qs.filter(project=project) if project is not None else qs.filter(team=team) reaped = 0 for task in qs: try: with transaction.atomic(): task.status = AITask.Status.FAILED task.error_message = "脚本生成进程中断(多为服务重启/部署),僵尸任务自动回收" task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) try: reservation = task.credit_reservation except ObjectDoesNotExist: reservation = None if reservation is not None: release_credit(reservation=reservation, reason="脚本任务僵尸回收") reaped += 1 except Exception: # noqa: BLE001 — 回收是兜底,失败不该挡住正常生成 logger.warning("reap stale script task %s failed", task.id, exc_info=True) if reaped: logger.info("reaped %s stale script task(s)", reaped) return reaped def _reap_stale_standalone_image_tasks(*, team) -> None: """兜底:worker 崩溃/重启(OOM、部署)可能留下卡在 RESERVED 的出图任务,额度被一直占住、 前端轮询也永远等不到结果。超过 10 分钟(远大于单张真实出图耗时 ~60s)仍 RESERVED 的判为僵尸: 标记失败并退还预留额度。趁每次新提交时顺手回收,无需额外的定时任务(与导出僵尸清理同思路)。""" cutoff = timezone.now() - timedelta(minutes=10) stale = AITask.objects.filter( team=team, project__isnull=True, task_type__in=[AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE], status=AITask.Status.RESERVED, updated_at__lt=cutoff, ) for task in stale: try: with transaction.atomic(): task.status = AITask.Status.FAILED task.error_message = "worker 未在预期时间内完成(僵尸任务自动回收)" task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) try: reservation = task.credit_reservation except ObjectDoesNotExist: reservation = None if reservation is not None: release_credit(reservation=reservation, reason="僵尸出图任务自动回收") except Exception: # noqa: BLE001 — 单个回收失败不应阻断新任务提交 continue def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]: """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + 预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。 这样 Web 层(gunicorn)不会被慢出图请求占住 worker → 健康探针不会被饿死 → 根治"几张图就整站 502"。 且任务一旦提交(额度已预留),浏览器关掉 / 断网都不影响——worker 照样把图生成并落库,扣费/退费在 worker 内闭环。返回已 RESERVED 的 AITask 列表,前端拿 id 轮询 GET /api/ai/generate-image/?ids=… 取结果。""" from apps.ai.tasks import generate_standalone_image_task _reap_stale_standalone_image_tasks(team=team) # 用户在工作室选的生图模型(火山 / gpt-image)优先;未选或解析不到则回落系统默认 model_config = resolve_image_model(image_model) or get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE) count = max(1, min(int(count or 1), 12)) ref_ids = [str(r) for r in (reference_image_ids or []) if r] # 已迁移范围: # 1) 普通图片创作 + 单张,覆盖无参考图、单参考图和多参考图; # 2) 绑定商品的模特上身图,每张仍是独立 AITask、独立尝试链; # 3) 绑定商品的平台套图,每个平台、每张图仍沿用原有独立任务与批次关系。 # 新建模特候选(mode=model 但无 product_id)等后续入口继续保持原流程。 use_model_routing = ( (mode == "image" and count == 1) or (mode == "model" and bool(product_id)) or (mode == "cover" and bool(product_id)) ) and not reference_product # 尚未迁移的非图片创作模式保留既有兼容保护;图片创作必须尊重用户选择的主模型, # Seedream 可通过 image_generation(image=...) 处理参考图,失败后再由统一路由动态切换。 if ref_ids and mode not in {"model", "image"} and not hasattr(build_provider(model_config), "image_edit"): alt = resolve_image_model("gpt-image") if alt is not None: model_config = alt # 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。 # 重跑/补图会带原批次的 batch_id 进来 → 沿用它并打 batch_append 标记,后端记录归回原批次 # 而不是裂成一条新批次(前端按 batch_id 归批,刷新/切换对话后重跑图仍在原卡里); # batch_append 任务不计入批次「应出张数」,前端据此在补图成功后收掉对应的失败格。 is_append = False if batch_id: try: batch_id = str(uuid.UUID(str(batch_id))) is_append = True except (TypeError, ValueError): batch_id = None if not is_append: batch_id = str(uuid.uuid4()) retry_of_task_id = None elif retry_of_task_id: retry_of_task = AITask.objects.filter( id=retry_of_task_id, team=team, project__isnull=True, is_deleted=False, purged_at__isnull=True, request_payload__batch_id=batch_id, status__in=(AITask.Status.FAILED, AITask.Status.CANCELLED), ).first() retry_of_task_id = str(retry_of_task.id) if retry_of_task else None # 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。 platform_key = str(platform_id or "").strip() if mode == "cover" else "" platform_name = _PLATFORM_NAMES.get(platform_key, "") from apps.billing.pricing import quote_flat # Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型; # Step 3 的 Worker 根据 MODEL_TRYON_PROMPT_V2_ENABLED 决定使用 V2 或旧提示词。 tryon_classification: dict[str, str | None] | None = None tryon_trouser_facts: dict[str, str | None] | None = None if mode == "model" and product_id: from apps.ai.tryon_prompt import ( ClassificationResult, ProductContext, ProductKind, classify_product_details, extract_trouser_facts, ) from apps.products.models import Product product_for_classification = Product.objects.filter(id=product_id, team=team).only( "title", "category", "description" ).first() if product_for_classification is None: classification = ClassificationResult( ProductKind.UNKNOWN, "fallback", None, "product_not_found", ) else: classification_context = ProductContext.create( title=product_for_classification.title, category=product_for_classification.category, description=product_for_classification.description, ) product_context = ProductContext.create( title=classification_context.title, category=classification_context.category, description=classification_context.description, selling_points=tuple( product_for_classification.selling_points.values_list("title", flat=True)[:8] ), ) classification = classify_product_details( classification_context, prompt, ) trouser_facts = extract_trouser_facts(product_context, classification.kind, prompt) if trouser_facts is not None: tryon_trouser_facts = trouser_facts.as_payload() tryon_classification = classification.as_payload() tasks: list[AITask] = [] for index in range(count): quote = quote_flat(model_config, team=team) request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None} if feature: request_payload["feature"] = str(feature) if use_model_routing: request_payload["model_routing_v1"] = True if tryon_classification is not None: request_payload["tryon_classification"] = dict(tryon_classification) if tryon_trouser_facts is not None: request_payload["tryon_trouser_facts"] = dict(tryon_trouser_facts) request_payload["tryon_batch_count"] = count # 仅供内部 A/B 工具使用;GenerateImageView 不接收这两个参数,用户请求无法绕过全局开关。 if tryon_prompt_v2_override: request_payload["tryon_prompt_v2_override"] = True if tryon_ab: request_payload["tryon_ab"] = { "experiment_id": str(tryon_ab.get("experiment_id") or ""), "variant": str(tryon_ab.get("variant") or ""), } # 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判 if is_append: request_payload["batch_append"] = True if retry_of_task_id: request_payload["retry_of_task_id"] = retry_of_task_id if quote.meta.get("rate"): request_payload["points_per_yuan_snapshot"] = quote.meta["rate"] task = AITask.objects.create( team=team, created_by=user, project=None, conversation=conversation, task_type=task_type, status=AITask.Status.CREATED, model_config=model_config, idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}", request_payload=request_payload, estimated_cost=quote.points, # 路由入口的平台成本改由每条 AIModelAttempt 累加,避免候选切换后仍记主模型旧成本。 base_cost=Decimal("0") if use_model_routing else quote.base_cost_yuan, ) # 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务) reserve_credit(team=team, user=user, task=task, amount=quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) tasks.append(task) # 额度都预留成功后再统一派发,避免"派发了任务但后面某张预留失败"的半成品状态 if dispatch: for task in tasks: generate_standalone_image_task.delay(str(task.id)) return tasks def run_standalone_image_task(*, task_id: str) -> None: """Celery worker 内执行**单张**图的慢活:调 ARK → 成功落库扣费 / 失败退费。 幂等:只处理 RESERVED 状态的任务,重复投递(celery retry / 重启重放)不会二次出图、二次扣费。""" task = AITask.objects.select_related("team", "created_by", "model_config").filter(id=task_id).first() if task is None or task.status != AITask.Status.RESERVED: return team = task.team user = task.created_by payload = dict(task.request_payload or {}) prompt = str(payload.get("prompt") or "") mode = str(payload.get("mode") or "image") index = int(payload.get("index") or 0) product_id = payload.get("product_id") or None # 模特上身图(mode=model 且绑了商品)= 图片趴「模特上身图」(引用模特库),归 model_tryon、不送审; # 无商品的 mode=model 仅用于新建模特候选,归 model_portrait;项目角色只由项目基础资产流程创建 person。 if mode == "model" and product_id: category = Asset.Category.MODEL_TRYON else: category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED) model_config = task.model_config use_model_routing = ( bool(payload.get("model_routing_v1")) and mode in {"image", "model", "cover"} and not payload.get("reference_product") ) # 路由入口必须在每次尝试时按真实候选重新构造 Provider;提前构造会让主模型配置错误绕过尝试日志。 provider = None if use_model_routing else get_image_provider(model_config) reservation = task.credit_reservation # 出图策略(都优先 image_edit 锁真实素材,模型不支持/无素材才回落纯文生图): # · 模特上身图(mode=model):参考图1=商品真实主图 + 参考图2=选中模特 → 生成「该模特用该商品」效果图; # · 平台套图(mode=cover):参考图1=商品真实主图(+ 有模特则参考图2=模特)→ 锁包装一致性出套图; # · 商品三视图(reference_product):参考图1=商品真实主图 → 锁包装一致性; # · 其余(图片创作):纯文生图。 product = None if product_id: from apps.products.models import Product product = Product.objects.filter(id=product_id, team=team).first() can_edit = hasattr(provider, "image_edit") if provider is not None else False model_url = "" if payload.get("model_id"): model_asset = Asset.objects.filter(id=payload.get("model_id")).first() if model_asset is not None: model_url = _asset_preview_url(model_asset) product_url = _product_cover_url(product) if product is not None else "" # 模特上身图:真实上传图优先、排除 AI 生成图,可多张(多角度更易锁外形/品牌) product_urls = _product_reference_urls(product, limit=3) if product is not None else [] # 图片创作自由模式:用户上传的参考图(已先传成 Asset)→ 取直链,作多图参考(image_edit / 图生图) ref_urls: list[str] = [] for rid in (payload.get("reference_image_ids") or []): ref_asset = Asset.objects.filter(id=rid).first() if ref_asset is not None: u = _asset_preview_url(ref_asset) if u: ref_urls.append(u) # 参考图收集与 provider 无关:先把「该用哪些参考图 + 哪条提示词」定下来,再按模型能力选调用方式。 edit_images: list[str] = [] edit_prompt = "" output_ratio = str(payload.get("ratio") or "") tryon_prompt_trace: dict | None = None tryon_rollout_source = ( _model_tryon_prompt_v2_rollout_source(team_id=team.id, payload=payload) if mode == "model" else None ) if mode == "model" and product_urls: # 模特上身图:参考图1~N=商品真实图(多角度),参考图N+1=模特(模特图可缺则让模型自取真人模特) edit_images = product_urls + ([model_url] if model_url else []) legacy_prompt = build_model_tryon_prompt_refs( product, has_model=bool(model_url), base_prompt=prompt, index=index, n_product=len(product_urls), ) edit_prompt = legacy_prompt if tryon_rollout_source is not None: try: edit_prompt, tryon_prompt_trace, output_ratio = _build_model_tryon_prompt_v2( product=product, payload=payload, has_model=bool(model_url), index=index, n_product=len(product_urls), rollout_source=tryon_rollout_source, ) except Exception as exc: # noqa: BLE001 — 规划器异常时安全回落旧提示词,不让已预扣任务悬空 tryon_prompt_trace = { "version": "v2.6", "applied": False, "rollout_source": tryon_rollout_source, "effective_prompt": legacy_prompt, "shot_index": index, "batch_count": payload.get("tryon_batch_count"), "requested_ratio": output_ratio or None, "resolved_ratio": output_ratio or None, "ratio_source": "user" if output_ratio else None, "fallback_reason": str(exc) if isinstance(exc, ValueError) else type(exc).__name__, "reference_roles": { "product_numbers": list(range(1, len(product_urls) + 1)), "model_portrait_number": len(product_urls) + 1 if model_url else None, "model_triview_number": None, }, } elif mode == "cover" and product_urls: # 平台套图:参考图1~N=商品真实图(多角度,_product_reference_urls 已优先真实上传图/排除 AI 图), # 有模特则参考图N+1=模特(锁人脸/身形)。platform_id 注入平台版式块(优化版)。 edit_images = product_urls + ([model_url] if model_url else []) edit_prompt = build_platform_cover_prompt_refs( product, has_model=bool(model_url), base_prompt=prompt, platform_id=str(payload.get("platform_id") or ""), index=index, product_ref_count=len(product_urls), ) elif bool(payload.get("reference_product")) and product_url: edit_images = [product_url] edit_prompt = build_product_triview_prompt_refs(product, "") elif ref_urls: # 图片创作自由模式:以用户上传的参考图为基底出图。提示词必须显式要求「保留参考图主体」, # 否则图生图只会松散借个色调、不真的还原上传的素材(=用户反馈的「没参考我的图」)。 edit_images = ref_urls edit_prompt = build_free_reference_prompt(prompt, n_refs=len(ref_urls), index=index) elif mode == "model" and product_id and tryon_rollout_source is not None: # 没有真实商品参考图时维持原纯文生图行为,但明确留下未应用原因,不能伪装成 V2 已生效。 tryon_prompt_trace = { "version": "v2.6", "applied": False, "rollout_source": tryon_rollout_source, "effective_prompt": prompt, "shot_index": index, "batch_count": payload.get("tryon_batch_count"), "requested_ratio": output_ratio or None, "resolved_ratio": output_ratio or None, "ratio_source": "user" if output_ratio else None, "fallback_reason": "no_product_reference", "reference_roles": { "product_numbers": [], "model_portrait_number": None, "model_triview_number": None, }, } use_edit = bool(edit_images) try: if tryon_prompt_trace is not None: payload["tryon_prompt"] = tryon_prompt_trace task.request_payload = payload task.save(update_fields=["request_payload", "updated_at"]) # 纯文生图同批多张要不同构图,否则雷同(PMC#24);index>0 追加换版式指令。 # 当前统一路由只接入单张图片创作,但这里仍保留原提示词构造供未迁移批量旧路径复用。 gen_prompt = prompt if index > 0: variation = _FREE_VARIATIONS[index % len(_FREE_VARIATIONS)] gen_prompt = f"{prompt}。{variation},不要在画面中添加任何文字、卖点、标题或价签。" if use_model_routing: call_prompt = edit_prompt if edit_images else gen_prompt routed = execute_routed_image_request( task=task, primary_model=model_config, prompt=call_prompt, reference_images=edit_images, aspect_ratio=output_ratio or None, edit_size=_ratio_to_image_size(output_ratio), direct_size=_ratio_to_volcano_size(output_ratio), ) response, media = routed.value elif use_edit and can_edit: # gpt-image 等支持 image_edit(多图参考编辑接口) if payload.get("reference_product"): size = "1536x1024" # 三视图固定横向 else: size = _ratio_to_image_size(output_ratio) # 模特图按用户比例或 V2 默认比例 response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size) elif use_edit: # 火山 Seedream 无 image_edit:走 image_generation 带 image=参考图(图生图多参考); # 尺寸按选中比例换算成火山可接受的 ~2K 尺寸(三视图固定横向) vsize = "2304x1728" if payload.get("reference_product") else _ratio_to_volcano_size(output_ratio) response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=edit_prompt, image=edit_images, size=vsize) else: response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=gen_prompt) if not use_model_routing: media = provider.extract_first_media_url(response) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) fileobj, content_type = VolcanoArkProvider.media_to_bytes(media) suffix = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png") asset_id = uuid.uuid4() object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}" stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type) # 商品三视图(reference_product):与视频项目基础资产阶段生成的商品三视图同款标记 —— # category=product_image + 名字含「商品三视图」+ metadata.view=three_view + product_id。 # 否则商品库自己生成的三视图认不出(无 view 标记),刷新即丢、视频项目第二步也读不到「商品已有三视图」(ZWQ#5 续)。 is_product_triview = bool(payload.get("reference_product")) asset_label = "商品三视图" if is_product_triview else {"model": "模特上身图", "cover": "平台套图", "image": "图片创作"}.get(mode, mode) asset_category = Asset.Category.PRODUCT_IMAGE if is_product_triview else category # 资产元数据:product_id(商品详情页据此只展示该商品素材)+ batch_id(成组)+ mode + model_entity_id(上身图溯源模特库) asset_meta: dict = {"mode": mode} if is_product_triview: asset_meta["view"] = "three_view" if product_id: asset_meta["product_id"] = str(product_id) if payload.get("batch_id"): asset_meta["batch_id"] = str(payload["batch_id"]) if payload.get("model_entity_id"): asset_meta["model_entity_id"] = str(payload["model_entity_id"]) # 全能创作出图只留在会话里,不进图片创作最近列表 / 资产库。 is_omni = str(payload.get("feature") or "") == "omni_create" if is_omni: asset_meta["feature"] = "omni_create" asset = Asset.objects.create( id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}", asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=asset_category, origin_task=task, metadata=asset_meta, # 模特候选与项目角色均是功能性资料:候选保存后进入模特库,不进入 /library; # 商品/创作等图片趴成图保持原有自动入库行为。 # 全能创作同视频:不自动入库。 in_library=( False if is_omni else asset_category not in (Asset.Category.PERSON, Asset.Category.MODEL_PORTRAIT) ), ) AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True) except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) task.status = AITask.Status.FAILED task.error_message = str(exc) task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) notify_generation_failure( task=task, project=task.project, recipient=user, stage_label="图片创作", raw=str(exc), hint=friendly_generation_error(str(exc)), ) from apps.ai.creation import sync_generating_for_task # 全能创作挂在这条任务上的 GENERATING 要立刻改成 RESULT/ERROR, # 不能干等前端下一次轮询 —— 否则页面会一直停在「正在生成」。 sync_generating_for_task(task) # ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ── # 音色按「语音合成(经典版)」试用包实测可用清单配置;大模型音色(*_bigtts)需另开通「语音合成大模型」服务,当前账号 403 VOICEOVER_VOICES = [ {"key": "BV700_streaming", "label": "灿灿 · 活力女声"}, {"key": "BV034_streaming", "label": "知性姐姐 · 沉稳女声"}, {"key": "BV001_streaming", "label": "通用女声"}, {"key": "BV056_streaming", "label": "阳光男声"}, {"key": "BV102_streaming", "label": "儒雅青年 · 解说男声"}, {"key": "BV002_streaming", "label": "通用男声"}, ] DEFAULT_VOICEOVER_VOICE = VOICEOVER_VOICES[0]["key"] def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type: str, speed_ratio: float = 1.0) -> dict: """每镜旁白 → **逐句** TTS 配音资产(一句一段音频,带句内起点 offset_ms),映射写入 timeline.metadata["voiceover"]。逐句才能支持「拖动字幕块 = 字幕和它的语音一起移动」; 一次调用 = 一个 AITask = 计一次费;任何一句失败则整体失败并释放预留(不留半套配音)。""" from apps.projects.services.export import _split_subtitle_text texts = [] # (片段 index, 句序 cue, 句文本) for n, item in enumerate(items or []): text = str(item.get("text") or "").strip() if not text: continue idx = int(item.get("index", n)) pieces = _split_subtitle_text(text) or [text] for j, piece in enumerate(pieces): texts.append((idx, j, piece)) if not texts: raise ValueError("没有可配音的旁白文本") voice_type = voice_type or DEFAULT_VOICEOVER_VOICE model_config = get_default_model(ModelConfig.Capability.AUDIO) if model_config is None: raise ValueError("no active audio model configured") primary_provider = get_audio_provider(model_config) # 保持当前直连未配置时“不建任务、不预扣”的旧体验;管理员将该模型的向外 Fallback 开关 # 打开后,则允许统一执行器把配置故障路由到已启用的 OpenAI 兼容音频候选。 if ( hasattr(primary_provider, "configured") and not primary_provider.configured and not model_allows_fallback(model_config) ): raise TtsNotConfigured( "语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN" "(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)" ) # 配音按字符数阶梯计价(默认每 500 字 10 积分,不足按 500):长短脚本不再同价 from apps.billing.pricing import quote_voiceover char_count = sum(len(text) for _, _, text in texts) quote = quote_voiceover(model_config, char_count=char_count, team=project.team) task = create_ai_task( project=project, user=user, task_type=AITask.Type.VOICEOVER, model_config=model_config, quote=quote, request_payload={ "voice_type": voice_type, "speed_ratio": float(speed_ratio or 1.0), "char_count": char_count, "items": [{"index": idx, "cue": j, "text": text} for idx, j, text in texts], "model_routing_v1": True, }, ) reservation = task.credit_reservation task.base_cost = Decimal("0") task.save(update_fields=["base_cost", "updated_at"]) try: task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) synthesized = [] for idx, j, text in texts: routed = execute_routed_audio_request( task=task, primary_model=model_config, text=text, public_voice=voice_type, speed_ratio=speed_ratio, user_id=str(user.id), request_summary={"segment_index": idx, "cue_index": j}, ) audio, duration_ms = routed.value synthesized.append((idx, j, text, audio, duration_ms)) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.actual_cost = task.estimated_cost task.completed_at = timezone.now() task.response_payload = {"segments": len(synthesized)} task.save(update_fields=["status", "actual_cost", "completed_at", "response_payload", "updated_at"]) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) vo_items = [] offset_acc: dict[int, int] = {} # 同一片段内逐句顺排:句 j 的默认起点 = 前面句时长之和 for idx, j, text, audio, duration_ms in synthesized: asset_id = uuid.uuid4() object_key = f"teams/{project.team_id}/projects/{project.id}/voiceover/{asset_id}.mp3" stored = TosStorage().upload_fileobj(fileobj=BytesIO(audio), object_key=object_key, content_type="audio/mpeg") asset = Asset.objects.create( id=asset_id, team=project.team, created_by=user, name=f"配音 · 场 {idx + 1} · 句 {j + 1}", asset_type=Asset.Type.AUDIO, source=Asset.Source.AI_GENERATED, category=Asset.Category.UNCATEGORIZED, origin_task=task, description=text, ) AssetFile.objects.create( asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True, ) offset_ms = offset_acc.get(idx, 0) offset_acc[idx] = offset_ms + (duration_ms or 0) vo_items.append({ "index": idx, "cue": j, "text": text, "asset": str(asset.id), "duration_ms": duration_ms, "offset_ms": offset_ms, }) timeline, _ = Timeline.objects.get_or_create( project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60} ) metadata = dict(timeline.metadata or {}) metadata["voiceover"] = { "enabled": True, "voice_type": voice_type, "speed_ratio": float(speed_ratio or 1.0), "items": vo_items, } timeline.metadata = metadata timeline.save(update_fields=["metadata", "updated_at"]) return metadata["voiceover"] except Exception as exc: task.status = AITask.Status.FAILED task.error_message = str(exc)[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) release_credit(reservation=reservation, reason=str(exc)) raise