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
+6
View File
@@ -162,6 +162,9 @@ CACHES = {
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
# 本地端到端联调开关:true = 任务同进程内联执行,不碰共享 Redis 队列(线上 worker 拓扑不受影响)。
# 生产/集群绝不开;test.py 恒为 True。自重排类任务(poll_free_video_task)在 eager 下会跳过重排防递归。
CELERY_TASK_ALWAYS_EAGER = str(env("CELERY_TASK_ALWAYS_EAGER", "false")).lower() == "true"
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
@@ -229,6 +232,9 @@ PROVIDER_API_VERSIONS = {
"yunqi": env("YUNQI_API_VERSION", "2025-04-01-preview"),
}
# 自由创作视频:团队在途任务并发上限(视频长时高价,限并发同时压 web 慢调用敞口)
FREE_VIDEO_MAX_CONCURRENT = int(env("FREE_VIDEO_MAX_CONCURRENT", "3"))
# 火山引擎人像素材库审核(真人资产绿/红盾)· AK/SK 暂借 AirDrama 已邀测账号,张业昌待换成 AirShelf 自有
ASSETS_API = {
"access_key": env("ASSETS_API_ACCESS_KEY", ""),
+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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -24,6 +24,7 @@ import {
AssetFactoryPage,
AuthScreen,
Dashboard,
FreeCreatePage,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
@@ -878,6 +879,8 @@ export function App() {
);
case "assetFactory":
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhoto":
+66
View File
@@ -17,6 +17,11 @@ import type {
AuthPayload,
BillingSummary,
BillingTrend,
FreeAssetGroup,
FreeAssetItem,
FreeVideoRef,
FreeVideoTask,
FreeVideoUploadResult,
Ledger,
LoginSession,
Invitation,
@@ -660,6 +665,67 @@ export const api = {
generateImageStatus(ids: string[]) {
return request<{ tasks: { id: string; status: string; error_message: string; assets: Asset[] }[] }>(`/api/ai/generate-image/?ids=${encodeURIComponent(ids.join(","))}`);
},
// —— 自由创作·视频生成 ——
// 提交秒回(火山 create 同步、慢轮询交给 worker 兜底 + 前端主动 poll);校验失败 400 带中文 detail。
submitFreeVideo(payload: {
prompt: string;
mode: "universal" | "keyframe";
model?: string;
aspect_ratio?: string;
resolution?: string;
duration?: number;
seed?: number;
generate_audio?: boolean;
references?: FreeVideoRef[];
}) {
return request<{ task: FreeVideoTask }>("/api/ai/free-video/", { method: "POST", body: JSON.stringify(payload) });
},
freeVideoTasks(offset = 0, pageSize = 20) {
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
`/api/ai/free-video/?offset=${offset}&page_size=${pageSize}`
);
},
// web 进程内单次轮询+终态化(幂等):前端渐进轮询打这里,本地无 worker 也能收尾
pollFreeVideo(id: string) {
return request<{ task: FreeVideoTask }>(`/api/ai/free-video/${id}/poll/`, { method: "POST" });
},
toggleFreeVideoFavorite(id: string) {
return request<{ is_favorited: boolean }>(`/api/ai/free-video/${id}/favorite/`, { method: "POST" });
},
deleteFreeVideo(id: string) {
return request<void>(`/api/ai/free-video/${id}/`, { method: "DELETE" });
},
uploadFreeVideoRef(formData: FormData) {
return request<FreeVideoUploadResult>("/api/ai/free-video/upload/", { method: "POST", body: formData });
},
// 自由创作·人物素材库(火山 Assets API asset:// 引用登记)
freeAssetGroups() {
return request<{ results: FreeAssetGroup[] }>("/api/assets/free-groups/");
},
createFreeAssetGroup(payload: { name: string; description?: string }) {
return request<{ group: FreeAssetGroup }>("/api/assets/free-groups/", { method: "POST", body: JSON.stringify(payload) });
},
freeAssetGroup(id: string) {
return request<{ group: FreeAssetGroup; assets: FreeAssetItem[] }>(`/api/assets/free-groups/${id}/`);
},
updateFreeAssetGroup(id: string, payload: { name?: string; description?: string }) {
return request<{ group: FreeAssetGroup }>(`/api/assets/free-groups/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteFreeAssetGroup(id: string) {
return request<void>(`/api/assets/free-groups/${id}/`, { method: "DELETE" });
},
uploadFreeAsset(groupId: string, formData: FormData) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
},
renameFreeAsset(id: string, name: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deleteFreeAsset(id: string) {
return request<void>(`/api/assets/free-assets/${id}/`, { method: "DELETE" });
},
pollFreeAsset(id: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
},
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
},
+2 -1
View File
@@ -27,7 +27,8 @@ const iconPaths: Record<string, string> = {
server: '<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01"/><path d="M6 17h.01"/>',
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>'
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
film: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18"/><path d="M17 3v18"/><path d="M3 7.5h4"/><path d="M3 16.5h4"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/><path d="M3 12h4"/><path d="M17 12h4"/>'
};
const iconAliases: Record<string, string> = {
@@ -17,6 +17,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -197,6 +198,7 @@ const NAV: NavDef[] = [
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" },
{ id: "library", page: "library", label: "资产库", icon: "library" },
{ id: "team", page: "team", label: "团队", icon: "users" },
{ id: "account", page: "account", label: "消费", icon: "creditCard" },
@@ -219,6 +221,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
modelPhotoDemoA: "assetFactory",
modelPhotoDemoB: "assetFactory",
platformCover: "assetFactory",
freeCreate: "freeCreate",
library: "library",
team: "team",
account: "account",
@@ -0,0 +1,289 @@
// 自由创作·人物素材库弹窗:组网格 → 组内素材列表(审核状态徽章)→ 建组/传素材/改名/删除;
// 选中 active 素材注入输入条(source=library,生成时后端换 asset:// 引用)。
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
import { api } from "../../api";
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
import { useBodyScrollLock } from "../overlays";
const STATUS_PILL: Record<FreeAssetItem["status"], { cls: string; label: string }> = {
processing: { cls: "pill-info", label: "审核中" },
active: { cls: "pill-ok", label: "可用" },
failed: { cls: "pill-err", label: "未通过" }
};
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
open: boolean;
onClose: () => void;
onPick: (ref: FreeVideoRef) => void;
notify: (type: "success" | "error" | "info", text: string) => void;
}) {
const [groups, setGroups] = useState<FreeAssetGroup[]>([]);
const [activeGroup, setActiveGroup] = useState<FreeAssetGroup | null>(null);
const [assets, setAssets] = useState<FreeAssetItem[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useBodyScrollLock(open);
const loadGroups = useCallback(async () => {
setLoading(true);
try {
const data = await api.freeAssetGroups();
setGroups(data.results);
} catch (error) {
notify("error", error instanceof Error ? error.message : "素材组加载失败");
} finally {
setLoading(false);
}
}, [notify]);
const loadGroupDetail = useCallback(async (group: FreeAssetGroup) => {
setLoading(true);
try {
const data = await api.freeAssetGroup(group.id);
setActiveGroup(data.group);
setAssets(data.assets);
} catch (error) {
notify("error", error instanceof Error ? error.message : "素材加载失败");
} finally {
setLoading(false);
}
}, [notify]);
useEffect(() => {
if (!open) return;
setActiveGroup(null);
setAssets([]);
void loadGroups();
}, [open, loadGroups]);
// processing 素材每 8s 轮询刷新
useEffect(() => {
if (!open || !activeGroup) return;
const pending = assets.filter((a) => a.status === "processing");
if (pending.length === 0) return;
const timer = window.setInterval(() => {
pending.forEach((item) => {
void api.pollFreeAsset(item.id).then((data) => {
setAssets((prev) => prev.map((a) => (a.id === data.asset.id ? data.asset : a)));
}).catch(() => undefined);
});
}, 8000);
return () => window.clearInterval(timer);
}, [open, activeGroup, assets]);
if (!open) return null;
const createGroup = async () => {
const name = newName.trim();
if (!name) return;
try {
const data = await api.createFreeAssetGroup({ name });
setGroups((prev) => [data.group, ...prev]);
setCreating(false);
setNewName("");
notify("success", `素材组「${name}」已创建`);
} catch (error) {
notify("error", error instanceof Error ? error.message : "创建失败");
}
};
const uploadAsset = async (file: File) => {
if (!activeGroup) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeAsset(activeGroup.id, form);
setAssets((prev) => [data.asset, ...prev]);
notify("success", "素材已上传,审核中");
} catch (error) {
notify("error", error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
};
const renameAsset = async (item: FreeAssetItem) => {
const name = window.prompt("素材名称(用于 @ 引用)", item.name);
if (!name || name.trim() === item.name) return;
try {
const data = await api.renameFreeAsset(item.id, name.trim());
setAssets((prev) => prev.map((a) => (a.id === item.id ? data.asset : a)));
} catch (error) {
notify("error", error instanceof Error ? error.message : "重命名失败");
}
};
const removeAsset = async (item: FreeAssetItem) => {
if (!window.confirm(`删除素材「${item.name}」?`)) return;
try {
await api.deleteFreeAsset(item.id);
setAssets((prev) => prev.filter((a) => a.id !== item.id));
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
}
};
const renameGroup = async (group: FreeAssetGroup) => {
const name = window.prompt("素材组名称", group.name);
if (!name || name.trim() === group.name) return;
try {
const data = await api.updateFreeAssetGroup(group.id, { name: name.trim() });
setGroups((prev) => prev.map((g) => (g.id === group.id ? data.group : g)));
if (activeGroup?.id === group.id) setActiveGroup(data.group);
} catch (error) {
notify("error", error instanceof Error ? error.message : "重命名失败");
}
};
const removeGroup = async (group: FreeAssetGroup) => {
if (!window.confirm(`删除素材组「${group.name}」及其全部素材?此操作不可恢复。`)) return;
try {
await api.deleteFreeAssetGroup(group.id);
setGroups((prev) => prev.filter((g) => g.id !== group.id));
if (activeGroup?.id === group.id) { setActiveGroup(null); setAssets([]); }
notify("success", "素材组已删除");
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
}
};
const pick = (item: FreeAssetItem) => {
if (item.status !== "active") {
notify("info", item.status === "processing" ? "素材还在审核中,请稍候" : "素材未通过审核,无法引用");
return;
}
onPick({
url: item.url,
type: item.type,
label: item.name,
thumb_url: item.thumb_url,
duration: item.duration || undefined,
asset_id: item.id,
source: "library"
});
onClose();
};
return createPortal(
<div className="modal-bg show" onClick={onClose}>
<div className="modal fc-lib-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Users size={16} /></div>
<div className="ti">
{activeGroup ? (
<button type="button" className="fc-lib-back" onClick={() => { setActiveGroup(null); setAssets([]); void loadGroups(); }}>
<ChevronLeft size={14} /> {activeGroup.name}
</button>
) : "人物素材库"}
<span>// 火山素材登记 · @ 引用生成</span>
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b fc-lib-body">
{!activeGroup ? (
<>
<div className="fc-lib-toolbar">
{creating ? (
<div className="fc-lib-create">
<input
className="input"
autoFocus
placeholder="素材组名称(如:碧碧)"
value={newName}
onChange={(event) => setNewName(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") void createGroup(); if (event.key === "Escape") setCreating(false); }}
/>
<button type="button" className="btn btn-sm btn-primary" onClick={() => void createGroup()}></button>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setCreating(false)}></button>
</div>
) : (
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> </button>
)}
</div>
{groups.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截</p>
</div>
) : (
<div className="fc-lib-groups">
{groups.map((group) => (
<div key={group.id} className="fc-lib-group" role="button" tabIndex={0} onClick={() => void loadGroupDetail(group)} onKeyDown={(event) => { if (event.key === "Enter") void loadGroupDetail(group); }}>
<div className="fc-lib-thumb">
{group.thumbnail_url ? <img src={group.thumbnail_url} alt={group.name} /> : <Users size={20} />}
</div>
<div className="fc-lib-name">{group.name}</div>
<div className="fc-lib-count mono">// {group.asset_count} 个素材</div>
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); void renameGroup(group); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); void removeGroup(group); }}><Trash2 size={12} /></button>
</div>
</div>
))}
</div>
)}
</>
) : (
<>
<div className="fc-lib-toolbar">
<input
ref={fileRef}
type="file"
hidden
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void uploadAsset(file);
}}
/>
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => fileRef.current?.click()}>
<Upload size={13} /> {uploading ? "上传中…" : "上传素材"}
</button>
<span className="mono fc-lib-hint">// 审核通过(可用)后才能被生成引用</span>
</div>
{assets.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Upload size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 上传该角色的图片/视频/音频</p>
</div>
) : (
<div className="fc-lib-assets">
{assets.map((item) => {
const pill = STATUS_PILL[item.status];
return (
<div key={item.id} className={`fc-lib-asset${item.status === "active" ? " pickable" : ""}`} role="button" tabIndex={0} onClick={() => pick(item)} onKeyDown={(event) => { if (event.key === "Enter") pick(item); }}>
<div className="fc-lib-thumb">
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
</div>
<div className="fc-lib-name" title={item.name}>{item.name}</div>
<span className={`pill pill-l3 ${pill.cls}`}><span className="dot" />{pill.label}</span>
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>// {item.error_message}</div>}
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); void renameAsset(item); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); void removeAsset(item); }}><Trash2 size={12} /></button>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,154 @@
// 自由创作·常量与预估计价(与后端 apps/ai/video_pricing.py 同一张表/同一公式,口径必须一致)
import type { FreeVideoRef, ModelConfig } from "../../types";
export type FreeMode = "universal" | "keyframe";
// 本地输入条里的参考素材(在 FreeVideoRef 之上带上传中间态)
export type LocalRef = FreeVideoRef & {
key: string; // 本地唯一键(删除/替换用)
uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交)
};
export const FC_MODELS = [
{ name: "doubao-seedance-2-0-260128", label: "Seedance 2.0", desc: "标准档 · 支持 1080P / 4K" },
{ name: "doubao-seedance-2-0-fast-260128", label: "Seedance 2.0 Fast", desc: "更快出片 · 480P / 720P" },
{ name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" }
] as const;
export const FC_STANDARD_MODEL = FC_MODELS[0].name;
export const FC_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const;
export const FC_RESOLUTIONS = ["480p", "720p", "1080p", "4k"] as const;
export const FC_DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const;
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
export const IMAGE_MAX_BYTES = 30 * 1024 * 1024;
export const VIDEO_MAX_BYTES = 50 * 1024 * 1024;
export const AUDIO_MAX_BYTES = 15 * 1024 * 1024;
export const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
export const VIDEO_TYPES = ["video/mp4", "video/quicktime"];
export const AUDIO_TYPES = ["audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"];
export const MAX_IMAGES = 9;
export const MAX_VIDEOS = 3;
export const MAX_AUDIOS = 3;
export const MAX_VIDEO_TOTAL_SECONDS = 15;
// 任务在途状态(继续轮询);终态 = succeeded / failed / cancelled / compensating
export const IN_FLIGHT_STATUSES = ["created", "reserved", "submitted", "polling", "postprocessing"];
export function isInFlight(status: string) {
return IN_FLIGHT_STATUSES.includes(status);
}
export const STATUS_LABELS: Record<string, string> = {
created: "排队中",
reserved: "排队中",
submitted: "生成中",
polling: "生成中",
postprocessing: "处理中",
succeeded: "已完成",
failed: "失败",
cancelled: "已取消",
compensating: "补偿中"
};
// 分辨率像素表(火山 Seedance 2.0 文档,键 = `${resolution}:${ratio}`)
const RESOLUTION_MAP: Record<string, [number, number]> = {
"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: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: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: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]
};
// 火山官方公式:(输入视频时长 + 输出时长) × 宽 × 高 × 24fps / 1024
export function estimateTokens(ratio: string, resolution: string, duration: number, inputVideoSeconds = 0): number {
const size = RESOLUTION_MAP[`${resolution}:${ratio}`];
if (!size) return 0;
const [w, h] = size;
return Math.round((w * h * 24 * (duration + inputVideoSeconds)) / 1024);
}
type PricingTier = { no_ref_video?: number; with_ref_video?: number };
type PricingTable = { default?: PricingTier } & Record<string, PricingTier | string | undefined>;
// 从 ModelConfig.metadata.pricing 取单价(元/百万tokens):resolution 精确键 → 回落 default
export function tokenPrice(config: ModelConfig | undefined, resolution: string, hasVideoRef: boolean): number {
const pricing = (config?.metadata?.pricing || {}) as PricingTable;
const tier = (pricing[resolution] as PricingTier | undefined) || pricing.default;
if (!tier) return 0;
return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0;
}
export function estimateCost(
config: ModelConfig | undefined,
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }
): { tokens: number; cost: number } {
const inputVideoSeconds = params.refs
.filter((r) => r.type === "video")
.reduce((sum, r) => sum + (r.duration || 0), 0);
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
const hasVideoRef = params.refs.some((r) => r.type === "video");
const price = tokenPrice(config, params.resolution, hasVideoRef);
return { tokens, cost: Math.round(((tokens * price) / 1e6) * 100) / 100 };
}
export function modelLabel(name: string): string {
return FC_MODELS.find((m) => m.name === name)?.label || name;
}
// —— 上传前的本地校验(后端仍会兜底) ——
export type FileCheck = { ok: true; type: "image" | "video" | "audio"; duration?: number } | { ok: false; error: string };
function probeImage(file: File): Promise<FileCheck> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const { naturalWidth: w, naturalHeight: h } = img;
if (w < 300 || w > 6000 || h < 300 || h > 6000) resolve({ ok: false, error: "图片边长需在 300-6000 像素之间" });
else if (w / h < 0.4 || w / h > 2.5) resolve({ ok: false, error: "图片宽高比需在 0.4-2.5 之间" });
else resolve({ ok: true, type: "image" });
};
img.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "图片解析失败,请更换文件" }); };
img.src = url;
});
}
function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const el = document.createElement(kind);
el.preload = "metadata";
el.onloadedmetadata = () => {
URL.revokeObjectURL(url);
const duration = el.duration;
if (!isFinite(duration)) resolve({ ok: true, type: kind });
else if (duration < 2 || duration > 15) resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
else resolve({ ok: true, type: kind, duration: Math.round(duration * 10) / 10 });
};
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
el.src = url;
});
}
export async function checkRefFile(file: File): Promise<FileCheck> {
const type = (file.type || "").toLowerCase();
if (IMAGE_TYPES.includes(type)) {
if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" };
return probeImage(file);
}
if (VIDEO_TYPES.includes(type)) {
if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" };
return probeMedia(file, "video");
}
if (AUDIO_TYPES.includes(type)) {
if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" };
return probeMedia(file, "audio");
}
return { ok: false, error: "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)" };
}
@@ -0,0 +1,93 @@
// 自由创作·任务卡:生成中(shimmer+平滑进度) / 失败(中文错误+重试) / 完成(悬停播放+操作)。
import { useRef } from "react";
import { Download, Heart, RotateCcw, Trash2 } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
function ratioStyle(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
}
export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: {
task: FreeVideoTask;
progress: number;
onOpen: () => void;
onRetry: () => void;
onToggleFavorite: () => void;
onDelete: () => void;
onDownload: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const inFlight = isInFlight(task.status);
const failed = task.status === "failed" || task.status === "cancelled";
const done = task.status === "succeeded" && !!task.video_url;
return (
<div className={`fc-card${done ? " done" : ""}`}>
<div
className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
role={done ? "button" : undefined}
tabIndex={done ? 0 : undefined}
onClick={done ? onOpen : undefined}
onKeyDown={done ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
onMouseEnter={() => { if (done) void videoRef.current?.play().catch(() => undefined); }}
onMouseLeave={() => { videoRef.current?.pause(); if (videoRef.current) videoRef.current.currentTime = 0; }}
>
{inFlight && (
<div className="fc-card-generating">
<span className="spinner" />
<div className="fc-progress"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
<span className="mono">{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
</div>
)}
{failed && (
<div className="fc-card-failed">
<span className="pill pill-l2 pill-err"><span className="dot" /></span>
<p>{task.error_message || "生成失败,请重试"}</p>
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} />
</button>
</div>
)}
{done && (
<>
<video
ref={videoRef}
src={task.video_url}
poster={task.thumbnail_url || undefined}
muted
loop
playsInline
preload="metadata"
/>
<div className="fc-card-hover">
<button type="button" className="fc-hover-btn" title="下载" onClick={(event) => { event.stopPropagation(); onDownload(); }}>
<Download size={14} />
</button>
<button type="button" className={`fc-hover-btn${task.is_favorited ? " fav" : ""}`} title={task.is_favorited ? "取消收藏" : "收藏"} onClick={(event) => { event.stopPropagation(); onToggleFavorite(); }}>
<Heart size={14} fill={task.is_favorited ? "currentColor" : "none"} />
</button>
<button type="button" className="fc-hover-btn danger" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={14} />
</button>
</div>
</>
)}
{task.status === "succeeded" && !task.video_url && (
<div className="fc-card-failed">
<p>{task.fallback_note ? `(${task.fallback_note})` : ""}</p>
</div>
)}
</div>
<div className="fc-card-meta">
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
<div className="fc-card-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
{task.status === "succeeded" && ` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
</div>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。
// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。
import { useRef, useState, type RefObject } from "react";
import { IconKitSvg } from "../IconKitSvg";
import type { ModelConfig } from "../../types";
import { MODE_LABELS, type FreeMode, type LocalRef } from "./constants";
import { PromptInput, type PromptInputHandle } from "./prompt-input";
import { FreeToolbar } from "./toolbar";
const ACCEPT_UNIVERSAL = "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav";
const ACCEPT_IMAGE = "image/jpeg,image/png,image/webp";
function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void }) {
return (
<div className={`fc-ref${item.uploading ? " uploading" : ""}`} title={item.label || ""}>
{item.type === "image" ? (
<img src={item.thumb_url || item.url} alt={item.label || "参考图"} />
) : item.type === "video" ? (
item.thumb_url ? <img src={item.thumb_url} alt={item.label || "参考视频"} /> : <span className="fc-ref-kind mono">MP4</span>
) : (
<span className="fc-ref-kind mono"></span>
)}
{item.type !== "image" && item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
{item.uploading && <span className="spinner" />}
{item.label ? <span className="fc-ref-label">@{item.label}</span> : null}
<button type="button" className="fc-ref-x" aria-label="删除素材" onClick={onRemove}>×</button>
</div>
);
}
function KeyframeSlot({ role, item, onPick, onRemove }: {
role: "first_frame" | "last_frame";
item: LocalRef | undefined;
onPick: () => void;
onRemove: () => void;
}) {
const label = role === "first_frame" ? "首帧" : "尾帧(可选)";
if (!item) {
return (
<button type="button" className="fc-kf-slot" onClick={onPick}>
<IconKitSvg name="images" size={20} />
<span>{label}</span>
</button>
);
}
return (
<div className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`}>
<img src={item.thumb_url || item.url} alt={label} />
{item.uploading && <span className="spinner" />}
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
</div>
);
}
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
resolution: string;
duration: number;
seed: number;
refs: LocalRef[];
videoConfigs: ModelConfig[];
submitting: boolean;
promptRef: RefObject<PromptInputHandle | null>;
/** 用户选择/拖入文件;keyframe 模式下带目标 role */
onFiles: (files: File[], role?: "first_frame" | "last_frame") => void;
onRemoveRef: (key: string) => void;
onModeChange: (mode: FreeMode) => void;
onModelChange: (model: string) => void;
onRatioChange: (ratio: string) => void;
onResolutionChange: (resolution: string) => void;
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onOpenLibrary: () => void;
onClear: () => void;
onSend: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const pendingRoleRef = useRef<"first_frame" | "last_frame" | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const [hasPrompt, setHasPrompt] = useState(false);
const pickFiles = (role?: "first_frame" | "last_frame") => {
pendingRoleRef.current = role;
if (fileRef.current) {
fileRef.current.accept = mode === "keyframe" ? ACCEPT_IMAGE : ACCEPT_UNIVERSAL;
fileRef.current.multiple = mode === "universal";
fileRef.current.click();
}
};
const firstFrame = refs.find((r) => r.role === "first_frame");
const lastFrame = refs.find((r) => r.role === "last_frame");
return (
<div
className={`fc-inputbar${dragOver ? " drag" : ""}`}
onDragOver={(event) => { event.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
const files = Array.from(event.dataTransfer.files || []);
if (files.length) onFiles(files, mode === "keyframe" ? (firstFrame ? "last_frame" : "first_frame") : undefined);
}}
>
<input
ref={fileRef}
type="file"
hidden
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length) onFiles(files, pendingRoleRef.current);
pendingRoleRef.current = undefined;
}}
/>
<div className="fc-input-top">
{mode === "universal" ? (
<div className="fc-refs">
<button type="button" className="fc-add" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
<IconKitSvg name="images" size={18} />
<span>+</span>
</button>
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={onOpenLibrary}>
<IconKitSvg name="users" size={18} />
</button>
{refs.map((item) => (
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
))}
</div>
) : (
<div className="fc-keyframes">
<KeyframeSlot role="first_frame" item={firstFrame} onPick={() => pickFiles("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
<span className="fc-kf-arrow mono"></span>
<KeyframeSlot role="last_frame" item={lastFrame} onPick={() => pickFiles("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
</div>
)}
<PromptInput
ref={promptRef}
refs={refs}
onSubmit={onSend}
onOpenLibrary={onOpenLibrary}
onTextChange={setHasPrompt}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
/>
</div>
<FreeToolbar
mode={mode}
model={model}
ratio={ratio}
resolution={resolution}
duration={duration}
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
hasPrompt={hasPrompt}
submitting={submitting}
onModeChange={onModeChange}
onModelChange={onModelChange}
onRatioChange={onRatioChange}
onResolutionChange={onResolutionChange}
onDurationChange={onDurationChange}
onSeedChange={onSeedChange}
onClear={onClear}
onSend={onSend}
/>
{dragOver && <div className="fc-drop-hint mono">{MODE_LABELS[mode]}</div>}
</div>
);
}
@@ -0,0 +1,349 @@
// 自由创作·提示词输入(contenteditable @mention 编辑器)
// 移植 jimeng-clone PromptInput 的核心机制:输入 @ 弹素材候选、插入不可编辑 mention chip、
// 删素材自动清孤儿 chip、序列化时 chip → "@label" 纯文本(「图片N」替换在后端做)。
// IME 注意:中文输入法 composition 期间不触发 @ 菜单/快捷键。
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { FreeVideoRef } from "../../types";
import type { LocalRef } from "./constants";
export type PromptInputHandle = {
/** 序列化为纯文本(chip → @label) */
getText(): string;
clear(): void;
/** 再次生成回填:纯文本里的 @label 命中 refs 则还原成带缩略图的 chip */
setContent(text: string, refs: FreeVideoRef[]): void;
/** 在光标处(未聚焦则在末尾)插入一个 mention chip */
insertMention(ref: { label: string; thumb?: string }): void;
/** 删除素材后清理孤儿 chip */
pruneMentions(validLabels: string[]): void;
focus(): void;
};
type Props = {
refs: LocalRef[];
placeholder?: string;
disabled?: boolean;
onSubmit: () => void;
onOpenLibrary: () => void;
onTextChange?: (hasText: boolean) => void;
};
function chipHtml(label: string, thumb?: string): HTMLSpanElement {
const chip = document.createElement("span");
chip.className = "fc-mention";
chip.setAttribute("data-fc-mention", "1");
chip.setAttribute("data-label", label);
chip.setAttribute("contenteditable", "false");
if (thumb) {
const img = document.createElement("img");
img.src = thumb;
img.alt = "";
chip.appendChild(img);
}
chip.appendChild(document.createTextNode(`@${label}`));
return chip;
}
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
if (el.hasAttribute("data-fc-mention")) return `@${el.getAttribute("data-label") || ""}`;
if (el.tagName === "BR") return "\n";
const inner = Array.from(el.childNodes).map(serializeNode).join("");
// contenteditable 换行会包 div:块级元素前补换行(首个除外,由调用方 trim)
if (el.tagName === "DIV" || el.tagName === "P") return `\n${inner}`;
return inner;
}
export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptInput(
{ refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onOpenLibrary, onTextChange },
handleRef
) {
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
const savedRangeRef = useRef<Range | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuQuery, setMenuQuery] = useState("");
const [menuIndex, setMenuIndex] = useState(0);
const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null);
const labeledRefs = refs.filter((r) => (r.label || "").trim());
const candidates = labeledRefs.filter((r) => !menuQuery || (r.label || "").toLowerCase().includes(menuQuery.toLowerCase()));
const emitChange = () => {
const text = editorRef.current ? Array.from(editorRef.current.childNodes).map(serializeNode).join("").trim() : "";
onTextChange?.(text.length > 0);
};
const saveRange = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
savedRangeRef.current = sel.getRangeAt(0).cloneRange();
}
};
const restoreRange = (): Range | null => {
const editor = editorRef.current;
if (!editor) return null;
const sel = window.getSelection();
if (!sel) return null;
let range = savedRangeRef.current;
if (!range || !editor.contains(range.startContainer)) {
range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
}
sel.removeAllRanges();
sel.addRange(range);
return range;
};
/** 找到光标前最近的 "@query" 触发串(同一文本节点内、不含空白),返回可删除的 Range */
const findTrigger = (): { range: Range; query: string } | null => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) return null;
const textNode = range.startContainer as Text;
const upto = (textNode.textContent || "").slice(0, range.startOffset);
const at = upto.lastIndexOf("@");
if (at === -1) return null;
const query = upto.slice(at + 1);
if (/[\s]/.test(query)) return null;
const del = document.createRange();
del.setStart(textNode, at);
del.setEnd(textNode, range.startOffset);
return { range: del, query };
};
const closeMenu = () => { setMenuOpen(false); setMenuQuery(""); setMenuIndex(0); };
const openMenuAtCaret = () => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
const editorRect = editorRef.current?.getBoundingClientRect();
const left = rect.left || editorRect?.left || 0;
const top = rect.top || editorRect?.top || 0;
setMenuPos({ left, top });
setMenuIndex(0);
setMenuOpen(true);
};
const insertChipAtTrigger = (label: string, thumb?: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
const trigger = findTrigger();
const sel = window.getSelection();
const chip = chipHtml(label, thumb);
const space = document.createTextNode(" ");
if (trigger) {
trigger.range.deleteContents();
trigger.range.insertNode(space);
trigger.range.insertNode(chip);
} else if (sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(space);
range.insertNode(chip);
} else {
editor.appendChild(chip);
editor.appendChild(space);
}
// 光标移到空格后
const after = document.createRange();
after.setStartAfter(space);
after.collapse(true);
sel?.removeAllRanges();
sel?.addRange(after);
saveRange();
closeMenu();
emitChange();
};
useImperativeHandle(handleRef, () => ({
getText() {
const editor = editorRef.current;
if (!editor) return "";
return Array.from(editor.childNodes).map(serializeNode).join("").replace(/ /g, " ").trim();
},
clear() {
if (editorRef.current) editorRef.current.innerHTML = "";
savedRangeRef.current = null;
emitChange();
},
setContent(text, contentRefs) {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = "";
// 按 label 长度降序切分,防「碧」吞「碧碧」(与后端替换同原则)
const labeled = contentRefs.filter((r) => (r.label || "").trim()).sort((a, b) => (b.label || "").length - (a.label || "").length);
let rest = text;
const parts: (string | FreeVideoRef)[] = [];
while (rest.length > 0) {
let hitIdx = -1;
let hitRef: FreeVideoRef | null = null;
for (const r of labeled) {
const idx = rest.indexOf(`@${r.label}`);
if (idx !== -1 && (hitIdx === -1 || idx < hitIdx)) { hitIdx = idx; hitRef = r; }
}
if (hitIdx === -1 || !hitRef) { parts.push(rest); break; }
if (hitIdx > 0) parts.push(rest.slice(0, hitIdx));
parts.push(hitRef);
rest = rest.slice(hitIdx + `@${hitRef.label}`.length);
}
for (const part of parts) {
if (typeof part === "string") editor.appendChild(document.createTextNode(part));
else editor.appendChild(chipHtml(part.label || "", part.thumb_url || (part.type === "image" ? part.url : "")));
}
emitChange();
},
insertMention(ref) {
insertChipAtTrigger(ref.label, ref.thumb);
},
pruneMentions(validLabels) {
const editor = editorRef.current;
if (!editor) return;
editor.querySelectorAll("[data-fc-mention]").forEach((chip) => {
if (!validLabels.includes(chip.getAttribute("data-label") || "")) chip.remove();
});
emitChange();
},
focus() { editorRef.current?.focus(); }
}));
// 素材集合变化 → 清孤儿 chip(删素材后 prompt 里的引用即时消失)
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const valid = labeledRefs.map((r) => r.label || "");
let changed = false;
editor.querySelectorAll("[data-fc-mention]").forEach((chip) => {
if (!valid.includes(chip.getAttribute("data-label") || "")) { chip.remove(); changed = true; }
});
if (changed) emitChange();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refs.map((r) => r.label).join("")]);
// 菜单开着时点外部关闭
useEffect(() => {
if (!menuOpen) return;
const onDown = (event: MouseEvent) => {
const target = event.target as Element;
if (target.closest?.(".fc-mention-menu") || editorRef.current?.contains(target as Node)) return;
closeMenu();
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [menuOpen]);
const menuItems: { key: string; label: string; thumb?: string; kind: "ref" | "library" }[] = [
...candidates.map((r) => ({
key: r.key,
label: r.label || "",
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
kind: "ref" as const
})),
{ key: "__library__", label: "从素材库选择…", kind: "library" as const }
];
const pickMenuItem = (item: (typeof menuItems)[number]) => {
if (item.kind === "library") {
// 先删掉触发串,再开素材库(选中后由页面 insertMention 插 chip)
const trigger = findTrigger();
trigger?.range.deleteContents();
closeMenu();
onOpenLibrary();
return;
}
insertChipAtTrigger(item.label, item.thumb);
};
return (
<div className="fc-prompt-wrap">
<div
ref={editorRef}
className="fc-prompt"
contentEditable={!disabled}
role="textbox"
aria-multiline="true"
aria-label="视频提示词"
data-placeholder={placeholder}
suppressContentEditableWarning
onInput={() => {
saveRange();
emitChange();
if (menuOpen) {
const trigger = findTrigger();
if (!trigger) closeMenu();
else setMenuQuery(trigger.query);
}
}}
onKeyUp={saveRange}
onMouseUp={saveRange}
onBlur={saveRange}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={() => { composingRef.current = false; }}
onPaste={(event) => {
// 只贴纯文本,防外部富文本污染编辑器结构
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
}}
onKeyDown={(event) => {
if (composingRef.current) return;
if (menuOpen) {
if (event.key === "ArrowDown") { event.preventDefault(); setMenuIndex((i) => Math.min(i + 1, menuItems.length - 1)); return; }
if (event.key === "ArrowUp") { event.preventDefault(); setMenuIndex((i) => Math.max(i - 1, 0)); return; }
if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); if (menuItems[menuIndex]) pickMenuItem(menuItems[menuIndex]); return; }
if (event.key === "Escape") { event.preventDefault(); closeMenu(); return; }
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
onSubmit();
return;
}
if (event.key === "@" || (event.key === "2" && event.shiftKey)) {
// 等字符落进 DOM 后再定位菜单
window.setTimeout(() => {
const trigger = findTrigger();
if (trigger) { setMenuQuery(trigger.query); openMenuAtCaret(); }
}, 0);
}
}}
/>
{menuOpen && menuPos && createPortal(
<div
className="fc-mention-menu"
style={{ left: Math.min(menuPos.left, window.innerWidth - 292), top: Math.max(12, menuPos.top - 8) }}
>
<div className="fc-mention-menu-inner">
<div className="fc-mention-menu-head mono">// 引用素材</div>
{menuItems.map((item, i) => (
<button
key={item.key}
type="button"
className={`fc-mention-item${i === menuIndex ? " active" : ""}`}
onMouseEnter={() => setMenuIndex(i)}
onMouseDown={(event) => { event.preventDefault(); pickMenuItem(item); }}
>
{item.kind === "ref" ? (
<>
{item.thumb ? <img src={item.thumb} alt="" /> : <span className="fc-mention-ph" />}
<span className="lbl">@{item.label}</span>
</>
) : (
<span className="lbl lib">{item.label}</span>
)}
</button>
))}
</div>
</div>,
document.body
)}
</div>
);
});
@@ -0,0 +1,177 @@
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
import { useEffect, useRef, useState } from "react";
import type { ModelConfig } from "../../types";
import {
FC_DURATIONS,
FC_MODELS,
FC_RATIOS,
FC_RESOLUTIONS,
FC_STANDARD_MODEL,
MODE_LABELS,
estimateCost,
type FreeMode,
type LocalRef
} from "./constants";
type MenuItem = { value: string; label: string; desc?: string; disabled?: boolean; hint?: string };
function FcDropdown({ label, display, items, onSelect, disabled }: {
label: string;
display: string;
items: MenuItem[];
onSelect: (value: string) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
}, [open]);
return (
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
<button type="button" className="fc-dd-btn" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
<span className="fc-dd-lbl mono">{label}</span>
<span className="fc-dd-val">{display}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
</button>
{open && (
<div className="fc-dd-menu">
{items.map((item) => (
<button
key={item.value}
type="button"
className={`fc-dd-item${item.value === display || item.label === display ? " selected" : ""}${item.disabled ? " disabled" : ""}`}
disabled={item.disabled}
title={item.disabled ? item.hint : undefined}
onClick={() => { if (!item.disabled) { onSelect(item.value); setOpen(false); } }}
>
<span className="ti">{item.label}</span>
{item.desc && <span className="de mono">{item.desc}</span>}
</button>
))}
</div>
)}
</div>
);
}
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
resolution: string;
duration: number;
seed: number;
refs: LocalRef[];
videoConfigs: ModelConfig[];
hasPrompt: boolean;
submitting: boolean;
onModeChange: (mode: FreeMode) => void;
onModelChange: (model: string) => void;
onRatioChange: (ratio: string) => void;
onResolutionChange: (resolution: string) => void;
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onClear: () => void;
onSend: () => void;
}) {
const isStandard = model === FC_STANDARD_MODEL;
const config = videoConfigs.find((c) => c.name === model);
const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs });
const [seedOpen, setSeedOpen] = useState(false);
const seedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!seedOpen) return;
const onDown = (event: MouseEvent) => { if (!seedRef.current?.contains(event.target as Node)) setSeedOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [seedOpen]);
const uploading = refs.some((r) => r.uploading);
const canSend = hasPrompt && !submitting && !uploading;
return (
<div className="fc-toolbar">
<div className="fc-toolbar-l">
<FcDropdown
label="模型"
display={FC_MODELS.find((m) => m.name === model)?.label || model}
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))}
onSelect={(value) => {
onModelChange(value);
// 非标准档不支持 1080P/4K:自动回落 720P(与 jimeng 行为一致)
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p");
}}
/>
<FcDropdown
label="模式"
display={MODE_LABELS[mode]}
items={[
{ value: "universal", label: "全能参考", desc: "文生 / 图·视频·音频参考" },
{ value: "keyframe", label: "首尾帧", desc: "首帧必填 · 尾帧可选" }
]}
onSelect={(value) => onModeChange(value as FreeMode)}
/>
<FcDropdown label="比例" display={ratio} items={FC_RATIOS.map((r) => ({ value: r, label: r }))} onSelect={onRatioChange} />
<FcDropdown
label="分辨率"
display={resolution.toUpperCase()}
items={FC_RESOLUTIONS.map((r) => ({
value: r,
label: r.toUpperCase(),
disabled: (r === "1080p" || r === "4k") && !isStandard,
hint: "仅 Seedance 2.0 标准档支持"
}))}
onSelect={onResolutionChange}
/>
<FcDropdown
label="时长"
display={`${duration}s`}
items={FC_DURATIONS.map((d) => ({ value: String(d), label: `${d}s` }))}
onSelect={(value) => onDurationChange(Number(value))}
/>
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
<button type="button" className="fc-dd-btn" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
<span className="fc-dd-lbl mono"></span>
<span className="fc-dd-val">{seed === -1 ? "随机" : seed}</span>
</button>
{seedOpen && (
<div className="fc-dd-menu fc-seed-menu">
<div className="fc-seed-row">
<input
className="input"
type="number"
value={seed}
min={-1}
onChange={(event) => {
const v = parseInt(event.target.value, 10);
onSeedChange(Number.isNaN(v) ? -1 : v);
}}
/>
<button type="button" className="btn btn-sm" onClick={() => { onSeedChange(-1); setSeedOpen(false); }}></button>
</div>
<div className="fc-seed-hint mono">// -1 = 随机;相同种子可复现相似结果</div>
</div>
)}
</div>
</div>
<div className="fc-toolbar-r">
<span className="fc-estimate mono" title="预估消耗(实际按火山返回用量结算)">
{tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)}
</span>
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}></button>
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成 →"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
// 自由创作·视频详情弹窗:自研全屏播放器(播放/暂停、seek、音量、全屏)+ 上下切换 +
// 下载 / 再次生成(回填输入条) / 收藏 / 删除。ESC / 点遮罩关闭。
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, Download, Heart, Maximize, Pause, Play, RotateCcw, Trash2, Volume2, VolumeX, X } from "lucide-react";
import type { FreeVideoTask } from "../../types";
import { useBodyScrollLock } from "../overlays";
import { MODE_LABELS, modelLabel } from "./constants";
function fmt(seconds: number): string {
if (!isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClose, onDownload, onToggleFavorite, onReuse, onDelete }: {
task: FreeVideoTask;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
onDownload: () => void;
onToggleFavorite: () => void;
onReuse: () => void;
onDelete: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(true);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [current, setCurrent] = useState(0);
const [total, setTotal] = useState(0);
useBodyScrollLock(true);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
else if (event.key === "ArrowLeft" && hasPrev) onPrev();
else if (event.key === "ArrowRight" && hasNext) onNext();
else if (event.key === " ") { event.preventDefault(); togglePlay(); }
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasPrev, hasNext, onPrev, onNext, onClose]);
// 切换任务时重置播放状态
useEffect(() => {
setPlaying(true);
setCurrent(0);
setTotal(0);
}, [task.id]);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) { void video.play().catch(() => undefined); } else { video.pause(); }
};
const [w, h] = task.aspect_ratio.split(":").map(Number);
return createPortal(
<div className="fc-player-bg" onClick={onClose}>
<button type="button" className="fc-player-x" aria-label="关闭" onClick={onClose}><X size={18} /></button>
{hasPrev && (
<button type="button" className="fc-player-nav prev" aria-label="上一条" onClick={(event) => { event.stopPropagation(); onPrev(); }}>
<ChevronLeft size={22} />
</button>
)}
{hasNext && (
<button type="button" className="fc-player-nav next" aria-label="下一条" onClick={(event) => { event.stopPropagation(); onNext(); }}>
<ChevronRight size={22} />
</button>
)}
<div className="fc-player" onClick={(event) => event.stopPropagation()}>
<div className="fc-player-stage" ref={stageRef} style={{ aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" }} onClick={togglePlay}>
<video
key={task.id}
ref={videoRef}
src={task.video_url}
poster={task.thumbnail_url || undefined}
autoPlay
playsInline
muted={muted}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => setTotal(event.currentTarget.duration)}
onVolumeChange={(event) => { setMuted(event.currentTarget.muted); setVolume(event.currentTarget.volume); }}
/>
</div>
<div className="fc-player-controls">
<button type="button" className="fc-ctl" aria-label={playing ? "暂停" : "播放"} onClick={togglePlay}>
{playing ? <Pause size={16} /> : <Play size={16} />}
</button>
<span className="fc-time mono">{fmt(current)} / {fmt(total || task.duration)}</span>
<input
className="fc-seek"
type="range"
min={0}
max={total || task.duration || 0}
step={0.05}
value={current}
aria-label="进度"
onChange={(event) => {
const t = Number(event.target.value);
if (videoRef.current) videoRef.current.currentTime = t;
setCurrent(t);
}}
/>
<button type="button" className="fc-ctl" aria-label={muted ? "取消静音" : "静音"} onClick={() => { if (videoRef.current) videoRef.current.muted = !muted; }}>
{muted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
className="fc-vol"
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
aria-label="音量"
onChange={(event) => {
const v = Number(event.target.value);
if (videoRef.current) { videoRef.current.volume = v; videoRef.current.muted = v === 0; }
}}
/>
<button
type="button"
className="fc-ctl"
aria-label="全屏"
onClick={() => { void stageRef.current?.requestFullscreen?.().catch(() => undefined); }}
>
<Maximize size={16} />
</button>
</div>
<div className="fc-player-info">
<div className="fc-player-prompt">{task.prompt}</div>
<div className="fc-player-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.aspect_ratio} · {task.resolution.toUpperCase()} · {task.duration}s
{task.seed_used != null && ` · seed ${task.seed_used}`}
{` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
</div>
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
<div className="fc-player-actions">
<button type="button" className="btn btn-sm" onClick={onDownload}><Download size={13} /> </button>
<button type="button" className="btn btn-sm" onClick={onReuse}><RotateCcw size={13} /> </button>
<button type="button" className={`btn btn-sm${task.is_favorited ? " fc-fav-on" : ""}`} onClick={onToggleFavorite}>
<Heart size={13} fill={task.is_favorited ? "currentColor" : "none"} /> {task.is_favorited ? "已收藏" : "收藏"}
</button>
<button type="button" className="btn btn-sm" onClick={onDelete}><Trash2 size={13} /> </button>
</div>
</div>
</div>
</div>,
document.body
);
}
+568
View File
@@ -0,0 +1,568 @@
/* 自由创作页(free-create)· 私有样式,全部 .fc- 前缀
颜色/圆角/间距全部走 design-restraint token(§8 Don't List:禁裸 hex / >12px 圆角 / 灰阴影) */
.fc-page { display: flex; flex-direction: column; min-height: calc(100vh - 64px - 120px); }
/* —— 任务流 —— */
.fc-feed-wrap { flex: 1; padding-bottom: 24px; }
.fc-feed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
align-items: start;
}
.fc-loading, .fc-sentinel {
padding: 28px 0;
text-align: center;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--black-alpha-48);
}
.fc-empty { margin: 48px auto; }
/* —— 任务卡 —— */
.fc-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s;
}
.fc-card.done { cursor: pointer; }
.fc-card.done:hover { border-color: var(--black-alpha-48); }
.fc-card-media {
position: relative;
background: var(--background-base);
max-height: 420px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-card-generating {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: linear-gradient(110deg, var(--black-alpha-4) 30%, var(--black-alpha-7) 50%, var(--black-alpha-4) 70%);
background-size: 200% 100%;
animation: fc-shimmer 1.6s linear infinite;
font-size: 11px;
color: var(--black-alpha-56);
}
@keyframes fc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.fc-progress {
width: 60%;
height: 4px;
border-radius: 2px;
background: var(--black-alpha-12);
overflow: hidden;
}
.fc-progress span { display: block; height: 100%; border-radius: 2px; background: var(--heat); transition: width 0.6s ease; }
.fc-card-failed {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
text-align: center;
}
.fc-card-failed p { font-size: 12px; line-height: 1.65; color: var(--black-alpha-64); max-width: 90%; margin: 0; }
.fc-card-hover {
position: absolute;
top: 10px;
right: 10px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.fc-card:hover .fc-card-hover { opacity: 1; }
.fc-hover-btn {
width: 30px;
height: 30px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
background: var(--surface);
color: var(--black-alpha-56);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.fc-hover-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.fc-hover-btn.fav { color: var(--heat); }
.fc-hover-btn.danger:hover { color: var(--accent-crimson); }
.fc-card-meta { padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
.fc-card-prompt {
font-size: 13px;
color: var(--accent-black);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-card-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
/* —— 底部输入条 —— */
.fc-inputbar {
position: sticky;
bottom: 16px;
z-index: 5;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
padding: 14px 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: var(--shadow-floating);
}
.fc-inputbar.drag { border-color: var(--heat-40); }
.fc-drop-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--heat-12);
border-radius: inherit;
color: var(--heat);
font-size: 12px;
pointer-events: none;
}
.fc-input-top { display: flex; gap: 12px; align-items: flex-start; }
/* 参考素材条(universal) */
.fc-refs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 300px; }
.fc-add {
width: 56px;
height: 56px;
border-radius: var(--r-md);
border: 1px dashed var(--black-alpha-24);
background: var(--background-base);
color: var(--black-alpha-56);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
font-size: 12px;
transition: border-color 0.2s, color 0.2s;
}
.fc-add:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-ref {
position: relative;
width: 56px;
height: 56px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
overflow: hidden;
background: var(--background-base);
display: flex;
align-items: center;
justify-content: center;
}
.fc-ref img { width: 100%; height: 100%; object-fit: cover; }
.fc-ref.uploading img { opacity: 0.4; }
.fc-ref .spinner { position: absolute; }
.fc-ref-kind { font-size: 11px; color: var(--black-alpha-48); }
.fc-ref-dur {
position: absolute;
right: 3px;
bottom: 3px;
font-size: 8.5px;
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
}
.fc-ref-label {
position: absolute;
left: 0;
right: 0;
bottom: 0;
font-size: 9px;
padding: 1px 4px;
background: var(--black-alpha-56);
color: var(--surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-ref-x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 999px;
border: none;
background: var(--black-alpha-56);
color: var(--surface);
font-size: 11px;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
.fc-ref:hover .fc-ref-x, .fc-kf-slot:hover .fc-ref-x { display: inline-flex; }
/* 首尾帧(keyframe) */
.fc-keyframes { display: flex; align-items: center; gap: 8px; }
.fc-kf-slot {
position: relative;
width: 88px;
height: 66px;
border-radius: var(--r-md);
border: 1px dashed var(--black-alpha-24);
background: var(--background-base);
color: var(--black-alpha-56);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
font-size: 11px;
overflow: hidden;
transition: border-color 0.2s, color 0.2s;
}
.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); cursor: default; }
.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fc-kf-slot .spinner { position: absolute; }
.fc-kf-tag {
position: absolute;
left: 3px;
top: 3px;
font-size: 8.5px;
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
}
.fc-kf-arrow { color: var(--black-alpha-48); font-size: 13px; }
/* 提示词编辑器(contenteditable) */
.fc-prompt-wrap { flex: 1; min-width: 0; }
.fc-prompt {
min-height: 64px;
max-height: 160px;
overflow-y: auto;
font-size: 13px;
line-height: 1.65;
color: var(--accent-black);
outline: none;
padding: 6px 2px;
word-break: break-word;
}
.fc-prompt:empty::before { content: attr(data-placeholder); color: var(--black-alpha-48); pointer-events: none; }
.fc-mention {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--r-sm);
background: var(--heat-12);
border: 1px solid var(--heat-20);
color: var(--heat);
font-size: 12px;
vertical-align: middle;
user-select: none;
white-space: nowrap;
}
.fc-mention img { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; }
/* @ 候选菜单(fixed,朝上弹) */
.fc-mention-menu { position: fixed; z-index: 300; transform: translateY(-100%); }
.fc-mention-menu-inner {
min-width: 220px;
max-width: 280px;
max-height: 260px;
overflow-y: auto;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
padding: 6px;
}
.fc-mention-menu-head { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); padding: 4px 8px; }
.fc-mention-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
font-size: 12.5px;
color: var(--accent-black);
text-align: left;
}
.fc-mention-item.active { background: var(--black-alpha-4); }
.fc-mention-item img { width: 24px; height: 24px; border-radius: var(--r-sm); object-fit: cover; }
.fc-mention-ph { width: 24px; height: 24px; border-radius: var(--r-sm); background: var(--black-alpha-7); }
.fc-mention-item .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-mention-item .lbl.lib { color: var(--black-alpha-56); }
/* —— 工具栏 —— */
.fc-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border-top: 1px solid var(--border-faint); padding-top: 10px; }
.fc-toolbar-l { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.fc-toolbar-r { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.fc-estimate { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); font-variant-numeric: tabular-nums; }
.fc-dd { position: relative; }
.fc-dd-btn {
height: 28px;
padding: 0 10px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
background: var(--surface);
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--accent-black);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.fc-dd-btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.fc-dd-btn:disabled { color: var(--black-alpha-32); cursor: not-allowed; }
.fc-dd-btn svg { color: var(--black-alpha-48); transition: transform 0.2s; }
.fc-dd.open .fc-dd-btn svg { transform: rotate(180deg); }
.fc-dd-lbl { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-dd-val { font-weight: 500; }
.fc-dd-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 60;
min-width: 180px;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
padding: 6px;
}
.fc-dd-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: var(--r-sm);
background: transparent;
cursor: pointer;
text-align: left;
}
.fc-dd-item:hover { background: var(--black-alpha-4); }
.fc-dd-item.selected { background: var(--heat-12); }
.fc-dd-item.selected .ti { color: var(--heat); }
.fc-dd-item.disabled { cursor: not-allowed; }
.fc-dd-item.disabled .ti, .fc-dd-item.disabled .de { color: var(--black-alpha-32); }
.fc-dd-item .ti { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }
.fc-dd-item .de { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-seed-menu { min-width: 220px; padding: 10px; }
.fc-seed-row { display: flex; gap: 8px; align-items: center; }
.fc-seed-row .input { height: 30px; flex: 1; }
.fc-seed-hint { margin-top: 6px; font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-fav-on { color: var(--heat); }
/* —— 全屏播放器 —— */
.fc-player-bg {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(21, 20, 15, 0.72);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 72px;
}
.fc-player-x {
position: absolute;
top: 20px;
right: 24px;
width: 36px;
height: 36px;
border-radius: var(--r-md);
border: none;
background: var(--black-alpha-24);
color: var(--surface);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fc-player-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 999px;
border: none;
background: var(--black-alpha-24);
color: var(--surface);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.fc-player-nav.prev { left: 20px; }
.fc-player-nav.next { right: 20px; }
.fc-player-nav:hover, .fc-player-x:hover { background: var(--black-alpha-48); }
.fc-player {
width: min(920px, 100%);
max-height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.fc-player-stage {
max-height: 62vh;
margin: 0 auto;
max-width: 100%;
background: #000; /* 视频画布留黑,行业惯例,非界面色 */
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
}
.fc-player-stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
.fc-player-controls {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface-raised);
border-radius: var(--r-md);
padding: 8px 12px;
}
.fc-ctl {
width: 30px;
height: 30px;
border: none;
border-radius: var(--r-md);
background: transparent;
color: var(--black-alpha-56);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fc-ctl:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.fc-time { font-size: 11px; color: var(--black-alpha-56); font-variant-numeric: tabular-nums; white-space: nowrap; }
.fc-seek { flex: 1; accent-color: var(--heat); }
.fc-vol { width: 72px; accent-color: var(--heat); }
.fc-player-info {
background: var(--surface-raised);
border-radius: var(--r-md);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.fc-player-prompt { font-size: 13px; line-height: 1.65; color: var(--accent-black); max-height: 72px; overflow-y: auto; }
.fc-player-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-player-warn { font-size: 11px; color: var(--accent-honey); }
.fc-player-actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
/* —— 素材库弹窗 —— */
.fc-lib-modal { width: min(720px, 92vw); }
.fc-lib-body { max-height: 60vh; overflow-y: auto; }
.fc-lib-back {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
font-size: inherit;
font-weight: inherit;
color: inherit;
cursor: pointer;
padding: 0;
}
.fc-lib-back:hover { color: var(--heat); }
.fc-lib-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.fc-lib-hint { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-lib-create { display: flex; gap: 8px; align-items: center; flex: 1; }
.fc-lib-create .input { height: 30px; flex: 1; max-width: 260px; }
.fc-lib-groups, .fc-lib-assets {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 12px;
}
.fc-lib-group, .fc-lib-asset {
position: relative;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.fc-lib-group:hover, .fc-lib-asset.pickable:hover { background: var(--surface); border-color: var(--heat-40); }
.fc-lib-asset:not(.pickable) { cursor: default; }
.fc-lib-thumb {
position: relative;
aspect-ratio: 1;
border-radius: var(--r-sm);
background: var(--black-alpha-4);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: var(--black-alpha-48);
}
.fc-lib-thumb img { width: 100%; height: 100%; object-fit: cover; }
.fc-lib-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
.fc-lib-ops button {
width: 22px;
height: 22px;
border: none;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fc-lib-ops button:hover { background: var(--accent-black); }
/* 移动端:输入条参数换行、任务流单列 */
@media (max-width: 720px) {
.fc-feed { grid-template-columns: 1fr; }
.fc-input-top { flex-direction: column; }
.fc-refs { max-width: none; }
.fc-player-bg { padding: 16px; }
.fc-player-nav.prev { left: 6px; }
.fc-player-nav.next { right: 6px; }
}
+1
View File
@@ -12,6 +12,7 @@ import "./library-page.css";
import "./messages-page.css";
import "./settings-page.css";
import "./ai-tools-page.css";
import "./free-create-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
import "./admin-page.css";
+549
View File
@@ -0,0 +1,549 @@
// 自由创作 · AI 视频生成(移植自 jimeng-clone,嫁接 AirShelf 底座)
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Users } from "lucide-react";
import { api, ApiError } from "../api";
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
import {
FC_MODELS,
FC_STANDARD_MODEL,
MAX_AUDIOS,
MAX_IMAGES,
MAX_VIDEOS,
MAX_VIDEO_TOTAL_SECONDS,
checkRefFile,
isInFlight,
type FreeMode,
type LocalRef
} from "../components/free-create/constants";
import { FreeInputBar } from "../components/free-create/input-bar";
import { GenerationCard } from "../components/free-create/generation-card";
import { VideoDetailModal } from "../components/free-create/video-detail-modal";
import { AssetLibraryModal } from "../components/free-create/asset-library-modal";
import type { PromptInputHandle } from "../components/free-create/prompt-input";
import { ConfirmModal } from "../components/overlays";
const PAGE_SIZE = 20;
const PROGRESS_KEY = "fc-progress";
function loadProgress(): Record<string, number> {
try {
return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record<string, number>;
} catch {
return {};
}
}
// 渐进轮询间隔:前 1 分钟 10s,1-3 分钟 30s,之后 60s(视频 5-10 分钟出片,别打爆后端)
function pollDelay(count: number): number {
if (count < 6) return 10000;
if (count < 10) return 30000;
return 60000;
}
let refKeySeq = 0;
const nextRefKey = () => `ref-${Date.now()}-${refKeySeq++}`;
export function FreeCreatePage({ modelConfigs, onNotify }: {
modelConfigs: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
}) {
// App 每次渲染会重建 onNotify 箭头函数;用 ref 稳定身份,否则依赖它的 effect(首屏拉取/轮询)
// 会随 App 任意 setState(如 30s 未读轮询)反复重跑 → 任务流无谓全量刷新(实测踩坑)。
const onNotifyRef = useRef(onNotify);
onNotifyRef.current = onNotify;
const notify = useCallback((type: "success" | "error" | "info", text: string) => onNotifyRef.current(type, text), []);
const videoConfigs = useMemo(
() => modelConfigs.filter((c) => c.capability === "video" && FC_MODELS.some((m) => m.name === c.name)),
[modelConfigs]
);
// —— 任务流 ——
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
// —— 输入条 ——
const [mode, setMode] = useState<FreeMode>("universal");
const [model, setModel] = useState<string>(FC_STANDARD_MODEL);
const [ratio, setRatio] = useState("16:9");
const [resolution, setResolution] = useState("720p");
const [duration, setDuration] = useState(5);
const [seed, setSeed] = useState(-1);
const [refs, setRefs] = useState<LocalRef[]>([]);
const [submitting, setSubmitting] = useState(false);
const promptRef = useRef<PromptInputHandle | null>(null);
// —— 弹窗 ——
const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
// —— 轮询/进度 ——
const pollTimersRef = useRef(new Map<string, number>());
const pollCountsRef = useRef(new Map<string, number>());
const [progress, setProgress] = useState<Record<string, number>>(() => loadProgress());
const tasksRef = useRef<FreeVideoTask[]>([]);
tasksRef.current = tasks;
const patchTask = useCallback((task: FreeVideoTask) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
}, []);
const stopPolling = useCallback((id: string) => {
const timer = pollTimersRef.current.get(id);
if (timer) window.clearTimeout(timer);
pollTimersRef.current.delete(id);
pollCountsRef.current.delete(id);
}, []);
const schedulePoll = useCallback((id: string) => {
if (pollTimersRef.current.has(id)) return;
const tick = async () => {
pollTimersRef.current.delete(id);
const current = tasksRef.current.find((t) => t.id === id);
if (!current || !isInFlight(current.status)) { stopPolling(id); return; }
const count = (pollCountsRef.current.get(id) || 0) + 1;
pollCountsRef.current.set(id, count);
try {
const data = await api.pollFreeVideo(id);
patchTask(data.task);
if (isInFlight(data.task.status)) {
pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count)));
} else {
stopPolling(id);
setProgress((prev) => { const next = { ...prev }; delete next[id]; sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next)); return next; });
if (data.task.status === "succeeded") notify("success", "视频生成完成");
else if (data.task.status === "failed") notify("error", data.task.error_message || "视频生成失败");
}
} catch {
// 单次轮询失败(网络抖动)不终结,下一轮继续
pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(count)));
}
};
pollTimersRef.current.set(id, window.setTimeout(() => void tick(), pollDelay(pollCountsRef.current.get(id) || 0)));
}, [patchTask, stopPolling, notify]);
// 平滑进度动画:每 2s 给在途任务 +0.6~1.6%,封顶 95,sessionStorage 持久(刷新可续)
useEffect(() => {
const timer = window.setInterval(() => {
const inflight = tasksRef.current.filter((t) => isInFlight(t.status));
if (inflight.length === 0) return;
setProgress((prev) => {
const next = { ...prev };
for (const t of inflight) {
next[t.id] = Math.min(95, (next[t.id] || 3) + 0.6 + Math.random());
}
sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next));
return next;
});
}, 2000);
return () => window.clearInterval(timer);
}, []);
// 首屏拉取 + 在途任务恢复轮询(后端持久恢复,换浏览器也不丢)
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const data = await api.freeVideoTasks(0, PAGE_SIZE);
if (cancelled) return;
setTasks(data.results);
setTotal(data.total);
setHasMore(data.has_more);
data.results.filter((t) => isInFlight(t.status)).forEach((t) => schedulePoll(t.id));
} catch (error) {
if (!cancelled) notify("error", error instanceof Error ? error.message : "任务列表加载失败");
} finally {
if (!cancelled) setLoading(false);
}
})();
const timers = pollTimersRef.current;
return () => {
cancelled = true;
timers.forEach((timer) => window.clearTimeout(timer));
timers.clear();
};
}, [schedulePoll, notify]);
// 滚动加载更多(旧任务)
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting || loadingMoreRef.current) return;
loadingMoreRef.current = true;
void api.freeVideoTasks(tasksRef.current.filter((t) => !t.id.startsWith("local-")).length, PAGE_SIZE)
.then((data) => {
setTasks((prev) => {
const seen = new Set(prev.map((t) => t.id));
return [...prev, ...data.results.filter((t) => !seen.has(t.id))];
});
setTotal(data.total);
setHasMore(data.has_more);
})
.catch(() => undefined)
.finally(() => { loadingMoreRef.current = false; });
}, { rootMargin: "200px" });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore]);
// —— 上传 ——
const addFiles = useCallback(async (files: File[], role?: "first_frame" | "last_frame") => {
for (const file of files) {
const check = await checkRefFile(file);
if (!check.ok) { notify("error", check.error); continue; }
if (mode === "keyframe") {
if (check.type !== "image") { notify("error", "首尾帧模式仅支持图片素材"); continue; }
} else {
const counts = { image: 0, video: 0, audio: 0 };
let videoSeconds = 0;
for (const r of refs) {
counts[r.type] += 1;
if (r.type === "video") videoSeconds += r.duration || 0;
}
if (check.type === "image" && counts.image >= MAX_IMAGES) { notify("error", `参考图片最多 ${MAX_IMAGES}`); continue; }
if (check.type === "video" && counts.video >= MAX_VIDEOS) { notify("error", `参考视频最多 ${MAX_VIDEOS}`); continue; }
if (check.type === "audio" && counts.audio >= MAX_AUDIOS) { notify("error", `参考音频最多 ${MAX_AUDIOS}`); continue; }
if (check.type === "video" && videoSeconds + (check.duration || 0) > MAX_VIDEO_TOTAL_SECONDS) {
notify("error", `参考视频总时长不能超过 ${MAX_VIDEO_TOTAL_SECONDS}`);
continue;
}
}
const key = nextRefKey();
const blobUrl = URL.createObjectURL(file);
const baseLabel = (file.name.replace(/\.[^.]+$/, "") || "素材").slice(0, 24);
let label = baseLabel;
let n = 2;
// 生成唯一 label(重名素材 @ 引用会歧义)
// eslint-disable-next-line no-loop-func
while (refs.some((r) => r.label === label)) label = `${baseLabel}${n++}`;
const local: LocalRef = {
key,
url: blobUrl,
type: check.type,
role: mode === "keyframe" ? role || "first_frame" : undefined,
label: mode === "keyframe" ? undefined : label,
duration: check.duration,
source: "upload",
uploading: true
};
setRefs((prev) => {
// keyframe:同 role 只留一张(替换)
const cleaned = mode === "keyframe" ? prev.filter((r) => r.role !== local.role) : prev;
return [...cleaned, local];
});
const form = new FormData();
form.append("file", file);
void api.uploadFreeVideoRef(form)
.then((data) => {
URL.revokeObjectURL(blobUrl);
setRefs((prev) => prev.map((r) => (r.key === key ? {
...r,
uploading: false,
url: data.url,
thumb_url: data.thumb_url || (data.type === "image" ? data.url : ""),
duration: data.duration || r.duration,
asset_id: data.asset_id
} : r)));
})
.catch((error) => {
URL.revokeObjectURL(blobUrl);
setRefs((prev) => prev.filter((r) => r.key !== key));
notify("error", error instanceof Error ? error.message : "素材上传失败");
});
}
}, [mode, refs, notify]);
const removeRef = useCallback((key: string) => {
setRefs((prev) => prev.filter((r) => r.key !== key));
}, []);
// 素材集合变化 → 编辑器清孤儿 chip
useEffect(() => {
promptRef.current?.pruneMentions(refs.map((r) => r.label || "").filter(Boolean));
}, [refs]);
// —— 提交 ——
const toPayloadRefs = (items: LocalRef[]): FreeVideoRef[] =>
items.map(({ key: _key, uploading: _uploading, ...rest }) => rest);
const doSubmit = useCallback(async (payload: {
prompt: string;
mode: FreeMode;
model: string;
aspect_ratio: string;
resolution: string;
duration: number;
seed: number;
references: FreeVideoRef[];
}) => {
const localId = `local-${Date.now()}`;
const placeholder: FreeVideoTask = {
id: localId,
status: "submitted",
mode: payload.mode,
model: payload.model,
prompt: payload.prompt,
aspect_ratio: payload.aspect_ratio,
resolution: payload.resolution,
duration: payload.duration,
seed: payload.seed,
generate_audio: true,
references: payload.references,
estimated_tokens: 0,
actual_tokens: 0,
estimated_cost: "0",
actual_cost: "0",
error_message: "",
is_favorited: false,
video_url: "",
thumbnail_url: "",
created_at: new Date().toISOString(),
completed_at: null
};
setTasks((prev) => [placeholder, ...prev]);
setSubmitting(true);
try {
const data = await api.submitFreeVideo(payload);
setTasks((prev) => prev.map((t) => (t.id === localId ? data.task : t)));
setTotal((prev) => prev + 1);
// 进度动画平移到真实任务 id
setProgress((prev) => {
const next = { ...prev, [data.task.id]: prev[localId] || 3 };
delete next[localId];
sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(next));
return next;
});
if (isInFlight(data.task.status)) schedulePoll(data.task.id);
else if (data.task.status === "failed") notify("error", data.task.error_message || "创建任务失败");
return true;
} catch (error) {
setTasks((prev) => prev.filter((t) => t.id !== localId));
notify("error", error instanceof ApiError ? error.message : "提交失败,请重试");
return false;
} finally {
setSubmitting(false);
}
}, [schedulePoll, notify]);
const handleSend = useCallback(async () => {
const prompt = promptRef.current?.getText() || "";
if (!prompt.trim()) { notify("error", "请先输入提示词"); return; }
if (refs.some((r) => r.uploading)) { notify("info", "素材上传中,请稍候"); return; }
if (mode === "keyframe" && !refs.some((r) => r.role === "first_frame")) {
notify("error", "首尾帧模式需要提供首帧图片");
return;
}
if (refs.length === 0 && /@(图片|视频|音频|素材)/.test(prompt)) {
notify("error", "提示词里 @ 引用的素材为空,请补充素材或删除该引用");
return;
}
const audioCount = refs.filter((r) => r.type === "audio").length;
if (audioCount > 0 && refs.length === audioCount) {
notify("error", "音频不能单独作为参考素材,请同时提供图片或视频");
return;
}
const ok = await doSubmit({
prompt: prompt.trim(),
mode,
model,
aspect_ratio: ratio,
resolution,
duration,
seed,
references: toPayloadRefs(refs)
});
if (ok) {
promptRef.current?.clear();
setRefs([]);
}
}, [refs, mode, model, ratio, resolution, duration, seed, doSubmit, notify]);
const handleRetry = useCallback((task: FreeVideoTask) => {
void doSubmit({
prompt: task.prompt,
mode: task.mode,
model: task.model,
aspect_ratio: task.aspect_ratio,
resolution: task.resolution,
duration: task.duration,
seed: task.seed,
references: task.references
});
}, [doSubmit]);
// 再次生成:参数 + 素材 + 提示词(含 mention chip)全部回填输入条
const handleReuse = useCallback((task: FreeVideoTask) => {
setDetailId(null);
setMode(task.mode);
setModel(task.model);
setRatio(task.aspect_ratio);
setResolution(task.resolution);
setDuration(task.duration);
setSeed(task.seed ?? -1);
setRefs(task.references.map((r) => ({ ...r, key: nextRefKey() })));
window.setTimeout(() => promptRef.current?.setContent(task.prompt, task.references), 0);
notify("info", "已回填参数,可修改后重新生成");
}, [notify]);
const handleFavorite = useCallback((task: FreeVideoTask) => {
void api.toggleFreeVideoFavorite(task.id)
.then((data) => patchTask({ ...task, is_favorited: data.is_favorited }))
.catch((error) => notify("error", error instanceof Error ? error.message : "操作失败"));
}, [patchTask, notify]);
const handleDownload = useCallback(async (task: FreeVideoTask) => {
if (!task.video_url) return;
try {
const response = await fetch(task.video_url);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `airshelf-free-${task.id.slice(0, 8)}.mp4`;
a.click();
URL.revokeObjectURL(url);
} catch {
window.open(task.video_url, "_blank");
}
}, []);
const confirmDelete = useCallback(async () => {
const target = deleteTarget;
if (!target) return;
try {
await api.deleteFreeVideo(target.id);
stopPolling(target.id);
setTasks((prev) => prev.filter((t) => t.id !== target.id));
setTotal((prev) => Math.max(0, prev - 1));
if (detailId === target.id) setDetailId(null);
notify("success", "已删除");
} catch (error) {
notify("error", error instanceof Error ? error.message : "删除失败");
} finally {
setDeleteTarget(null);
}
}, [deleteTarget, detailId, stopPolling, notify]);
// 素材库选中 → 注入输入条 + 插 mention chip
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
if (mode === "keyframe") { notify("info", "首尾帧模式请直接上传图片"); return; }
let label = ref.label || "素材";
let n = 2;
while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`;
setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]);
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
}, [mode, refs, notify]);
const clearInput = useCallback(() => {
promptRef.current?.clear();
setRefs([]);
}, []);
// 详情弹窗的上一条/下一条(只在已完成的任务间切换)
const doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url);
const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null;
const detailIndex = detailTask ? doneTasks.findIndex((t) => t.id === detailTask.id) : -1;
return (
<div className="fc-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// {total} 个视频</span> · AI 视频生成 · 全能参考 / 首尾帧
</div>
</div>
<div className="actions">
<button type="button" className="btn" onClick={() => setLibraryOpen(true)}>
<Users size={14} />
</button>
</div>
</div>
<div className="fc-feed-wrap">
{loading ? (
<div className="fc-loading mono">// 加载中…</div>
) : tasks.length === 0 ? (
<div className="empty-state show fc-empty">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 在下方输入提示词,生成你的第一条视频</p>
</div>
) : (
<div className="fc-feed">
{tasks.map((task) => (
<GenerationCard
key={task.id}
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="fc-sentinel mono">// 下滑加载更多</div>}
</div>
<FreeInputBar
mode={mode}
model={model}
ratio={ratio}
resolution={resolution}
duration={duration}
seed={seed}
refs={refs}
videoConfigs={videoConfigs}
submitting={submitting}
promptRef={promptRef}
onFiles={(files, role) => void addFiles(files, role)}
onRemoveRef={removeRef}
onModeChange={(next) => { setMode(next); setRefs([]); if (next === "keyframe") { setRatio("16:9"); } }}
onModelChange={setModel}
onRatioChange={setRatio}
onResolutionChange={setResolution}
onDurationChange={setDuration}
onSeedChange={setSeed}
onOpenLibrary={() => setLibraryOpen(true)}
onClear={clearInput}
onSend={() => void handleSend()}
/>
{detailTask && (
<VideoDetailModal
task={detailTask}
hasPrev={detailIndex > 0}
hasNext={detailIndex >= 0 && detailIndex < doneTasks.length - 1}
onPrev={() => { if (detailIndex > 0) setDetailId(doneTasks[detailIndex - 1].id); }}
onNext={() => { if (detailIndex < doneTasks.length - 1) setDetailId(doneTasks[detailIndex + 1].id); }}
onClose={() => setDetailId(null)}
onDownload={() => void handleDownload(detailTask)}
onToggleFavorite={() => handleFavorite(detailTask)}
onReuse={() => handleReuse(detailTask)}
onDelete={() => setDeleteTarget(detailTask)}
/>
)}
<AssetLibraryModal open={libraryOpen} onClose={() => setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} />
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
detail={`确定删除这条视频吗?删除后可在资产库垃圾桶找回素材,任务记录不可恢复。`}
confirmText="确认删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>
</div>
);
}
+1
View File
@@ -10,4 +10,5 @@ export { AccountPage } from "./account";
export { TeamPage } from "./team";
export { MessagesPage } from "./messages";
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
export { FreeCreatePage } from "./free-create";
export { SettingsPage } from "./settings";
+5
View File
@@ -27,6 +27,7 @@ export type Page =
| "team"
| "messages"
| "assetFactory"
| "freeCreate"
| "imageOptimize"
| "modelPhoto"
| "modelPhotoDemoA"
@@ -92,6 +93,7 @@ export const routeLabels: Record<Page, string> = {
team: "团队",
messages: "消息",
assetFactory: "图片工具",
freeCreate: "自由创作",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
@@ -151,6 +153,7 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/team") return { page: "team", authMode: "login", hash };
if (path === "/messages") return { page: "messages", authMode: "login", hash };
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
if (path === "/image-optimize") return { page: "imageOptimize", authMode: "login", hash };
if (path === "/model-photo") return { page: "modelPhoto", authMode: "login", hash };
if (path === "/model-photo/demo-a") return { page: "modelPhotoDemoA", authMode: "login", hash };
@@ -190,6 +193,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/messages";
case "assetFactory":
return "/asset-factory";
case "freeCreate":
return "/free-create";
case "imageOptimize":
return "/image-optimize";
case "modelPhoto":
+74
View File
@@ -534,6 +534,80 @@ export type ModelConfig = {
capability: string;
status: string;
unit_price?: string; // 单位价(每张图/每次调用),前端据此算「预估扣费」与后端实扣一致(PMC#20)
// 模型元数据:自由创作视频模型带 pricing(元/百万tokens 分档价表)/resolutions/durations,前端预估消耗读它
metadata?: Record<string, unknown>;
};
// —— 自由创作·视频生成 ——
export type FreeVideoRef = {
url: string;
type: "image" | "video" | "audio";
role?: string; // universal: reference_image/video/audio;keyframe: first_frame/last_frame
label?: string; // @mention 引用名
thumb_url?: string;
duration?: number; // 视频/音频时长(秒)
asset_id?: string; // 直传素材的 Asset id / 素材库 FreeAsset id
source?: "upload" | "library" | "library_group";
group_id?: string;
};
export type FreeVideoTask = {
id: string;
status: string; // AITask 状态机原样透传:created/reserved/submitted/polling/postprocessing/succeeded/failed/cancelled
mode: "universal" | "keyframe";
model: string;
prompt: string;
aspect_ratio: string;
resolution: string;
duration: number;
seed: number;
seed_used?: number | null;
generate_audio: boolean;
references: FreeVideoRef[];
estimated_tokens: number;
actual_tokens: number;
estimated_cost: string;
actual_cost: string;
error_message: string;
fallback_note?: string;
is_favorited: boolean;
video_url: string;
thumbnail_url: string;
created_at: string | null;
completed_at: string | null;
};
export type FreeVideoUploadResult = {
asset_id: string;
url: string;
type: "image" | "video" | "audio";
name: string;
duration: number | null;
width: number | null;
height: number | null;
thumb_url: string;
};
// 自由创作·人物素材库(火山 Assets API 引用登记)
export type FreeAssetItem = {
id: string;
name: string;
url: string;
type: "image" | "video" | "audio";
thumb_url: string;
duration: number | null;
status: "processing" | "active" | "failed";
error_message: string;
created_at: string;
};
export type FreeAssetGroup = {
id: string;
name: string;
description: string;
thumbnail_url: string;
asset_count: number;
created_at: string;
};
export type AITask = {