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:
@@ -66,9 +66,54 @@ VOLCANO_MODELS = [
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
"resolutions": ["480p", "720p"],
|
||||
"resolutions": ["480p", "720p", "1080p", "4k"],
|
||||
"watermark": False,
|
||||
"source": "video-flow/data/vendor/volcengine.ts",
|
||||
# 自由创作按 token 计费:元/百万tokens,按 分辨率档 × 是否含视频参考 取价,
|
||||
# 无精确分辨率键回落 default。数字来源火山 Seedance 2.0 官方价目。
|
||||
"pricing": {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 46, "with_ref_video": 28},
|
||||
"1080p": {"no_ref_video": 51, "with_ref_video": 31},
|
||||
"4k": {"no_ref_video": 26, "with_ref_video": 16},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"display_name": "Seedance-2.0-Fast",
|
||||
"name": "doubao-seedance-2-0-fast-260128",
|
||||
"capability": "video",
|
||||
"endpoint": "contents/generations/tasks",
|
||||
"metadata": {
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
# 1080p/4k 仅标准档支持(火山限制),提交侧校验拒绝
|
||||
"resolutions": ["480p", "720p"],
|
||||
"watermark": False,
|
||||
"source": "jimeng-clone/backend/utils/airdrama_client.py",
|
||||
"pricing": {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 37, "with_ref_video": 22},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"display_name": "Seedance-2.0-Mini",
|
||||
"name": "doubao-seedance-2-0-mini-260615",
|
||||
"capability": "video",
|
||||
"endpoint": "contents/generations/tasks",
|
||||
"metadata": {
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
"resolutions": ["480p", "720p"],
|
||||
"watermark": False,
|
||||
"source": "jimeng-clone/backend/utils/airdrama_client.py",
|
||||
"pricing": {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 23, "with_ref_video": 14},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""自由创作·上传素材探测(移植自 jimeng-clone utils/media_utils.py)。
|
||||
|
||||
ffprobe 取视频/音频时长、ffmpeg 抽视频首帧缩略图。生产镜像已带 ffmpeg
|
||||
(_generate_video_poster 在用)。全部 best-effort:探测失败返回 None,由调用方决定拒绝或放行。
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def probe_duration(file_path: str) -> float | None:
|
||||
"""ffprobe 取媒体时长(秒)。失败返回 None。"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(file_path),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return float(proc.stdout.decode().strip())
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def extract_video_poster(file_path: str) -> bytes | None:
|
||||
"""ffmpeg 抽视频首帧 jpg 字节。失败返回 None。"""
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-freeprobe-") as tmp:
|
||||
poster_path = Path(tmp) / "poster.jpg"
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-ss", "0", "-i", str(file_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if proc.returncode != 0 or not poster_path.exists():
|
||||
return None
|
||||
data = poster_path.read_bytes()
|
||||
return data or None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-02 07:23
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_team_monthly_credit_limit"),
|
||||
("ai", "0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx"),
|
||||
("projects", "0006_migrate_storyboard_to_shots"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="aitask",
|
||||
name="is_deleted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aitask",
|
||||
name="is_favorited",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="aitask",
|
||||
name="task_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("script_generation", "Script Generation"),
|
||||
("script_optimization", "Script Optimization"),
|
||||
("entity_extraction", "Entity Extraction"),
|
||||
("product_image", "Product Image"),
|
||||
("person_image", "Person Image"),
|
||||
("scene_image", "Scene Image"),
|
||||
("storyboard", "Storyboard"),
|
||||
("video_segment", "Video Segment"),
|
||||
("voiceover", "Voiceover"),
|
||||
("export", "Export"),
|
||||
("free_video", "Free Video"),
|
||||
],
|
||||
max_length=48,
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="aitask",
|
||||
index=models.Index(
|
||||
fields=["team", "task_type", "-created_at"],
|
||||
name="ai_aitask_team_id_6a4627_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Seed 自由创作视频三档模型(Seedance 2.0 标准/Fast/Mini)+ token 定价表。
|
||||
|
||||
- 定价存 ModelConfig.metadata["pricing"](元/百万tokens,按 分辨率档 × 是否含视频参考 取价),
|
||||
与 apps/ai/catalog.py 保持一致;数字来源火山 Seedance 2.0 官方价目。
|
||||
- 标准档已存在(catalog bootstrap 种的),只 merge pricing/resolutions 进 metadata,不覆盖其它键;
|
||||
fast/mini 新建,is_default=False 且 created_at 晚于现有视频模型 →
|
||||
get_default_model(VIDEO) 仍取原默认,pipeline 视频链路零回归。
|
||||
- 幂等:可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
STANDARD_PRICING = {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 46, "with_ref_video": 28},
|
||||
"1080p": {"no_ref_video": 51, "with_ref_video": 31},
|
||||
"4k": {"no_ref_video": 26, "with_ref_video": 16},
|
||||
}
|
||||
|
||||
NEW_MODELS = [
|
||||
# name, display_name, pricing
|
||||
(
|
||||
"doubao-seedance-2-0-fast-260128",
|
||||
"Seedance-2.0-Fast",
|
||||
{"unit": "cny_per_million_tokens", "default": {"no_ref_video": 37, "with_ref_video": 22}},
|
||||
),
|
||||
(
|
||||
"doubao-seedance-2-0-mini-260615",
|
||||
"Seedance-2.0-Mini",
|
||||
{"unit": "cny_per_million_tokens", "default": {"no_ref_video": 23, "with_ref_video": 14}},
|
||||
),
|
||||
]
|
||||
|
||||
COMMON_METADATA = {
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
"resolutions": ["480p", "720p"],
|
||||
"watermark": False,
|
||||
"source": "jimeng-clone/backend/utils/airdrama_client.py",
|
||||
}
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
|
||||
provider, _ = ModelProvider.objects.get_or_create(
|
||||
name="volcengine",
|
||||
defaults={
|
||||
"display_name": "火山引擎(豆包)",
|
||||
"status": "active",
|
||||
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
)
|
||||
|
||||
# 标准档:merge pricing + 扩分辨率,不动 status/is_default/unit_price/其它 metadata
|
||||
standard = ModelConfig.objects.filter(
|
||||
provider=provider, name="doubao-seedance-2-0-260128", capability="video"
|
||||
).first()
|
||||
if standard:
|
||||
meta = dict(standard.metadata or {})
|
||||
meta["pricing"] = STANDARD_PRICING
|
||||
meta["resolutions"] = ["480p", "720p", "1080p", "4k"]
|
||||
standard.metadata = meta
|
||||
standard.save(update_fields=["metadata"])
|
||||
else:
|
||||
meta = dict(COMMON_METADATA)
|
||||
meta["pricing"] = STANDARD_PRICING
|
||||
meta["resolutions"] = ["480p", "720p", "1080p", "4k"]
|
||||
ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="doubao-seedance-2-0-260128",
|
||||
capability="video",
|
||||
display_name="Seedance-2.0",
|
||||
endpoint="contents/generations/tasks",
|
||||
status="active",
|
||||
metadata=meta,
|
||||
)
|
||||
|
||||
for name, display, pricing in NEW_MODELS:
|
||||
meta = dict(COMMON_METADATA)
|
||||
meta["pricing"] = pricing
|
||||
obj, created = ModelConfig.objects.get_or_create(
|
||||
provider=provider,
|
||||
name=name,
|
||||
capability="video",
|
||||
defaults={
|
||||
"display_name": display,
|
||||
"endpoint": "contents/generations/tasks",
|
||||
"status": "active",
|
||||
"metadata": meta,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
existing = dict(obj.metadata or {})
|
||||
existing.setdefault("resolutions", meta["resolutions"])
|
||||
existing.setdefault("durations", meta["durations"])
|
||||
existing["pricing"] = pricing
|
||||
obj.metadata = existing
|
||||
if obj.status != "active":
|
||||
obj.status = "active"
|
||||
obj.save(update_fields=["metadata", "status"])
|
||||
else:
|
||||
obj.save(update_fields=["metadata"])
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
# 保守反向:只停用本迁移新建的两档,不删数据、不动标准档 metadata
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="volcengine",
|
||||
capability="video",
|
||||
name__in=[n for n, _, _ in NEW_MODELS],
|
||||
).update(status="disabled")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("ai", "0022_aitask_is_deleted_aitask_is_favorited_and_more")]
|
||||
operations = [migrations.RunPython(seed, unseed)]
|
||||
@@ -97,6 +97,8 @@ class AITask(TeamOwnedModel):
|
||||
VIDEO_SEGMENT = "video_segment", "Video Segment"
|
||||
VOICEOVER = "voiceover", "Voiceover"
|
||||
EXPORT = "export", "Export"
|
||||
# 自由创作(不绑 project 的独立视频生成,universal 全能参考 / keyframe 首尾帧)
|
||||
FREE_VIDEO = "free_video", "Free Video"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
CREATED = "created", "Created"
|
||||
@@ -140,6 +142,9 @@ class AITask(TeamOwnedModel):
|
||||
# YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊
|
||||
# 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
# 自由创作任务流的收藏 / 软删(其它任务类型恒 False,无行为影响)
|
||||
is_favorited = models.BooleanField(default=False)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
@@ -152,6 +157,8 @@ class AITask(TeamOwnedModel):
|
||||
models.Index(fields=["conversation", "created_at"]),
|
||||
# YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标)
|
||||
models.Index(fields=["team", "read_at"]),
|
||||
# 自由创作任务流:按团队 + 类型倒序分页
|
||||
models.Index(fields=["team", "task_type", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -192,12 +192,19 @@ class VolcanoArkProvider:
|
||||
resolution: str = "720p",
|
||||
reference_images: list[str] | None = None,
|
||||
generate_audio: bool = True,
|
||||
content_items: list[dict[str, Any]] | None = None,
|
||||
seed: int | None = None,
|
||||
search_mode: str = "off",
|
||||
) -> dict[str, Any]:
|
||||
if not self.api_key:
|
||||
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for image_url in reference_images or []:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
|
||||
if content_items is not None:
|
||||
# 自由创作:调用方整段接管参考素材(混合 image/video/audio + role first_frame/last_frame 等)
|
||||
content.extend(content_items)
|
||||
else:
|
||||
for image_url in reference_images or []:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
|
||||
body = {
|
||||
"model": model,
|
||||
"content": content,
|
||||
@@ -208,6 +215,10 @@ class VolcanoArkProvider:
|
||||
# Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。
|
||||
"generate_audio": generate_audio,
|
||||
}
|
||||
if seed is not None and seed != -1:
|
||||
body["seed"] = seed
|
||||
if search_mode == "smart":
|
||||
body["tools"] = [{"type": "web_search"}]
|
||||
response = requests.post(
|
||||
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||||
@@ -236,9 +247,24 @@ class VolcanoArkProvider:
|
||||
return item["url"]
|
||||
if item.get("b64_json"):
|
||||
return item["b64_json"]
|
||||
content = data.get("content") or {}
|
||||
if content.get("video_url"):
|
||||
return content["video_url"]
|
||||
# 视频任务响应的 content 有两种形态:dict {video_url: "..."} 或
|
||||
# list [{type:"video_url", video_url:{url:"..."}}](Seedance 2.0 多模态响应)。
|
||||
content = data.get("content")
|
||||
if isinstance(content, dict):
|
||||
video_url = content.get("video_url")
|
||||
if isinstance(video_url, str) and video_url:
|
||||
return video_url
|
||||
if isinstance(video_url, dict) and video_url.get("url"):
|
||||
return video_url["url"]
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
video_url = item.get("video_url")
|
||||
if isinstance(video_url, str) and video_url:
|
||||
return video_url
|
||||
if isinstance(video_url, dict) and video_url.get("url"):
|
||||
return video_url["url"]
|
||||
raise ValueError("Volcano response does not contain media url")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -698,6 +698,9 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
||||
suffix = ".jpg"
|
||||
elif "webp" in content_type:
|
||||
suffix = ".webp"
|
||||
# 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 抛
|
||||
# "I/O operation on closed file" 被下面的 except 吞掉 → 视频封面一直静默抽不出来(自由创作联调实测)。
|
||||
raw_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
@@ -721,11 +724,7 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
||||
)
|
||||
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
|
||||
if "video" in content_type:
|
||||
try:
|
||||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
except Exception: # noqa: BLE001
|
||||
video_bytes = b""
|
||||
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
|
||||
poster = _generate_video_poster(video_bytes=raw_bytes, team=team, project=project, asset_id=asset_id)
|
||||
if poster:
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
|
||||
@@ -50,3 +50,30 @@ def generate_triview_task(self, task_id: str) -> str:
|
||||
run_triview_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
||||
"""自由创作视频·worker 兜底轮询:每 30s 一次自重排(不依赖 celery beat),
|
||||
上限 60 次(≈30 分钟,足够 Seedance 5-10 分钟出片)。finalize 幂等(POSTPROCESSING 认领),
|
||||
与前端主动 poll 并存不双扣。轮询本身出错不重试(max_retries=0),下一次自重排继续。"""
|
||||
from apps.ai.free_video import finalize_free_video
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.select_related("model_config", "model_config__provider", "team").filter(id=task_id).first()
|
||||
if task is None:
|
||||
return task_id
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动等)不终结任务,等下一轮
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("poll_free_video_task %s attempt %s failed", task_id, attempt, exc_info=True)
|
||||
# eager(本地联调/单测)下 apply_async 会内联立即执行,自重排=同步死循环 → 跳过,收尾交给前端主动 poll
|
||||
from django.conf import settings as dj_settings
|
||||
|
||||
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False):
|
||||
return task_id
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < 60:
|
||||
poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30)
|
||||
return task_id
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""自由创作视频(free_video)单测:估价 / @替换 / 提交与退费 / 终态幂等 / 并发闸 / 僵尸回收 / API。
|
||||
|
||||
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_free_video --settings=airshelf.settings.test
|
||||
provider 全程 mock(不触网);模型行由迁移 0023 种子提供。
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.free_video import (
|
||||
build_content_items,
|
||||
finalize_free_video,
|
||||
find_orphan_material_mention,
|
||||
submit_free_video,
|
||||
)
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.video_pricing import (
|
||||
RESERVE_BUFFER,
|
||||
calculate_cost,
|
||||
estimate_tokens,
|
||||
estimate_video_cost,
|
||||
get_resolution,
|
||||
get_token_price,
|
||||
)
|
||||
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
|
||||
|
||||
STANDARD = "doubao-seedance-2-0-260128"
|
||||
FAST = "doubao-seedance-2-0-fast-260128"
|
||||
MINI = "doubao-seedance-2-0-mini-260615"
|
||||
|
||||
|
||||
def _model(name=STANDARD) -> ModelConfig:
|
||||
return ModelConfig.objects.get(name=name, capability="video")
|
||||
|
||||
|
||||
class VideoPricingTests(TestCase):
|
||||
"""token 公式与分档取价(与 jimeng billing.py / QuotaConfig 数字对齐)。"""
|
||||
|
||||
def test_resolution_map_spot_checks(self):
|
||||
self.assertEqual(get_resolution("16:9", "720p"), (1280, 720))
|
||||
self.assertEqual(get_resolution("9:16", "480p"), (496, 864))
|
||||
self.assertEqual(get_resolution("21:9", "4k"), (4398, 1886))
|
||||
with self.assertRaises(KeyError):
|
||||
get_resolution("2:3", "720p")
|
||||
|
||||
def test_estimate_tokens_formula(self):
|
||||
# (1280×720×24×5)/1024 = 108000
|
||||
self.assertEqual(estimate_tokens(1280, 720, 5), 108000)
|
||||
# 输入参考视频时长计入
|
||||
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=3), estimate_tokens(1280, 720, 8))
|
||||
|
||||
def test_token_price_tiers(self):
|
||||
std = _model(STANDARD)
|
||||
self.assertEqual(get_token_price(std, False, "720p"), Decimal("46"))
|
||||
self.assertEqual(get_token_price(std, True, "720p"), Decimal("28"))
|
||||
self.assertEqual(get_token_price(std, False, "1080p"), Decimal("51"))
|
||||
self.assertEqual(get_token_price(std, True, "4k"), Decimal("16"))
|
||||
self.assertEqual(get_token_price(_model(FAST), False, "480p"), Decimal("37"))
|
||||
self.assertEqual(get_token_price(_model(MINI), True, "720p"), Decimal("14"))
|
||||
|
||||
def test_fast_1080p_fails_loud(self):
|
||||
# fast/mini 没有 1080p/4k 档价:绝不静默按 default 计费
|
||||
with self.assertRaises(ValueError):
|
||||
get_token_price(_model(FAST), False, "1080p")
|
||||
with self.assertRaises(ValueError):
|
||||
get_token_price(_model(MINI), True, "4k")
|
||||
|
||||
def test_estimate_video_cost_with_video_reference(self):
|
||||
std = _model(STANDARD)
|
||||
refs = [{"type": "video", "duration": 3.0}]
|
||||
tokens, cost = estimate_video_cost(std, aspect_ratio="16:9", resolution="720p", duration=5, references=refs)
|
||||
self.assertEqual(tokens, estimate_tokens(1280, 720, 5, input_video_duration=3.0))
|
||||
self.assertEqual(cost, calculate_cost(tokens, Decimal("28"))) # 含视频参考 → with_ref_video 价
|
||||
|
||||
|
||||
class BuildContentItemsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvowner", password="p")
|
||||
self.team = Team.objects.create(name="FV", owner=self.user)
|
||||
|
||||
def test_label_replacement_length_desc_no_substring_swallow(self):
|
||||
refs = [
|
||||
{"url": "http://x/a.png", "type": "image", "label": "碧"},
|
||||
{"url": "http://x/b.png", "type": "image", "label": "碧碧"},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="@碧碧 拥抱 @碧", mode="universal", references=refs)
|
||||
# 「碧碧」(图片2)必须先于「碧」(图片1)替换,否则被吞成「图片1碧」
|
||||
self.assertEqual(built["api_prompt"], "图片2 拥抱 图片1")
|
||||
|
||||
def test_counters_match_content_items(self):
|
||||
refs = [
|
||||
{"url": "http://x/1.png", "type": "image", "label": "图a"},
|
||||
{"url": "http://x/2.png", "type": "image", "label": "图b"},
|
||||
{"url": "http://x/v.mp4", "type": "video", "label": "视a", "duration": 3},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="@图a @图b @视a", mode="universal", references=refs)
|
||||
self.assertEqual(built["image_n"], 2)
|
||||
self.assertEqual(built["video_n"], 1)
|
||||
self.assertEqual(built["api_prompt"], "图片1 图片2 视频1")
|
||||
roles = [i.get("role") for i in built["content_items"]]
|
||||
self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"])
|
||||
self.assertEqual(built["video_duration_total"], 3.0)
|
||||
|
||||
def test_keyframe_roles(self):
|
||||
refs = [
|
||||
{"url": "http://x/f.png", "type": "image", "role": "first_frame"},
|
||||
{"url": "http://x/l.png", "type": "image", "role": "last_frame"},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs)
|
||||
self.assertEqual([i["role"] for i in built["content_items"]], ["first_frame", "last_frame"])
|
||||
|
||||
def test_too_many_images_rejected(self):
|
||||
refs = [{"url": f"http://x/{i}.png", "type": "image"} for i in range(10)]
|
||||
with self.assertRaisesMessage(ValueError, "最多 9 张"):
|
||||
build_content_items(team=self.team, prompt="p", mode="universal", references=refs)
|
||||
|
||||
def test_audio_alone_rejected(self):
|
||||
refs = [{"url": "http://x/a.mp3", "type": "audio", "duration": 5}]
|
||||
with self.assertRaisesMessage(ValueError, "音频不能单独"):
|
||||
build_content_items(team=self.team, prompt="p", mode="universal", references=refs)
|
||||
|
||||
def test_blob_url_rejected(self):
|
||||
refs = [{"url": "blob:http://x/abc", "type": "image", "label": "残"}]
|
||||
with self.assertRaisesMessage(ValueError, "上传失败"):
|
||||
build_content_items(team=self.team, prompt="p", mode="universal", references=refs)
|
||||
|
||||
def test_orphan_mention(self):
|
||||
self.assertIsNotNone(find_orphan_material_mention("让 @图片1 动起来", []))
|
||||
self.assertIsNone(find_orphan_material_mention("让 @图片1 动起来", [{"url": "u"}]))
|
||||
self.assertIsNone(find_orphan_material_mention("普通提示词", []))
|
||||
|
||||
|
||||
def _ark_create_response(task_id="ark-1"):
|
||||
return {"id": task_id, "status": "queued"}
|
||||
|
||||
|
||||
class SubmitFreeVideoTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvsub", password="p")
|
||||
self.team = Team.objects.create(name="FVS", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start() # eager celery 会同步跑,隔离掉
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _params(self, **over):
|
||||
base = {
|
||||
"prompt": "一只猫在海边奔跑",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
def test_submit_success_reserves_with_buffer(self):
|
||||
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
self.assertEqual(task.provider_task_id, "ark-1")
|
||||
tokens, cost = estimate_video_cost(
|
||||
_model(STANDARD), aspect_ratio="16:9", resolution="480p", duration=4, references=[]
|
||||
)
|
||||
self.assertEqual(task.estimated_cost, cost)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.amount, (cost * RESERVE_BUFFER).quantize(Decimal("0.01")))
|
||||
# 提交参数按契约落 payload
|
||||
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
|
||||
self.assertEqual(task.request_payload["feature"], "free_video")
|
||||
|
||||
def test_insufficient_balance_leaves_nothing(self):
|
||||
CreditAccount.objects.filter(team=self.team).update(balance="0.0100")
|
||||
with self.assertRaisesMessage(ValueError, "余额不足"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
# 不留半套:任务行随事务回滚
|
||||
self.assertEqual(AITask.objects.filter(team=self.team, task_type=AITask.Type.FREE_VIDEO).count(), 0)
|
||||
|
||||
def test_provider_failure_marks_failed_and_releases(self):
|
||||
self.provider.create_video_task.side_effect = RuntimeError(
|
||||
"火山报错 [InputTextSensitiveContentDetected] text blocked"
|
||||
)
|
||||
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertEqual(task.error_message, "提示词包含敏感内容,请修改后重试")
|
||||
self.assertEqual(task.error_code, "InputTextSensitiveContentDetected")
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.reserved_balance, Decimal("0"))
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
|
||||
|
||||
def test_fast_1080p_rejected(self):
|
||||
with self.assertRaisesMessage(ValueError, "仅标准档"):
|
||||
submit_free_video(
|
||||
team=self.team, user=self.user, params=self._params(model=FAST, resolution="1080p")
|
||||
)
|
||||
|
||||
def test_orphan_mention_rejected(self):
|
||||
with self.assertRaisesMessage(ValueError, "对应的内容为空"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params(prompt="让 @图片1 动"))
|
||||
|
||||
def test_keyframe_requires_first_frame(self):
|
||||
with self.assertRaisesMessage(ValueError, "首帧"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params(mode="keyframe"))
|
||||
|
||||
def test_concurrency_gate(self):
|
||||
for _ in range(3):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
with self.assertRaisesMessage(ValueError, "上限"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
|
||||
def test_reap_stale_reserved_refunds(self):
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
# 人造僵尸:RESERVED 且 20 分钟没动静
|
||||
AITask.objects.filter(id=task.id).update(
|
||||
status=AITask.Status.RESERVED, updated_at=timezone.now() - timedelta(minutes=20)
|
||||
)
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
|
||||
|
||||
|
||||
class FinalizeFreeVideoTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvfin", password="p")
|
||||
self.team = Team.objects.create(name="FVF", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
self.provider.extract_first_media_url.return_value = "http://ark/video.mp4"
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
# 媒体落库(下载/TOS/建资产)单测里 mock 掉,聚焦状态机与账务
|
||||
self.store = patch("apps.ai.free_video._store_free_video_media").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.task = submit_free_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
params={
|
||||
"prompt": "海边",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_running_sets_polling_once(self):
|
||||
self.provider.poll_video_task.return_value = {"status": "running"}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.POLLING)
|
||||
|
||||
def test_failed_maps_error_and_releases(self):
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "failed",
|
||||
"error": {"code": "OutputVideoSensitiveContentDetected", "message": "output blocked"},
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertIn("已被系统拦截", task.error_message)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
|
||||
|
||||
def test_succeeded_charges_by_actual_tokens(self):
|
||||
# 真实 tokens 略低于预估:按真实结算,差额自动 RELEASE
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 30000},
|
||||
"seed": 42,
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
expected = calculate_cost(30000, Decimal("46"))
|
||||
self.assertEqual(task.actual_cost, expected)
|
||||
self.assertEqual(task.request_payload["seed_used"], 42)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("100.0000") - expected)
|
||||
self.assertEqual(account.reserved_balance, Decimal("0"))
|
||||
self.store.assert_called_once()
|
||||
|
||||
def test_succeeded_clamps_when_tokens_exceed_reserve(self):
|
||||
# 真实 tokens 远超预估:clamp 到预留额,不抛错、不超扣
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 10_000_000},
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(task.actual_cost, reservation.amount)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||
|
||||
def test_double_finalize_charges_once(self):
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 30000},
|
||||
}
|
||||
first = finalize_free_video(task=self.task)
|
||||
self.task.refresh_from_db()
|
||||
second = finalize_free_video(task=self.task)
|
||||
self.assertEqual(first.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(second.status, AITask.Status.SUCCEEDED)
|
||||
charges = CreditLedger.objects.filter(task=self.task, ledger_type=CreditLedger.Type.CHARGE).count()
|
||||
self.assertEqual(charges, 1)
|
||||
self.store.assert_called_once()
|
||||
|
||||
def test_store_failure_falls_back_to_raw_url_and_still_charges(self):
|
||||
# TOS 转存失败:不吞成功——记火山临时 URL + 正常结算(jimeng 同行为)
|
||||
self.store.side_effect = RuntimeError("tos down")
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 30000},
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.request_payload["fallback_video_url"], "http://ark/video.mp4")
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||
|
||||
|
||||
class FreeVideoApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvapi", password="p")
|
||||
self.team = Team.objects.create(name="FVA", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _submit(self):
|
||||
return self.client.post(
|
||||
"/api/ai/free-video/",
|
||||
{
|
||||
"prompt": "一只猫",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
def test_submit_and_list(self):
|
||||
resp = self._submit()
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
body = resp.json()["task"]
|
||||
self.assertEqual(body["status"], AITask.Status.SUBMITTED)
|
||||
self.assertEqual(body["model"], STANDARD)
|
||||
listing = self.client.get("/api/ai/free-video/").json()
|
||||
self.assertEqual(listing["total"], 1)
|
||||
self.assertEqual(listing["results"][0]["id"], body["id"])
|
||||
self.assertFalse(listing["has_more"])
|
||||
|
||||
def test_validation_error_returns_400(self):
|
||||
resp = self.client.post("/api/ai/free-video/", {"prompt": ""}, format="json")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_favorite_toggle_and_delete_rules(self):
|
||||
task_id = self._submit().json()["task"]["id"]
|
||||
fav = self.client.post(f"/api/ai/free-video/{task_id}/favorite/")
|
||||
self.assertTrue(fav.json()["is_favorited"])
|
||||
# 在途拒删
|
||||
resp = self.client.delete(f"/api/ai/free-video/{task_id}/")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
AITask.objects.filter(id=task_id).update(status=AITask.Status.SUCCEEDED)
|
||||
resp = self.client.delete(f"/api/ai/free-video/{task_id}/")
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
listing = self.client.get("/api/ai/free-video/").json()
|
||||
self.assertEqual(listing["total"], 0)
|
||||
|
||||
def test_team_isolation(self):
|
||||
self._submit()
|
||||
stranger = User.objects.create_user(username="stranger", password="p")
|
||||
other_team = Team.objects.create(name="Other", owner=stranger)
|
||||
TeamMember.objects.create(team=other_team, user=stranger, role="owner", status="active")
|
||||
other = APIClient()
|
||||
other.force_authenticate(stranger)
|
||||
listing = other.get("/api/ai/free-video/").json()
|
||||
self.assertEqual(listing["total"], 0)
|
||||
|
||||
def test_poll_endpoint_finalizes(self):
|
||||
task_id = self._submit().json()["task"]["id"]
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "failed",
|
||||
"error": {"code": "InternalError", "message": "boom"},
|
||||
}
|
||||
resp = self.client.post(f"/api/ai/free-video/{task_id}/poll/")
|
||||
self.assertEqual(resp.json()["task"]["status"], AITask.Status.FAILED)
|
||||
self.assertIn("服务异常", resp.json()["task"]["error_message"])
|
||||
@@ -276,7 +276,7 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
image_edit 的 gpt-image-2——否则参考图形同虚设。回归保护「选火山+传参考图=完全不参考」。"""
|
||||
from apps.ai.models import ModelProvider
|
||||
|
||||
vp = ModelProvider.objects.create(name="volcengine", display_name="火山")
|
||||
vp, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "火山"})
|
||||
ModelConfig.objects.create(provider=vp, name="seedream-4", display_name="Seedream", capability=ModelConfig.Capability.IMAGE)
|
||||
self._patch_provider()
|
||||
ref = Asset.objects.create(
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import AITaskViewSet, GenerateImageView, ImageConversationViewSet, ModelConfigViewSet
|
||||
from .views import (
|
||||
AITaskViewSet,
|
||||
FreeVideoDetailView,
|
||||
FreeVideoFavoriteView,
|
||||
FreeVideoPollView,
|
||||
FreeVideoUploadView,
|
||||
FreeVideoView,
|
||||
GenerateImageView,
|
||||
ImageConversationViewSet,
|
||||
ModelConfigViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("tasks", AITaskViewSet, basename="ai-task")
|
||||
@@ -10,4 +20,9 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
|
||||
|
||||
urlpatterns = [
|
||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||
path("free-video/", FreeVideoView.as_view(), name="ai-free-video"),
|
||||
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
|
||||
path("free-video/<uuid:task_id>/poll/", FreeVideoPollView.as_view(), name="ai-free-video-poll"),
|
||||
path("free-video/<uuid:task_id>/favorite/", FreeVideoFavoriteView.as_view(), name="ai-free-video-favorite"),
|
||||
path("free-video/<uuid:task_id>/", FreeVideoDetailView.as_view(), name="ai-free-video-detail"),
|
||||
] + router.urls
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""自由创作视频·火山错误码 → 用户友好中文映射(移植自 jimeng-clone utils/airdrama_client.py)。
|
||||
|
||||
两级匹配:① error code 精确匹配 ② message 关键词兜底。
|
||||
仅供 free_video 链路使用,不动现有 friendly_generation_error(避免影响项目视频链路文案)。
|
||||
原始报错(code + message)必须同时落 task.error_code / 日志,不允许只留友好文案吞根因
|
||||
(故事板 moderation_blocked 事故教训)。
|
||||
"""
|
||||
import re
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
# 输入内容审核 — 人脸/敏感内容
|
||||
"InputImageSensitiveContentDetected.PrivacyInformation": "参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片",
|
||||
"InputImageSensitiveContentDetected": "参考图片包含敏感内容,请更换图片后重试",
|
||||
"InputVideoSensitiveContentDetected.PrivacyInformation": "参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频",
|
||||
"InputVideoSensitiveContentDetected": "参考视频包含敏感内容,请更换视频后重试",
|
||||
"InputTextSensitiveContentDetected": "提示词包含敏感内容,请修改后重试",
|
||||
"InputAudioSensitiveContentDetected": "参考音频包含敏感内容,请更换音频后重试",
|
||||
# 输出内容审核
|
||||
"OutputVideoSensitiveContentDetected": "生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试",
|
||||
"OutputVideoSensitiveContentDetected.PolicyViolation": "生成的视频涉及版权限制内容(如知名IP、名人肖像等),已被系统拦截,请修改提示词后重试",
|
||||
"OutputImageSensitiveContentDetected": "生成的图片包含敏感内容,已被系统拦截",
|
||||
# 参数错误
|
||||
"InvalidParameter": "请求参数无效,请检查输入内容",
|
||||
"InvalidImage": "图片格式或尺寸不符合要求,请检查后重试",
|
||||
"InvalidVideo": "视频格式或尺寸不符合要求,请检查后重试",
|
||||
"InvalidAudio": "音频格式不符合要求,请检查后重试",
|
||||
"AudioDurationExceeded": "音频总时长超过15秒限制,请缩短音频后重试",
|
||||
"AudioFormatNotSupported": "音频格式不支持,请使用 MP3 或 WAV 格式",
|
||||
# 限流
|
||||
"RateLimitExceeded": "请求过于频繁,请稍后重试",
|
||||
"ConcurrencyLimitExceeded": "当前生成任务过多,请稍后重试",
|
||||
# 账户
|
||||
"InsufficientBalance": "平台账户余额不足,请联系管理员",
|
||||
# 素材
|
||||
"AssetNotFound": "引用的素材不存在或已被删除,请检查素材库",
|
||||
# 服务端
|
||||
"ServerOverloaded": "服务器繁忙,请稍后重试",
|
||||
"InternalError": "视频生成服务异常,请稍后重试",
|
||||
"Timeout": "生成超时,请重试",
|
||||
}
|
||||
|
||||
# 关键词匹配:message 包含这些关键词时映射为中文提示(code 未命中时兜底)
|
||||
_MESSAGE_KEYWORDS = {
|
||||
"face": "检测到真实人脸,请使用虚拟人像素材替代真人照片",
|
||||
"privacy": "检测到真实人脸,请使用虚拟人像素材替代真人照片",
|
||||
"sensitive": "内容包含敏感信息,请修改后重试",
|
||||
"not found": "引用的素材不存在或已被删除,请检查素材库",
|
||||
"not valid": "请求参数无效,请检查输入内容",
|
||||
"audio duration": "音频总时长超过15秒限制,请缩短音频后重试",
|
||||
"audio": "音频不符合要求(支持MP3/WAV,单条2-15秒,总时长≤15秒)",
|
||||
}
|
||||
|
||||
# provider 创建阶段抛 RuntimeError("火山报错 [code] message"),从中抽 code/message
|
||||
_RUNTIME_ERROR_RE = re.compile(r"火山报错 \[([^\]]*)\]\s*(.*)", re.S)
|
||||
|
||||
|
||||
def map_video_error(code: str, message: str) -> str:
|
||||
"""error code / message → 用户友好中文。永远返回非空文案。"""
|
||||
friendly = ERROR_MESSAGES.get(code or "")
|
||||
if not friendly:
|
||||
msg_lower = (message or "").lower()
|
||||
for keyword, hint in _MESSAGE_KEYWORDS.items():
|
||||
if keyword in msg_lower:
|
||||
friendly = hint
|
||||
break
|
||||
return friendly or "生成失败,请重试"
|
||||
|
||||
|
||||
def parse_provider_error(exc: Exception) -> tuple[str, str]:
|
||||
"""从 provider 抛出的异常抽 (code, message)。抽不出则 code 为空、message 取异常文本。"""
|
||||
text = str(exc)
|
||||
m = _RUNTIME_ERROR_RE.search(text)
|
||||
if m:
|
||||
return m.group(1).strip(), m.group(2).strip()
|
||||
return "", text
|
||||
@@ -0,0 +1,136 @@
|
||||
"""自由创作视频 token 计费(移植自 jimeng-clone backend/utils/billing.py)。
|
||||
|
||||
Token 预估公式(火山官方):(输入视频时长 + 输出时长) × 宽 × 高 × 帧率 / 1024
|
||||
单价:元/百万tokens,存 ModelConfig.metadata["pricing"],按 分辨率档 × 是否含视频参考 取价。
|
||||
|
||||
⚠️ 预估仅用于前端展示与额度预留;真实费用以火山返回 usage.total_tokens 结算。
|
||||
预留额外加 RESERVE_BUFFER(ledger 禁超预留扣费,真实 tokens 可能高于预估),
|
||||
结算时 clamp 到预留额并对差额告警。
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
# 分辨率 → 像素映射(火山 Seedance 2.0 API 文档)
|
||||
RESOLUTION_MAP = {
|
||||
# 720p
|
||||
("720p", "16:9"): (1280, 720),
|
||||
("720p", "9:16"): (720, 1280),
|
||||
("720p", "4:3"): (1112, 834),
|
||||
("720p", "1:1"): (960, 960),
|
||||
("720p", "3:4"): (834, 1112),
|
||||
("720p", "21:9"): (1470, 630),
|
||||
# 480p
|
||||
("480p", "16:9"): (864, 496),
|
||||
("480p", "9:16"): (496, 864),
|
||||
("480p", "4:3"): (752, 560),
|
||||
("480p", "1:1"): (640, 640),
|
||||
("480p", "3:4"): (560, 752),
|
||||
("480p", "21:9"): (992, 432),
|
||||
# 1080p(仅标准档)
|
||||
("1080p", "16:9"): (1920, 1080),
|
||||
("1080p", "9:16"): (1080, 1920),
|
||||
("1080p", "4:3"): (1664, 1248),
|
||||
("1080p", "1:1"): (1440, 1440),
|
||||
("1080p", "3:4"): (1248, 1664),
|
||||
("1080p", "21:9"): (2206, 946),
|
||||
# 4k(仅标准档)
|
||||
("4k", "16:9"): (3840, 2160),
|
||||
("4k", "9:16"): (2160, 3840),
|
||||
("4k", "4:3"): (3326, 2494),
|
||||
("4k", "1:1"): (2880, 2880),
|
||||
("4k", "3:4"): (2494, 3326),
|
||||
("4k", "21:9"): (4398, 1886),
|
||||
}
|
||||
|
||||
DEFAULT_FPS = 24
|
||||
|
||||
# 预留 = 预估费用 × buffer。ledger 的 charge_reserved_credit 在 actual > reserved 时抛错,
|
||||
# 而火山真实 tokens 有最低用量限制/输入视频真实时长偏差,可能略高于预估。
|
||||
RESERVE_BUFFER = Decimal("1.10")
|
||||
|
||||
|
||||
def get_resolution(aspect_ratio: str, tier: str) -> tuple:
|
||||
"""(tier, aspect_ratio) → (width, height)。非法组合 KeyError fail loud,不静默降级。"""
|
||||
key = (tier, aspect_ratio)
|
||||
if key not in RESOLUTION_MAP:
|
||||
raise KeyError(
|
||||
f"不支持的分辨率组合: tier={tier!r}, aspect_ratio={aspect_ratio!r}. "
|
||||
f"仅支持 480p/720p/1080p/4k × 16:9/9:16/4:3/1:1/3:4/21:9"
|
||||
)
|
||||
return RESOLUTION_MAP[key]
|
||||
|
||||
|
||||
def estimate_tokens(
|
||||
width: int,
|
||||
height: int,
|
||||
duration: int,
|
||||
fps: int = DEFAULT_FPS,
|
||||
input_video_duration: float = 0,
|
||||
) -> int:
|
||||
total_duration = duration + (input_video_duration or 0)
|
||||
return round(width * height * fps * total_duration / 1024)
|
||||
|
||||
|
||||
def has_video_reference(references: list) -> bool:
|
||||
return any((ref or {}).get("type") == "video" for ref in references or [])
|
||||
|
||||
|
||||
def sum_video_duration(references: list) -> float:
|
||||
"""输入参考视频总时长(秒),计入 token 公式的输入时长项。"""
|
||||
return sum(
|
||||
float(ref.get("duration") or 0)
|
||||
for ref in references or []
|
||||
if (ref or {}).get("type") == "video"
|
||||
)
|
||||
|
||||
|
||||
def get_token_price(model_config, with_video_ref: bool, resolution: str) -> Decimal:
|
||||
"""从 ModelConfig.metadata["pricing"] 取单价(元/百万tokens)。
|
||||
|
||||
先按 resolution 精确键,无则回落 "default";缺 pricing/缺键抛 ValueError fail loud
|
||||
(1080p/4k 只有标准档配了键,fast/mini 在提交校验就被拒,不允许静默按 720p 计价)。
|
||||
"""
|
||||
pricing = (model_config.metadata or {}).get("pricing") or {}
|
||||
# 1080p/4k 有独立价:缺该档键 = 模型不支持该分辨率(fast/mini),fail loud,
|
||||
# 绝不按 default(480p/720p)价静默计费——那是欺骗用户(jimeng _get_token_price 同原则)。
|
||||
if resolution in ("1080p", "4k") and resolution not in pricing:
|
||||
raise ValueError(f"模型 {model_config.name} 不支持 {resolution}——提交校验应已拦截,不应进到计价")
|
||||
tier = pricing.get(resolution) or pricing.get("default")
|
||||
if not tier:
|
||||
raise ValueError(f"模型 {model_config.name} 未配置 pricing(resolution={resolution})")
|
||||
key = "with_ref_video" if with_video_ref else "no_ref_video"
|
||||
price = tier.get(key)
|
||||
if price is None:
|
||||
raise ValueError(f"模型 {model_config.name} pricing 缺 {resolution}/{key} 档单价")
|
||||
return Decimal(str(price))
|
||||
|
||||
|
||||
def calculate_cost(tokens: int, price: Decimal) -> Decimal:
|
||||
"""tokens × 单价(元/百万tokens),保留 2 位小数。"""
|
||||
cost = Decimal(str(tokens)) * Decimal(str(price)) / Decimal("1000000")
|
||||
return cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def estimate_video_cost(
|
||||
model_config,
|
||||
*,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
duration: int,
|
||||
references: list,
|
||||
) -> tuple[int, Decimal]:
|
||||
"""返回 (预估 tokens, 预估费用元)。供提交预留与前端预估口径对齐。"""
|
||||
width, height = get_resolution(aspect_ratio, resolution)
|
||||
tokens = estimate_tokens(
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
input_video_duration=sum_video_duration(references),
|
||||
)
|
||||
price = get_token_price(model_config, has_video_reference(references), resolution)
|
||||
return tokens, calculate_cost(tokens, price)
|
||||
|
||||
|
||||
def tokens_to_cost(model_config, tokens: int, *, with_video_ref: bool, resolution: str) -> Decimal:
|
||||
"""按真实 usage.total_tokens 计价(结算口径,与预估同一张价表)。"""
|
||||
price = get_token_price(model_config, with_video_ref, resolution)
|
||||
return calculate_cost(tokens, price)
|
||||
@@ -2,6 +2,7 @@ from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
@@ -308,6 +309,253 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return Response({"conversation_id": str(conversation.id), "tasks": data})
|
||||
|
||||
|
||||
def _free_video_task_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
POST /api/ai/free-video/ 提交任务,秒回(火山 create 同步调、轮询交给 worker 兜底 + 前端主动 poll)
|
||||
GET /api/ai/free-video/ 任务流分页(offset/page_size,新→旧)
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
require_worker() # 生成闸:无 worker 时任务提交火山后无人兜底轮询(额度冻结、结果丢失)
|
||||
from .free_video import serialize_free_video_task, submit_free_video
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
task = submit_free_video(team=team, user=request.user, params=request.data or {})
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 重取带 prefetch 的实例,序列化统一走同一条路
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def get(self, request):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
offset = max(0, int(request.query_params.get("offset") or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_task_queryset(team).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_free_video_task(t) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoPollView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/poll/ —— web 进程内单次轮询+终态化(幂等)。
|
||||
前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import finalize_free_video, serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_task_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动)不终结任务,返回现状继续轮
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("free video poll failed for %s", task_id, exc_info=True)
|
||||
# 终态后重取(finalize 里可能新建了资产)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)})
|
||||
|
||||
|
||||
class FreeVideoFavoriteView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/favorite/ —— 收藏开关。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
task.is_favorited = not task.is_favorited
|
||||
task.save(update_fields=["is_favorited", "updated_at"])
|
||||
return Response({"is_favorited": task.is_favorited})
|
||||
|
||||
|
||||
class FreeVideoDetailView(APIView):
|
||||
"""DELETE /api/ai/free-video/<id>/ —— 软删(在途任务拒删,等终态)。"""
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
|
||||
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# 上传参考素材的格式/尺寸限制(与 jimeng inputBar 校验对齐;后端兜底,前端也拦)
|
||||
_FREE_REF_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
|
||||
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
|
||||
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
class FreeVideoUploadView(APIView):
|
||||
"""POST /api/ai/free-video/upload/ —— 参考素材上传(图/视频/音频)。
|
||||
|
||||
校验(图 300-6000px、比例(0.4,2.5)、≤30MB;视频 mp4/mov ≤50MB、2-15s;音频 mp3/wav ≤15MB、2-15s)
|
||||
→ TOS → Asset(source=UPLOAD, in_library=False) → {asset_id,url,type,duration,thumb_url}。
|
||||
视频顺带 ffmpeg 抽首帧缩略图。"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
import tempfile
|
||||
import uuid as _uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
from .media_probe import extract_video_poster, probe_duration
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = get_current_team(request.user)
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
|
||||
if content_type in _FREE_REF_IMAGE_TYPES:
|
||||
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
|
||||
if size > _FREE_REF_IMAGE_MAX:
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_VIDEO_TYPES:
|
||||
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
|
||||
if size > _FREE_REF_VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_AUDIO_TYPES:
|
||||
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
|
||||
if size > _FREE_REF_AUDIO_MAX:
|
||||
return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
raw = upload.read()
|
||||
width = height = None
|
||||
duration = None
|
||||
poster_bytes = None
|
||||
|
||||
if kind == "image":
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
width, height = im.size
|
||||
except Exception: # noqa: BLE001
|
||||
return Response({"detail": "图片解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (300 <= width <= 6000 and 300 <= height <= 6000):
|
||||
return Response({"detail": "图片边长需在 300-6000 像素之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
ratio = width / height
|
||||
if not (0.4 <= ratio <= 2.5):
|
||||
return Response({"detail": "图片宽高比需在 0.4-2.5 之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-upload-") as tmp:
|
||||
tmp_path = Path(tmp) / f"in{suffix}"
|
||||
tmp_path.write_bytes(raw)
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
poster_bytes = extract_video_poster(str(tmp_path))
|
||||
|
||||
asset_id = _uuid.uuid4()
|
||||
storage = TosStorage()
|
||||
object_key = f"teams/{team.id}/free-create/uploads/{asset_id}{suffix}"
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
|
||||
name = (upload.name or f"素材{suffix}")[:255]
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
name=name,
|
||||
asset_type=asset_type,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.UPLOAD,
|
||||
in_library=False, # 仅作生成参考,不进资产库列表
|
||||
metadata={"feature": "free_video_reference"},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
duration_ms=int(duration * 1000) if duration else None,
|
||||
is_primary=True,
|
||||
)
|
||||
url = storage.public_url(object_key=stored.object_key)
|
||||
thumb_url = ""
|
||||
if poster_bytes:
|
||||
poster_key = f"teams/{team.id}/free-create/uploads/{asset_id}-poster.jpg"
|
||||
poster_stored = storage.upload_fileobj(
|
||||
fileobj=BytesIO(poster_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,
|
||||
)
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"url": url,
|
||||
"type": kind,
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
||||
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
||||
|
||||
Reference in New Issue
Block a user