Files
yingqing/core/backend/apps/ai/video_replace.py
T
2026-08-27 17:58:48 +08:00

769 lines
31 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 换商品或换角色。
不新建任务类型、不接检测/抠图。提交仍走 submit_free_video,只把
feature=video_replace 和 replace_mode 写进 payload,提示词由后端写死。
真人参考必须先送火山素材库审核,过审后用 asset:// 生成;审核失败不扣费。
"""
from __future__ import annotations
import logging
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import transaction
from django.db.models import Q
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,
IN_FLIGHT_STATUSES,
RATIOS,
RESOLUTIONS,
_reap_stale_free_video_tasks,
serialize_free_video_task,
start_pending_free_video,
submit_free_video,
)
from .media_probe import REF_DURATION_MAX
from .models import AITask, ModelConfig
logger = logging.getLogger(__name__)
FEATURE = "video_replace"
REPLACE_MODES = {"product", "character"}
MAX_IMAGES = 9
LEGACY_PROMPT_PREFIX = "[视频复刻]"
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步:
# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成
# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。
# 角色复刻仍是「参考视频 + 角色图」直传,已验证有效,不要顺手一起改。
PRODUCT_DIGEST_HEAD = (
"下面是参考视频的完整分镜稿,它逐镜记录了这条片子的时间、景别、机位、运镜、画面、"
"人物动作与表情、台词旁白和声音。请把它当作导演脚本,按镜头顺序还原成一条新视频。"
)
PRODUCT_DIGEST_TAIL = (
"【复刻要求】\n"
"1. 严格按上面分镜稿的镜头顺序、时间分配、景别、机位、运镜和剪辑节奏还原全片,不要自行增删镜头。\n"
"2. 分镜稿里出现的原商品,全部替换成 @目标商品;商品的外形、颜色、材质、包装和标识必须与参考图完全一致,"
"不要改造型、不要改配色、不要凭空补细节。\n"
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
)
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
# 分镜稿是给人逐镜校对的导演稿,整篇塞给视频模型太长也太杂,按需瘦身:
# ·「字幕」栏是原片花字的记录,留着会诱导模型把字画到画面上,与全站「成片无字幕」冲突;
# ·【拆解存疑】是给用户校对用的,视频模型用不上。
DIGEST_DROP_FIELDS = ("字幕",)
# 瘦身完还超长时再砍这几栏(信息量最低,砍掉不影响镜头结构)。
DIGEST_TRIM_FIELDS = ("音效", "背景音乐", "备注")
DIGEST_TAIL_SECTION = "【拆解存疑】"
MAX_DIGEST_CHARS = 4000
CHARACTER_PROMPT = (
"使用@参考视频作为镜头、节奏与口播氛围基准,"
"将画面中需要替换的原人物完整替换为@目标角色。"
"保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围,"
"角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。"
)
def _drop_digest_fields(lines: list[str], fields: tuple[str, ...]) -> list[str]:
prefixes = tuple(f"{name}" for name in fields)
return [line for line in lines if not line.strip().startswith(prefixes)]
def compact_digest_for_video(digest_text: str) -> str:
"""分镜稿 → 交给视频模型的精简版。保留镜头结构,砍掉给人看的部分。"""
body = (digest_text or "").strip()
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
if sep:
body = head.strip()
lines = _drop_digest_fields(body.split("\n"), DIGEST_DROP_FIELDS)
if sum(len(line) for line in lines) > MAX_DIGEST_CHARS:
lines = _drop_digest_fields(lines, DIGEST_TRIM_FIELDS)
return "\n".join(line for line in lines if line.strip()).strip()
def build_product_replace_prompt(digest_text: str, subject_name: str) -> str:
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
不会把它换成火山认的「图片N」指代。
"""
from .services import enforce_no_embedded_captions
body = compact_digest_for_video(digest_text)
if not body:
raise ValueError("参考视频拆解结果为空,请重试")
subject = (subject_name or "").strip() or "目标商品"
tail = PRODUCT_DIGEST_TAIL.format(subject=subject)
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
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
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 0,
"digest_source_name": payload.get("digest_source_name") or "",
"digest_source": payload.get("digest_source_ref") or None,
})
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("请选择角色或上传角色参考图")
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
video_seconds = _asset_duration_seconds(video)
if video_seconds > REF_DURATION_MAX:
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
if has_product:
subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
elif has_model:
subject_name, image_refs, subject_source = _character_library_refs(team, model_id)
else:
noun = "商品" if replace_mode == "product" else "角色"
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
duration = _output_duration(params.get("duration"), video_seconds)
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 "",
}
base_params = {
"mode": "universal",
"model": str(params.get("model") or HIGH_RES_MODEL),
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
"resolution": str(params.get("resolution") or "720p"),
"duration": duration,
"seed": params.get("seed", -1),
"generate_audio": True,
"feature": FEATURE,
}
if replace_mode == "product":
# 商品复刻:参考视频只喂给提炼模型,不进火山 references(所以也不用送火山审核)。
# 先秒回一个 CREATED 任务,拆解这种慢活(Gemini 半分钟起)交给 worker。
extra.update({
"digest_source_asset_id": str(video.id),
"digest_source_name": video.name or "参考视频",
"digest_source_duration": round(video_seconds, 2),
# 参考视频虽然不进 references,历史卡的「原视频」和「重新生成」仍要拿到它,
# 这里存一份引用快照,免得序列化历史时每条再查一次库。
"digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"),
})
return _create_reviewing_task(
team=team,
user=user,
params={
**base_params,
"prompt": DIGEST_PENDING_PROMPT,
"references": image_refs,
"extra_payload": extra,
},
digest_pending=True,
)
references = [
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
*image_refs,
]
review_state = _ensure_replace_refs_reviewed(team, references)
if review_state == "failed":
raise ValueError(REVIEW_FAILED)
references = _refresh_replace_refs(team, references)
extra["review_pending"] = review_state != "ready"
submit_params = {
**base_params,
"prompt": CHARACTER_PROMPT,
"references": references,
"extra_payload": extra,
}
if review_state == "ready":
_assert_replace_refs_ready(references)
return submit_free_video(team=team, user=user, params=submit_params)
return _create_reviewing_task(team=team, user=user, params=submit_params)
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 = 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_pending_free_video(task)
def run_replace_digest(task) -> None:
"""Worker:商品复刻第一道工序——参考视频 → 分镜稿 → 完整提示词 → 走审核提交火山。
这一步失败 = 整条复刻失败。此时还没预留视频积分(CREATED 阶段不预留),不扣费。
"""
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, "参考视频已失效,请重新上传")
return
try:
digest, meta = digest_asset_video(asset=asset, task=task)
prompt = build_product_replace_prompt(digest, str(payload.get("subject_name") or ""))
except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc))
return
except Exception as exc: # noqa: BLE001 — 拆解任何异常都要把任务收尾,别留 CREATED 僵尸
logger.exception("video replace digest failed for %s", task.id)
_fail_reviewing_task(task, f"参考视频拆解失败:{exc}")
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["prompt"] = prompt
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 _legacy_replace_mode(prompt: str) -> str:
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
def _fail_reviewing_task(task, message: str):
from .free_video import _fail_pending_free_video
return _fail_pending_free_video(task, message)
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 HIGH_RES_MODEL)
aspect_ratio = str(params.get("aspect_ratio") or "9:16")
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:
raise ValueError("画面比例无效")
if resolution not in RESOLUTIONS:
raise ValueError("分辨率无效")
if not 4 <= duration <= 15:
raise ValueError("视频时长需在 4-15 秒之间")
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 []
tokens, quote = quote_video_estimate(
model_config,
aspect_ratio=aspect_ratio,
resolution=resolution,
duration=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
extra = params.get("extra_payload") if isinstance(params.get("extra_payload"), dict) else {}
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,
"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:
raise ValueError(f"「{label}{REVIEW_SUBMIT_FAILED}")
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 _output_duration(requested, video_seconds: float) -> int:
try:
value = int(requested) if requested not in (None, "") else 0
except (TypeError, ValueError):
value = 0
if value:
return min(15, max(4, value))
if video_seconds:
return min(15, max(4, int(round(video_seconds))))
return 15
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 _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_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("商品不存在或已被删除")
assets = []
seen = 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) >= MAX_IMAGES:
break
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted:
assets.append(product.cover_asset)
if not assets:
raise ValueError("这个商品还没有可用图片")
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
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"