Files
yingqing/core/backend/apps/ai/video_replace.py
T
Azmat@qq.com 0bd1db6bf9 模特库标签分页与全能创作收口:藏长视频、选择器分页
角色库导入打标签并支持筛选;模特库与全能创作角色/商品选择改为每页 20 条分页。临时限制成片 ≤60 秒,过滤对话里的超长时长选项,并收拢本地全能创作与后台用户相关修复。
2026-09-21 16:24:37 +08:00

2026 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频复刻:参考视频提炼分镜稿 → Seedance 2.5 按稿出片。
流程很短:
1. 用户上传的参考视频只走「提炼提示词」同一套接口,拆成中文分镜稿;
2. 把这份稿(格式与提炼页一模一样)加上换商品/换角色,交给 Seedance 2.5;
3. 只带商品图/角色图。用户原片不传火山,避免素材审核报错。
商品图/人物图过审后用 asset://;审核失败不扣费。
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from decimal import Decimal
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from apps.assets.models import Asset, Model
from apps.billing.models import CreditAccount
from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload, video_reserve_amount
from apps.products.models import Product
from .free_video import (
FREE_VIDEO_MODELS,
HIGH_RES_MODEL,
REPLACE_MODEL,
IN_FLIGHT_STATUSES,
RATIOS,
RESOLUTIONS,
normalize_video_seed,
_reap_stale_free_video_tasks,
model_duration_range,
serialize_free_video_task,
start_pending_free_video,
submit_free_video,
)
from .models import AITask, ModelConfig
logger = logging.getLogger(__name__)
FEATURE = "video_replace"
REPLACE_MODES = {"product", "character"}
MAX_IMAGES = 9
LEGACY_PROMPT_PREFIX = "[视频复刻]"
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
TRIVIEW_LABEL = "目标商品三视图"
# 复刻走 Seedance 2.5(单次最长 30 秒)。半秒容差同 media_probe(30 秒成片常被探成 30.02)。
REPLACE_REF_DURATION_MAX = 30.5
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
class VideoReplaceInProgress(ValueError):
"""本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
def __init__(self, task):
super().__init__("已有一个视频正在复刻中,完成或取消后才能再提交")
self.task = task
def get_inflight_video_replace(team):
"""本团队正在跑的复刻任务(含审核中 / 提炼中)。没有返回 None。"""
_reap_stale_free_video_tasks(team=team)
return (
AITask.objects.filter(
team=team,
task_type=AITask.Type.FREE_VIDEO,
status__in=IN_FLIGHT_STATUSES,
is_deleted=False,
purged_at__isnull=True,
)
.filter(video_replace_q())
.order_by("-created_at")
.first()
)
PRODUCT_PROMPT = (
"按下面的商品语义重构分镜生成视频。@目标商品只用于锁定外观、材质与包装。"
"严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。"
)
# 角色复刻提示词:极简单句。
# ⚠️ 只换画面,不碰声音 —— 成片保留参考视频原声。
# 历史(别再走一遍):试过让 Seedance 换声的四条路,提示词声线规则、音频参考走 TOS 直链、
# 走 asset:// 素材库引用、以及平台预置样音,全部只做到「中途才切到目标音色」或完全无效。
# 编辑模式(参考视频 + duration=-1 + adaptive)对音轨的处理不受这些输入控制。
# 因此产品上收敛为:只支持同性别角色替换,声音一律保留原声,前端明确告知用户。
CHARACTER_PROMPT = (
"将@参考视频中出现的角色替换为@参考角色,"
"其它部分例如构图、运镜、人物动作、场景、商品、剪辑、画面节奏等保持与原视频一致。"
)
def build_character_submit_prompt(subject_brief: str = "", *, has_voice_ref: bool = False) -> str:
"""角色复刻提交提示词。就是那一句。
参数保留是为了兼容既有调用点;声音已不再由提示词控制,两个参数都不影响输出。
"""
return CHARACTER_PROMPT
# 用户在第 3 步填的补充信息(商品名/品类,或角色性别年龄音色)。选填,不填维持原行为。
MAX_SUBJECT_BRIEF = 500
def _clean_subject_brief(value) -> str:
return str(value or "").strip()[:MAX_SUBJECT_BRIEF]
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
# 商品库里有三视图时才追加(没有就不提,免得模型去找一张不存在的图)
PRODUCT_TRIVIEW_NOTE = "商品各面以@目标商品三视图为准。"
# 只在用户主动把成片调得比参考片短很多时才出现。时长一致时绝不出现 ——
# 它和「节奏一模一样」是互相打架的。
PRODUCT_CONDENSE_NOTE = (
"参考片约 {source} 秒,本次成片 {output} 秒:整镜省略次要镜头,不要快放、不要把两镜压成一镜。"
)
SHOT_HEADER = "【镜头"
DIGEST_TAIL_SECTION = "【拆解存疑】"
# 整片设定里值得留的(顺序即输出顺序)
KEEP_HEADER_FIELDS = ("画面比例", "整体风格", "主线")
# 喂给 Seedance 的镜头原文栏位。字幕/音效/BGM/备注会诱使模型画花字、加戏,一律丢掉。
SHOT_COPY_FIELDS = ("时间", "时长", "景别", "机位", "运镜", "画面", "人物动作", "人物表情", "台词/旁白")
SHOT_INLINE_FIELDS = ("时间", "时长", "景别", "机位", "运镜")
SHOT_DETAIL_FIELDS = (("画面", "画面"), ("人物动作", "动作"), ("人物表情", "表情"), ("台词/旁白", "口播"))
_EMPTY_VALUES = {"", "无", "不可见", "看不出", "看不清", "听不清"}
# 模型会写「不可见。」「无。」这种带句读的空值,比对前先剥掉尾部标点
_TRAILING_PUNCT = "。.,,、;:!? "
def _is_empty_value(value: str) -> bool:
return value.strip().rstrip(_TRAILING_PUNCT) in _EMPTY_VALUES
def _parse_digest(digest_text: str) -> tuple[dict, list[dict]]:
"""分镜稿纯文本 → (整片设定, 镜头列表)。解析不出镜头返回 ([], []),由调用方回落原文。"""
body = (digest_text or "").strip()
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
if sep:
body = head.strip()
header: dict[str, str] = {}
shots: list[dict] = []
current: dict | None = None
for raw in body.split("\n"):
line = raw.strip()
if not line:
continue
if line.startswith(SHOT_HEADER):
current = {}
shots.append(current)
continue
key, sep_char, value = line.partition("")
if not sep_char:
continue
key, value = key.strip(), value.strip()
if current is None:
header[key] = value
else:
current[key] = value
return header, [shot for shot in shots if shot]
def _render_shot_table(header: dict, shots: list[dict]) -> str:
"""镜头表:每镜一行摘要(时间/景别/机位/运镜)+ 画面、动作、表情、口播四行细节。
空值(无 / 不可见)整行省掉 —— 反复出现的「无」只会稀释真正的指令。"""
lines: list[str] = ["【整片设定】"]
for field in KEEP_HEADER_FIELDS:
value = (header.get(field) or "").strip()
if not _is_empty_value(value):
lines.append(f"{field}{value}")
lines.append(f"镜头数:{len(shots)} 镜(下面逐镜照拍,不要增删、不要改顺序)")
lines.append("")
lines.append("【逐镜还原】")
for index, shot in enumerate(shots, start=1):
inline = [shot.get(f, "").strip() for f in SHOT_INLINE_FIELDS]
inline = [v for v in inline if not _is_empty_value(v)]
lines.append(f"镜头 {index:02d} | " + " | ".join(inline) if inline else f"镜头 {index:02d}")
for field, label in SHOT_DETAIL_FIELDS:
value = (shot.get(field) or "").strip()
if not _is_empty_value(value):
lines.append(f" {label}{value}")
return "\n".join(lines)
# —— 逐镜生成 ——
# 一镜一次 Seedance,每段只还原原片对应时间,并把 @参考视频 带上锁切镜。
MIN_SHOT_SECONDS = 4 # 火山单次出片下限
MAX_CLIP_SECONDS = 15 # Seedance 2.0 单次上限;单镜再长也一次出,避免凭空切一刀
MAX_SHOTS = 12 # 再多就是积分黑洞,也超出用户预期
_CLOCK_RE = re.compile(r"(\d{1,2}):(\d{2})")
def _raw_shot_seconds(shot: dict) -> int:
"""分镜稿里这一镜多长,不做上下限裁切。优先「时长」,其次用「时间」区间算。"""
raw = (shot.get("时长") or "").strip()
digits = re.sub(r"[^0-9]", "", raw)
if digits:
return max(1, int(digits))
stamps = _CLOCK_RE.findall(shot.get("时间") or "")
if len(stamps) >= 2:
start = int(stamps[0][0]) * 60 + int(stamps[0][1])
end = int(stamps[1][0]) * 60 + int(stamps[1][1])
if end > start:
return end - start
return 5
def _clip_seconds(raw: int) -> int:
return max(MIN_SHOT_SECONDS, min(MAX_CLIP_SECONDS, int(raw or 0) or MIN_SHOT_SECONDS))
def _pack_shot_groups(shots: list[dict]) -> list[list[dict]]:
"""不足 4 秒的相邻镜合并成一次出片,避免火山拒单,也避免把 2 秒镜硬拉成 4 秒。"""
groups: list[list[dict]] = []
current: list[dict] = []
current_sec = 0
for shot in shots[:MAX_SHOTS]:
sec = _raw_shot_seconds(shot)
if current and current_sec < MIN_SHOT_SECONDS:
current.append(shot)
current_sec += sec
continue
if current:
groups.append(current)
current = [shot]
current_sec = sec
if current:
if groups and current_sec < MIN_SHOT_SECONDS:
groups[-1].extend(current)
else:
groups.append(current)
return groups
def _trim_groups_to_output(groups: list[list[dict]], output_seconds: int) -> list[list[dict]]:
"""用户把成片要得比参考片短很多时,整组省略尾镜,不要压缩画面。"""
if not output_seconds or not groups:
return groups
total = sum(_clip_seconds(sum(_raw_shot_seconds(s) for s in group)) for group in groups)
if total - output_seconds < 3:
return groups
kept: list[list[dict]] = []
acc = 0
for group in groups:
if acc >= output_seconds:
break
kept.append(group)
acc += _clip_seconds(sum(_raw_shot_seconds(s) for s in group))
return kept or groups[:1]
def _verbatim_shot_block(shot: dict) -> str:
"""提炼稿这一镜的原文栏位,不改写、不换标签名。"""
lines = []
for field in SHOT_COPY_FIELDS:
value = (shot.get(field) or "").strip()
if not _is_empty_value(value):
lines.append(f"{field}{value}")
return "\n".join(lines)
def _shot_clock(shot: dict) -> str:
clock = (shot.get("时间") or "").strip()
return clock if clock and not _is_empty_value(clock) else ""
def build_shot_plan(
digest_text: str,
subject_name: str,
*,
has_triview: bool = False,
replace_mode: str = "product",
output_seconds: int = 0,
) -> list[dict]:
"""分镜稿 → 逐镜生成计划。解析不出镜头返回 [],调用方回落单次生成。"""
_header, shots = _parse_digest(digest_text)
if not shots:
return []
groups = _trim_groups_to_output(_pack_shot_groups(shots), output_seconds)
total = len(groups)
plan = []
for index, group in enumerate(groups, start=1):
seconds = _clip_seconds(sum(_raw_shot_seconds(shot) for shot in group))
plan.append({
"index": index,
"seconds": seconds,
"prompt": build_shot_prompt(
group, index=index, total=total, seconds=seconds,
subject_name=subject_name, has_triview=has_triview, replace_mode=replace_mode,
),
})
return plan
def build_shot_prompt(
shots, *, index: int, total: int, seconds: int, setting: str = "",
subject_name: str, has_triview: bool = False, replace_mode: str = "product",
) -> str:
"""2.0 短指令 + 这一镜的提炼原文。@参考视频 才是切镜来源,不要扩写。"""
from .services import enforce_no_embedded_captions
group = shots if isinstance(shots, list) else [shots]
subject = (subject_name or "").strip() or ("目标角色" if replace_mode == "character" else "目标商品")
is_character = replace_mode == "character"
if len(group) == 1:
clock = _shot_clock(group[0])
span = f",对应原片 {clock}" if clock else ""
scope = f"只生成下面这一镜(第 {index}/{total} 镜),时长 {seconds}{span}。不要拍前后镜头,不要额外切镜。"
shot_body = _verbatim_shot_block(group[0])
else:
scope = (
f"按顺序只拍下面 {len(group)} 个连续切点(第 {index}/{total} 段),共 {seconds} 秒。"
"只在这些切点切换,不要加镜。"
)
blocks = []
for offset, shot in enumerate(group, start=1):
clock = _shot_clock(shot)
head = f"切点 {offset}" + (f"(原片 {clock}" if clock else "")
block = _verbatim_shot_block(shot)
blocks.append(f"{head}\n{block}" if block else head)
shot_body = "\n".join(blocks)
if is_character:
swap = (
"将画面中需要替换的原人物完整替换为@目标角色。"
"保留@参考视频的商品、场景、镜头运动、剪辑节奏与口播。"
)
else:
swap_bits = [
"将画面中需要替换的原商品完整替换为@目标商品的外观。",
"保留@参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围。",
]
if has_triview:
swap_bits.append(PRODUCT_TRIVIEW_NOTE)
if subject:
swap_bits.append(f"口播里的旧商品名改说「{subject}」。")
swap = "".join(swap_bits)
parts = [
"使用@参考视频作为镜头、节奏与口播氛围基准。",
scope,
swap,
shot_body,
]
return enforce_no_embedded_captions("\n".join(part for part in parts if part))
def _digest_for_seedance(digest_text: str) -> str:
"""提炼页同款分镜稿。只砍给用户校对的【拆解存疑】,其余原文照传。"""
body = (digest_text or "").strip()
if not body:
raise ValueError("参考视频拆解结果为空,请重试")
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
return (head if sep else body).strip()
def _semantic_skill_dir() -> Path:
"""语义重构 skill 必须在 backend/skills 中,确保镜像内也能加载。"""
base = Path(settings.BASE_DIR)
for candidate in (base / "skills", base.parent.parent / "skills"):
skill = candidate / "product-semantic-video-remix" / "SKILL.md"
if skill.is_file():
return skill
return base / "skills" / "product-semantic-video-remix" / "SKILL.md"
def load_product_semantic_remix_skill() -> str:
path = _semantic_skill_dir()
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError("商品语义重构规则未部署,请联系管理员") from exc
def _product_semantic_facts(product: Product) -> dict:
"""商品库中已填写的事实 + 动作继承策略。
⚠️ 「资料不足」只限制**能说什么**(功效/成分/卖点),不限制**能拍什么动作**。
商品的物理形态(瓶/软管/按压泵/滴管/膏体/盒装)从商品图就能直接看出来,
是可见事实而非推断;拿取、旋开、按压、涂抹这类动作由形态决定,不由功效决定。
早期版本把两者混为一谈,资料一不全就把原片的使用镜全改成「摆在桌上转一转」,
化妆品复刻化妆品也会退化成静物摆拍 —— 这里明确拆开两个维度。
"""
selling_points = [
{"标题": point.title.strip(), "说明": point.detail.strip()}
for point in product.selling_points.all()
if point.title.strip() or point.detail.strip()
]
category = product.category.strip()
has_text = bool(product.description.strip() or selling_points)
if category and has_text:
facts_status = "完整"
elif category or has_text:
facts_status = "部分"
else:
facts_status = "仅外观"
return {
"商品名称": product.title.strip(),
"品牌": product.brand.strip(),
"商品品类": category,
"商品描述": product.description.strip(),
"真实卖点": selling_points,
"目标用户": product.target_audience.strip(),
"规格": product.specs or {},
"允许使用场景": (product.specs or {}).get("allowed_use_cases") or (product.specs or {}).get("使用场景") or [],
"禁止宣称": (product.specs or {}).get("prohibited_claims") or (product.specs or {}).get("禁止宣称") or [],
"资料状态": facts_status,
"资料状态的含义": "该状态只约束口播能说什么(功效、成分、卖点必须有据);不约束画面能拍什么动作。",
"形态判定": "商品的形态、部件、开合方式和使用接口以商品参考图为准,可直接据图判断,这属于可见事实,不是猜测。",
"动作策略": "同族继承优先:目标商品与参考片原商品属于同一形态族或品类族时,逐镜沿用原片的取出、开启、使用、作用部位等动作,只替换商品外观与台词事实。仅当形态确实不兼容时,才按目标商品形态重新设计动作。",
"禁止推断": "不得编造功效、成分、认证、检测结果,以及任何未在本资料中出现的卖点。",
}
def build_product_semantic_remix_messages(*, digest_text: str, product_facts: dict, duration: int, aspect_ratio: str) -> list[dict]:
"""参考片只作为结构输入;商品事实和参考片信息严格分栏,防止语义串台。"""
facts = json.dumps(product_facts, ensure_ascii=False, indent=2)
return [
{"role": "system", "content": load_product_semantic_remix_skill()},
{
"role": "user",
"content": (
"请执行商品语义重构,不要直接把参考分镜改几个名词。\n"
f"成片时长:{duration} 秒;画面比例:{aspect_ratio}\n\n"
"【新商品的唯一事实来源】\n"
f"{facts}\n\n"
"【参考视频拆解稿:镜头骨架、叙事结构与画面表达的基准。"
"同族判定成立时,其中的商品使用动作也在可继承范围内】\n"
f"{_digest_for_seedance(digest_text)}\n\n"
"保留参考片的人物、场景、拍摄视角、镜头构图、运镜、剪辑顺序、节奏、情绪和营销结构;将原商品完整替换为目标商品。\n"
"【第一步·同族判定】先对比目标商品与参考片原商品的形态族(瓶、软管、按压泵、滴管、喷雾、罐、盒、"
"卡片、穿戴件、电子设备等)和品类族(护肤彩妆、食品饮料、日化清洁、数码电子、服饰鞋包、家居用品等),"
"判定属于同族还是异族,再决定每一镜怎么写:\n"
"· 判定为同族:逐镜沿用原片的取出、开启、使用方式、作用部位和动作幅度,只把商品外观和台词事实换掉。"
"原片是化妆品的挤出、按压、上脸、涂抹、试色、补妆镜时,目标商品同为化妆品就必须照着拍同样的使用动作,"
"只是换成目标商品的瓶身、质地和取用方式;绝不允许降级成静物展示。\n"
"· 判定为异族:保留该镜的景别、运镜、时长和叙事功能,按目标商品自己的形态和真实用途重新设计合理动作,"
"同样要拍成「在用」,而不是「在摆」。\n"
"所有持握、开启、穿戴、使用、安装和展示必须符合真实物理常识:不得尺寸不匹配、虚构配件、商品变形换款变色,"
"也不得生成未经商品信息支持的功能。\n"
"【资料不全时怎么办】资料状态为‘部分’或‘仅外观’时,只收紧**口播能说什么**:不说功效、成分、认证和具体卖点,"
"改说看得见的外观、质地、取用手感和使用感受。**画面动作照旧按同族判定继承**,"
"不得因为资料不全就把使用镜改成摆拍,也不要输出 NEEDS_PRODUCT_FACTS。\n"
"【硬性禁止】禁止无理由把原片的使用镜改写成「把商品放在桌面上」「手持缓慢转动展示」「桌面陈列特写」"
"这类静物摆拍。只有目标商品的形态确实无法完成原动作时才可以改,且必须改成目标商品自己的合理使用动作。"
"成片里出现摆拍镜的数量不得超过参考片里原本就是纯展示镜的数量。\n"
"镜头数量、编号、时间区间、时长、景别、机位、运镜、切换顺序和剪辑点必须与拆解稿完全一致,绝不能增删、合并或调换镜头。"
"参考镜为画内人物对白时,成片必须由画面内人物直接开口说话并口型同步,绝不能改成旁白。"
"任何人声都不得说出新商品名称、品牌或旧商品名称,统一用‘这个’、‘它’等自然指代。"
"必须同步重写与原商品有关的口播、音效和画面内容;保留原口播的句数、时间位置、语速节奏和情绪,"
"但不得保留原商品的名称、功能和卖点。"
"最后只输出【SEEDANCE_PROMPT】后的中文分镜稿:保留【镜头 01】格式和每镜时间、时长、景别、机位、运镜、人声方式;"
"每镜的画面、人物动作、台词/旁白按同族判定结果处理 —— 同族沿用原动作只换商品,异族按目标商品形态重新设计动作。"
"禁止输出分析过程、分类标签或 Markdown。"
),
},
]
def validate_product_semantic_remix(text: str) -> str:
"""取出模型的最终分镜稿;任何不可用结果由调用方自动降级,绝不阻断用户任务。"""
cleaned = (text or "").strip()
# skill 的结构化输出只把最终可出片部分送到 Seedance,避免把原商品分析带进提示词。
marker = "【SEEDANCE_PROMPT】"
if marker in cleaned:
cleaned = cleaned.split(marker, 1)[1].strip()
# 这些是模型的“索取资料”占位语,不能作为视频提示词发送;返回空串使上层直接走展示兜底。
if any(token in cleaned for token in ("MISSING_PRODUCT_FACTS", "NEEDS_PRODUCT_FACTS", "MANUAL_REVIEW_REQUIRED")):
return ""
return cleaned if len(cleaned) >= 120 and "【镜头" in cleaned else ""
def build_safe_product_showcase_prompt(*, digest_text: str, product_name: str) -> str:
"""模型不可用时的确定性兜底:不猜功效,但**保留原片的动作结构**。
⚠️ 这里的「安全」只针对口播和功效宣称,不针对画面动作。早期版本一律写死
「拿起 → 放到桌上 → 转一转 → 特写」,导致模型一失败,化妆品复刻就退化成静物摆拍。
现在改为:沿用原镜的人物姿态、动作幅度和身体关系,由 Seedance 按参考图里
可见的商品形态做自然使用动作;只有口播被收紧到不谈功效。
"""
from .services import enforce_no_embedded_captions
header, shots = _parse_digest(digest_text)
safe_actions = (
"人物保持原镜的姿态和站位,自然拿起@目标商品,手部动作与原镜一致。",
"人物手持@目标商品,按其在参考图中可见的形态做自然的取用动作,身体与镜头关系保持原镜。",
"人物将@目标商品移到原镜的使用位置,保持原镜的动作幅度、朝向与节奏。",
"镜头贴近人物手中的@目标商品,拍摄外观、材质与取用细节,人物动作不中断。",
)
safe_dialogues = (
"你看这个,拿在手里的质感很清楚。",
"用起来挺顺手的,细节能看得更完整。",
"从这个角度看,它的外观和手感都很直观。",
"镜头靠近一点,材质细节看得很清楚。",
)
lines = [
"【成片规则】",
"@目标商品的外观、包装、材质和形态以参考图为准。",
"保留原镜的人物动作结构、身体朝向、动作幅度和镜头节奏;"
"按@目标商品在参考图中可见的形态做自然使用动作,不要改成静止摆拍或桌面陈列。",
"不得出现原商品、原容器、原配件、原台词,也不得宣称任何功效、成分或未经确认的效果。",
"镜头数量、时间、时长、景别、机位、运镜和切换顺序必须与参考分镜一致;不得增删、合并、拆分或调换镜头。",
"所有人声禁止说出商品名称和品牌。参考镜中人物开口说话时,必须生成画内人物对白和同步口型,禁止改成旁白。",
]
if header.get("整体风格"):
lines.append(f"整体风格:{header['整体风格']}")
for index, shot in enumerate(shots, start=1):
lines.extend([
"",
f"【镜头 {index:02d}】",
*(f"{field}{shot[field]}" for field in ("时间", "时长", "景别", "机位", "运镜") if shot.get(field)),
f"画面:保持原镜的环境氛围、人物关系、动作节奏与镜头调度,原商品换成@目标商品。",
f"人物动作:{safe_actions[(index - 1) % len(safe_actions)]}",
"人物表情:自然、专注。",
f"人声方式:{'画内人物对白' if shot.get('台词/旁白') and not _is_empty_value(shot.get('台词/旁白') or '') else '无'}",
f"台词/旁白:{'画内人物对白(人物对镜开口说话,非旁白):' + safe_dialogues[(index - 1) % len(safe_dialogues)] if shot.get('台词/旁白') and not _is_empty_value(shot.get('台词/旁白') or '') else '无'}",
])
if not shots:
lines.extend([
"", "【镜头 01】", "时长:5秒", "景别:中近景", "机位:平视", "运镜:缓慢推近",
"画面:干净自然的环境中,人物手持@目标商品,保持自然的使用状态。",
"人物动作:人物按@目标商品在参考图中可见的形态做自然取用动作。",
"人物表情:自然、专注。",
"人声方式:画内人物对白。",
"台词/旁白:画内人物对白(人物对镜开口说话,非旁白):你看这个,外观和包装细节很清楚。",
])
return enforce_no_embedded_captions("\n".join(lines))
def rewrite_product_semantic_remix(*, task, digest_text: str, product_facts: dict, duration: int, aspect_ratio: str) -> str:
"""Gemini 负责重写商品语义,Seedance 只负责按这份新分镜出片。"""
from .video_digest import DIGEST_MAX_TOKENS, resolve_digest_model_config
from .services import _collect_extract_text, get_text_provider
product_name = str(product_facts.get("商品名称") or "")
model_config = resolve_digest_model_config()
if model_config is None:
logger.warning("商品语义重构模型未配置,回退至安全展示分镜")
return build_safe_product_showcase_prompt(
digest_text=digest_text,
product_name=product_name,
)
provider = get_text_provider(model_config)
messages = build_product_semantic_remix_messages(
digest_text=digest_text,
product_facts=product_facts,
duration=duration,
aspect_ratio=aspect_ratio,
)
last_error: Exception | None = None
try:
text, _payload = _collect_extract_text(
provider, model_config, messages, temperature=0.2,
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
)
prompt = validate_product_semantic_remix(text)
if prompt:
return prompt
last_error = ValueError("模型未提供可用分镜")
except Exception as exc:
logger.warning("商品语义重构模型调用异常,直接使用展示分镜: %s", exc)
last_error = exc
# 不再因资料、动作兼容或模型格式而拒绝任务:始终继续走 Seedance。
logger.info("商品语义重构未产出可用分镜,自动使用展示分镜 (原因: %s)", last_error)
return build_safe_product_showcase_prompt(
digest_text=digest_text,
product_name=product_name,
)
def build_character_replace_prompt(
digest_text: str,
*,
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""提炼页分镜稿 + 换角色。原片不传 Seedance。"""
from .services import enforce_no_embedded_captions
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
condense = (
PRODUCT_CONDENSE_NOTE.format(source=source, output=output)
if source and output and source - output >= 3
else ""
)
return enforce_no_embedded_captions(
"\n".join(part for part in (CHARACTER_PROMPT, _digest_for_seedance(digest_text), condense) if part)
)
def compact_digest_for_video(digest_text: str) -> str:
"""分镜稿 → 交给视频模型的镜头表。
解析失败(模型没按 skill 的格式吐)就回落:只砍掉【拆解存疑】,原文照传 ——
宁可啰嗦也不能把镜头信息弄丢。
"""
header, shots = _parse_digest(digest_text)
if not shots:
body = (digest_text or "").strip()
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
return (head if sep else body).strip()
return _render_shot_table(header, shots)
def build_product_replace_prompt(
digest_text: str,
subject_name: str,
*,
has_triview: bool = False,
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""提炼页分镜稿 + 换商品。原片不传 Seedance。"""
from .services import enforce_no_embedded_captions
subject = (subject_name or "").strip()
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
condense = (
PRODUCT_CONDENSE_NOTE.format(source=source, output=output)
if source and output and source - output >= 3
else ""
)
extra = []
if has_triview:
extra.append(PRODUCT_TRIVIEW_NOTE)
if subject:
extra.append(f"口播里的旧商品名改说「{subject}」。")
return enforce_no_embedded_captions(
"\n".join(part for part in (PRODUCT_PROMPT, *extra, _digest_for_seedance(digest_text), condense) if part)
)
def is_video_replace_task(task) -> bool:
payload = task.request_payload or {}
if payload.get("feature") == FEATURE:
return True
return str(payload.get("prompt") or "").startswith(LEGACY_PROMPT_PREFIX)
def video_replace_q() -> Q:
return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
def not_video_replace_q() -> Q:
"""「不是视频复刻」。不能写成 exclude(video_replace_q())。
JSON 键不存在时取值是 SQL NULLNOT(NULL = 'x') 仍是 NULL,整行会被 exclude 一起筛掉 ——
于是 request_payload 里没有 feature 的历史任务在列表和回收站里凭空消失。必须把「键缺失」
显式算作不匹配。
"""
return (
Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature=FEATURE)
) & (
Q(request_payload__prompt__isnull=True)
| ~Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
)
def _fresh_digest_source(payload: dict) -> dict | None:
"""历史卡「原视频」用的原片快照。快照 URL 会过期,统一走 rehydrate_ref_urls 重新取长期直链。"""
from .free_video import rehydrate_ref_urls
ref = payload.get("digest_source_ref") or None
if not isinstance(ref, dict):
return None
return rehydrate_ref_urls([ref])[0]
def serialize_video_replace_task(task, *, include_deleted_assets: bool = False) -> dict:
data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets)
payload = task.request_payload or {}
replace_mode = payload.get("replace_mode") or _legacy_replace_mode(payload.get("prompt") or "")
created = task.status == AITask.Status.CREATED
digesting = created and bool(payload.get("digest_pending"))
reviewing = created and bool(payload.get("review_pending")) and not digesting
plan = list(payload.get("shot_plan") or [])
shot_total = int(payload.get("shot_total") or len(plan) or 0)
shot_index = 0
if plan:
active = next((item for item in plan if item.get("status") in {"submitted", "polling"}), None)
if active is None:
done = sum(1 for item in plan if item.get("status") == "succeeded")
shot_index = done
else:
shot_index = int(active.get("index") or 0)
data.update({
"feature": FEATURE,
"replace_mode": replace_mode,
"subject_name": payload.get("subject_name") or "",
"subject_source": payload.get("subject_source") or "",
"subject_brief": payload.get("subject_brief") or "",
"product_id": payload.get("product_id") or "",
"model_id": payload.get("model_id") or "",
"review_stage": "reviewing" if reviewing else "",
"digest_stage": "digesting" if digesting else "",
"digest_text": str(payload.get("digest_text") or ""),
"digest_shots": payload.get("digest_shots") or shot_total or 0,
"digest_source_name": payload.get("digest_source_name") or "",
# 原片快照存的是提交当时的直链,历史卡要隔天还能播 → 按 asset_id 重新取
"digest_source": _fresh_digest_source(payload),
"shot_index": shot_index,
"shot_total": shot_total,
})
# 火山编辑接口内部用 adaptive/-1;页面仍展示用户实际上传视频的比例和时长。
if replace_mode == "character":
data["duration"] = int(payload.get("billing_duration") or 0)
data["aspect_ratio"] = str(payload.get("billing_aspect_ratio") or data.get("aspect_ratio") or "")
return data
def submit_video_replace(*, team, user, params: dict):
"""校验素材 → 送审 → 已过审则直接生成,否则建 CREATED 任务等绿盾。失败抛 ValueError。"""
replace_mode = str(params.get("replace_mode") or "").strip()
if replace_mode not in REPLACE_MODES:
raise ValueError("请选择替换商品或替换角色")
product_id = _optional_uuid(params.get("product_id"), "商品")
model_id = _optional_uuid(params.get("model_id"), "角色")
image_ids = _uuid_list(params.get("image_asset_ids"), "参考图")
has_product = product_id is not None
has_model = model_id is not None
has_temp = bool(image_ids)
if replace_mode == "product":
if has_model:
raise ValueError("商品复刻请选择商品,不要同时选择角色")
if has_product and has_temp:
raise ValueError("请从商品库选择,或临时上传商品图,不要混用")
if not has_product and not has_temp:
raise ValueError("请选择商品或上传商品参考图")
else:
if has_product:
raise ValueError("角色复刻请选择角色,不要同时选择商品")
if has_model and has_temp:
raise ValueError("请从人物库选择,或临时上传角色图,不要混用")
if not has_model and not has_temp:
raise ValueError("请选择角色或上传角色参考图")
# 单飞闸:一个团队同时只允许一条复刻在跑。刷新页面时前端拉状态有空档,
# 用户容易以为「没任务」再点一次,重复扣费。_reap_stale_free_video_tasks 会先回收
# 死任务(CREATED 超 16 分钟等),所以不会被永久锁住。
running = get_inflight_video_replace(team)
if running is not None:
raise VideoReplaceInProgress(running)
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
video_seconds = _asset_duration_seconds(video)
if video_seconds > REPLACE_REF_DURATION_MAX:
raise ValueError("参考视频不能超过 30 秒,请剪短后重试")
# 第 3 步用户填的补充信息。商品:名称/品类/卖点;角色:性别/年龄/音色。选填。
subject_brief = _clean_subject_brief(params.get("subject_brief"))
# 角色描述识别到性别时,成片后调用火山音色转换;识别不到即保留原声。
if has_product:
subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
product_facts = _product_semantic_facts_for_id(team, product_id)
elif has_model:
subject_name, image_refs, subject_source = _character_library_refs(team, model_id)
product_facts = {}
else:
noun = "商品" if replace_mode == "product" else "角色"
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
product_facts = (
{
"商品名称": subject_name,
"资料状态": "仅外观",
"资料状态的含义": "该状态只约束口播能说什么(不得宣称功效、成分和卖点);不约束画面能拍什么动作。",
"形态判定": "商品的形态、部件、开合方式和使用接口以临时上传的参考图为准,可直接据图判断。",
"动作策略": "同族继承优先:参考图中的商品与参考片原商品属于同一形态族或品类族时,逐镜沿用原片的取出、开启、使用、作用部位等动作,只替换商品外观。仅当形态确实不兼容时,才按参考图形态重新设计动作。",
"禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。",
}
if replace_mode == "product"
else {}
)
# 用户手填的商品信息优先级最高:临时上传时这往往是唯一的品类来源,
# 商品库选品时它补充库里没填的字段。填了就不再是「仅外观」。
if replace_mode == "product" and subject_brief:
product_facts = {
**product_facts,
"用户补充信息": subject_brief,
"用户补充信息的权威性": "这是用户手动填写的商品事实,可直接作为品类、用途和卖点的依据,优先级高于据图推断。",
}
if product_facts.get("资料状态") == "仅外观":
product_facts["资料状态"] = "部分"
# 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。
duration = -1 if replace_mode == "character" else _output_duration(params.get("duration"), video_seconds)
billing_ratio = str(params.get("aspect_ratio") or "9:16")
if billing_ratio not in RATIOS:
billing_ratio = "9:16"
extra = {
"replace_mode": replace_mode,
"subject_name": subject_name,
"subject_source": subject_source,
"product_id": str(product_id) if product_id else "",
"model_id": str(model_id) if model_id else "",
"product_facts": product_facts,
"subject_brief": subject_brief,
# adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。
"billing_duration": max(4, int(round(video_seconds or 4))),
"billing_aspect_ratio": billing_ratio,
}
base_params = {
"mode": "universal",
# 固定 Seedance 2.5,不接受前端指定别的档
"model": REPLACE_MODEL,
"aspect_ratio": "adaptive" if replace_mode == "character" else billing_ratio,
"resolution": str(params.get("resolution") or "720p"),
"duration": duration,
"seed": params.get("seed", -1),
"generate_audio": True,
"feature": FEATURE,
}
# 商品复刻需要 Gemini 拆镜后重构商品语义;角色复刻直接交给 Seedance 2.5
# 原视频与角色图都先进入同一火山素材库审核,再以 asset:// 作为参考。
if replace_mode == "product":
# 商品复刻靠拆镜重构语义。
from .video_digest import resolve_digest_model_config
if resolve_digest_model_config() is None:
raise ValueError("视频提炼模型未配置,请联系管理员")
if replace_mode == "character":
image_refs = [
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
*image_refs,
]
# 商品图/角色图仍要过审才能当生成参考。提前送审 → 审核和提炼并行跑,明确不过审的直接 400。
review_state = _ensure_replace_refs_reviewed(team, image_refs)
if review_state == "failed":
raise ValueError(REVIEW_FAILED)
image_refs = _refresh_replace_refs(team, image_refs)
extra.update({
"digest_source_asset_id": str(video.id),
"digest_source_name": video.name or "参考视频",
"digest_source_duration": round(video_seconds, 2),
# 原片快照只给历史卡「原视频」和重新生成用,不进 Seedance references。
"digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"),
"review_pending": review_state != "ready",
})
return _create_reviewing_task(
team=team,
user=user,
params={
**base_params,
"prompt": (
DIGEST_PENDING_PROMPT if replace_mode == "product"
else build_character_submit_prompt()
),
"references": image_refs,
"extra_payload": extra,
},
digest_pending=replace_mode == "product",
)
def advance_video_replace(task):
"""推进复刻:审核中 → 整份分镜稿一次出片。旧逐镜任务也按单次收尾,不再拼接。"""
if not is_video_replace_task(task):
return task
if task.status != AITask.Status.CREATED:
from .free_video import finalize_free_video
return finalize_free_video(task=task)
payload = task.request_payload or {}
if payload.get("digest_pending"):
# worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
return task
references = (
_image_refs(payload.get("references") or [])
if payload.get("replace_mode") == "product"
else list(payload.get("references") or [])
)
try:
state = _ensure_replace_refs_reviewed(task.team, references)
except ValueError as exc:
return _fail_reviewing_task(task, str(exc))
if state == "failed":
return _fail_reviewing_task(task, REVIEW_FAILED)
if state != "ready":
return task
refreshed = _refresh_replace_refs(task.team, references)
try:
_assert_replace_refs_ready(refreshed)
except ValueError as exc:
return _fail_reviewing_task(task, str(exc))
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status != AITask.Status.CREATED:
return locked
next_payload = dict(locked.request_payload or {})
next_payload["references"] = refreshed
next_payload["review_pending"] = False
locked.request_payload = next_payload
locked.save(update_fields=["request_payload", "updated_at"])
task = locked
return start_replace_generation(task)
def start_replace_generation(task):
"""审核过了:整份分镜稿一次交给 Seedance 2.5。只带商品图/角色图,不传用户原片。"""
payload = dict(task.request_payload or {})
if payload.get("replace_mode") == "product":
payload["references"] = _image_refs(payload.get("references") or [])
payload.pop("shot_plan", None)
payload.pop("shot_total", None)
task.request_payload = payload
task.save(update_fields=["request_payload", "updated_at"])
return start_pending_free_video(task)
def run_replace_digest(task) -> None:
"""Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。
商品和角色共用。这一步失败 = 整条复刻失败、不扣费。成功后由 start_pending_free_video 按「Gemini 挂牌 + Seedance 挂牌」一并预留。
"""
from apps.ai.video_digest import VideoDigestError, digest_asset_video
if not is_video_replace_task(task) or task.status != AITask.Status.CREATED:
return
payload = task.request_payload or {}
if not payload.get("digest_pending"):
return
try:
asset = _load_replace_asset(
task.team, uuid.UUID(str(payload.get("digest_source_asset_id") or "")), "参考视频"
)
except (ValueError, TypeError):
_fail_reviewing_task(task, "参考视频已失效,请重新上传", error_code="asset_unavailable")
return
replace_mode = str(payload.get("replace_mode") or "product")
subject = str(payload.get("subject_name") or "")
has_triview = any(
(ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or [])
)
source_seconds = float(payload.get("digest_source_duration") or 0)
output_seconds = int(payload.get("duration") or 0)
try:
digest, meta = digest_asset_video(asset=asset, task=task)
if replace_mode == "character":
# 角色复刻不把分镜稿塞进提示词,保持已验证的编辑画面效果。
# 编辑模式的画面效果是用户认可的,提示词一动画面就可能跟着变。
prompt = str(payload.get("prompt") or "") or build_character_submit_prompt(
str(payload.get("subject_brief") or "")
)
else:
prompt = rewrite_product_semantic_remix(
task=task,
digest_text=digest,
product_facts=payload.get("product_facts") or {
"商品名称": subject,
"资料状态": "仅外观",
"资料状态的含义": "只约束口播能说什么;不约束画面能拍什么动作。",
"动作策略": "同族继承优先:与参考片原商品同族时,逐镜沿用原片的使用动作,只替换商品外观。",
},
duration=output_seconds,
aspect_ratio=str(payload.get("aspect_ratio") or "9:16"),
)
if has_triview and TRIVIEW_LABEL not in prompt:
prompt = f"{prompt}\n商品各面与材质细节以@{TRIVIEW_LABEL}为准。"
except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc), error_code="processing_failed")
return
except Exception as exc: # noqa: BLE001 — 拆解任何异常都要把任务收尾,别留 CREATED 僵尸
logger.exception("video replace digest failed for %s", task.id)
_fail_reviewing_task(task, f"参考视频拆解失败:{exc}", error_code="processing_failed")
return
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status != AITask.Status.CREATED:
return
next_payload = dict(locked.request_payload or {})
next_payload.update(meta)
next_payload["digest_pending"] = False
next_payload["digest_text"] = digest[:32000]
next_payload["semantic_remix"] = replace_mode == "product"
next_payload["prompt"] = prompt
next_payload["references"] = _image_refs(next_payload.get("references") or [])
next_payload.pop("shot_plan", None)
next_payload.pop("shot_total", None)
locked.request_payload = next_payload
locked.save(update_fields=["request_payload", "updated_at"])
task = locked
# 拆完就地推进一次:参考图多半早已过审,能直接提交火山,省掉一轮 8s 轮询。
try:
task = advance_video_replace(task)
except Exception: # noqa: BLE001 — 推进失败交给轮询重试,任务还在 CREATED
logger.warning("video replace advance after digest failed for %s", task.id, exc_info=True)
task.refresh_from_db()
if task.status == AITask.Status.CREATED:
_enqueue_replace_review_poll(task)
def start_pending_replace_shots(task):
"""旧逐镜路径已下线。一律整片一次出,且不传用户原片。"""
return start_replace_generation(task)
def _legacy_start_pending_replace_shots(task):
"""CREATED → 按全部镜头总时长预留积分 → 提交第一镜。"""
from apps.billing.services.ledger import reserve_credit
from .free_video import (
_dispatch_free_video_provider,
_fail_pending_free_video,
build_content_items,
model_duration_range,
)
if task.status != AITask.Status.CREATED:
return task
payload = task.request_payload or {}
plan = list(payload.get("shot_plan") or [])
if not plan:
return start_pending_free_video(task)
first = plan[0]
prompt = str(first.get("prompt") or payload.get("prompt") or "").strip()
mode = str(payload.get("mode") or "universal")
aspect_ratio = str(payload.get("aspect_ratio") or "9:16")
resolution = str(payload.get("resolution") or "720p")
generate_audio = bool(payload.get("generate_audio", True))
search_mode = str(payload.get("search_mode") or "off")
feature = str(payload.get("feature") or FEATURE)
seed = normalize_video_seed(payload.get("seed"))
billed_duration = sum(int(item.get("seconds") or 0) for item in plan) or int(payload.get("duration") or 5)
references = _seedance_references(payload)
try:
built = build_content_items(
team=task.team, prompt=prompt, mode=mode, references=references,
max_ref_seconds=model_duration_range(task.model_config)[1],
)
except ValueError as exc:
message = str(exc)
if "正在审核" in message or "已提交审核" in message or "尚未完成合规审核" in message:
return task
return _fail_pending_free_video(task, message)
tokens, quote = quote_video_estimate(
task.model_config,
aspect_ratio=aspect_ratio,
resolution=resolution,
duration=billed_duration,
references=built["snapshots"],
team=task.team,
)
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
with transaction.atomic():
locked = (
AITask.objects.select_for_update()
.select_related("model_config", "model_config__provider", "team", "created_by")
.get(id=task.id)
)
if locked.status != AITask.Status.CREATED:
return locked
try:
reserve_credit(team=locked.team, user=locked.created_by, task=locked, amount=reserve_amount)
except ValueError as exc:
message = "团队余额不足,请充值后重试" if "insufficient credit" in str(exc) else str(exc)
locked.status = AITask.Status.FAILED
locked.error_code = "user_credit_insufficient" if "余额不足" in message else "invalid_input"
locked.error_message = message[:2000]
locked.completed_at = timezone.now()
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
return locked
next_payload = dict(locked.request_payload or {})
plan = list(next_payload.get("shot_plan") or plan)
plan[0] = {**plan[0], "status": "submitted"}
next_payload["shot_plan"] = plan
next_payload["prompt"] = prompt
next_payload["api_prompt"] = built["api_prompt"]
next_payload["references"] = built["snapshots"]
next_payload["estimated_tokens"] = tokens
next_payload["review_pending"] = False
next_payload["duration"] = billed_duration
next_payload.update(video_quote_payload(quote))
locked.request_payload = next_payload
locked.estimated_cost = quote.points
locked.status = AITask.Status.RESERVED
locked.save(update_fields=["request_payload", "estimated_cost", "status", "updated_at"])
dispatched = _dispatch_free_video_provider(
task=locked,
built=built,
model_config=locked.model_config,
aspect_ratio=aspect_ratio,
duration=int(first.get("seconds") or MIN_SHOT_SECONDS),
resolution=resolution,
generate_audio=generate_audio,
seed=seed,
search_mode=search_mode,
feature=feature,
mode=mode,
poll_countdown=30,
)
return _stamp_current_shot_id(dispatched)
def advance_replace_shots(task):
"""轮询当前这一镜:还在跑就等;失败整单失败;成功则提交下一镜,全部完成再拼接。"""
from .free_video import (
_dispatch_free_video_provider,
build_content_items,
finalize_free_video,
model_duration_range,
)
from .generation_errors import classify_generation_error
from .routing_policy import load_model_routing_policy
from .services import get_video_provider
payload = dict(task.request_payload or {})
plan = list(payload.get("shot_plan") or [])
if not plan:
return finalize_free_video(task=task)
if task.status not in (AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING):
return task
current = _active_shot(plan)
if current is None:
if plan and all(item.get("status") == "succeeded" and item.get("media") for item in plan):
return complete_replace_shots(task)
return task
if not current.get("provider_task_id"):
if task.provider_task_id and current.get("status") in {"submitted", "polling", "queued"}:
current = {**current, "provider_task_id": task.provider_task_id, "status": "submitted"}
_write_shot(task, current)
plan = _read_shot_plan(task)
current = _shot_by_index(plan, current["index"]) or current
else:
return _dispatch_next_replace_shot(task, current)
video_policy = load_model_routing_policy().video
submit_attempt = (
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 task.model_config
provider = get_video_provider(actual_model)
response = provider.poll_video_task(
endpoint=actual_model.endpoint,
provider_task_id=current["provider_task_id"],
timeout=video_policy.poll_request_timeout,
)
remote_status = str(response.get("status") or "")
if remote_status in {"queued", "running", "processing", "submitted"}:
if task.status != AITask.Status.POLLING or current.get("status") != "polling":
current = {**current, "status": "polling"}
task = _write_shot(task, current)
if task.status != AITask.Status.POLLING:
task.status = AITask.Status.POLLING
task.save(update_fields=["status", "updated_at"])
return task
if remote_status in {"failed", "expired", "cancelled"}:
err = response.get("error") or {}
code = str(err.get("code") or "")
raw_message = str(err.get("message") or "video generation failed")
public_error = classify_generation_error(
RuntimeError(raw_message),
operation="video_generate",
provider_code=code,
reference_id=str(task.id),
)
return _fail_replace_generation(
task, raw_message, error_code=code[:64] or "CreateTaskError", hint=public_error.fallback_message,
)
try:
media = provider.extract_first_media_url(response)
except Exception as exc: # noqa: BLE001
return _fail_replace_generation(task, str(exc), error_code="PostprocessError")
usage = response.get("usage") or {}
try:
tokens = int(usage.get("total_tokens") or 0)
except (TypeError, ValueError):
tokens = 0
current = {**current, "status": "succeeded", "media": media, "tokens": tokens}
task = _write_shot(task, current)
plan = _read_shot_plan(task)
nxt = next((item for item in plan if item.get("status") in {"queued", "", None}), None)
if nxt is None:
if len(plan) == 1:
return finalize_free_video(task=task)
return complete_replace_shots(task)
return _dispatch_next_replace_shot(task, nxt)
def _dispatch_next_replace_shot(task, shot: dict):
from .free_video import _dispatch_free_video_provider, build_content_items, model_duration_range
payload = dict(task.request_payload or {})
prompt = str(shot.get("prompt") or "").strip()
if not prompt:
return _fail_replace_generation(task, "分镜提示词缺失,请重试", error_code="processing_failed")
try:
built = build_content_items(
team=task.team, prompt=prompt, mode=str(payload.get("mode") or "universal"),
references=_seedance_references(payload),
max_ref_seconds=model_duration_range(task.model_config)[1],
)
except ValueError as exc:
return _fail_replace_generation(task, str(exc), error_code="invalid_input")
with transaction.atomic():
locked = AITask.objects.select_for_update().select_related(
"model_config", "model_config__provider", "team", "created_by",
).get(id=task.id)
if locked.status not in (AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING):
return locked
plan = list((locked.request_payload or {}).get("shot_plan") or [])
target = _shot_by_index(plan, shot["index"])
if target is None or target.get("status") not in {"queued", "", None, "submitted"}:
return locked
if target.get("provider_task_id") and target.get("status") in {"submitted", "polling"}:
return locked
next_payload = dict(locked.request_payload or {})
next_payload["prompt"] = prompt
next_payload["api_prompt"] = built["api_prompt"]
plan = [
{**item, "status": "submitted"} if item.get("index") == shot["index"] else item
for item in plan
]
next_payload["shot_plan"] = plan
locked.request_payload = next_payload
locked.save(update_fields=["request_payload", "updated_at"])
seed = normalize_video_seed(payload.get("seed"))
dispatched = _dispatch_free_video_provider(
task=locked,
built=built,
model_config=locked.model_config,
aspect_ratio=str(payload.get("aspect_ratio") or "9:16"),
duration=int(shot.get("seconds") or MIN_SHOT_SECONDS),
resolution=str(payload.get("resolution") or "720p"),
generate_audio=bool(payload.get("generate_audio", True)),
seed=seed,
search_mode=str(payload.get("search_mode") or "off"),
feature=str(payload.get("feature") or FEATURE),
mode=str(payload.get("mode") or "universal"),
poll_countdown=30,
)
return _stamp_current_shot_id(dispatched)
def complete_replace_shots(task):
"""全部镜头出完:下载 → ffmpeg 拼接 → 转存 TOS → 按 tokens 合计结算。"""
from decimal import Decimal
from apps.billing.services.ledger import charge_reserved_credit, release_credit
from .free_video import _notify_failure, _store_free_video_media
from .generation_errors import classify_generation_error
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
return locked
locked.status = AITask.Status.POSTPROCESSING
locked.save(update_fields=["status", "updated_at"])
payload = dict(locked.request_payload or {})
plan = list(payload.get("shot_plan") or [])
try:
if len(plan) == 1 and plan[0].get("media"):
_store_free_video_media(task=locked, media=plan[0]["media"])
else:
video_bytes = _concat_shot_media([item.get("media") or "" for item in plan])
_store_free_video_media(task=locked, video_bytes=video_bytes)
total_tokens = 0
for item in plan:
try:
total_tokens += int(item.get("tokens") or 0)
except (TypeError, ValueError):
pass
# 挂牌秒价按快照扣;无挂牌才按 tokens true-up
if "with_ref_video" not in payload:
payload["with_ref_video"] = True # 替换片默认带视频参考
settle = settle_video_from_payload(locked.model_config, payload=payload, tokens=total_tokens)
if settle.meta.get("rule") == "missing_usage":
actual, base_cost = locked.estimated_cost, locked.base_cost
else:
actual, base_cost = settle.points, settle.base_cost_yuan
if total_tokens > 0:
payload["actual_tokens"] = total_tokens
if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=locked.id)
if locked.status != AITask.Status.POSTPROCESSING:
return locked
reservation = locked.credit_reservation
if actual > reservation.amount:
logger.warning(
"video replace task %s actual cost %s exceeds reserved %s, clamped",
locked.id, actual, reservation.amount,
)
actual = reservation.amount
locked.status = AITask.Status.SUCCEEDED
locked.actual_cost = actual
locked.base_cost = base_cost
locked.request_payload = payload
locked.completed_at = timezone.now()
locked.save(
update_fields=["status", "actual_cost", "base_cost", "request_payload", "completed_at", "updated_at"]
)
charge_reserved_credit(reservation=reservation, actual_amount=actual)
return locked
except Exception as exc: # noqa: BLE001
logger.exception("video replace concat failed for task %s", locked.id)
public_error = classify_generation_error(
exc, operation="video_generate", internal_kind="processing_failed",
reference_id=str(locked.id),
)
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=locked.id)
if locked.status != AITask.Status.POSTPROCESSING:
return locked
locked.status = AITask.Status.FAILED
locked.error_code = "PostprocessError"
locked.error_message = str(exc)[:2000]
locked.completed_at = timezone.now()
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200])
_notify_failure(locked, raw=str(exc), hint=public_error.fallback_message)
return locked
def _concat_shot_media(urls: list[str]) -> bytes:
import subprocess
import tempfile
from pathlib import Path
from .providers.volcano import VolcanoArkProvider
clips = [url for url in urls if url]
if not clips:
raise ValueError("没有可拼接的镜头成片")
if len(clips) == 1:
fileobj, _ctype = VolcanoArkProvider.media_to_bytes(clips[0])
return fileobj.getvalue()
with tempfile.TemporaryDirectory(prefix="airshelf-replace-concat-") as tmp:
tmp_dir = Path(tmp)
paths = []
for index, url in enumerate(clips):
fileobj, _ctype = VolcanoArkProvider.media_to_bytes(url)
path = tmp_dir / f"shot{index:02d}.mp4"
path.write_bytes(fileobj.getvalue())
paths.append(path)
output = tmp_dir / "out.mp4"
inputs: list[str] = []
for path in paths:
inputs.extend(["-i", str(path)])
n = len(paths)
parts = [f"[{i}:v]fps=24,format=yuv420p,setsar=1[v{i}]" for i in range(n)]
audio_ok = []
for path in paths:
probe = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path)],
capture_output=True, text=True, timeout=30,
)
audio_ok.append(bool((probe.stdout or "").strip()))
for i, path in enumerate(paths):
if audio_ok[i]:
parts.append(f"[{i}:a]aresample=44100,aformat=sample_fmts=fltp:channel_layouts=stereo[a{i}]")
else:
dur_proc = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", str(path)],
capture_output=True, text=True, timeout=30,
)
try:
dur = max(0.1, float((dur_proc.stdout or "4").strip() or "4"))
except ValueError:
dur = 4.0
parts.append(
f"anullsrc=channel_layout=stereo:sample_rate=44100,atrim=0:{dur:.3f},"
f"asetpts=PTS-STARTPTS,aformat=sample_fmts=fltp:channel_layouts=stereo[a{i}]"
)
concat_in = "".join(f"[v{i}][a{i}]" for i in range(n))
parts.append(f"{concat_in}concat=n={n}:v=1:a=1[v][a]")
cmd = [
"ffmpeg", "-y", *inputs, "-filter_complex", ";".join(parts),
"-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-threads", "2",
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output),
]
# 180 秒成片最多 6 段,1080p 重编码明显可能超过旧的 180 秒固定超时。
# 按片段数放宽,但仍保留上限式超时,避免异常 ffmpeg 永久占住 worker。
proc = subprocess.run(cmd, capture_output=True, timeout=max(180, len(paths) * 120))
if proc.returncode != 0 or not output.exists() or not output.stat().st_size:
err = (proc.stderr or b"").decode("utf-8", errors="replace")[-500:]
raise RuntimeError(f"镜头拼接失败:{err or 'ffmpeg 未产出文件'}")
return output.read_bytes()
def _stamp_current_shot_id(task):
"""把刚提交火山拿到的 provider_task_id 写回当前这一镜,下一轮 poll 才找得到。"""
if task.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING) or not task.provider_task_id:
return task
payload = dict(task.request_payload or {})
plan = list(payload.get("shot_plan") or [])
changed = False
for item in plan:
if item.get("status") in {"submitted", "polling"} and not item.get("provider_task_id"):
item["provider_task_id"] = task.provider_task_id
changed = True
break
if not changed:
return task
payload["shot_plan"] = plan
task.request_payload = payload
task.save(update_fields=["request_payload", "updated_at"])
return task
def _active_shot(plan: list[dict]) -> dict | None:
for item in plan:
if item.get("status") in {"submitted", "polling"}:
return item
for item in plan:
if item.get("status") in {"queued", "", None}:
return item
return None
def _shot_by_index(plan: list[dict], index) -> dict | None:
try:
index = int(index)
except (TypeError, ValueError):
return None
return next((item for item in plan if int(item.get("index") or 0) == index), None)
def _read_shot_plan(task) -> list[dict]:
return list((task.request_payload or {}).get("shot_plan") or [])
def _write_shot(task, shot: dict):
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
payload = dict(locked.request_payload or {})
plan = list(payload.get("shot_plan") or [])
plan = [
shot if int(item.get("index") or 0) == int(shot.get("index") or 0) else item
for item in plan
]
payload["shot_plan"] = plan
if shot.get("provider_task_id"):
locked.provider_task_id = shot["provider_task_id"]
locked.request_payload = payload
locked.save(update_fields=["request_payload", "provider_task_id", "updated_at"])
return locked
def _fail_replace_generation(task, message: str, *, error_code: str = "", hint: str = ""):
from apps.billing.services.ledger import release_credit
from .free_video import _fail_pending_free_video, _notify_failure
if task.status in (AITask.Status.CREATED, AITask.Status.RESERVED):
return _fail_reviewing_task(task, message, error_code=error_code or "processing_failed")
public_hint = hint or message
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status in (AITask.Status.SUCCEEDED, AITask.Status.FAILED, AITask.Status.CANCELLED):
return locked
locked.status = AITask.Status.FAILED
locked.error_code = (error_code or "CreateTaskError")[:64]
locked.error_message = message[:2000]
locked.completed_at = timezone.now()
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
reservation = getattr(locked, "credit_reservation", None)
if reservation is not None:
release_credit(reservation=reservation, reason=message[:200])
_notify_failure(locked, raw=message, hint=public_hint)
return locked
def _legacy_replace_mode(prompt: str) -> str:
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
def _fail_reviewing_task(task, message: str, *, error_code: str = ""):
"""收尾一个还没提交火山的任务。
``_fail_pending_free_video`` 把错误码写死成 content_rejected —— 那是审核不通过的语义,
前端会渲染成「内容未通过生成审核」且不可重试。拆解失败/素材丢失不是那回事,
这里按实际原因改码,否则模型抖一下会被误报成合规问题,用户白白去换素材。
"""
from .free_video import _fail_pending_free_video
task = _fail_pending_free_video(task, message)
if error_code and task.status == AITask.Status.FAILED and task.error_code != error_code:
task.error_code = error_code
task.save(update_fields=["error_code", "updated_at"])
return task
def _enqueue_replace_review_poll(task):
try:
from .tasks import poll_free_video_task
poll_free_video_task.apply_async(args=[str(task.id), 0], countdown=8)
except Exception: # noqa: BLE001
logger.error("video replace review poll enqueue failed; relying on client polling", exc_info=True)
def _enqueue_replace_digest(task):
try:
from .tasks import run_video_replace_digest_task
run_video_replace_digest_task.delay(str(task.id))
except Exception: # noqa: BLE001
logger.error("video replace digest enqueue failed", exc_info=True)
def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = False):
"""还不能提交火山时:只建 CREATED 任务,不预留积分。
商品/角色复刻都先拆解参考视频;参考图若还在审核,拆完再等绿盾。
"""
model_name = str(params.get("model") or REPLACE_MODEL)
aspect_ratio = str(params.get("aspect_ratio") or "9:16")
extra = params.get("extra_payload") if isinstance(params.get("extra_payload"), dict) else {}
is_character_edit = extra.get("replace_mode") == "character"
resolution = str(params.get("resolution") or "720p")
try:
duration = int(params.get("duration") or 5)
except (TypeError, ValueError):
raise ValueError("时长参数无效")
if model_name not in FREE_VIDEO_MODELS:
raise ValueError("模型无效")
if aspect_ratio not in RATIOS and not (is_character_edit and aspect_ratio == "adaptive"):
raise ValueError("画面比例无效")
if resolution not in RESOLUTIONS:
raise ValueError("分辨率无效")
low, high = replace_duration_range()
if is_character_edit and duration != -1:
raise ValueError("角色替换时长必须跟随参考视频")
if not is_character_edit and not low <= duration <= high:
raise ValueError(f"视频时长需在 {low}-{high} 秒之间")
model_config = (
ModelConfig.objects.select_related("provider")
.filter(name=model_name, capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.first()
)
if model_config is None:
raise ValueError("视频模型未配置,请联系管理员")
_reap_stale_free_video_tasks(team=team)
max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3))
in_flight = AITask.objects.filter(
team=team, task_type=AITask.Type.FREE_VIDEO, status__in=IN_FLIGHT_STATUSES
).count()
if in_flight >= max_concurrent:
raise ValueError(f"当前有 {in_flight} 个视频任务进行中(上限 {max_concurrent}),请等待完成后再提交")
references = params.get("references") or []
billing_duration = int(extra.get("billing_duration") or duration)
billing_ratio = str(extra.get("billing_aspect_ratio") or aspect_ratio)
# 商品复刻:原片不进 Seedance 引用 → 按无视频引用秒价;角色复刻 references 含视频 → 含引用秒价
tokens, quote = quote_video_estimate(
model_config,
aspect_ratio=billing_ratio,
resolution=resolution,
duration=billing_duration,
references=references,
team=team,
)
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
# 商品复刻另加 Gemini 提炼挂牌(points_per_call;未挂牌回落 30)
digest_points = Decimal("0")
digest_model_name = ""
if digest_pending:
from apps.ai.video_digest import resolve_digest_model_config
from apps.billing.pricing import quote_video_digest
digest_model = resolve_digest_model_config()
if digest_model is None:
raise ValueError("视频提炼模型未配置,请联系管理员")
digest_quote = quote_video_digest(team=team, model_config=digest_model)
digest_points = Decimal(digest_quote.points)
digest_model_name = str(digest_model.name or "")
reserve_amount = reserve_amount + digest_points
account = CreditAccount.objects.filter(team=team).first()
available = (account.balance - account.reserved_balance) if account else Decimal("0")
if available < reserve_amount:
raise ValueError("团队余额不足,请充值后重试")
seed = normalize_video_seed(params.get("seed"))
request_payload = {
"feature": FEATURE,
"mode": "universal",
"model": model_name,
"endpoint": model_config.endpoint,
"prompt": params.get("prompt") or "",
"api_prompt": "",
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"duration": duration,
"billing_duration": billing_duration,
"billing_aspect_ratio": billing_ratio,
"seed": seed,
"generate_audio": True,
"search_mode": "off",
"estimated_tokens": tokens,
**video_quote_payload(quote),
"references": references,
"model_routing_v1": True,
"review_pending": True,
"digest_pending": digest_pending,
# 复刻内拆镜挂牌;start_pending_free_video / settle 会加回预留与实扣
"digest_points": str(digest_points),
"digest_model": digest_model_name,
}
for key, value in extra.items():
if key in request_payload or value in (None, ""):
continue
request_payload[key] = value
task = AITask.objects.create(
team=team,
created_by=user,
project=None,
task_type=AITask.Type.FREE_VIDEO,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=quote.points + digest_points,
base_cost=Decimal("0"),
)
if digest_pending:
_enqueue_replace_digest(task)
else:
_enqueue_replace_review_poll(task)
return task
def _replace_ref_assets(team, references: list) -> list[tuple[dict, Asset]]:
out = []
seen = set()
for ref in references or []:
raw_id = ref.get("asset_id")
if not raw_id:
continue
try:
parsed = uuid.UUID(str(raw_id))
except (TypeError, ValueError):
continue
if parsed in seen:
continue
seen.add(parsed)
asset = _load_replace_asset(team, parsed, ref.get("label") or "参考素材")
out.append((ref, asset))
return out
def _load_replace_asset(team, asset_id: uuid.UUID, label: str) -> Asset:
asset = Asset.objects.filter(id=asset_id, is_deleted=False, purged_at__isnull=True).first()
if asset is None:
raise ValueError(f"{label}不存在或已被删除")
if asset.team_id == team.id:
return asset
if Model.objects.filter(Q(is_official=True), Q(portrait_asset=asset) | Q(triview_asset=asset)).exists():
return asset
raise ValueError(f"{label}不存在或已被删除")
def _ensure_replace_refs_reviewed(team, references: list) -> str:
"""送审/轮询全部参考素材。返回 ready / pending / failed;审核未配置且无 remote_id 抛错。"""
from apps.assets import assets_client
from apps.assets.review import poll_asset_review, submit_asset_for_review
pairs = _replace_ref_assets(team, references)
if not pairs:
raise ValueError("请先上传参考素材")
states = []
for ref, asset in pairs:
label = ref.get("label") or asset.name or "参考素材"
if asset.review_status == "active" and asset.review_remote_id:
states.append("ready")
continue
if not assets_client.is_enabled():
raise ValueError(REVIEW_UNAVAILABLE)
if asset.review_status == "processing" and asset.review_remote_id:
poll_asset_review(asset)
asset.refresh_from_db(fields=["review_status", "review_remote_id", "review_error"])
elif asset.review_status != "active" or not asset.review_remote_id:
ok = submit_asset_for_review(asset, force=True)
asset.refresh_from_db(fields=["review_status", "review_remote_id", "review_error"])
if not ok and not asset.review_remote_id:
# 带上火山给的原因(submit_asset_for_review 会写进 review_error),
# 否则用户只看到「提交审核失败」,不知道是图不合规、拉不到图还是服务抽风。
asset.refresh_from_db(fields=["review_error"])
reason = (asset.review_error or "").strip()
detail = f"{REVIEW_SUBMIT_FAILED}({reason})" if reason else REVIEW_SUBMIT_FAILED
raise ValueError(f"「{label}{detail}")
if asset.review_status == "processing" and asset.review_remote_id:
poll_asset_review(asset)
asset.refresh_from_db(fields=["review_status", "review_remote_id", "review_error"])
if asset.review_status == "active" and asset.review_remote_id:
states.append("ready")
elif asset.review_status == "failed":
states.append("failed")
else:
states.append("pending")
if any(state == "failed" for state in states):
return "failed"
if all(state == "ready" for state in states):
return "ready"
return "pending"
def _refresh_replace_refs(team, references: list) -> list:
"""同一团队走 source=asset;官方跨团队素材把过审 id 写成 resolved_url=asset://。"""
out = []
for ref in references or []:
item = dict(ref)
raw_id = item.get("asset_id")
if not raw_id:
out.append(item)
continue
try:
parsed = uuid.UUID(str(raw_id))
except (TypeError, ValueError):
out.append(item)
continue
try:
asset = _load_replace_asset(team, parsed, item.get("label") or "参考素材")
except ValueError:
out.append(item)
continue
if asset.team_id == team.id:
item["source"] = "asset"
item.pop("resolved_url", None)
elif asset.review_remote_id:
item["source"] = "upload"
item["resolved_url"] = f"asset://{asset.review_remote_id}"
out.append(item)
return out
def _assert_replace_refs_ready(references: list) -> None:
missing = []
for ref in references or []:
raw_id = ref.get("asset_id")
if not raw_id:
continue
try:
parsed = uuid.UUID(str(raw_id))
except (TypeError, ValueError):
continue
asset = Asset.objects.filter(id=parsed).first()
if asset is None or not asset.review_remote_id or asset.review_status != "active":
missing.append(ref.get("label") or "参考素材")
if missing:
raise ValueError("素材尚未完成合规审核,请稍后再试")
def _optional_uuid(value, label: str):
text = str(value or "").strip()
if not text:
return None
try:
return uuid.UUID(text)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label}无效") from exc
def _uuid_list(value, label: str) -> list:
if value in (None, ""):
return []
if not isinstance(value, (list, tuple)):
raise ValueError(f"{label}格式无效")
if len(value) > MAX_IMAGES:
raise ValueError(f"{label}最多 {MAX_IMAGES} 张")
seen = set()
out = []
for item in value:
parsed = _optional_uuid(item, label)
if parsed is None or parsed in seen:
continue
seen.add(parsed)
out.append(parsed)
return out
def _team_asset(team, asset_id, *, kind: str, label: str) -> Asset:
parsed = _optional_uuid(asset_id, label)
if parsed is None:
raise ValueError(f"请先上传{label}")
asset = Asset.objects.filter(id=parsed, team=team, is_deleted=False, purged_at__isnull=True).first()
if asset is None:
raise ValueError(f"{label}不存在或已被删除")
if asset.asset_type != kind:
raise ValueError(f"{label}类型不正确")
return asset
def _asset_duration_seconds(asset: Asset) -> float:
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None or not primary.duration_ms:
return 0.0
return primary.duration_ms / 1000.0
def replace_duration_range() -> tuple[int, int]:
"""复刻模型(Seedance 2.5)支持的出片时长区间。模型没配好时回落 4–15,不至于炸。"""
model = (
ModelConfig.objects.filter(
name=REPLACE_MODEL, capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE
).first()
)
return model_duration_range(model)
def _output_duration(requested, video_seconds: float) -> int:
low, high = replace_duration_range()
try:
value = int(requested) if requested not in (None, "") else 0
except (TypeError, ValueError):
value = 0
if value:
return min(high, max(low, value))
if video_seconds:
return min(high, max(low, int(round(video_seconds))))
return high
def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
# 快照要长期可播(历史卡「原视频」),所以取不过期的公读直链,不用 1 小时预签名。
from .services import asset_stable_url
url, thumb = asset_stable_url(asset)
if not url:
raise ValueError(f"「{label}」没有可用文件")
ref = {
"url": url,
"type": kind,
"role": role,
"label": label,
"source": "asset",
"asset_id": str(asset.id),
}
if thumb:
ref["thumb_url"] = thumb
seconds = _asset_duration_seconds(asset)
if seconds:
ref["duration"] = seconds
return ref
def _image_refs(references) -> list:
"""出片/审核只用图。用户原片哪怕还躺在旧 payload 里,也一律丢掉。"""
return [
dict(item)
for item in (references or [])
if (item or {}).get("type") != "video"
]
def _seedance_references(payload: dict) -> list:
"""出片只带商品图/角色图。用户上传的原片不传 Seedance,避免素材审核报错。"""
return _image_refs(payload.get("references") or [])
def _library_image_ref(asset: Asset, *, team, label: str) -> dict:
from .services import _asset_preview_url, _seedance_ref_url
if asset.team_id == team.id and not asset.is_deleted:
return {
"url": _asset_preview_url(asset) or "",
"type": "image",
"role": "reference_image",
"label": label,
"source": "asset",
"asset_id": str(asset.id),
}
raw = _asset_preview_url(asset)
url = _seedance_ref_url(raw, asset.review_status, asset.review_remote_id)
if not url:
raise ValueError(f"「{label}」没有可用文件")
return {
"url": url,
"type": "image",
"role": "reference_image",
"label": label,
"source": "upload",
"asset_id": str(asset.id),
}
def _product_triview_asset(team, product_id: uuid.UUID):
"""商品库里这个商品的三视图(白底多角度单张 16:9)。没有就返回 None,不挡生成。"""
return (
Asset.objects.filter(
team=team,
is_deleted=False,
purged_at__isnull=True,
metadata__product_id=str(product_id),
metadata__view="three_view",
)
.order_by("-created_at")
.first()
)
def _product_semantic_facts_for_id(team, product_id: uuid.UUID) -> dict:
product = (
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE)
.prefetch_related("selling_points")
.first()
)
if product is None:
raise ValueError("商品不存在或已被删除")
return _product_semantic_facts(product)
def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
product = (
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE)
.select_related("cover_asset")
.prefetch_related("images__asset")
.first()
)
if product is None:
raise ValueError("商品不存在或已被删除")
triview = _product_triview_asset(team, product_id)
# 三视图也占一张参考图的名额,商品图要给它让位,否则火山那边超 9 张直接拒。
image_budget = MAX_IMAGES - 1 if triview is not None else MAX_IMAGES
assets = []
seen = {triview.id} if triview is not None else set()
for image in product.images.all():
asset = image.asset
if asset is None or asset.id in seen or asset.is_deleted:
continue
seen.add(asset.id)
assets.append(asset)
if len(assets) >= image_budget:
break
if not assets and product.cover_asset_id and product.cover_asset_id not in seen and not product.cover_asset.is_deleted:
assets.append(product.cover_asset)
if not assets:
# 只有三视图、没有任何实拍图时,把三视图顶上来当 @目标商品。
# 否则提示词里的 @目标商品 找不到同名 label,火山拿到的是一句没有指代的字面量。
if triview is None:
raise ValueError("这个商品还没有可用图片")
assets = [triview]
triview = None
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
if triview is not None:
# 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。
refs.append(_library_image_ref(triview, team=team, label=TRIVIEW_LABEL))
return product.title, refs, "library"
def _character_library_refs(team, model_id: uuid.UUID) -> tuple[str, list, str]:
model = (
Model.objects.filter(Q(team=team) | Q(is_official=True), id=model_id, is_deleted=False, purged_at__isnull=True)
.select_related("portrait_asset", "triview_asset")
.first()
)
if model is None:
raise ValueError("角色不存在或已被删除")
assets = []
seen = set()
for asset in (model.portrait_asset, model.triview_asset):
if asset is None or asset.id in seen or asset.is_deleted:
continue
seen.add(asset.id)
assets.append(asset)
if len(assets) >= MAX_IMAGES:
break
if not assets:
raise ValueError("这个角色还没有可用图片")
labels = ["参考角色", "参考角色三视图"]
refs = [_library_image_ref(asset, team=team, label=labels[index] if index < len(labels) else f"参考角色{index + 1}") for index, asset in enumerate(assets)]
return model.name, refs, "library"
def _temporary_image_refs(team, image_ids: list, *, noun: str) -> tuple[str, list, str]:
refs = []
for index, asset_id in enumerate(image_ids):
asset = _team_asset(team, asset_id, kind=Asset.Type.IMAGE, label=f"{noun}参考图")
label = "目标商品" if noun == "商品" else "参考角色"
if index > 0:
label = f"{label}{index + 1}"
refs.append(_owned_ref(asset, kind="image", role="reference_image", label=label))
fallback = "临时商品素材" if noun == "商品" else "临时角色素材"
name = Asset.objects.filter(id=image_ids[0]).values_list("name", flat=True).first() or fallback
subject = name.rsplit(".", 1)[0] if name else fallback
if len(refs) > 1:
subject = f"{subject}{len(refs)}张参考图)"
return subject, refs, "temporary"