完善视频复刻
This commit is contained in:
@@ -2,23 +2,47 @@
|
||||
|
||||
不新建任务类型、不接检测/抠图。提交仍走 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 HIGH_RES_MODEL, serialize_free_video_task, submit_free_video
|
||||
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 = "素材提交审核失败,请稍后重试"
|
||||
|
||||
PRODUCT_PROMPT = (
|
||||
"使用@参考视频作为镜头、节奏与口播氛围基准,"
|
||||
@@ -49,6 +73,7 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
|
||||
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 "")
|
||||
reviewing = task.status == AITask.Status.CREATED and bool(payload.get("review_pending"))
|
||||
data.update({
|
||||
"feature": FEATURE,
|
||||
"replace_mode": replace_mode,
|
||||
@@ -56,12 +81,13 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
|
||||
"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 "",
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
def submit_video_replace(*, team, user, params: dict):
|
||||
"""校验素材 → 套提示词 → 复用 free_video 提交。失败抛 ValueError。"""
|
||||
"""校验素材 → 送审 → 已过审则直接生成,否则建 CREATED 任务等绿盾。失败抛 ValueError。"""
|
||||
replace_mode = str(params.get("replace_mode") or "").strip()
|
||||
if replace_mode not in REPLACE_MODES:
|
||||
raise ValueError("请选择替换商品或替换角色")
|
||||
@@ -107,35 +133,305 @@ def submit_video_replace(*, team, user, params: dict):
|
||||
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
||||
*image_refs,
|
||||
]
|
||||
return submit_free_video(
|
||||
team=team,
|
||||
user=user,
|
||||
params={
|
||||
"prompt": prompt,
|
||||
"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,
|
||||
"references": references,
|
||||
"feature": FEATURE,
|
||||
"extra_payload": {
|
||||
"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 "",
|
||||
},
|
||||
},
|
||||
)
|
||||
review_state = _ensure_replace_refs_reviewed(team, references)
|
||||
if review_state == "failed":
|
||||
raise ValueError(REVIEW_FAILED)
|
||||
references = _refresh_replace_refs(team, references)
|
||||
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 "",
|
||||
"review_pending": review_state != "ready",
|
||||
}
|
||||
submit_params = {
|
||||
"prompt": prompt,
|
||||
"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,
|
||||
"references": references,
|
||||
"feature": FEATURE,
|
||||
"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 {}
|
||||
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 _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 _create_reviewing_task(*, team, user, params: dict):
|
||||
"""审核未完成:只建 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,
|
||||
}
|
||||
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"),
|
||||
)
|
||||
_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:
|
||||
@@ -206,7 +502,7 @@ def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
|
||||
"type": kind,
|
||||
"role": role,
|
||||
"label": label,
|
||||
"source": "upload",
|
||||
"source": "asset",
|
||||
"asset_id": str(asset.id),
|
||||
}
|
||||
seconds = _asset_duration_seconds(asset)
|
||||
|
||||
Reference in New Issue
Block a user