1814 lines
74 KiB
Python
1814 lines
74 KiB
Python
"""视频复刻:参考视频提炼分镜稿 → 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, 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,
|
||
_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 = (
|
||
"按下面的商品语义重构分镜生成视频。@目标商品只用于锁定外观、材质与包装。"
|
||
"严格遵守每镜的新商品动作、场景与口播;不要恢复参考片原商品、原动作、原卖点或原台词。"
|
||
)
|
||
CHARACTER_PROMPT = (
|
||
"以@参考视频的镜头、人物动作、场景、节奏、口播和剪辑为唯一基准生成视频。"
|
||
"将画面中所有可见的原人物替换为@目标角色;五官、发型、体态和服装以角色参考图为准。"
|
||
"保留原商品、场景、镜头运动、动作顺序和声音节奏,不添加字幕、花字或额外人物。"
|
||
)
|
||
|
||
# 拆解期间的占位提示词。真正的提示词在 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()
|
||
]
|
||
return {
|
||
"商品名称": product.title.strip(),
|
||
"品牌": product.brand.strip(),
|
||
"商品品类": product.category.strip(),
|
||
"商品描述": product.description.strip(),
|
||
"真实卖点": selling_points,
|
||
"目标用户": product.target_audience.strip(),
|
||
"规格": product.specs or {},
|
||
"资料状态": "完整" if (product.category.strip() and (product.description.strip() or selling_points)) else "不足",
|
||
}
|
||
|
||
|
||
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"
|
||
"输出给视频模型的中文分镜稿:保留【镜头 01】格式和每镜时间、景别、机位、运镜;"
|
||
"每镜的画面、人物动作、台词/旁白必须按新商品真实用途重写。"
|
||
"不要输出分析过程、分类标签或 Markdown。"
|
||
),
|
||
},
|
||
]
|
||
|
||
|
||
def validate_product_semantic_remix(text: str) -> str:
|
||
"""拒绝模型在事实不足时瞎编,或只给一段泛泛分析而没有可出片分镜。"""
|
||
cleaned = (text or "").strip()
|
||
if "MISSING_PRODUCT_FACTS" in cleaned:
|
||
raise ValueError("商品资料不足:请先在商品库补充品类,以及商品描述或至少一条真实卖点后再复刻")
|
||
if len(cleaned) < 120 or "【镜头" not in cleaned:
|
||
raise ValueError("商品语义重构结果不完整,请重试")
|
||
return cleaned
|
||
|
||
|
||
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
|
||
|
||
model_config = resolve_digest_model_config()
|
||
if model_config is None:
|
||
raise ValueError("商品语义重构模型未配置,请联系管理员")
|
||
provider = get_text_provider(model_config)
|
||
text, _payload = _collect_extract_text(
|
||
provider,
|
||
model_config,
|
||
build_product_semantic_remix_messages(
|
||
digest_text=digest_text,
|
||
product_facts=product_facts,
|
||
duration=duration,
|
||
aspect_ratio=aspect_ratio,
|
||
),
|
||
temperature=0.2,
|
||
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
|
||
)
|
||
return validate_product_semantic_remix(text)
|
||
|
||
|
||
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 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 "",
|
||
"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 "",
|
||
"digest_source": payload.get("digest_source_ref") or None,
|
||
"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 秒,请剪短后重试")
|
||
|
||
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,
|
||
"资料状态": "不足",
|
||
"说明": "临时上传图片只提供外观,未提供品类、用途或真实卖点。",
|
||
}
|
||
|
||
# 角色替换是 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,
|
||
# 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("视频提炼模型未配置,请联系管理员")
|
||
else:
|
||
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 CHARACTER_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 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
|
||
# worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。
|
||
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。不传原片。
|
||
|
||
商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。
|
||
"""
|
||
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 {}
|
||
# 角色复刻没有 Gemini 拆镜环节;审核通过后由 advance_video_replace 直接提交 Seedance。
|
||
if payload.get("replace_mode") == "character":
|
||
return
|
||
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 = build_character_replace_prompt(
|
||
digest, source_seconds=source_seconds, output_seconds=output_seconds,
|
||
)
|
||
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)
|
||
try:
|
||
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
|
||
except (TypeError, ValueError):
|
||
seed = -1
|
||
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)
|
||
|
||
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
|
||
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"])
|
||
|
||
try:
|
||
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
|
||
except (TypeError, ValueError):
|
||
seed = -1
|
||
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.pricing import quote_video_actual
|
||
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
|
||
resolution = payload.get("resolution") or "720p"
|
||
if total_tokens > 0:
|
||
settle = quote_video_actual(
|
||
locked.model_config, tokens=total_tokens, with_video_ref=True,
|
||
resolution=resolution, multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
|
||
)
|
||
actual, base_cost = settle.points, settle.base_cost_yuan
|
||
payload["actual_tokens"] = total_tokens
|
||
if settle.meta.get("rate"):
|
||
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
|
||
else:
|
||
actual, base_cost = locked.estimated_cost, locked.base_cost
|
||
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),
|
||
]
|
||
proc = subprocess.run(cmd, capture_output=True, timeout=180)
|
||
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)
|
||
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)
|
||
account = CreditAccount.objects.filter(team=team).first()
|
||
available = (account.balance - account.reserved_balance) if account else Decimal("0")
|
||
if available < reserve_amount:
|
||
raise ValueError("团队余额不足,请充值后重试")
|
||
|
||
try:
|
||
seed = int(params.get("seed") if params.get("seed") is not None else -1)
|
||
except (TypeError, ValueError):
|
||
seed = -1
|
||
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,
|
||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
|
||
"references": references,
|
||
"model_routing_v1": True,
|
||
"review_pending": True,
|
||
"digest_pending": digest_pending,
|
||
}
|
||
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,
|
||
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:
|
||
from .services import _asset_preview_url
|
||
|
||
url = _asset_preview_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),
|
||
}
|
||
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"
|