894 lines
40 KiB
Python
894 lines
40 KiB
Python
"""自由创作·独立视频生成(不绑 project)。
|
||
|
||
移植自 jimeng-clone(apps/generation/views.py 的 video_generate_view / tasks.py 轮询),
|
||
嫁接 AirShelf 底座:AITask 状态机 + CreditAccount 三段式计费(reserve/charge/release)+
|
||
TOS 存储 + VolcanoArkProvider。
|
||
|
||
链路:
|
||
submit_free_video → 校验/估价/并发闸 → AITask(RESERVED) → 调火山(SUBMITTED) → 派兜底轮询
|
||
finalize_free_video → 查火山;终态幂等化(POSTPROCESSING 认领防双 poll 双扣)
|
||
成功: 下载→TOS→Asset(FREE_CREATE)+首帧封面 → 按真实 tokens 结算
|
||
失败: 错误码映射中文 + 退费
|
||
web poll 端点与 worker 兜底任务共用 finalize;本地无 worker 也能全程收尾。
|
||
"""
|
||
import logging
|
||
import re
|
||
import uuid
|
||
from datetime import timedelta
|
||
from decimal import Decimal
|
||
from io import BytesIO
|
||
|
||
from django.conf import settings
|
||
from django.core.exceptions import ObjectDoesNotExist
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
|
||
from apps.assets.storage import TosStorage
|
||
from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount
|
||
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||
|
||
from .models import AITask, ModelConfig
|
||
from .providers.volcano import VolcanoArkProvider
|
||
from .generation_errors import classify_generation_error, public_error_for_task
|
||
from .video_errors import parse_provider_error
|
||
from .video_pricing import get_resolution
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
FREE_VIDEO_MODELS = {
|
||
"doubao-seedance-2-0-260128",
|
||
"doubao-seedance-2-0-fast-260128",
|
||
"doubao-seedance-2-0-mini-260615",
|
||
}
|
||
HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制)
|
||
RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||
RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
|
||
MODES = {"universal", "keyframe"}
|
||
IN_FLIGHT_STATUSES = (
|
||
AITask.Status.RESERVED,
|
||
AITask.Status.SUBMITTED,
|
||
AITask.Status.POLLING,
|
||
AITask.Status.POSTPROCESSING,
|
||
)
|
||
|
||
_ORPHAN_MATERIAL_MENTION_RE = re.compile(r"@(?:图片|视频|音频|素材)[^\s,。!?、;:,.!?;:))]*")
|
||
|
||
|
||
def find_orphan_material_mention(prompt: str, references: list) -> str | None:
|
||
"""prompt 里 @了素材类占位但 references 为空 → 返回该 mention(供 400 提示)。"""
|
||
if not prompt or references:
|
||
return None
|
||
match = _ORPHAN_MATERIAL_MENTION_RE.search(prompt)
|
||
return match.group(0) if match else None
|
||
|
||
|
||
def _format_prompt_for_ark(prompt: str, label_placeholders: list[tuple[str, str]]) -> str:
|
||
"""@label → 「图片N/视频N/音频N」。火山只认「类型+序号」指代(官方 FAQ Q3),
|
||
文件名/asset id 会按位置概率对齐 → 人物颠倒。调用方保证按 label 长度降序,
|
||
防「碧」先于「碧碧」被替换的子串吞噬;用 str.replace 防 label 含正则元字符崩溃。
|
||
用户原文保留在 request_payload 供「再次生成」回填 mention chip。"""
|
||
result = prompt
|
||
for label, placeholder in label_placeholders:
|
||
if not label:
|
||
continue
|
||
result = result.replace(f"@{label}", placeholder)
|
||
return result
|
||
|
||
|
||
def _normalize_remote_asset_id(remote_id: str) -> str:
|
||
# 火山返回 "Asset-xxx",引用时须小写前缀 "asset-xxx"
|
||
if remote_id.startswith("Asset-"):
|
||
return "asset-" + remote_id[6:]
|
||
return remote_id
|
||
|
||
|
||
def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
|
||
"""本地 processing 的素材实时查火山刷新。返回是否 active。best-effort。"""
|
||
try:
|
||
from apps.assets import assets_client
|
||
|
||
result = assets_client.get_asset(free_asset.remote_asset_id)
|
||
if result and result.get("Status") == "Active":
|
||
free_asset.status = FreeAsset.Status.ACTIVE
|
||
free_asset.url = result.get("Url", free_asset.url) or free_asset.url
|
||
free_asset.save(update_fields=["status", "url", "updated_at"])
|
||
return True
|
||
except Exception: # noqa: BLE001 — 刷新失败按未就绪处理
|
||
logger.warning("free asset %s refresh failed", free_asset.id, exc_info=True)
|
||
return False
|
||
|
||
|
||
def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict:
|
||
"""references → 火山 content_items + api_prompt(@label 已替换)。
|
||
|
||
移植 jimeng views.py:369-567:URL 去重、blob: 拦截、素材库引用(FreeAsset → asset://)、
|
||
素材组展开、图≤9/视频≤3/音频≤3 校验、@label→「图片N」映射(不变量:任意时刻
|
||
image_n/video_n/audio_n == content_items 里该类型已 push 的个数)。
|
||
校验失败抛 ValueError(用户友好中文),由 view 转 400。
|
||
"""
|
||
content_items: list[dict] = []
|
||
snapshots: list[dict] = []
|
||
resolved_library_assets: list[dict[str, str]] = []
|
||
seen_urls: set[str] = set()
|
||
group_cache: dict[str, list[FreeAsset]] = {}
|
||
label_to_placeholder: dict[str, str] = {}
|
||
image_n = video_n = audio_n = 0
|
||
video_duration_total = 0.0 # 输入参考视频总时长(token 公式的输入项 + ≤15s 校验)
|
||
|
||
def _placeholder_for(asset_type: str) -> str:
|
||
if asset_type == "Video":
|
||
return f"视频{video_n}"
|
||
if asset_type == "Audio":
|
||
return f"音频{audio_n}"
|
||
return f"图片{image_n}"
|
||
|
||
def _push(kind: str, url: str, role: str, duration: float = 0.0) -> str:
|
||
"""push 一个 content_item 并递增 counter,返回素材类型(Image/Video/Audio)。"""
|
||
nonlocal image_n, video_n, audio_n, video_duration_total
|
||
if kind == "video":
|
||
video_n += 1
|
||
video_duration_total += duration or 0.0
|
||
item: dict = {"type": "video_url", "video_url": {"url": url}}
|
||
if role:
|
||
item["role"] = role
|
||
content_items.append(item)
|
||
return "Video"
|
||
if kind == "audio":
|
||
audio_n += 1
|
||
item = {"type": "audio_url", "audio_url": {"url": url}}
|
||
if role:
|
||
item["role"] = role
|
||
content_items.append(item)
|
||
return "Audio"
|
||
image_n += 1
|
||
item = {"type": "image_url", "image_url": {"url": url}}
|
||
if role:
|
||
item["role"] = role
|
||
content_items.append(item)
|
||
return "Image"
|
||
|
||
def _resolve_group_assets(group: FreeAssetGroup) -> list[FreeAsset]:
|
||
resolved: list[FreeAsset] = []
|
||
for fa in group.assets.exclude(remote_asset_id="").order_by("created_at"):
|
||
if fa.status == FreeAsset.Status.PROCESSING and not _refresh_processing_free_asset(fa):
|
||
continue # 未就绪的跳过
|
||
if fa.status != FreeAsset.Status.ACTIVE:
|
||
continue
|
||
resolved.append(fa)
|
||
return resolved
|
||
|
||
def _remember_library_asset(fa: FreeAsset) -> None:
|
||
if any(item["local_asset_id"] == str(fa.id) for item in resolved_library_assets):
|
||
return
|
||
resolved_library_assets.append(
|
||
{
|
||
"local_asset_id": str(fa.id),
|
||
"remote_asset_id": fa.remote_asset_id,
|
||
"submitted_remote_asset_id": _normalize_remote_asset_id(fa.remote_asset_id),
|
||
"asset_name": fa.name,
|
||
}
|
||
)
|
||
|
||
for ref in references or []:
|
||
url = str(ref.get("url") or "")
|
||
ref_type = str(ref.get("type") or "image")
|
||
role = str(ref.get("role") or "")
|
||
label = str(ref.get("label") or "")
|
||
source = str(ref.get("source") or "upload")
|
||
duration = float(ref.get("duration") or 0)
|
||
|
||
dedupe_base = url or f"{source}:{ref.get('asset_id') or ref.get('group_id')}"
|
||
dedupe_key = f"{role}:{dedupe_base}" if mode == "keyframe" else dedupe_base
|
||
if dedupe_key in seen_urls:
|
||
continue
|
||
seen_urls.add(dedupe_key)
|
||
|
||
if url.startswith("blob:"):
|
||
raise ValueError(f"素材「{label or '未命名'}」上传失败,请删除后重新添加")
|
||
|
||
snap = {"url": url, "type": ref_type, "role": role, "label": label, "source": source}
|
||
if ref.get("thumb_url"):
|
||
snap["thumb_url"] = ref["thumb_url"]
|
||
if duration:
|
||
snap["duration"] = duration
|
||
if ref.get("asset_id"):
|
||
snap["asset_id"] = str(ref["asset_id"])
|
||
if ref.get("group_id"):
|
||
snap["group_id"] = str(ref["group_id"])
|
||
snapshots.append(snap)
|
||
|
||
# 素材库单素材:FreeAsset → asset://{remote_id}
|
||
if source == "library" and ref.get("asset_id"):
|
||
fa = FreeAsset.objects.filter(
|
||
id=ref["asset_id"], group__team=team, group__is_deleted=False
|
||
).first()
|
||
if fa is None:
|
||
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
|
||
if fa.status == FreeAsset.Status.PROCESSING and not _refresh_processing_free_asset(fa):
|
||
raise ValueError(f"素材「{label or fa.name}」尚在处理中,请稍后重试")
|
||
if fa.status != FreeAsset.Status.ACTIVE or not fa.remote_asset_id:
|
||
raise ValueError(f"素材「{label or fa.name}」尚未就绪,请稍后重试")
|
||
resolved_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}"
|
||
_remember_library_asset(fa)
|
||
kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image")
|
||
if mode == "keyframe":
|
||
if kind != "image":
|
||
raise ValueError("首尾帧模式仅支持图片素材")
|
||
effective_role = role if role in {"first_frame", "last_frame"} else "first_frame"
|
||
else:
|
||
effective_role = "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image")
|
||
asset_type = _push(kind, resolved_url, effective_role, fa.duration or 0.0)
|
||
if label and label not in label_to_placeholder:
|
||
label_to_placeholder[label] = _placeholder_for(asset_type)
|
||
continue
|
||
|
||
# 素材组引用:展开组内全部 active 素材(一个 label 对应 N 素材,语义变化 → 不登记 label)
|
||
if source == "library_group" and ref.get("group_id"):
|
||
gid = str(ref["group_id"])
|
||
if gid not in group_cache:
|
||
group = FreeAssetGroup.objects.filter(id=gid, team=team, is_deleted=False).first()
|
||
group_cache[gid] = _resolve_group_assets(group) if group else []
|
||
asset_list = group_cache[gid]
|
||
if not asset_list:
|
||
raise ValueError(f"素材「{label or '未命名'}」尚未就绪,请在素材库中确认状态为「可用」后重试")
|
||
for fa in asset_list:
|
||
kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image")
|
||
asset_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}"
|
||
_push(
|
||
kind,
|
||
asset_url,
|
||
"reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"),
|
||
fa.duration or 0.0,
|
||
)
|
||
_remember_library_asset(fa)
|
||
continue
|
||
|
||
# 直传素材(已上传 TOS 的直链)
|
||
if ref_type == "image":
|
||
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
|
||
effective_role = "reference_image" if mode == "universal" else (role or "first_frame")
|
||
asset_type = _push("image", url, effective_role)
|
||
elif ref_type == "video":
|
||
asset_type = _push("video", url, role or "reference_video", duration)
|
||
elif ref_type == "audio":
|
||
asset_type = _push("audio", url, role or "reference_audio", duration)
|
||
else:
|
||
logger.warning("unknown ref_type=%s url=%s label=%s, skipped", ref_type, url, label)
|
||
continue
|
||
|
||
if label and label not in label_to_placeholder:
|
||
label_to_placeholder[label] = _placeholder_for(asset_type)
|
||
|
||
if image_n > 9:
|
||
raise ValueError(f"参考图片最多 9 张(含素材库引用,同一素材按 1 张计算),当前 {image_n} 张,请减少后重试")
|
||
if video_n > 3:
|
||
raise ValueError(f"参考视频最多 3 条,当前 {video_n} 条,请减少后重试")
|
||
if audio_n > 3:
|
||
raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试")
|
||
if audio_n > 0 and image_n + video_n == 0:
|
||
raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频")
|
||
if video_duration_total > 15:
|
||
raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试")
|
||
|
||
# @label 替换:按 label 长度降序,防子串吞噬
|
||
ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True)
|
||
api_prompt = _format_prompt_for_ark(prompt, ordered)
|
||
|
||
return {
|
||
"content_items": content_items,
|
||
"api_prompt": api_prompt,
|
||
"snapshots": snapshots,
|
||
"resolved_library_assets": resolved_library_assets,
|
||
"image_n": image_n,
|
||
"video_n": video_n,
|
||
"audio_n": audio_n,
|
||
"video_duration_total": video_duration_total,
|
||
}
|
||
|
||
|
||
def _unavailable_library_asset_target(resolved_assets: list[dict], raw_message: str) -> dict | None:
|
||
"""从本次已解析引用中精确定位 Provider 指出的失效素材;不按陌生字符串模糊查库。"""
|
||
|
||
message = str(raw_message or "").casefold()
|
||
matches = []
|
||
for item in resolved_assets:
|
||
submitted_id = str(item.get("submitted_remote_asset_id") or "").casefold()
|
||
original_id = str(item.get("remote_asset_id") or "").casefold()
|
||
if (submitted_id and submitted_id in message) or (original_id and original_id in message):
|
||
matches.append(item)
|
||
if len(matches) == 1:
|
||
return matches[0]
|
||
if not matches and len(resolved_assets) == 1:
|
||
return resolved_assets[0]
|
||
return None
|
||
|
||
|
||
def _reap_stale_free_video_tasks(*, team) -> None:
|
||
"""僵尸回收(趁每次新提交顺手做,无需定时任务):
|
||
· RESERVED 超 10 分钟:没提交到火山就死(worker 崩溃/进程重启)→ 标失败退费;
|
||
· SUBMITTED/POLLING 超 2 小时:轮询链早已断且无人认领(正常出片 5-10 分钟)→ 标失败退费;
|
||
· POSTPROCESSING 超 30 分钟:转存/结算中途崩溃 → 标失败退费(火山可能已出片,平台承担该笔成本)。"""
|
||
now = timezone.now()
|
||
from .routing_policy import load_model_routing_policy
|
||
|
||
video_policy = load_model_routing_policy().video
|
||
buckets = [
|
||
(
|
||
[AITask.Status.RESERVED],
|
||
{"updated_at__lt": now - timedelta(seconds=video_policy.submit_total_timeout)},
|
||
"任务未在配置的提交总时限内完成(自动回收)",
|
||
),
|
||
(
|
||
[AITask.Status.SUBMITTED, AITask.Status.POLLING],
|
||
{"submitted_at__lt": now - timedelta(seconds=video_policy.generation_timeout)},
|
||
"生成超过配置的成片等待总时限(自动回收)",
|
||
),
|
||
(
|
||
[AITask.Status.POSTPROCESSING],
|
||
{"updated_at__lt": now - timedelta(minutes=30)},
|
||
"视频结果处理超时(自动回收)",
|
||
),
|
||
]
|
||
for statuses, stale_filter, reason in buckets:
|
||
stale = AITask.objects.filter(
|
||
team=team,
|
||
project__isnull=True,
|
||
task_type=AITask.Type.FREE_VIDEO,
|
||
status__in=statuses,
|
||
**stale_filter,
|
||
)
|
||
for task in stale:
|
||
try:
|
||
with transaction.atomic():
|
||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||
if locked.status not in statuses:
|
||
continue
|
||
locked.status = AITask.Status.FAILED
|
||
locked.error_message = reason
|
||
locked.completed_at = timezone.now()
|
||
locked.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||
try:
|
||
reservation = locked.credit_reservation
|
||
except ObjectDoesNotExist:
|
||
reservation = None
|
||
if reservation is not None:
|
||
release_credit(reservation=reservation, reason=reason)
|
||
except Exception: # noqa: BLE001 — 单个回收失败不应阻断新提交
|
||
logger.warning("reap stale free video task %s failed", task.id, exc_info=True)
|
||
continue
|
||
|
||
|
||
def submit_free_video(*, team, user, params: dict) -> AITask:
|
||
"""提交一条自由创作视频。校验/估价/预留在前(失败不留半套),火山调用在事务外。
|
||
创建失败不抛:返回 FAILED 任务(带友好中文错误),前端渲染失败卡。校验类错误抛 ValueError → 400。"""
|
||
prompt = str(params.get("prompt") or "").strip()
|
||
mode = str(params.get("mode") or "universal")
|
||
model_name = str(params.get("model") or HIGH_RES_MODEL)
|
||
aspect_ratio = str(params.get("aspect_ratio") or "16:9")
|
||
resolution = str(params.get("resolution") or "720p")
|
||
generate_audio = bool(params.get("generate_audio", True))
|
||
search_mode = str(params.get("search_mode") or "off")
|
||
references = params.get("references") or []
|
||
try:
|
||
duration = int(params.get("duration") or 5)
|
||
except (TypeError, ValueError):
|
||
raise ValueError("时长参数无效")
|
||
try:
|
||
seed = int(params.get("seed") if params.get("seed") is not None else -1)
|
||
except (TypeError, ValueError):
|
||
seed = -1
|
||
|
||
if not prompt:
|
||
raise ValueError("提示词不能为空")
|
||
if mode not in MODES:
|
||
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 秒之间")
|
||
if resolution in ("1080p", "4k") and model_name != HIGH_RES_MODEL:
|
||
raise ValueError(f"{resolution} 仅标准档模型支持,请切换模型或降低分辨率")
|
||
get_resolution(aspect_ratio, resolution) # 组合合法性 fail loud
|
||
|
||
orphan = find_orphan_material_mention(prompt, references)
|
||
if orphan:
|
||
raise ValueError(f"「{orphan}」对应的内容为空,请补充素材或删除该引用")
|
||
|
||
if mode == "keyframe":
|
||
roles = [str(r.get("role") or "") for r in references]
|
||
if any(str(r.get("type") or "image") != "image" for r in references):
|
||
raise ValueError("首尾帧模式仅支持图片素材")
|
||
if "first_frame" not in roles:
|
||
raise ValueError("首尾帧模式需要提供首帧图片")
|
||
if len(references) > 2:
|
||
raise ValueError("首尾帧模式最多提供首帧和尾帧各一张图片")
|
||
|
||
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)
|
||
|
||
# 团队并发闸(移植 jimeng Layer2.6):视频是长时高价任务,必须限并发
|
||
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}),请等待完成后再提交")
|
||
|
||
built = build_content_items(team=team, prompt=prompt, mode=mode, references=references)
|
||
|
||
# 统一计价引擎:¥成本 × 毛利系数 → 积分;预留 = 积分 × buffer(均为 BillingConfig 可配)
|
||
tokens, quote = quote_video_estimate(
|
||
model_config,
|
||
aspect_ratio=aspect_ratio,
|
||
resolution=resolution,
|
||
duration=duration,
|
||
references=built["snapshots"],
|
||
team=team,
|
||
)
|
||
reserve_amount = video_reserve_amount(quote.points)
|
||
|
||
request_payload = {
|
||
"feature": "free_video",
|
||
"mode": mode,
|
||
"model": model_name,
|
||
"endpoint": model_config.endpoint,
|
||
"prompt": prompt,
|
||
"api_prompt": built["api_prompt"],
|
||
"aspect_ratio": aspect_ratio,
|
||
"resolution": resolution,
|
||
"duration": duration,
|
||
"seed": seed,
|
||
"generate_audio": generate_audio,
|
||
"search_mode": search_mode,
|
||
"estimated_tokens": tokens,
|
||
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务
|
||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
|
||
"references": built["snapshots"],
|
||
"model_routing_v1": True,
|
||
}
|
||
|
||
# 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套
|
||
with transaction.atomic():
|
||
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"),
|
||
)
|
||
try:
|
||
reserve_credit(team=team, user=user, task=task, amount=reserve_amount)
|
||
except ValueError as exc:
|
||
if "insufficient credit" in str(exc):
|
||
raise ValueError("团队余额不足,请充值后重试") from exc
|
||
raise
|
||
task.status = AITask.Status.RESERVED
|
||
task.save(update_fields=["status", "updated_at"])
|
||
|
||
# 火山调用在事务外(不持锁调外网)
|
||
try:
|
||
from .services import execute_routed_video_submit
|
||
|
||
routed = execute_routed_video_submit(
|
||
task=task,
|
||
primary_model=model_config,
|
||
prompt=built["api_prompt"],
|
||
ratio=aspect_ratio,
|
||
duration=duration,
|
||
resolution=resolution,
|
||
reference_images=[],
|
||
content_items=built["content_items"],
|
||
pricing_references=built["snapshots"],
|
||
generate_audio=generate_audio,
|
||
seed=seed if seed != -1 else None,
|
||
search_mode=search_mode,
|
||
request_summary={"feature": "free_video", "mode": mode},
|
||
)
|
||
response, provider_task_id = routed.value
|
||
# execute_model_call 以原子 F 表达式累计实际尝试的平台成本;刷新内存对象,
|
||
# 保证本方法返回值与数据库中的 AITask.base_cost 完全一致。
|
||
task.refresh_from_db(fields=["base_cost"])
|
||
task.provider_task_id = provider_task_id
|
||
task.response_payload = response
|
||
payload = dict(task.request_payload or {})
|
||
payload["actual_model_config_id"] = str(routed.actual_model.id)
|
||
task.request_payload = payload
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(
|
||
update_fields=[
|
||
"provider_task_id",
|
||
"response_payload",
|
||
"request_payload",
|
||
"status",
|
||
"submitted_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
except Exception as exc: # noqa: BLE001 — 创建失败:标失败退费,返回失败卡(不向上抛)
|
||
code, raw_message = parse_provider_error(exc)
|
||
public_error = classify_generation_error(
|
||
exc,
|
||
operation="video_generate",
|
||
provider_code=code,
|
||
reference_id=str(task.id),
|
||
)
|
||
if public_error.code == "asset_unavailable":
|
||
target = _unavailable_library_asset_target(
|
||
built["resolved_library_assets"],
|
||
raw_message,
|
||
)
|
||
if target is not None:
|
||
try:
|
||
from apps.assets.free_asset_state import mark_remote_asset_unavailable
|
||
|
||
mark_remote_asset_unavailable(
|
||
team=team,
|
||
local_asset_id=target["local_asset_id"],
|
||
remote_asset_id=target["remote_asset_id"],
|
||
)
|
||
except Exception: # noqa: BLE001 - 素材状态同步是附带自愈,不能遮蔽任务失败与退费。
|
||
logger.exception("failed to mark unavailable free asset for task %s", task.id)
|
||
task.status = AITask.Status.FAILED
|
||
task.error_code = (code or "CreateTaskError")[:64]
|
||
task.error_message = raw_message[:2000]
|
||
task.completed_at = timezone.now()
|
||
task.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
|
||
release_credit(reservation=task.credit_reservation, reason=raw_message[:200])
|
||
_notify_failure(
|
||
task,
|
||
raw=f"[{code}] {raw_message}" if code else raw_message,
|
||
hint=public_error.fallback_message,
|
||
)
|
||
logger.warning("free video create failed: %s", exc)
|
||
return task
|
||
|
||
# worker 兜底轮询(自重排);派发失败仅 log,前端主动 poll 仍能收尾
|
||
try:
|
||
from .tasks import poll_free_video_task
|
||
|
||
poll_free_video_task.apply_async(args=[str(task.id), 0], countdown=30)
|
||
except Exception: # noqa: BLE001
|
||
logger.error("poll_free_video_task enqueue failed; relying on client polling", exc_info=True)
|
||
|
||
return task
|
||
|
||
|
||
def _notify_failure(task: AITask, *, raw: str, hint: str) -> None:
|
||
from .services import notify_generation_failure
|
||
|
||
notify_generation_failure(
|
||
task=task,
|
||
project=None,
|
||
recipient=task.created_by,
|
||
stage_label="自由创作视频",
|
||
raw=raw,
|
||
hint=hint,
|
||
)
|
||
|
||
|
||
def _store_free_video_media(*, task: AITask, media: str) -> Asset:
|
||
"""下载火山结果 → 转存 TOS(火山原始 URL 仅 7 天有效)→ 建 Asset(FREE_CREATE,自动入库)
|
||
+ ffmpeg 抽首帧封面挂同 Asset 非主文件。"""
|
||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||
if "video" not in content_type:
|
||
content_type = "video/mp4"
|
||
# 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛
|
||
# "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。
|
||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||
asset_id = uuid.uuid4()
|
||
object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4"
|
||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||
prompt = (task.request_payload or {}).get("prompt") or ""
|
||
asset = Asset.objects.create(
|
||
id=asset_id,
|
||
team=task.team,
|
||
created_by=task.created_by,
|
||
# Asset.name 是通用资产字段,受 255 字符上限约束;完整显示标题由资产接口从
|
||
# 关联任务的 request_payload.prompt 派生,避免再出现 50 字符业务截断。
|
||
name=(prompt[:255] or "自由创作视频"),
|
||
asset_type=Asset.Type.VIDEO,
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=Asset.Category.FREE_CREATE,
|
||
origin_task=task,
|
||
metadata={"feature": "free_video"},
|
||
)
|
||
AssetFile.objects.create(
|
||
asset=asset,
|
||
object_key=stored.object_key,
|
||
bucket=stored.bucket,
|
||
content_type=stored.content_type,
|
||
size_bytes=stored.size_bytes,
|
||
is_primary=True,
|
||
)
|
||
# 首帧封面(best-effort):任务流/资产库缩略图
|
||
try:
|
||
if video_bytes:
|
||
import subprocess
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-poster-") as tmp:
|
||
tmp_dir = Path(tmp)
|
||
video_path = tmp_dir / "in.mp4"
|
||
poster_path = tmp_dir / "poster.jpg"
|
||
video_path.write_bytes(video_bytes)
|
||
proc = subprocess.run(
|
||
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||
capture_output=True,
|
||
timeout=60,
|
||
)
|
||
if proc.returncode == 0 and poster_path.exists() and poster_path.stat().st_size:
|
||
poster_key = f"teams/{task.team_id}/free-create/{asset_id}-poster.jpg"
|
||
poster_stored = TosStorage().upload_fileobj(
|
||
fileobj=BytesIO(poster_path.read_bytes()), object_key=poster_key, content_type="image/jpeg"
|
||
)
|
||
AssetFile.objects.create(
|
||
asset=asset,
|
||
object_key=poster_stored.object_key,
|
||
bucket=poster_stored.bucket,
|
||
content_type=poster_stored.content_type,
|
||
size_bytes=poster_stored.size_bytes,
|
||
is_primary=False,
|
||
)
|
||
except Exception: # noqa: BLE001 — 封面仅用于展示,失败不阻断
|
||
logger.warning("free video poster extract failed for task %s", task.id, exc_info=True)
|
||
return asset
|
||
|
||
|
||
def finalize_free_video(*, task: AITask) -> AITask:
|
||
"""单次轮询 + 幂等终态化。web poll 端点与 worker 兜底共用。
|
||
|
||
防双 poll 双扣:succeeded 时先持锁把任务从 SUBMITTED/POLLING「认领」成 POSTPROCESSING,
|
||
并发的另一路看到 POSTPROCESSING 直接返回;认领者独占 下载→TOS→建资产→结算 全程。
|
||
认领后崩溃由 _reap_stale_free_video_tasks(30 分钟)兜底退费。"""
|
||
if task.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||
return task
|
||
if not task.provider_task_id:
|
||
return task
|
||
|
||
from .routing_policy import load_model_routing_policy
|
||
from .services import get_video_provider
|
||
|
||
video_policy = load_model_routing_policy().video
|
||
if task.submitted_at and (
|
||
timezone.now() - task.submitted_at
|
||
).total_seconds() >= video_policy.generation_timeout:
|
||
timeout_message = "视频生成超过配置的成片等待总时限"
|
||
public_error = classify_generation_error(
|
||
TimeoutError(timeout_message),
|
||
operation="video_generate",
|
||
reference_id=str(task.id),
|
||
)
|
||
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.FAILED
|
||
locked.error_code = "GenerationTimeout"
|
||
locked.error_message = timeout_message
|
||
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=timeout_message)
|
||
_notify_failure(locked, raw=timeout_message, hint=public_error.fallback_message)
|
||
return locked
|
||
|
||
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=task.provider_task_id,
|
||
timeout=video_policy.poll_request_timeout,
|
||
)
|
||
remote_status = str(response.get("status") or "")
|
||
|
||
if remote_status in {"queued", "running", "processing", "submitted"}:
|
||
# 仍在生成:只在首次进入 POLLING 时落一次库(不逐次回写完整 response,省写带宽)
|
||
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),
|
||
)
|
||
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.FAILED
|
||
locked.error_code = code[:64]
|
||
locked.error_message = raw_message[:2000]
|
||
locked.response_payload = response
|
||
locked.completed_at = timezone.now()
|
||
locked.save(
|
||
update_fields=["status", "error_code", "error_message", "response_payload", "completed_at", "updated_at"]
|
||
)
|
||
release_credit(reservation=locked.credit_reservation, reason=raw_message[:200])
|
||
_notify_failure(locked, raw=f"[{code}] {raw_message}", hint=public_error.fallback_message)
|
||
return locked
|
||
|
||
# succeeded —— 认领 POSTPROCESSING(并发 finalize 只有一路进入慢活)
|
||
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 {})
|
||
try:
|
||
media = provider.extract_first_media_url(response)
|
||
try:
|
||
_store_free_video_media(task=locked, media=media)
|
||
except Exception: # noqa: BLE001 — TOS 转存失败:兜底记火山临时 URL(约 24h 内可看),不吞成功
|
||
logger.exception("free video TOS store failed for task %s, falling back to raw url", locked.id)
|
||
payload["fallback_video_url"] = media
|
||
payload["fallback_note"] = "结果转存失败,当前链接约 24 小时后失效"
|
||
|
||
usage = response.get("usage") or {}
|
||
try:
|
||
total_tokens = int(usage.get("total_tokens") or 0)
|
||
except (TypeError, ValueError):
|
||
total_tokens = 0
|
||
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or [])
|
||
resolution = payload.get("resolution") or "720p"
|
||
if total_tokens > 0:
|
||
from decimal import Decimal
|
||
|
||
settle = quote_video_actual(
|
||
actual_model, tokens=total_tokens, with_video_ref=with_video_ref, 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
|
||
seed_out = response.get("seed")
|
||
if seed_out is not None:
|
||
payload["seed_used"] = seed_out
|
||
|
||
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:
|
||
# ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 buffer)
|
||
logger.warning(
|
||
"free video 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.response_payload = response
|
||
locked.completed_at = timezone.now()
|
||
locked.save(
|
||
update_fields=["status", "actual_cost", "base_cost", "request_payload", "response_payload", "completed_at", "updated_at"]
|
||
)
|
||
charge_reserved_credit(reservation=reservation, actual_amount=actual)
|
||
return locked
|
||
except Exception as exc: # noqa: BLE001 — 后处理失败:标失败退费(release 幂等,已扣则不动)
|
||
logger.exception("free video finalize failed for task %s", 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"
|
||
public_error = classify_generation_error(
|
||
exc,
|
||
operation="video_generate",
|
||
internal_kind="processing_failed",
|
||
reference_id=str(locked.id),
|
||
)
|
||
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 serialize_free_video_task(task: AITask, *, include_deleted_assets: bool = False) -> dict:
|
||
"""任务 → 前端契约。视频/封面直链从 generated_assets 取(TOS 公读),转存失败回落火山临时 URL。"""
|
||
payload = task.request_payload or {}
|
||
video_url = ""
|
||
thumbnail_url = ""
|
||
try:
|
||
storage = TosStorage() if settings.TOS.get("endpoint") else None
|
||
except Exception: # noqa: BLE001
|
||
storage = None
|
||
|
||
def _file_url(f) -> str:
|
||
if f.preview_url:
|
||
return f.preview_url
|
||
if storage and f.object_key:
|
||
try:
|
||
return storage.public_url(object_key=f.object_key, bucket=f.bucket or None)
|
||
except Exception: # noqa: BLE001
|
||
return ""
|
||
return ""
|
||
|
||
generated_assets = list(task.generated_assets.all())
|
||
for asset in generated_assets:
|
||
if asset.purged_at is not None or (asset.is_deleted and not include_deleted_assets):
|
||
continue
|
||
for f in asset.files.all():
|
||
url = _file_url(f)
|
||
if not url:
|
||
continue
|
||
if f.is_primary and not video_url:
|
||
video_url = url
|
||
elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url:
|
||
thumbnail_url = url
|
||
if not video_url and not generated_assets:
|
||
video_url = payload.get("fallback_video_url") or ""
|
||
|
||
public_error = public_error_for_task(task, operation="video_generate")
|
||
return {
|
||
"id": str(task.id),
|
||
"status": task.status,
|
||
"mode": payload.get("mode") or "universal",
|
||
"model": payload.get("model") or "",
|
||
"prompt": payload.get("prompt") or "",
|
||
"aspect_ratio": payload.get("aspect_ratio") or "16:9",
|
||
"resolution": payload.get("resolution") or "720p",
|
||
"duration": payload.get("duration") or 5,
|
||
"seed": payload.get("seed", -1),
|
||
"seed_used": payload.get("seed_used"),
|
||
"generate_audio": payload.get("generate_audio", True),
|
||
"references": payload.get("references") or [],
|
||
"estimated_tokens": payload.get("estimated_tokens") or 0,
|
||
"actual_tokens": payload.get("actual_tokens") or 0,
|
||
"estimated_cost": str(task.estimated_cost),
|
||
"actual_cost": str(task.actual_cost),
|
||
"error": public_error.as_dict() if public_error else None,
|
||
"error_message": public_error.fallback_message if public_error else "",
|
||
"fallback_note": payload.get("fallback_note") or "",
|
||
"is_favorited": task.is_favorited,
|
||
"video_url": video_url,
|
||
"thumbnail_url": thumbnail_url,
|
||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
|
||
}
|