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,它最早创建)
|
||||
|
||||
@@ -59,7 +59,11 @@ def _get_service():
|
||||
Credentials(ak, sk, SERVICE, REGION),
|
||||
10, 30,
|
||||
)
|
||||
actions = ["CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset"]
|
||||
actions = [
|
||||
"CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset",
|
||||
# 自由创作素材库(组/素材全生命周期)
|
||||
"GetAssetGroup", "UpdateAssetGroup", "UpdateAsset", "DeleteAssetGroup",
|
||||
]
|
||||
api_info = {a: ApiInfo("POST", "/", {"Action": a, "Version": API_VERSION}, {}, {}) for a in actions}
|
||||
return Service(service_info, api_info)
|
||||
|
||||
@@ -117,3 +121,49 @@ def list_asset_groups(page: int = 1, page_size: int = 20, name: str | None = Non
|
||||
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
|
||||
)
|
||||
return result.get("Items", []), result.get("TotalCount", 0)
|
||||
|
||||
|
||||
def list_assets(group_ids: list | None = None, status: str | None = None,
|
||||
name: str | None = None, page: int = 1, page_size: int = 20) -> tuple:
|
||||
"""列组内素材。返回 (items, total_count)。"""
|
||||
filter_dict: dict = {"GroupType": "AIGC"}
|
||||
if group_ids:
|
||||
filter_dict["GroupIds"] = group_ids
|
||||
if status:
|
||||
filter_dict["Statuses"] = [status]
|
||||
if name:
|
||||
filter_dict["Name"] = name
|
||||
result = _do_request(
|
||||
"ListAssets",
|
||||
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
|
||||
)
|
||||
return result.get("Items", []), result.get("TotalCount", 0)
|
||||
|
||||
|
||||
def get_asset_group(group_id: str) -> dict:
|
||||
return _do_request("GetAssetGroup", {"Id": group_id, "ProjectName": _project()})
|
||||
|
||||
|
||||
def update_asset_group(group_id: str, name: str | None = None, description: str | None = None) -> None:
|
||||
body: dict = {"Id": group_id, "ProjectName": _project()}
|
||||
if name is not None:
|
||||
body["Name"] = name
|
||||
if description is not None:
|
||||
body["Description"] = description
|
||||
_do_request("UpdateAssetGroup", body)
|
||||
|
||||
|
||||
def update_asset(asset_id: str, name: str | None = None) -> None:
|
||||
body: dict = {"Id": asset_id, "ProjectName": _project()}
|
||||
if name is not None:
|
||||
body["Name"] = name
|
||||
_do_request("UpdateAsset", body)
|
||||
|
||||
|
||||
def delete_asset(asset_id: str) -> None:
|
||||
_do_request("DeleteAsset", {"Id": asset_id, "ProjectName": _project()})
|
||||
|
||||
|
||||
def delete_asset_group(group_id: str) -> None:
|
||||
"""删组(远程级联删组内素材)。"""
|
||||
_do_request("DeleteAssetGroup", {"Id": group_id, "ProjectName": _project()})
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""自由创作·人物素材库端点(FreeAssetGroup / FreeAsset ←→ 火山 Assets API)。
|
||||
|
||||
移植自 jimeng-clone 素材库:建组=火山 CreateAssetGroup+本地记录;传素材=TOS 上传→CreateAsset(URL)
|
||||
→FreeAsset(processing);状态轮询=GetAsset 刷新 active/failed;删除=远程 NotFound 幂等继续清本地。
|
||||
生成时以 asset://{remote_asset_id} 引用(见 apps/ai/free_video.build_content_items)。
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.common.api import get_current_team
|
||||
|
||||
from . import assets_client
|
||||
from .assets_client import AssetsAPIError
|
||||
from .models import FreeAsset, FreeAssetGroup
|
||||
from .storage import TosStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
_VIDEO_TYPES = {"video/mp4": ".mp4", "video/quicktime": ".mov"}
|
||||
_AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav"}
|
||||
_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_VIDEO_MAX = 50 * 1024 * 1024
|
||||
_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||
return {
|
||||
"id": str(fa.id),
|
||||
"name": fa.name,
|
||||
"url": fa.url,
|
||||
"type": fa.asset_type.lower(),
|
||||
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
|
||||
"duration": fa.duration,
|
||||
"status": fa.status,
|
||||
"error_message": fa.error_message,
|
||||
"created_at": fa.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_group(group: FreeAssetGroup, asset_count: int | None = None) -> dict:
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"thumbnail_url": group.thumbnail_url,
|
||||
"asset_count": asset_count if asset_count is not None else group.assets.count(),
|
||||
"created_at": group.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _assets_api_unavailable() -> Response:
|
||||
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
|
||||
class FreeAssetGroupListView(APIView):
|
||||
"""GET 列素材组 / POST 建组。"""
|
||||
|
||||
def get(self, request):
|
||||
team = get_current_team(request.user)
|
||||
groups = FreeAssetGroup.objects.filter(team=team, is_deleted=False).order_by("-created_at")
|
||||
counts = {str(g.id): g.assets.count() for g in groups}
|
||||
return Response({"results": [_serialize_group(g, counts[str(g.id)]) for g in groups]})
|
||||
|
||||
def post(self, request):
|
||||
if not assets_client.is_enabled():
|
||||
return _assets_api_unavailable()
|
||||
team = get_current_team(request.user)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
description = str(request.data.get("description") or "").strip()
|
||||
if not name:
|
||||
return Response({"detail": "素材组名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
remote_group_id = assets_client.create_asset_group(name, description)
|
||||
except AssetsAPIError as exc:
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group = FreeAssetGroup.objects.create(
|
||||
team=team, created_by=request.user, name=name, description=description, remote_group_id=remote_group_id
|
||||
)
|
||||
return Response({"group": _serialize_group(group, 0)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FreeAssetGroupDetailView(APIView):
|
||||
"""GET 组详情+素材列表 / PATCH 改名 / DELETE 删组(远程级联,NotFound 幂等清本地)。"""
|
||||
|
||||
def _get_group(self, request, group_id) -> FreeAssetGroup | None:
|
||||
team = get_current_team(request.user)
|
||||
return FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||
|
||||
def get(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
assets = list(group.assets.order_by("-created_at"))
|
||||
return Response({"group": _serialize_group(group, len(assets)), "assets": [_serialize_asset(a) for a in assets]})
|
||||
|
||||
def patch(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = request.data.get("name")
|
||||
description = request.data.get("description")
|
||||
update_fields = ["updated_at"]
|
||||
if name is not None and str(name).strip():
|
||||
group.name = str(name).strip()
|
||||
update_fields.append("name")
|
||||
if description is not None:
|
||||
group.description = str(description)
|
||||
update_fields.append("description")
|
||||
if len(update_fields) == 1:
|
||||
return Response({"group": _serialize_group(group)})
|
||||
try:
|
||||
assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group.save(update_fields=update_fields)
|
||||
return Response({"group": _serialize_group(group)})
|
||||
|
||||
def delete(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
assets_client.delete_asset_group(group.remote_group_id)
|
||||
except AssetsAPIError as exc:
|
||||
# 远程已不存在 → 幂等继续清本地;其它错误如实反馈
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group.is_deleted = True
|
||||
group.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeAssetUploadView(APIView):
|
||||
"""POST /free-groups/<id>/assets/ —— 上传素材进组:TOS → 火山 CreateAsset → FreeAsset(processing)。"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request, group_id):
|
||||
if not assets_client.is_enabled():
|
||||
return _assets_api_unavailable()
|
||||
team = get_current_team(request.user)
|
||||
group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
if content_type in _IMAGE_TYPES:
|
||||
kind, suffix, asset_type = "image", _IMAGE_TYPES[content_type], FreeAsset.Type.IMAGE
|
||||
if size > _IMAGE_MAX:
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _VIDEO_TYPES:
|
||||
kind, suffix, asset_type = "video", _VIDEO_TYPES[content_type], FreeAsset.Type.VIDEO
|
||||
if size > _VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _AUDIO_TYPES:
|
||||
kind, suffix, asset_type = "audio", _AUDIO_TYPES[content_type], FreeAsset.Type.AUDIO
|
||||
if size > _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()
|
||||
duration = None
|
||||
thumb_url = ""
|
||||
storage = TosStorage()
|
||||
file_id = uuid.uuid4()
|
||||
|
||||
if kind != "image":
|
||||
from apps.ai.media_probe import extract_video_poster, probe_duration
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") 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 = extract_video_poster(str(tmp_path))
|
||||
if poster:
|
||||
poster_key = f"teams/{team.id}/free-create/library/{file_id}-poster.jpg"
|
||||
storage.upload_fileobj(fileobj=BytesIO(poster), object_key=poster_key, content_type="image/jpeg")
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
object_key = f"teams/{team.id}/free-create/library/{file_id}{suffix}"
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
|
||||
url = storage.public_url(object_key=stored.object_key)
|
||||
|
||||
name = str(request.data.get("name") or "").strip() or (upload.name or f"素材{suffix}")
|
||||
name = name[:128]
|
||||
try:
|
||||
remote_asset_id = assets_client.create_asset(
|
||||
group.remote_group_id, url, name=name, asset_type=asset_type
|
||||
)
|
||||
except AssetsAPIError as exc:
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
fa = FreeAsset.objects.create(
|
||||
group=group,
|
||||
remote_asset_id=remote_asset_id,
|
||||
name=name,
|
||||
url=url,
|
||||
asset_type=asset_type,
|
||||
thumbnail_url=thumb_url or (url if kind == "image" else ""),
|
||||
duration=duration,
|
||||
status=FreeAsset.Status.PROCESSING,
|
||||
)
|
||||
if not group.thumbnail_url and fa.thumbnail_url:
|
||||
group.thumbnail_url = fa.thumbnail_url
|
||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
||||
return Response({"asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FreeAssetDetailView(APIView):
|
||||
"""PATCH 改名 / DELETE 删素材(远程 NotFound 幂等清本地)。"""
|
||||
|
||||
def _get_asset(self, request, asset_id) -> FreeAsset | None:
|
||||
team = get_current_team(request.user)
|
||||
return FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).select_related("group").first()
|
||||
|
||||
def patch(self, request, asset_id):
|
||||
fa = self._get_asset(request, asset_id)
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
if not name:
|
||||
return Response({"detail": "素材名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
assets_client.update_asset(fa.remote_asset_id, name=name)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
fa.name = name[:128]
|
||||
fa.save(update_fields=["name", "updated_at"])
|
||||
return Response({"asset": _serialize_asset(fa)})
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
fa = self._get_asset(request, asset_id)
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
assets_client.delete_asset(fa.remote_asset_id)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
fa.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeAssetPollView(APIView):
|
||||
"""POST /free-assets/<id>/poll/ —— 查火山刷新审核状态(processing → active/failed)。"""
|
||||
|
||||
def post(self, request, asset_id):
|
||||
team = get_current_team(request.user)
|
||||
fa = FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).first()
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if fa.status == FreeAsset.Status.PROCESSING:
|
||||
try:
|
||||
result = assets_client.get_asset(fa.remote_asset_id)
|
||||
remote_status = str(result.get("Status") or "")
|
||||
if remote_status == "Active":
|
||||
fa.status = FreeAsset.Status.ACTIVE
|
||||
fa.url = result.get("Url") or fa.url
|
||||
fa.save(update_fields=["status", "url", "updated_at"])
|
||||
elif remote_status == "Failed":
|
||||
fa.status = FreeAsset.Status.FAILED
|
||||
fa.error_message = str(result.get("ErrorMessage") or "素材审核未通过")
|
||||
fa.save(update_fields=["status", "error_message", "updated_at"])
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code == "NotFound":
|
||||
fa.status = FreeAsset.Status.FAILED
|
||||
fa.error_message = "素材在远程已不存在"
|
||||
fa.save(update_fields=["status", "error_message", "updated_at"])
|
||||
else:
|
||||
logger.warning("free asset %s poll failed: %s", asset_id, exc)
|
||||
return Response({"asset": _serialize_asset(fa)})
|
||||
@@ -0,0 +1,135 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-02 07:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_team_monthly_credit_limit"),
|
||||
("assets", "0008_backfill_workbench_in_library"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FreeAssetGroup",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=128)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("remote_group_id", models.CharField(max_length=128)),
|
||||
("thumbnail_url", models.URLField(blank=True)),
|
||||
("is_deleted", models.BooleanField(default=False)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_%(class)s_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(class)s_set",
|
||||
to="accounts.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FreeAsset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("remote_asset_id", models.CharField(max_length=128)),
|
||||
("name", models.CharField(blank=True, max_length=128)),
|
||||
("url", models.URLField(blank=True, max_length=1024)),
|
||||
(
|
||||
"asset_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Image", "Image"),
|
||||
("Video", "Video"),
|
||||
("Audio", "Audio"),
|
||||
],
|
||||
default="Image",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("thumbnail_url", models.URLField(blank=True, max_length=1024)),
|
||||
("duration", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("processing", "Processing"),
|
||||
("active", "Active"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="processing",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="assets.freeassetgroup",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeassetgroup",
|
||||
index=models.Index(
|
||||
fields=["team", "-created_at"], name="assets_free_team_id_c0cbb2_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeasset",
|
||||
index=models.Index(
|
||||
fields=["group", "-created_at"], name="assets_free_group_i_b2a9ec_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeasset",
|
||||
index=models.Index(
|
||||
fields=["remote_asset_id"], name="assets_free_remote__49909d_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -178,3 +178,61 @@ class AssetUsage(TimeStampedModel):
|
||||
usage_type = models.CharField(max_length=64)
|
||||
context = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class FreeAssetGroup(TeamOwnedModel):
|
||||
"""自由创作·人物素材组(一角色一组)。
|
||||
|
||||
本质是「火山 Assets API 引用登记表」:组内素材登记到火山素材库拿 remote_asset_id,
|
||||
生成时以 asset://{remote_asset_id} 引用(免重复上传 + 火山侧预审)。
|
||||
与 AssetReviewGroup(一团队一审核组)职责不同,与资产库 Asset(TOS 文件资产)也不同,独立建表。
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
remote_group_id = models.CharField(max_length=128)
|
||||
thumbnail_url = models.URLField(blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["team", "-created_at"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"free-group:{self.name}"
|
||||
|
||||
|
||||
class FreeAsset(TimeStampedModel):
|
||||
"""自由创作·素材组内单素材(火山 Assets API 登记项)。"""
|
||||
|
||||
class Type(models.TextChoices):
|
||||
IMAGE = "Image", "Image"
|
||||
VIDEO = "Video", "Video"
|
||||
AUDIO = "Audio", "Audio"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PROCESSING = "processing", "Processing" # 火山侧处理/审核中
|
||||
ACTIVE = "active", "Active" # 可用,可 asset:// 引用
|
||||
FAILED = "failed", "Failed" # 审核/处理失败
|
||||
|
||||
group = models.ForeignKey(FreeAssetGroup, on_delete=models.CASCADE, related_name="assets")
|
||||
remote_asset_id = models.CharField(max_length=128)
|
||||
name = models.CharField(max_length=128, blank=True)
|
||||
url = models.URLField(max_length=1024, blank=True) # TOS 公读直链(火山从这里拉源文件)
|
||||
asset_type = models.CharField(max_length=16, choices=Type.choices, default=Type.IMAGE)
|
||||
thumbnail_url = models.URLField(max_length=1024, blank=True)
|
||||
duration = models.FloatField(null=True, blank=True) # 视频/音频时长(秒)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PROCESSING)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["group", "-created_at"]),
|
||||
models.Index(fields=["remote_asset_id"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"free-asset:{self.name or self.remote_asset_id}"
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .free_library import (
|
||||
FreeAssetDetailView,
|
||||
FreeAssetGroupDetailView,
|
||||
FreeAssetGroupListView,
|
||||
FreeAssetPollView,
|
||||
FreeAssetUploadView,
|
||||
)
|
||||
from .views import AssetUploadView, AssetViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -8,4 +15,10 @@ router.register("", AssetViewSet, basename="asset")
|
||||
|
||||
urlpatterns = [
|
||||
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
|
||||
# 自由创作·人物素材库(火山 Assets API 引用登记)。必须在 router 通配之前注册。
|
||||
path("free-groups/", FreeAssetGroupListView.as_view(), name="free-asset-group-list"),
|
||||
path("free-groups/<uuid:group_id>/", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"),
|
||||
path("free-groups/<uuid:group_id>/assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"),
|
||||
path("free-assets/<uuid:asset_id>/", FreeAssetDetailView.as_view(), name="free-asset-detail"),
|
||||
path("free-assets/<uuid:asset_id>/poll/", FreeAssetPollView.as_view(), name="free-asset-poll"),
|
||||
] + router.urls
|
||||
|
||||
@@ -15,7 +15,7 @@ class CreditLedgerTests(TestCase):
|
||||
self.team = Team.objects.create(name="Billing Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
|
||||
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
|
||||
self.provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "Volcano"})
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider,
|
||||
name="doubao-seed-2-0-pro-260215",
|
||||
@@ -110,7 +110,7 @@ class MemberLimitTests(TestCase):
|
||||
self.team = Team.objects.create(name="Limit Team", owner=self.user)
|
||||
self.member = TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000"))
|
||||
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
|
||||
self.provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "Volcano"})
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE,
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ class ProductMaterialsTests(TestCase):
|
||||
mk("product_image", "正面主图", {"product_id": pid})
|
||||
mk("model_tryon", "上身图1", {"product_id": pid})
|
||||
# 项目包:场景 + 视频素材(走 origin_task→project)
|
||||
prov = ModelProvider.objects.create(name="volcengine", display_name="V", base_url="https://x")
|
||||
prov, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V", "base_url": "https://x"})
|
||||
mc = ModelConfig.objects.create(provider=prov, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE)
|
||||
task = AITask.objects.create(team=self.team, project=self.proj, task_type=AITask.Type.SCENE_IMAGE, status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="pm-1")
|
||||
mk("scene", "场景1", task=task)
|
||||
|
||||
@@ -32,10 +32,13 @@ class ProjectApiTests(TestCase):
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="Test Product")
|
||||
self.provider = ModelProvider.objects.create(
|
||||
# 迁移 0023(自由创作视频种子)已在测试库预建 volcengine provider,这里 get_or_create 避免撞唯一键
|
||||
self.provider, _ = ModelProvider.objects.get_or_create(
|
||||
name="volcengine",
|
||||
display_name="Volcano",
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
defaults={
|
||||
"display_name": "Volcano",
|
||||
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
)
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider,
|
||||
|
||||
Reference in New Issue
Block a user