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:
zyc
2026-07-06 10:58:36 +08:00
co-authored by Claude Fable 5
parent 94202e7bce
commit c5f7eb2acc
45 changed files with 5186 additions and 21 deletions
+46 -1
View File
@@ -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},
},
},
},
{
+728
View File
@@ -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,
}
+46
View File
@@ -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
@@ -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)]
+7
View File
@@ -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:
+31 -5
View File
@@ -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
+4 -5
View File
@@ -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,
+27
View File
@@ -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
+412
View File
@@ -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"])
+1 -1
View File
@@ -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(
+16 -1
View File
@@ -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
+75
View File
@@ -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
+136
View File
@@ -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)
+248
View File
@@ -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 50MB2-15s;音频 mp3/wav 15MB2-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,它最早创建)
+51 -1
View File
@@ -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()})
+296
View File
@@ -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"
),
),
]
+58
View File
@@ -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}"
+13
View File
@@ -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
+2 -2
View File
@@ -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,
)
+1 -1
View File
@@ -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)
+6 -3
View File
@@ -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,