feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库
后端: - free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022) - video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测 - catalog/volcano: Seedance free-video 模型接入+seed(migration 0023) - 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口 - settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关) - 测试: test_free_video.py 新增; billing/products/projects tests 配套调整 前端: - /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗) - api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入 bug/: 测试清单 (11)(12) 与截图归档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,728 @@
|
||||
"""自由创作·独立视频生成(不绑 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, ROUND_HALF_UP
|
||||
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.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||||
|
||||
from .models import AITask, ModelConfig
|
||||
from .providers.volcano import VolcanoArkProvider
|
||||
from .video_errors import map_video_error, parse_provider_error
|
||||
from .video_pricing import (
|
||||
RESERVE_BUFFER,
|
||||
estimate_video_cost,
|
||||
get_resolution,
|
||||
tokens_to_cost,
|
||||
)
|
||||
|
||||
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] = []
|
||||
seen_urls: set[str] = set()
|
||||
group_cache: dict[str, list[tuple[str, str, float]]] = {}
|
||||
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[tuple[str, str, float]]:
|
||||
resolved: list[tuple[str, str, float]] = []
|
||||
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(
|
||||
(f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}", fa.asset_type, fa.duration or 0.0)
|
||||
)
|
||||
return resolved
|
||||
|
||||
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_key = url or f"{source}:{ref.get('asset_id') or ref.get('group_id')}"
|
||||
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)}"
|
||||
kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image")
|
||||
asset_type = _push(kind, resolved_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), 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 asset_url, asset_type, dur in asset_list:
|
||||
kind = {"Video": "video", "Audio": "audio"}.get(asset_type, "image")
|
||||
_push(kind, asset_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), dur)
|
||||
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,
|
||||
"image_n": image_n,
|
||||
"video_n": video_n,
|
||||
"audio_n": audio_n,
|
||||
"video_duration_total": video_duration_total,
|
||||
}
|
||||
|
||||
|
||||
def _reap_stale_free_video_tasks(*, team) -> None:
|
||||
"""僵尸回收(趁每次新提交顺手做,无需定时任务):
|
||||
· RESERVED 超 10 分钟:没提交到火山就死(worker 崩溃/进程重启)→ 标失败退费;
|
||||
· SUBMITTED/POLLING 超 2 小时:轮询链早已断且无人认领(正常出片 5-10 分钟)→ 标失败退费;
|
||||
· POSTPROCESSING 超 30 分钟:转存/结算中途崩溃 → 标失败退费(火山可能已出片,平台承担该笔成本)。"""
|
||||
now = timezone.now()
|
||||
buckets = [
|
||||
([AITask.Status.RESERVED], now - timedelta(minutes=10), "任务未在预期时间内提交(自动回收)"),
|
||||
([AITask.Status.SUBMITTED, AITask.Status.POLLING], now - timedelta(hours=2), "生成超时(自动回收)"),
|
||||
([AITask.Status.POSTPROCESSING], now - timedelta(minutes=30), "视频结果处理超时(自动回收)"),
|
||||
]
|
||||
for statuses, cutoff, reason in buckets:
|
||||
stale = AITask.objects.filter(
|
||||
team=team,
|
||||
project__isnull=True,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=statuses,
|
||||
updated_at__lt=cutoff,
|
||||
)
|
||||
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)
|
||||
|
||||
tokens, cost = estimate_video_cost(
|
||||
model_config,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
references=built["snapshots"],
|
||||
)
|
||||
reserve_amount = (cost * RESERVE_BUFFER).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
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,
|
||||
"references": built["snapshots"],
|
||||
}
|
||||
|
||||
# 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套
|
||||
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=cost,
|
||||
)
|
||||
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 build_provider
|
||||
|
||||
provider = build_provider(model_config)
|
||||
response = provider.create_video_task(
|
||||
model=model_config.name,
|
||||
endpoint=model_config.endpoint,
|
||||
prompt=built["api_prompt"],
|
||||
ratio=aspect_ratio,
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
generate_audio=generate_audio,
|
||||
content_items=built["content_items"],
|
||||
seed=seed if seed != -1 else None,
|
||||
search_mode=search_mode,
|
||||
)
|
||||
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
|
||||
task.response_payload = response
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
|
||||
except Exception as exc: # noqa: BLE001 — 创建失败:标失败退费,返回失败卡(不向上抛)
|
||||
code, raw_message = parse_provider_error(exc)
|
||||
friendly = map_video_error(code, raw_message)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_code = (code or "CreateTaskError")[:64]
|
||||
task.error_message = friendly
|
||||
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=friendly)
|
||||
_notify_failure(task, raw=f"[{code}] {raw_message}" if code else raw_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) -> None:
|
||||
from .services import notify_generation_failure
|
||||
|
||||
notify_generation_failure(
|
||||
task=task,
|
||||
project=None,
|
||||
recipient=task.created_by,
|
||||
stage_label="自由创作视频",
|
||||
raw=raw,
|
||||
hint=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
name=(prompt[:50] 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 .services import build_provider
|
||||
|
||||
provider = build_provider(task.model_config)
|
||||
response = provider.poll_video_task(
|
||||
endpoint=task.model_config.endpoint, provider_task_id=task.provider_task_id
|
||||
)
|
||||
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")
|
||||
friendly = map_video_error(code, raw_message)
|
||||
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 = friendly
|
||||
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=friendly)
|
||||
_notify_failure(locked, raw=f"[{code}] {raw_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:
|
||||
actual = tokens_to_cost(
|
||||
locked.model_config, total_tokens, with_video_ref=with_video_ref, resolution=resolution
|
||||
)
|
||||
payload["actual_tokens"] = total_tokens
|
||||
else:
|
||||
actual = locked.estimated_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 到预留额,差额平台承担并告警(长期观测调 RESERVE_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.request_payload = payload
|
||||
locked.response_payload = response
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(
|
||||
update_fields=["status", "actual_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"
|
||||
locked.error_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=str(exc)[:200])
|
||||
_notify_failure(locked, raw=str(exc))
|
||||
return locked
|
||||
|
||||
|
||||
def serialize_free_video_task(task: AITask) -> 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 ""
|
||||
|
||||
for asset in task.generated_assets.all():
|
||||
if asset.is_deleted:
|
||||
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:
|
||||
video_url = payload.get("fallback_video_url") or ""
|
||||
|
||||
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_message": task.error_message or "",
|
||||
"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,
|
||||
}
|
||||
Reference in New Issue
Block a user