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:
@@ -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", ""),
|
||||
|
||||
@@ -66,9 +66,54 @@ VOLCANO_MODELS = [
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
"resolutions": ["480p", "720p"],
|
||||
"resolutions": ["480p", "720p", "1080p", "4k"],
|
||||
"watermark": False,
|
||||
"source": "video-flow/data/vendor/volcengine.ts",
|
||||
# 自由创作按 token 计费:元/百万tokens,按 分辨率档 × 是否含视频参考 取价,
|
||||
# 无精确分辨率键回落 default。数字来源火山 Seedance 2.0 官方价目。
|
||||
"pricing": {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 46, "with_ref_video": 28},
|
||||
"1080p": {"no_ref_video": 51, "with_ref_video": 31},
|
||||
"4k": {"no_ref_video": 26, "with_ref_video": 16},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"display_name": "Seedance-2.0-Fast",
|
||||
"name": "doubao-seedance-2-0-fast-260128",
|
||||
"capability": "video",
|
||||
"endpoint": "contents/generations/tasks",
|
||||
"metadata": {
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
# 1080p/4k 仅标准档支持(火山限制),提交侧校验拒绝
|
||||
"resolutions": ["480p", "720p"],
|
||||
"watermark": False,
|
||||
"source": "jimeng-clone/backend/utils/airdrama_client.py",
|
||||
"pricing": {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 37, "with_ref_video": 22},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"display_name": "Seedance-2.0-Mini",
|
||||
"name": "doubao-seedance-2-0-mini-260615",
|
||||
"capability": "video",
|
||||
"endpoint": "contents/generations/tasks",
|
||||
"metadata": {
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
"resolutions": ["480p", "720p"],
|
||||
"watermark": False,
|
||||
"source": "jimeng-clone/backend/utils/airdrama_client.py",
|
||||
"pricing": {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 23, "with_ref_video": 14},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
"""自由创作·独立视频生成(不绑 project)。
|
||||
|
||||
移植自 jimeng-clone(apps/generation/views.py 的 video_generate_view / tasks.py 轮询),
|
||||
嫁接 AirShelf 底座:AITask 状态机 + CreditAccount 三段式计费(reserve/charge/release)+
|
||||
TOS 存储 + VolcanoArkProvider。
|
||||
|
||||
链路:
|
||||
submit_free_video → 校验/估价/并发闸 → AITask(RESERVED) → 调火山(SUBMITTED) → 派兜底轮询
|
||||
finalize_free_video → 查火山;终态幂等化(POSTPROCESSING 认领防双 poll 双扣)
|
||||
成功: 下载→TOS→Asset(FREE_CREATE)+首帧封面 → 按真实 tokens 结算
|
||||
失败: 错误码映射中文 + 退费
|
||||
web poll 端点与 worker 兜底任务共用 finalize;本地无 worker 也能全程收尾。
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
|
||||
from apps.assets.storage import TosStorage
|
||||
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||||
|
||||
from .models import AITask, ModelConfig
|
||||
from .providers.volcano import VolcanoArkProvider
|
||||
from .video_errors import map_video_error, parse_provider_error
|
||||
from .video_pricing import (
|
||||
RESERVE_BUFFER,
|
||||
estimate_video_cost,
|
||||
get_resolution,
|
||||
tokens_to_cost,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FREE_VIDEO_MODELS = {
|
||||
"doubao-seedance-2-0-260128",
|
||||
"doubao-seedance-2-0-fast-260128",
|
||||
"doubao-seedance-2-0-mini-260615",
|
||||
}
|
||||
HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制)
|
||||
RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||||
RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
|
||||
MODES = {"universal", "keyframe"}
|
||||
IN_FLIGHT_STATUSES = (
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
)
|
||||
|
||||
_ORPHAN_MATERIAL_MENTION_RE = re.compile(r"@(?:图片|视频|音频|素材)[^\s,。!?、;:,.!?;:))]*")
|
||||
|
||||
|
||||
def find_orphan_material_mention(prompt: str, references: list) -> str | None:
|
||||
"""prompt 里 @了素材类占位但 references 为空 → 返回该 mention(供 400 提示)。"""
|
||||
if not prompt or references:
|
||||
return None
|
||||
match = _ORPHAN_MATERIAL_MENTION_RE.search(prompt)
|
||||
return match.group(0) if match else None
|
||||
|
||||
|
||||
def _format_prompt_for_ark(prompt: str, label_placeholders: list[tuple[str, str]]) -> str:
|
||||
"""@label → 「图片N/视频N/音频N」。火山只认「类型+序号」指代(官方 FAQ Q3),
|
||||
文件名/asset id 会按位置概率对齐 → 人物颠倒。调用方保证按 label 长度降序,
|
||||
防「碧」先于「碧碧」被替换的子串吞噬;用 str.replace 防 label 含正则元字符崩溃。
|
||||
用户原文保留在 request_payload 供「再次生成」回填 mention chip。"""
|
||||
result = prompt
|
||||
for label, placeholder in label_placeholders:
|
||||
if not label:
|
||||
continue
|
||||
result = result.replace(f"@{label}", placeholder)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_remote_asset_id(remote_id: str) -> str:
|
||||
# 火山返回 "Asset-xxx",引用时须小写前缀 "asset-xxx"
|
||||
if remote_id.startswith("Asset-"):
|
||||
return "asset-" + remote_id[6:]
|
||||
return remote_id
|
||||
|
||||
|
||||
def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
|
||||
"""本地 processing 的素材实时查火山刷新。返回是否 active。best-effort。"""
|
||||
try:
|
||||
from apps.assets import assets_client
|
||||
|
||||
result = assets_client.get_asset(free_asset.remote_asset_id)
|
||||
if result and result.get("Status") == "Active":
|
||||
free_asset.status = FreeAsset.Status.ACTIVE
|
||||
free_asset.url = result.get("Url", free_asset.url) or free_asset.url
|
||||
free_asset.save(update_fields=["status", "url", "updated_at"])
|
||||
return True
|
||||
except Exception: # noqa: BLE001 — 刷新失败按未就绪处理
|
||||
logger.warning("free asset %s refresh failed", free_asset.id, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict:
|
||||
"""references → 火山 content_items + api_prompt(@label 已替换)。
|
||||
|
||||
移植 jimeng views.py:369-567:URL 去重、blob: 拦截、素材库引用(FreeAsset → asset://)、
|
||||
素材组展开、图≤9/视频≤3/音频≤3 校验、@label→「图片N」映射(不变量:任意时刻
|
||||
image_n/video_n/audio_n == content_items 里该类型已 push 的个数)。
|
||||
校验失败抛 ValueError(用户友好中文),由 view 转 400。
|
||||
"""
|
||||
content_items: list[dict] = []
|
||||
snapshots: list[dict] = []
|
||||
seen_urls: set[str] = set()
|
||||
group_cache: dict[str, list[tuple[str, str, float]]] = {}
|
||||
label_to_placeholder: dict[str, str] = {}
|
||||
image_n = video_n = audio_n = 0
|
||||
video_duration_total = 0.0 # 输入参考视频总时长(token 公式的输入项 + ≤15s 校验)
|
||||
|
||||
def _placeholder_for(asset_type: str) -> str:
|
||||
if asset_type == "Video":
|
||||
return f"视频{video_n}"
|
||||
if asset_type == "Audio":
|
||||
return f"音频{audio_n}"
|
||||
return f"图片{image_n}"
|
||||
|
||||
def _push(kind: str, url: str, role: str, duration: float = 0.0) -> str:
|
||||
"""push 一个 content_item 并递增 counter,返回素材类型(Image/Video/Audio)。"""
|
||||
nonlocal image_n, video_n, audio_n, video_duration_total
|
||||
if kind == "video":
|
||||
video_n += 1
|
||||
video_duration_total += duration or 0.0
|
||||
item: dict = {"type": "video_url", "video_url": {"url": url}}
|
||||
if role:
|
||||
item["role"] = role
|
||||
content_items.append(item)
|
||||
return "Video"
|
||||
if kind == "audio":
|
||||
audio_n += 1
|
||||
item = {"type": "audio_url", "audio_url": {"url": url}}
|
||||
if role:
|
||||
item["role"] = role
|
||||
content_items.append(item)
|
||||
return "Audio"
|
||||
image_n += 1
|
||||
item = {"type": "image_url", "image_url": {"url": url}}
|
||||
if role:
|
||||
item["role"] = role
|
||||
content_items.append(item)
|
||||
return "Image"
|
||||
|
||||
def _resolve_group_assets(group: FreeAssetGroup) -> list[tuple[str, str, float]]:
|
||||
resolved: list[tuple[str, str, float]] = []
|
||||
for fa in group.assets.exclude(remote_asset_id="").order_by("created_at"):
|
||||
if fa.status == FreeAsset.Status.PROCESSING and not _refresh_processing_free_asset(fa):
|
||||
continue # 未就绪的跳过
|
||||
if fa.status != FreeAsset.Status.ACTIVE:
|
||||
continue
|
||||
resolved.append(
|
||||
(f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}", fa.asset_type, fa.duration or 0.0)
|
||||
)
|
||||
return resolved
|
||||
|
||||
for ref in references or []:
|
||||
url = str(ref.get("url") or "")
|
||||
ref_type = str(ref.get("type") or "image")
|
||||
role = str(ref.get("role") or "")
|
||||
label = str(ref.get("label") or "")
|
||||
source = str(ref.get("source") or "upload")
|
||||
duration = float(ref.get("duration") or 0)
|
||||
|
||||
dedupe_key = url or f"{source}:{ref.get('asset_id') or ref.get('group_id')}"
|
||||
if dedupe_key in seen_urls:
|
||||
continue
|
||||
seen_urls.add(dedupe_key)
|
||||
|
||||
if url.startswith("blob:"):
|
||||
raise ValueError(f"素材「{label or '未命名'}」上传失败,请删除后重新添加")
|
||||
|
||||
snap = {"url": url, "type": ref_type, "role": role, "label": label, "source": source}
|
||||
if ref.get("thumb_url"):
|
||||
snap["thumb_url"] = ref["thumb_url"]
|
||||
if duration:
|
||||
snap["duration"] = duration
|
||||
if ref.get("asset_id"):
|
||||
snap["asset_id"] = str(ref["asset_id"])
|
||||
if ref.get("group_id"):
|
||||
snap["group_id"] = str(ref["group_id"])
|
||||
snapshots.append(snap)
|
||||
|
||||
# 素材库单素材:FreeAsset → asset://{remote_id}
|
||||
if source == "library" and ref.get("asset_id"):
|
||||
fa = FreeAsset.objects.filter(
|
||||
id=ref["asset_id"], group__team=team, group__is_deleted=False
|
||||
).first()
|
||||
if fa is None:
|
||||
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
|
||||
if fa.status == FreeAsset.Status.PROCESSING and not _refresh_processing_free_asset(fa):
|
||||
raise ValueError(f"素材「{label or fa.name}」尚在处理中,请稍后重试")
|
||||
if fa.status != FreeAsset.Status.ACTIVE or not fa.remote_asset_id:
|
||||
raise ValueError(f"素材「{label or fa.name}」尚未就绪,请稍后重试")
|
||||
resolved_url = f"asset://{_normalize_remote_asset_id(fa.remote_asset_id)}"
|
||||
kind = {"Video": "video", "Audio": "audio"}.get(fa.asset_type, "image")
|
||||
asset_type = _push(kind, resolved_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), fa.duration or 0.0)
|
||||
if label and label not in label_to_placeholder:
|
||||
label_to_placeholder[label] = _placeholder_for(asset_type)
|
||||
continue
|
||||
|
||||
# 素材组引用:展开组内全部 active 素材(一个 label 对应 N 素材,语义变化 → 不登记 label)
|
||||
if source == "library_group" and ref.get("group_id"):
|
||||
gid = str(ref["group_id"])
|
||||
if gid not in group_cache:
|
||||
group = FreeAssetGroup.objects.filter(id=gid, team=team, is_deleted=False).first()
|
||||
group_cache[gid] = _resolve_group_assets(group) if group else []
|
||||
asset_list = group_cache[gid]
|
||||
if not asset_list:
|
||||
raise ValueError(f"素材「{label or '未命名'}」尚未就绪,请在素材库中确认状态为「可用」后重试")
|
||||
for asset_url, asset_type, dur in asset_list:
|
||||
kind = {"Video": "video", "Audio": "audio"}.get(asset_type, "image")
|
||||
_push(kind, asset_url, "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image"), dur)
|
||||
continue
|
||||
|
||||
# 直传素材(已上传 TOS 的直链)
|
||||
if ref_type == "image":
|
||||
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
|
||||
effective_role = "reference_image" if mode == "universal" else (role or "first_frame")
|
||||
asset_type = _push("image", url, effective_role)
|
||||
elif ref_type == "video":
|
||||
asset_type = _push("video", url, role or "reference_video", duration)
|
||||
elif ref_type == "audio":
|
||||
asset_type = _push("audio", url, role or "reference_audio", duration)
|
||||
else:
|
||||
logger.warning("unknown ref_type=%s url=%s label=%s, skipped", ref_type, url, label)
|
||||
continue
|
||||
|
||||
if label and label not in label_to_placeholder:
|
||||
label_to_placeholder[label] = _placeholder_for(asset_type)
|
||||
|
||||
if image_n > 9:
|
||||
raise ValueError(f"参考图片最多 9 张(含素材库引用,同一素材按 1 张计算),当前 {image_n} 张,请减少后重试")
|
||||
if video_n > 3:
|
||||
raise ValueError(f"参考视频最多 3 条,当前 {video_n} 条,请减少后重试")
|
||||
if audio_n > 3:
|
||||
raise ValueError(f"参考音频最多 3 条,当前 {audio_n} 条,请减少后重试")
|
||||
if audio_n > 0 and image_n + video_n == 0:
|
||||
raise ValueError("音频不能单独作为参考素材,请同时提供参考图片或视频")
|
||||
if video_duration_total > 15:
|
||||
raise ValueError("参考视频总时长不能超过 15 秒,请缩短后重试")
|
||||
|
||||
# @label 替换:按 label 长度降序,防子串吞噬
|
||||
ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True)
|
||||
api_prompt = _format_prompt_for_ark(prompt, ordered)
|
||||
|
||||
return {
|
||||
"content_items": content_items,
|
||||
"api_prompt": api_prompt,
|
||||
"snapshots": snapshots,
|
||||
"image_n": image_n,
|
||||
"video_n": video_n,
|
||||
"audio_n": audio_n,
|
||||
"video_duration_total": video_duration_total,
|
||||
}
|
||||
|
||||
|
||||
def _reap_stale_free_video_tasks(*, team) -> None:
|
||||
"""僵尸回收(趁每次新提交顺手做,无需定时任务):
|
||||
· RESERVED 超 10 分钟:没提交到火山就死(worker 崩溃/进程重启)→ 标失败退费;
|
||||
· SUBMITTED/POLLING 超 2 小时:轮询链早已断且无人认领(正常出片 5-10 分钟)→ 标失败退费;
|
||||
· POSTPROCESSING 超 30 分钟:转存/结算中途崩溃 → 标失败退费(火山可能已出片,平台承担该笔成本)。"""
|
||||
now = timezone.now()
|
||||
buckets = [
|
||||
([AITask.Status.RESERVED], now - timedelta(minutes=10), "任务未在预期时间内提交(自动回收)"),
|
||||
([AITask.Status.SUBMITTED, AITask.Status.POLLING], now - timedelta(hours=2), "生成超时(自动回收)"),
|
||||
([AITask.Status.POSTPROCESSING], now - timedelta(minutes=30), "视频结果处理超时(自动回收)"),
|
||||
]
|
||||
for statuses, cutoff, reason in buckets:
|
||||
stale = AITask.objects.filter(
|
||||
team=team,
|
||||
project__isnull=True,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=statuses,
|
||||
updated_at__lt=cutoff,
|
||||
)
|
||||
for task in stale:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status not in statuses:
|
||||
continue
|
||||
locked.status = AITask.Status.FAILED
|
||||
locked.error_message = reason
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
try:
|
||||
reservation = locked.credit_reservation
|
||||
except ObjectDoesNotExist:
|
||||
reservation = None
|
||||
if reservation is not None:
|
||||
release_credit(reservation=reservation, reason=reason)
|
||||
except Exception: # noqa: BLE001 — 单个回收失败不应阻断新提交
|
||||
logger.warning("reap stale free video task %s failed", task.id, exc_info=True)
|
||||
continue
|
||||
|
||||
|
||||
def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
"""提交一条自由创作视频。校验/估价/预留在前(失败不留半套),火山调用在事务外。
|
||||
创建失败不抛:返回 FAILED 任务(带友好中文错误),前端渲染失败卡。校验类错误抛 ValueError → 400。"""
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
mode = str(params.get("mode") or "universal")
|
||||
model_name = str(params.get("model") or HIGH_RES_MODEL)
|
||||
aspect_ratio = str(params.get("aspect_ratio") or "16:9")
|
||||
resolution = str(params.get("resolution") or "720p")
|
||||
generate_audio = bool(params.get("generate_audio", True))
|
||||
search_mode = str(params.get("search_mode") or "off")
|
||||
references = params.get("references") or []
|
||||
try:
|
||||
duration = int(params.get("duration") or 5)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("时长参数无效")
|
||||
try:
|
||||
seed = int(params.get("seed") if params.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
|
||||
if not prompt:
|
||||
raise ValueError("提示词不能为空")
|
||||
if mode not in MODES:
|
||||
raise ValueError("生成模式无效")
|
||||
if model_name not in FREE_VIDEO_MODELS:
|
||||
raise ValueError("模型无效")
|
||||
if aspect_ratio not in RATIOS:
|
||||
raise ValueError("画面比例无效")
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise ValueError("分辨率无效")
|
||||
if not 4 <= duration <= 15:
|
||||
raise ValueError("视频时长需在 4-15 秒之间")
|
||||
if resolution in ("1080p", "4k") and model_name != HIGH_RES_MODEL:
|
||||
raise ValueError(f"{resolution} 仅标准档模型支持,请切换模型或降低分辨率")
|
||||
get_resolution(aspect_ratio, resolution) # 组合合法性 fail loud
|
||||
|
||||
orphan = find_orphan_material_mention(prompt, references)
|
||||
if orphan:
|
||||
raise ValueError(f"「{orphan}」对应的内容为空,请补充素材或删除该引用")
|
||||
|
||||
if mode == "keyframe":
|
||||
roles = [str(r.get("role") or "") for r in references]
|
||||
if any(str(r.get("type") or "image") != "image" for r in references):
|
||||
raise ValueError("首尾帧模式仅支持图片素材")
|
||||
if "first_frame" not in roles:
|
||||
raise ValueError("首尾帧模式需要提供首帧图片")
|
||||
if len(references) > 2:
|
||||
raise ValueError("首尾帧模式最多提供首帧和尾帧各一张图片")
|
||||
|
||||
model_config = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(name=model_name, capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
|
||||
.first()
|
||||
)
|
||||
if model_config is None:
|
||||
raise ValueError("视频模型未配置,请联系管理员")
|
||||
|
||||
_reap_stale_free_video_tasks(team=team)
|
||||
|
||||
# 团队并发闸(移植 jimeng Layer2.6):视频是长时高价任务,必须限并发
|
||||
max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3))
|
||||
in_flight = AITask.objects.filter(
|
||||
team=team, task_type=AITask.Type.FREE_VIDEO, status__in=IN_FLIGHT_STATUSES
|
||||
).count()
|
||||
if in_flight >= max_concurrent:
|
||||
raise ValueError(f"当前有 {in_flight} 个视频任务进行中(上限 {max_concurrent}),请等待完成后再提交")
|
||||
|
||||
built = build_content_items(team=team, prompt=prompt, mode=mode, references=references)
|
||||
|
||||
tokens, cost = estimate_video_cost(
|
||||
model_config,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
duration=duration,
|
||||
references=built["snapshots"],
|
||||
)
|
||||
reserve_amount = (cost * RESERVE_BUFFER).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
request_payload = {
|
||||
"feature": "free_video",
|
||||
"mode": mode,
|
||||
"model": model_name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"prompt": prompt,
|
||||
"api_prompt": built["api_prompt"],
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"duration": duration,
|
||||
"seed": seed,
|
||||
"generate_audio": generate_audio,
|
||||
"search_mode": search_mode,
|
||||
"estimated_tokens": tokens,
|
||||
"references": built["snapshots"],
|
||||
}
|
||||
|
||||
# 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套
|
||||
with transaction.atomic():
|
||||
task = AITask.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
project=None,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
|
||||
request_payload=request_payload,
|
||||
estimated_cost=cost,
|
||||
)
|
||||
try:
|
||||
reserve_credit(team=team, user=user, task=task, amount=reserve_amount)
|
||||
except ValueError as exc:
|
||||
if "insufficient credit" in str(exc):
|
||||
raise ValueError("团队余额不足,请充值后重试") from exc
|
||||
raise
|
||||
task.status = AITask.Status.RESERVED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
|
||||
# 火山调用在事务外(不持锁调外网)
|
||||
try:
|
||||
from .services import build_provider
|
||||
|
||||
provider = build_provider(model_config)
|
||||
response = provider.create_video_task(
|
||||
model=model_config.name,
|
||||
endpoint=model_config.endpoint,
|
||||
prompt=built["api_prompt"],
|
||||
ratio=aspect_ratio,
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
generate_audio=generate_audio,
|
||||
content_items=built["content_items"],
|
||||
seed=seed if seed != -1 else None,
|
||||
search_mode=search_mode,
|
||||
)
|
||||
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
|
||||
task.response_payload = response
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
|
||||
except Exception as exc: # noqa: BLE001 — 创建失败:标失败退费,返回失败卡(不向上抛)
|
||||
code, raw_message = parse_provider_error(exc)
|
||||
friendly = map_video_error(code, raw_message)
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_code = (code or "CreateTaskError")[:64]
|
||||
task.error_message = friendly
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=task.credit_reservation, reason=friendly)
|
||||
_notify_failure(task, raw=f"[{code}] {raw_message}" if code else raw_message)
|
||||
logger.warning("free video create failed: %s", exc)
|
||||
return task
|
||||
|
||||
# worker 兜底轮询(自重排);派发失败仅 log,前端主动 poll 仍能收尾
|
||||
try:
|
||||
from .tasks import poll_free_video_task
|
||||
|
||||
poll_free_video_task.apply_async(args=[str(task.id), 0], countdown=30)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.error("poll_free_video_task enqueue failed; relying on client polling", exc_info=True)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def _notify_failure(task: AITask, *, raw: str) -> None:
|
||||
from .services import notify_generation_failure
|
||||
|
||||
notify_generation_failure(
|
||||
task=task,
|
||||
project=None,
|
||||
recipient=task.created_by,
|
||||
stage_label="自由创作视频",
|
||||
raw=raw,
|
||||
hint=task.error_message,
|
||||
)
|
||||
|
||||
|
||||
def _store_free_video_media(*, task: AITask, media: str) -> Asset:
|
||||
"""下载火山结果 → 转存 TOS(火山原始 URL 仅 7 天有效)→ 建 Asset(FREE_CREATE,自动入库)
|
||||
+ ffmpeg 抽首帧封面挂同 Asset 非主文件。"""
|
||||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||||
if "video" not in content_type:
|
||||
content_type = "video/mp4"
|
||||
# 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛
|
||||
# "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。
|
||||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
prompt = (task.request_payload or {}).get("prompt") or ""
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=task.team,
|
||||
created_by=task.created_by,
|
||||
name=(prompt[:50] or "自由创作视频"),
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.FREE_CREATE,
|
||||
origin_task=task,
|
||||
metadata={"feature": "free_video"},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
# 首帧封面(best-effort):任务流/资产库缩略图
|
||||
try:
|
||||
if video_bytes:
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-poster-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
video_path = tmp_dir / "in.mp4"
|
||||
poster_path = tmp_dir / "poster.jpg"
|
||||
video_path.write_bytes(video_bytes)
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if proc.returncode == 0 and poster_path.exists() and poster_path.stat().st_size:
|
||||
poster_key = f"teams/{task.team_id}/free-create/{asset_id}-poster.jpg"
|
||||
poster_stored = TosStorage().upload_fileobj(
|
||||
fileobj=BytesIO(poster_path.read_bytes()), object_key=poster_key, content_type="image/jpeg"
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=poster_stored.object_key,
|
||||
bucket=poster_stored.bucket,
|
||||
content_type=poster_stored.content_type,
|
||||
size_bytes=poster_stored.size_bytes,
|
||||
is_primary=False,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 封面仅用于展示,失败不阻断
|
||||
logger.warning("free video poster extract failed for task %s", task.id, exc_info=True)
|
||||
return asset
|
||||
|
||||
|
||||
def finalize_free_video(*, task: AITask) -> AITask:
|
||||
"""单次轮询 + 幂等终态化。web poll 端点与 worker 兜底共用。
|
||||
|
||||
防双 poll 双扣:succeeded 时先持锁把任务从 SUBMITTED/POLLING「认领」成 POSTPROCESSING,
|
||||
并发的另一路看到 POSTPROCESSING 直接返回;认领者独占 下载→TOS→建资产→结算 全程。
|
||||
认领后崩溃由 _reap_stale_free_video_tasks(30 分钟)兜底退费。"""
|
||||
if task.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
return task
|
||||
if not task.provider_task_id:
|
||||
return task
|
||||
|
||||
from .services import build_provider
|
||||
|
||||
provider = build_provider(task.model_config)
|
||||
response = provider.poll_video_task(
|
||||
endpoint=task.model_config.endpoint, provider_task_id=task.provider_task_id
|
||||
)
|
||||
remote_status = str(response.get("status") or "")
|
||||
|
||||
if remote_status in {"queued", "running", "processing", "submitted"}:
|
||||
# 仍在生成:只在首次进入 POLLING 时落一次库(不逐次回写完整 response,省写带宽)
|
||||
if task.status != AITask.Status.POLLING:
|
||||
task.status = AITask.Status.POLLING
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
return task
|
||||
|
||||
if remote_status in {"failed", "expired", "cancelled"}:
|
||||
err = response.get("error") or {}
|
||||
code = str(err.get("code") or "")
|
||||
raw_message = str(err.get("message") or "video generation failed")
|
||||
friendly = map_video_error(code, raw_message)
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
return locked
|
||||
locked.status = AITask.Status.FAILED
|
||||
locked.error_code = code[:64]
|
||||
locked.error_message = friendly
|
||||
locked.response_payload = response
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(
|
||||
update_fields=["status", "error_code", "error_message", "response_payload", "completed_at", "updated_at"]
|
||||
)
|
||||
release_credit(reservation=locked.credit_reservation, reason=friendly)
|
||||
_notify_failure(locked, raw=f"[{code}] {raw_message}")
|
||||
return locked
|
||||
|
||||
# succeeded —— 认领 POSTPROCESSING(并发 finalize 只有一路进入慢活)
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
return locked
|
||||
locked.status = AITask.Status.POSTPROCESSING
|
||||
locked.save(update_fields=["status", "updated_at"])
|
||||
|
||||
payload = dict(locked.request_payload or {})
|
||||
try:
|
||||
media = provider.extract_first_media_url(response)
|
||||
try:
|
||||
_store_free_video_media(task=locked, media=media)
|
||||
except Exception: # noqa: BLE001 — TOS 转存失败:兜底记火山临时 URL(约 24h 内可看),不吞成功
|
||||
logger.exception("free video TOS store failed for task %s, falling back to raw url", locked.id)
|
||||
payload["fallback_video_url"] = media
|
||||
payload["fallback_note"] = "结果转存失败,当前链接约 24 小时后失效"
|
||||
|
||||
usage = response.get("usage") or {}
|
||||
try:
|
||||
total_tokens = int(usage.get("total_tokens") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total_tokens = 0
|
||||
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or [])
|
||||
resolution = payload.get("resolution") or "720p"
|
||||
if total_tokens > 0:
|
||||
actual = tokens_to_cost(
|
||||
locked.model_config, total_tokens, with_video_ref=with_video_ref, resolution=resolution
|
||||
)
|
||||
payload["actual_tokens"] = total_tokens
|
||||
else:
|
||||
actual = locked.estimated_cost
|
||||
seed_out = response.get("seed")
|
||||
if seed_out is not None:
|
||||
payload["seed_used"] = seed_out
|
||||
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=locked.id)
|
||||
if locked.status != AITask.Status.POSTPROCESSING:
|
||||
return locked
|
||||
reservation = locked.credit_reservation
|
||||
if actual > reservation.amount:
|
||||
# ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 RESERVE_BUFFER)
|
||||
logger.warning(
|
||||
"free video task %s actual cost %s exceeds reserved %s, clamped",
|
||||
locked.id, actual, reservation.amount,
|
||||
)
|
||||
actual = reservation.amount
|
||||
locked.status = AITask.Status.SUCCEEDED
|
||||
locked.actual_cost = actual
|
||||
locked.request_payload = payload
|
||||
locked.response_payload = response
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(
|
||||
update_fields=["status", "actual_cost", "request_payload", "response_payload", "completed_at", "updated_at"]
|
||||
)
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=actual)
|
||||
return locked
|
||||
except Exception as exc: # noqa: BLE001 — 后处理失败:标失败退费(release 幂等,已扣则不动)
|
||||
logger.exception("free video finalize failed for task %s", locked.id)
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=locked.id)
|
||||
if locked.status != AITask.Status.POSTPROCESSING:
|
||||
return locked
|
||||
locked.status = AITask.Status.FAILED
|
||||
locked.error_code = "PostprocessError"
|
||||
locked.error_message = "视频结果处理失败,请重试"
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=locked.credit_reservation, reason=str(exc)[:200])
|
||||
_notify_failure(locked, raw=str(exc))
|
||||
return locked
|
||||
|
||||
|
||||
def serialize_free_video_task(task: AITask) -> dict:
|
||||
"""任务 → 前端契约。视频/封面直链从 generated_assets 取(TOS 公读),转存失败回落火山临时 URL。"""
|
||||
payload = task.request_payload or {}
|
||||
video_url = ""
|
||||
thumbnail_url = ""
|
||||
try:
|
||||
storage = TosStorage() if settings.TOS.get("endpoint") else None
|
||||
except Exception: # noqa: BLE001
|
||||
storage = None
|
||||
|
||||
def _file_url(f) -> str:
|
||||
if f.preview_url:
|
||||
return f.preview_url
|
||||
if storage and f.object_key:
|
||||
try:
|
||||
return storage.public_url(object_key=f.object_key, bucket=f.bucket or None)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
return ""
|
||||
|
||||
for asset in task.generated_assets.all():
|
||||
if asset.is_deleted:
|
||||
continue
|
||||
for f in asset.files.all():
|
||||
url = _file_url(f)
|
||||
if not url:
|
||||
continue
|
||||
if f.is_primary and not video_url:
|
||||
video_url = url
|
||||
elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url:
|
||||
thumbnail_url = url
|
||||
if not video_url:
|
||||
video_url = payload.get("fallback_video_url") or ""
|
||||
|
||||
return {
|
||||
"id": str(task.id),
|
||||
"status": task.status,
|
||||
"mode": payload.get("mode") or "universal",
|
||||
"model": payload.get("model") or "",
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"aspect_ratio": payload.get("aspect_ratio") or "16:9",
|
||||
"resolution": payload.get("resolution") or "720p",
|
||||
"duration": payload.get("duration") or 5,
|
||||
"seed": payload.get("seed", -1),
|
||||
"seed_used": payload.get("seed_used"),
|
||||
"generate_audio": payload.get("generate_audio", True),
|
||||
"references": payload.get("references") or [],
|
||||
"estimated_tokens": payload.get("estimated_tokens") or 0,
|
||||
"actual_tokens": payload.get("actual_tokens") or 0,
|
||||
"estimated_cost": str(task.estimated_cost),
|
||||
"actual_cost": str(task.actual_cost),
|
||||
"error_message": task.error_message or "",
|
||||
"fallback_note": payload.get("fallback_note") or "",
|
||||
"is_favorited": task.is_favorited,
|
||||
"video_url": video_url,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""自由创作·上传素材探测(移植自 jimeng-clone utils/media_utils.py)。
|
||||
|
||||
ffprobe 取视频/音频时长、ffmpeg 抽视频首帧缩略图。生产镜像已带 ffmpeg
|
||||
(_generate_video_poster 在用)。全部 best-effort:探测失败返回 None,由调用方决定拒绝或放行。
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def probe_duration(file_path: str) -> float | None:
|
||||
"""ffprobe 取媒体时长(秒)。失败返回 None。"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(file_path),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return float(proc.stdout.decode().strip())
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def extract_video_poster(file_path: str) -> bytes | None:
|
||||
"""ffmpeg 抽视频首帧 jpg 字节。失败返回 None。"""
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-freeprobe-") as tmp:
|
||||
poster_path = Path(tmp) / "poster.jpg"
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-ss", "0", "-i", str(file_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if proc.returncode != 0 or not poster_path.exists():
|
||||
return None
|
||||
data = poster_path.read_bytes()
|
||||
return data or None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-02 07:23
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_team_monthly_credit_limit"),
|
||||
("ai", "0021_rename_ai_aitask_team_read_idx_ai_aitask_team_id_d668d5_idx"),
|
||||
("projects", "0006_migrate_storyboard_to_shots"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="aitask",
|
||||
name="is_deleted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aitask",
|
||||
name="is_favorited",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="aitask",
|
||||
name="task_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("script_generation", "Script Generation"),
|
||||
("script_optimization", "Script Optimization"),
|
||||
("entity_extraction", "Entity Extraction"),
|
||||
("product_image", "Product Image"),
|
||||
("person_image", "Person Image"),
|
||||
("scene_image", "Scene Image"),
|
||||
("storyboard", "Storyboard"),
|
||||
("video_segment", "Video Segment"),
|
||||
("voiceover", "Voiceover"),
|
||||
("export", "Export"),
|
||||
("free_video", "Free Video"),
|
||||
],
|
||||
max_length=48,
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="aitask",
|
||||
index=models.Index(
|
||||
fields=["team", "task_type", "-created_at"],
|
||||
name="ai_aitask_team_id_6a4627_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Seed 自由创作视频三档模型(Seedance 2.0 标准/Fast/Mini)+ token 定价表。
|
||||
|
||||
- 定价存 ModelConfig.metadata["pricing"](元/百万tokens,按 分辨率档 × 是否含视频参考 取价),
|
||||
与 apps/ai/catalog.py 保持一致;数字来源火山 Seedance 2.0 官方价目。
|
||||
- 标准档已存在(catalog bootstrap 种的),只 merge pricing/resolutions 进 metadata,不覆盖其它键;
|
||||
fast/mini 新建,is_default=False 且 created_at 晚于现有视频模型 →
|
||||
get_default_model(VIDEO) 仍取原默认,pipeline 视频链路零回归。
|
||||
- 幂等:可重复 apply。
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
STANDARD_PRICING = {
|
||||
"unit": "cny_per_million_tokens",
|
||||
"default": {"no_ref_video": 46, "with_ref_video": 28},
|
||||
"1080p": {"no_ref_video": 51, "with_ref_video": 31},
|
||||
"4k": {"no_ref_video": 26, "with_ref_video": 16},
|
||||
}
|
||||
|
||||
NEW_MODELS = [
|
||||
# name, display_name, pricing
|
||||
(
|
||||
"doubao-seedance-2-0-fast-260128",
|
||||
"Seedance-2.0-Fast",
|
||||
{"unit": "cny_per_million_tokens", "default": {"no_ref_video": 37, "with_ref_video": 22}},
|
||||
),
|
||||
(
|
||||
"doubao-seedance-2-0-mini-260615",
|
||||
"Seedance-2.0-Mini",
|
||||
{"unit": "cny_per_million_tokens", "default": {"no_ref_video": 23, "with_ref_video": 14}},
|
||||
),
|
||||
]
|
||||
|
||||
COMMON_METADATA = {
|
||||
"audio": "optional",
|
||||
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
|
||||
"durations": list(range(4, 16)),
|
||||
"resolutions": ["480p", "720p"],
|
||||
"watermark": False,
|
||||
"source": "jimeng-clone/backend/utils/airdrama_client.py",
|
||||
}
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
ModelProvider = apps.get_model("ai", "ModelProvider")
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
|
||||
provider, _ = ModelProvider.objects.get_or_create(
|
||||
name="volcengine",
|
||||
defaults={
|
||||
"display_name": "火山引擎(豆包)",
|
||||
"status": "active",
|
||||
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
)
|
||||
|
||||
# 标准档:merge pricing + 扩分辨率,不动 status/is_default/unit_price/其它 metadata
|
||||
standard = ModelConfig.objects.filter(
|
||||
provider=provider, name="doubao-seedance-2-0-260128", capability="video"
|
||||
).first()
|
||||
if standard:
|
||||
meta = dict(standard.metadata or {})
|
||||
meta["pricing"] = STANDARD_PRICING
|
||||
meta["resolutions"] = ["480p", "720p", "1080p", "4k"]
|
||||
standard.metadata = meta
|
||||
standard.save(update_fields=["metadata"])
|
||||
else:
|
||||
meta = dict(COMMON_METADATA)
|
||||
meta["pricing"] = STANDARD_PRICING
|
||||
meta["resolutions"] = ["480p", "720p", "1080p", "4k"]
|
||||
ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="doubao-seedance-2-0-260128",
|
||||
capability="video",
|
||||
display_name="Seedance-2.0",
|
||||
endpoint="contents/generations/tasks",
|
||||
status="active",
|
||||
metadata=meta,
|
||||
)
|
||||
|
||||
for name, display, pricing in NEW_MODELS:
|
||||
meta = dict(COMMON_METADATA)
|
||||
meta["pricing"] = pricing
|
||||
obj, created = ModelConfig.objects.get_or_create(
|
||||
provider=provider,
|
||||
name=name,
|
||||
capability="video",
|
||||
defaults={
|
||||
"display_name": display,
|
||||
"endpoint": "contents/generations/tasks",
|
||||
"status": "active",
|
||||
"metadata": meta,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
existing = dict(obj.metadata or {})
|
||||
existing.setdefault("resolutions", meta["resolutions"])
|
||||
existing.setdefault("durations", meta["durations"])
|
||||
existing["pricing"] = pricing
|
||||
obj.metadata = existing
|
||||
if obj.status != "active":
|
||||
obj.status = "active"
|
||||
obj.save(update_fields=["metadata", "status"])
|
||||
else:
|
||||
obj.save(update_fields=["metadata"])
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
# 保守反向:只停用本迁移新建的两档,不删数据、不动标准档 metadata
|
||||
ModelConfig = apps.get_model("ai", "ModelConfig")
|
||||
ModelConfig.objects.filter(
|
||||
provider__name="volcengine",
|
||||
capability="video",
|
||||
name__in=[n for n, _, _ in NEW_MODELS],
|
||||
).update(status="disabled")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("ai", "0022_aitask_is_deleted_aitask_is_favorited_and_more")]
|
||||
operations = [migrations.RunPython(seed, unseed)]
|
||||
@@ -97,6 +97,8 @@ class AITask(TeamOwnedModel):
|
||||
VIDEO_SEGMENT = "video_segment", "Video Segment"
|
||||
VOICEOVER = "voiceover", "Voiceover"
|
||||
EXPORT = "export", "Export"
|
||||
# 自由创作(不绑 project 的独立视频生成,universal 全能参考 / keyframe 首尾帧)
|
||||
FREE_VIDEO = "free_video", "Free Video"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
CREATED = "created", "Created"
|
||||
@@ -140,6 +142,9 @@ class AITask(TeamOwnedModel):
|
||||
# YYX#row22:用户「已读」时间。null = 未读 → 用于导航栏「图片生成」未读数字胶囊
|
||||
# 与每个商品预览右下角的未读分数。团队级共享(一人看过即全团队已读)。
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
# 自由创作任务流的收藏 / 软删(其它任务类型恒 False,无行为影响)
|
||||
is_favorited = models.BooleanField(default=False)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
@@ -152,6 +157,8 @@ class AITask(TeamOwnedModel):
|
||||
models.Index(fields=["conversation", "created_at"]),
|
||||
# YYX#row22:按团队 + 已读状态聚合未读数(导航/商品角标)
|
||||
models.Index(fields=["team", "read_at"]),
|
||||
# 自由创作任务流:按团队 + 类型倒序分页
|
||||
models.Index(fields=["team", "task_type", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -192,12 +192,19 @@ class VolcanoArkProvider:
|
||||
resolution: str = "720p",
|
||||
reference_images: list[str] | None = None,
|
||||
generate_audio: bool = True,
|
||||
content_items: list[dict[str, Any]] | None = None,
|
||||
seed: int | None = None,
|
||||
search_mode: str = "off",
|
||||
) -> dict[str, Any]:
|
||||
if not self.api_key:
|
||||
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for image_url in reference_images or []:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
|
||||
if content_items is not None:
|
||||
# 自由创作:调用方整段接管参考素材(混合 image/video/audio + role first_frame/last_frame 等)
|
||||
content.extend(content_items)
|
||||
else:
|
||||
for image_url in reference_images or []:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
|
||||
body = {
|
||||
"model": model,
|
||||
"content": content,
|
||||
@@ -208,6 +215,10 @@ class VolcanoArkProvider:
|
||||
# Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。
|
||||
"generate_audio": generate_audio,
|
||||
}
|
||||
if seed is not None and seed != -1:
|
||||
body["seed"] = seed
|
||||
if search_mode == "smart":
|
||||
body["tools"] = [{"type": "web_search"}]
|
||||
response = requests.post(
|
||||
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
|
||||
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||||
@@ -236,9 +247,24 @@ class VolcanoArkProvider:
|
||||
return item["url"]
|
||||
if item.get("b64_json"):
|
||||
return item["b64_json"]
|
||||
content = data.get("content") or {}
|
||||
if content.get("video_url"):
|
||||
return content["video_url"]
|
||||
# 视频任务响应的 content 有两种形态:dict {video_url: "..."} 或
|
||||
# list [{type:"video_url", video_url:{url:"..."}}](Seedance 2.0 多模态响应)。
|
||||
content = data.get("content")
|
||||
if isinstance(content, dict):
|
||||
video_url = content.get("video_url")
|
||||
if isinstance(video_url, str) and video_url:
|
||||
return video_url
|
||||
if isinstance(video_url, dict) and video_url.get("url"):
|
||||
return video_url["url"]
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
video_url = item.get("video_url")
|
||||
if isinstance(video_url, str) and video_url:
|
||||
return video_url
|
||||
if isinstance(video_url, dict) and video_url.get("url"):
|
||||
return video_url["url"]
|
||||
raise ValueError("Volcano response does not contain media url")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -698,6 +698,9 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
||||
suffix = ".jpg"
|
||||
elif "webp" in content_type:
|
||||
suffix = ".webp"
|
||||
# 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 抛
|
||||
# "I/O operation on closed file" 被下面的 except 吞掉 → 视频封面一直静默抽不出来(自由创作联调实测)。
|
||||
raw_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
@@ -721,11 +724,7 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str,
|
||||
)
|
||||
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
|
||||
if "video" in content_type:
|
||||
try:
|
||||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||||
except Exception: # noqa: BLE001
|
||||
video_bytes = b""
|
||||
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
|
||||
poster = _generate_video_poster(video_bytes=raw_bytes, team=team, project=project, asset_id=asset_id)
|
||||
if poster:
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
|
||||
@@ -50,3 +50,30 @@ def generate_triview_task(self, task_id: str) -> str:
|
||||
run_triview_task(task_id=task_id)
|
||||
return task_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0)
|
||||
def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
|
||||
"""自由创作视频·worker 兜底轮询:每 30s 一次自重排(不依赖 celery beat),
|
||||
上限 60 次(≈30 分钟,足够 Seedance 5-10 分钟出片)。finalize 幂等(POSTPROCESSING 认领),
|
||||
与前端主动 poll 并存不双扣。轮询本身出错不重试(max_retries=0),下一次自重排继续。"""
|
||||
from apps.ai.free_video import finalize_free_video
|
||||
from apps.ai.models import AITask
|
||||
|
||||
task = AITask.objects.select_related("model_config", "model_config__provider", "team").filter(id=task_id).first()
|
||||
if task is None:
|
||||
return task_id
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动等)不终结任务,等下一轮
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("poll_free_video_task %s attempt %s failed", task_id, attempt, exc_info=True)
|
||||
# eager(本地联调/单测)下 apply_async 会内联立即执行,自重排=同步死循环 → 跳过,收尾交给前端主动 poll
|
||||
from django.conf import settings as dj_settings
|
||||
|
||||
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False):
|
||||
return task_id
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < 60:
|
||||
poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30)
|
||||
return task_id
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""自由创作视频(free_video)单测:估价 / @替换 / 提交与退费 / 终态幂等 / 并发闸 / 僵尸回收 / API。
|
||||
|
||||
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_free_video --settings=airshelf.settings.test
|
||||
provider 全程 mock(不触网);模型行由迁移 0023 种子提供。
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.free_video import (
|
||||
build_content_items,
|
||||
finalize_free_video,
|
||||
find_orphan_material_mention,
|
||||
submit_free_video,
|
||||
)
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.video_pricing import (
|
||||
RESERVE_BUFFER,
|
||||
calculate_cost,
|
||||
estimate_tokens,
|
||||
estimate_video_cost,
|
||||
get_resolution,
|
||||
get_token_price,
|
||||
)
|
||||
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
|
||||
|
||||
STANDARD = "doubao-seedance-2-0-260128"
|
||||
FAST = "doubao-seedance-2-0-fast-260128"
|
||||
MINI = "doubao-seedance-2-0-mini-260615"
|
||||
|
||||
|
||||
def _model(name=STANDARD) -> ModelConfig:
|
||||
return ModelConfig.objects.get(name=name, capability="video")
|
||||
|
||||
|
||||
class VideoPricingTests(TestCase):
|
||||
"""token 公式与分档取价(与 jimeng billing.py / QuotaConfig 数字对齐)。"""
|
||||
|
||||
def test_resolution_map_spot_checks(self):
|
||||
self.assertEqual(get_resolution("16:9", "720p"), (1280, 720))
|
||||
self.assertEqual(get_resolution("9:16", "480p"), (496, 864))
|
||||
self.assertEqual(get_resolution("21:9", "4k"), (4398, 1886))
|
||||
with self.assertRaises(KeyError):
|
||||
get_resolution("2:3", "720p")
|
||||
|
||||
def test_estimate_tokens_formula(self):
|
||||
# (1280×720×24×5)/1024 = 108000
|
||||
self.assertEqual(estimate_tokens(1280, 720, 5), 108000)
|
||||
# 输入参考视频时长计入
|
||||
self.assertEqual(estimate_tokens(1280, 720, 5, input_video_duration=3), estimate_tokens(1280, 720, 8))
|
||||
|
||||
def test_token_price_tiers(self):
|
||||
std = _model(STANDARD)
|
||||
self.assertEqual(get_token_price(std, False, "720p"), Decimal("46"))
|
||||
self.assertEqual(get_token_price(std, True, "720p"), Decimal("28"))
|
||||
self.assertEqual(get_token_price(std, False, "1080p"), Decimal("51"))
|
||||
self.assertEqual(get_token_price(std, True, "4k"), Decimal("16"))
|
||||
self.assertEqual(get_token_price(_model(FAST), False, "480p"), Decimal("37"))
|
||||
self.assertEqual(get_token_price(_model(MINI), True, "720p"), Decimal("14"))
|
||||
|
||||
def test_fast_1080p_fails_loud(self):
|
||||
# fast/mini 没有 1080p/4k 档价:绝不静默按 default 计费
|
||||
with self.assertRaises(ValueError):
|
||||
get_token_price(_model(FAST), False, "1080p")
|
||||
with self.assertRaises(ValueError):
|
||||
get_token_price(_model(MINI), True, "4k")
|
||||
|
||||
def test_estimate_video_cost_with_video_reference(self):
|
||||
std = _model(STANDARD)
|
||||
refs = [{"type": "video", "duration": 3.0}]
|
||||
tokens, cost = estimate_video_cost(std, aspect_ratio="16:9", resolution="720p", duration=5, references=refs)
|
||||
self.assertEqual(tokens, estimate_tokens(1280, 720, 5, input_video_duration=3.0))
|
||||
self.assertEqual(cost, calculate_cost(tokens, Decimal("28"))) # 含视频参考 → with_ref_video 价
|
||||
|
||||
|
||||
class BuildContentItemsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvowner", password="p")
|
||||
self.team = Team.objects.create(name="FV", owner=self.user)
|
||||
|
||||
def test_label_replacement_length_desc_no_substring_swallow(self):
|
||||
refs = [
|
||||
{"url": "http://x/a.png", "type": "image", "label": "碧"},
|
||||
{"url": "http://x/b.png", "type": "image", "label": "碧碧"},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="@碧碧 拥抱 @碧", mode="universal", references=refs)
|
||||
# 「碧碧」(图片2)必须先于「碧」(图片1)替换,否则被吞成「图片1碧」
|
||||
self.assertEqual(built["api_prompt"], "图片2 拥抱 图片1")
|
||||
|
||||
def test_counters_match_content_items(self):
|
||||
refs = [
|
||||
{"url": "http://x/1.png", "type": "image", "label": "图a"},
|
||||
{"url": "http://x/2.png", "type": "image", "label": "图b"},
|
||||
{"url": "http://x/v.mp4", "type": "video", "label": "视a", "duration": 3},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="@图a @图b @视a", mode="universal", references=refs)
|
||||
self.assertEqual(built["image_n"], 2)
|
||||
self.assertEqual(built["video_n"], 1)
|
||||
self.assertEqual(built["api_prompt"], "图片1 图片2 视频1")
|
||||
roles = [i.get("role") for i in built["content_items"]]
|
||||
self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"])
|
||||
self.assertEqual(built["video_duration_total"], 3.0)
|
||||
|
||||
def test_keyframe_roles(self):
|
||||
refs = [
|
||||
{"url": "http://x/f.png", "type": "image", "role": "first_frame"},
|
||||
{"url": "http://x/l.png", "type": "image", "role": "last_frame"},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="p", mode="keyframe", references=refs)
|
||||
self.assertEqual([i["role"] for i in built["content_items"]], ["first_frame", "last_frame"])
|
||||
|
||||
def test_too_many_images_rejected(self):
|
||||
refs = [{"url": f"http://x/{i}.png", "type": "image"} for i in range(10)]
|
||||
with self.assertRaisesMessage(ValueError, "最多 9 张"):
|
||||
build_content_items(team=self.team, prompt="p", mode="universal", references=refs)
|
||||
|
||||
def test_audio_alone_rejected(self):
|
||||
refs = [{"url": "http://x/a.mp3", "type": "audio", "duration": 5}]
|
||||
with self.assertRaisesMessage(ValueError, "音频不能单独"):
|
||||
build_content_items(team=self.team, prompt="p", mode="universal", references=refs)
|
||||
|
||||
def test_blob_url_rejected(self):
|
||||
refs = [{"url": "blob:http://x/abc", "type": "image", "label": "残"}]
|
||||
with self.assertRaisesMessage(ValueError, "上传失败"):
|
||||
build_content_items(team=self.team, prompt="p", mode="universal", references=refs)
|
||||
|
||||
def test_orphan_mention(self):
|
||||
self.assertIsNotNone(find_orphan_material_mention("让 @图片1 动起来", []))
|
||||
self.assertIsNone(find_orphan_material_mention("让 @图片1 动起来", [{"url": "u"}]))
|
||||
self.assertIsNone(find_orphan_material_mention("普通提示词", []))
|
||||
|
||||
|
||||
def _ark_create_response(task_id="ark-1"):
|
||||
return {"id": task_id, "status": "queued"}
|
||||
|
||||
|
||||
class SubmitFreeVideoTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvsub", password="p")
|
||||
self.team = Team.objects.create(name="FVS", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start() # eager celery 会同步跑,隔离掉
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _params(self, **over):
|
||||
base = {
|
||||
"prompt": "一只猫在海边奔跑",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
def test_submit_success_reserves_with_buffer(self):
|
||||
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||
self.assertEqual(task.provider_task_id, "ark-1")
|
||||
tokens, cost = estimate_video_cost(
|
||||
_model(STANDARD), aspect_ratio="16:9", resolution="480p", duration=4, references=[]
|
||||
)
|
||||
self.assertEqual(task.estimated_cost, cost)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.amount, (cost * RESERVE_BUFFER).quantize(Decimal("0.01")))
|
||||
# 提交参数按契约落 payload
|
||||
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
|
||||
self.assertEqual(task.request_payload["feature"], "free_video")
|
||||
|
||||
def test_insufficient_balance_leaves_nothing(self):
|
||||
CreditAccount.objects.filter(team=self.team).update(balance="0.0100")
|
||||
with self.assertRaisesMessage(ValueError, "余额不足"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
# 不留半套:任务行随事务回滚
|
||||
self.assertEqual(AITask.objects.filter(team=self.team, task_type=AITask.Type.FREE_VIDEO).count(), 0)
|
||||
|
||||
def test_provider_failure_marks_failed_and_releases(self):
|
||||
self.provider.create_video_task.side_effect = RuntimeError(
|
||||
"火山报错 [InputTextSensitiveContentDetected] text blocked"
|
||||
)
|
||||
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertEqual(task.error_message, "提示词包含敏感内容,请修改后重试")
|
||||
self.assertEqual(task.error_code, "InputTextSensitiveContentDetected")
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.reserved_balance, Decimal("0"))
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
|
||||
|
||||
def test_fast_1080p_rejected(self):
|
||||
with self.assertRaisesMessage(ValueError, "仅标准档"):
|
||||
submit_free_video(
|
||||
team=self.team, user=self.user, params=self._params(model=FAST, resolution="1080p")
|
||||
)
|
||||
|
||||
def test_orphan_mention_rejected(self):
|
||||
with self.assertRaisesMessage(ValueError, "对应的内容为空"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params(prompt="让 @图片1 动"))
|
||||
|
||||
def test_keyframe_requires_first_frame(self):
|
||||
with self.assertRaisesMessage(ValueError, "首帧"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params(mode="keyframe"))
|
||||
|
||||
def test_concurrency_gate(self):
|
||||
for _ in range(3):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
with self.assertRaisesMessage(ValueError, "上限"):
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
|
||||
def test_reap_stale_reserved_refunds(self):
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
# 人造僵尸:RESERVED 且 20 分钟没动静
|
||||
AITask.objects.filter(id=task.id).update(
|
||||
status=AITask.Status.RESERVED, updated_at=timezone.now() - timedelta(minutes=20)
|
||||
)
|
||||
submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
|
||||
|
||||
|
||||
class FinalizeFreeVideoTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvfin", password="p")
|
||||
self.team = Team.objects.create(name="FVF", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
self.provider.extract_first_media_url.return_value = "http://ark/video.mp4"
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
# 媒体落库(下载/TOS/建资产)单测里 mock 掉,聚焦状态机与账务
|
||||
self.store = patch("apps.ai.free_video._store_free_video_media").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
self.task = submit_free_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
params={
|
||||
"prompt": "海边",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_running_sets_polling_once(self):
|
||||
self.provider.poll_video_task.return_value = {"status": "running"}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.POLLING)
|
||||
|
||||
def test_failed_maps_error_and_releases(self):
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "failed",
|
||||
"error": {"code": "OutputVideoSensitiveContentDetected", "message": "output blocked"},
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertIn("已被系统拦截", task.error_message)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
|
||||
|
||||
def test_succeeded_charges_by_actual_tokens(self):
|
||||
# 真实 tokens 略低于预估:按真实结算,差额自动 RELEASE
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 30000},
|
||||
"seed": 42,
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
expected = calculate_cost(30000, Decimal("46"))
|
||||
self.assertEqual(task.actual_cost, expected)
|
||||
self.assertEqual(task.request_payload["seed_used"], 42)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("100.0000") - expected)
|
||||
self.assertEqual(account.reserved_balance, Decimal("0"))
|
||||
self.store.assert_called_once()
|
||||
|
||||
def test_succeeded_clamps_when_tokens_exceed_reserve(self):
|
||||
# 真实 tokens 远超预估:clamp 到预留额,不抛错、不超扣
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 10_000_000},
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(task.actual_cost, reservation.amount)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||
|
||||
def test_double_finalize_charges_once(self):
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 30000},
|
||||
}
|
||||
first = finalize_free_video(task=self.task)
|
||||
self.task.refresh_from_db()
|
||||
second = finalize_free_video(task=self.task)
|
||||
self.assertEqual(first.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(second.status, AITask.Status.SUCCEEDED)
|
||||
charges = CreditLedger.objects.filter(task=self.task, ledger_type=CreditLedger.Type.CHARGE).count()
|
||||
self.assertEqual(charges, 1)
|
||||
self.store.assert_called_once()
|
||||
|
||||
def test_store_failure_falls_back_to_raw_url_and_still_charges(self):
|
||||
# TOS 转存失败:不吞成功——记火山临时 URL + 正常结算(jimeng 同行为)
|
||||
self.store.side_effect = RuntimeError("tos down")
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 30000},
|
||||
}
|
||||
task = finalize_free_video(task=self.task)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.request_payload["fallback_video_url"], "http://ark/video.mp4")
|
||||
reservation = CreditReservation.objects.get(task=task)
|
||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||
|
||||
|
||||
class FreeVideoApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="fvapi", password="p")
|
||||
self.team = Team.objects.create(name="FVA", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active")
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.provider = MagicMock()
|
||||
self.provider.create_video_task.return_value = _ark_create_response()
|
||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _submit(self):
|
||||
return self.client.post(
|
||||
"/api/ai/free-video/",
|
||||
{
|
||||
"prompt": "一只猫",
|
||||
"mode": "universal",
|
||||
"model": STANDARD,
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "480p",
|
||||
"duration": 4,
|
||||
"references": [],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
def test_submit_and_list(self):
|
||||
resp = self._submit()
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
body = resp.json()["task"]
|
||||
self.assertEqual(body["status"], AITask.Status.SUBMITTED)
|
||||
self.assertEqual(body["model"], STANDARD)
|
||||
listing = self.client.get("/api/ai/free-video/").json()
|
||||
self.assertEqual(listing["total"], 1)
|
||||
self.assertEqual(listing["results"][0]["id"], body["id"])
|
||||
self.assertFalse(listing["has_more"])
|
||||
|
||||
def test_validation_error_returns_400(self):
|
||||
resp = self.client.post("/api/ai/free-video/", {"prompt": ""}, format="json")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_favorite_toggle_and_delete_rules(self):
|
||||
task_id = self._submit().json()["task"]["id"]
|
||||
fav = self.client.post(f"/api/ai/free-video/{task_id}/favorite/")
|
||||
self.assertTrue(fav.json()["is_favorited"])
|
||||
# 在途拒删
|
||||
resp = self.client.delete(f"/api/ai/free-video/{task_id}/")
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
AITask.objects.filter(id=task_id).update(status=AITask.Status.SUCCEEDED)
|
||||
resp = self.client.delete(f"/api/ai/free-video/{task_id}/")
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
listing = self.client.get("/api/ai/free-video/").json()
|
||||
self.assertEqual(listing["total"], 0)
|
||||
|
||||
def test_team_isolation(self):
|
||||
self._submit()
|
||||
stranger = User.objects.create_user(username="stranger", password="p")
|
||||
other_team = Team.objects.create(name="Other", owner=stranger)
|
||||
TeamMember.objects.create(team=other_team, user=stranger, role="owner", status="active")
|
||||
other = APIClient()
|
||||
other.force_authenticate(stranger)
|
||||
listing = other.get("/api/ai/free-video/").json()
|
||||
self.assertEqual(listing["total"], 0)
|
||||
|
||||
def test_poll_endpoint_finalizes(self):
|
||||
task_id = self._submit().json()["task"]["id"]
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "failed",
|
||||
"error": {"code": "InternalError", "message": "boom"},
|
||||
}
|
||||
resp = self.client.post(f"/api/ai/free-video/{task_id}/poll/")
|
||||
self.assertEqual(resp.json()["task"]["status"], AITask.Status.FAILED)
|
||||
self.assertIn("服务异常", resp.json()["task"]["error_message"])
|
||||
@@ -276,7 +276,7 @@ class StandaloneImageReferenceTests(TestCase):
|
||||
image_edit 的 gpt-image-2——否则参考图形同虚设。回归保护「选火山+传参考图=完全不参考」。"""
|
||||
from apps.ai.models import ModelProvider
|
||||
|
||||
vp = ModelProvider.objects.create(name="volcengine", display_name="火山")
|
||||
vp, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "火山"})
|
||||
ModelConfig.objects.create(provider=vp, name="seedream-4", display_name="Seedream", capability=ModelConfig.Capability.IMAGE)
|
||||
self._patch_provider()
|
||||
ref = Asset.objects.create(
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import AITaskViewSet, GenerateImageView, ImageConversationViewSet, ModelConfigViewSet
|
||||
from .views import (
|
||||
AITaskViewSet,
|
||||
FreeVideoDetailView,
|
||||
FreeVideoFavoriteView,
|
||||
FreeVideoPollView,
|
||||
FreeVideoUploadView,
|
||||
FreeVideoView,
|
||||
GenerateImageView,
|
||||
ImageConversationViewSet,
|
||||
ModelConfigViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("tasks", AITaskViewSet, basename="ai-task")
|
||||
@@ -10,4 +20,9 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
|
||||
|
||||
urlpatterns = [
|
||||
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
|
||||
path("free-video/", FreeVideoView.as_view(), name="ai-free-video"),
|
||||
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
|
||||
path("free-video/<uuid:task_id>/poll/", FreeVideoPollView.as_view(), name="ai-free-video-poll"),
|
||||
path("free-video/<uuid:task_id>/favorite/", FreeVideoFavoriteView.as_view(), name="ai-free-video-favorite"),
|
||||
path("free-video/<uuid:task_id>/", FreeVideoDetailView.as_view(), name="ai-free-video-detail"),
|
||||
] + router.urls
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""自由创作视频·火山错误码 → 用户友好中文映射(移植自 jimeng-clone utils/airdrama_client.py)。
|
||||
|
||||
两级匹配:① error code 精确匹配 ② message 关键词兜底。
|
||||
仅供 free_video 链路使用,不动现有 friendly_generation_error(避免影响项目视频链路文案)。
|
||||
原始报错(code + message)必须同时落 task.error_code / 日志,不允许只留友好文案吞根因
|
||||
(故事板 moderation_blocked 事故教训)。
|
||||
"""
|
||||
import re
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
# 输入内容审核 — 人脸/敏感内容
|
||||
"InputImageSensitiveContentDetected.PrivacyInformation": "参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照片",
|
||||
"InputImageSensitiveContentDetected": "参考图片包含敏感内容,请更换图片后重试",
|
||||
"InputVideoSensitiveContentDetected.PrivacyInformation": "参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频",
|
||||
"InputVideoSensitiveContentDetected": "参考视频包含敏感内容,请更换视频后重试",
|
||||
"InputTextSensitiveContentDetected": "提示词包含敏感内容,请修改后重试",
|
||||
"InputAudioSensitiveContentDetected": "参考音频包含敏感内容,请更换音频后重试",
|
||||
# 输出内容审核
|
||||
"OutputVideoSensitiveContentDetected": "生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试",
|
||||
"OutputVideoSensitiveContentDetected.PolicyViolation": "生成的视频涉及版权限制内容(如知名IP、名人肖像等),已被系统拦截,请修改提示词后重试",
|
||||
"OutputImageSensitiveContentDetected": "生成的图片包含敏感内容,已被系统拦截",
|
||||
# 参数错误
|
||||
"InvalidParameter": "请求参数无效,请检查输入内容",
|
||||
"InvalidImage": "图片格式或尺寸不符合要求,请检查后重试",
|
||||
"InvalidVideo": "视频格式或尺寸不符合要求,请检查后重试",
|
||||
"InvalidAudio": "音频格式不符合要求,请检查后重试",
|
||||
"AudioDurationExceeded": "音频总时长超过15秒限制,请缩短音频后重试",
|
||||
"AudioFormatNotSupported": "音频格式不支持,请使用 MP3 或 WAV 格式",
|
||||
# 限流
|
||||
"RateLimitExceeded": "请求过于频繁,请稍后重试",
|
||||
"ConcurrencyLimitExceeded": "当前生成任务过多,请稍后重试",
|
||||
# 账户
|
||||
"InsufficientBalance": "平台账户余额不足,请联系管理员",
|
||||
# 素材
|
||||
"AssetNotFound": "引用的素材不存在或已被删除,请检查素材库",
|
||||
# 服务端
|
||||
"ServerOverloaded": "服务器繁忙,请稍后重试",
|
||||
"InternalError": "视频生成服务异常,请稍后重试",
|
||||
"Timeout": "生成超时,请重试",
|
||||
}
|
||||
|
||||
# 关键词匹配:message 包含这些关键词时映射为中文提示(code 未命中时兜底)
|
||||
_MESSAGE_KEYWORDS = {
|
||||
"face": "检测到真实人脸,请使用虚拟人像素材替代真人照片",
|
||||
"privacy": "检测到真实人脸,请使用虚拟人像素材替代真人照片",
|
||||
"sensitive": "内容包含敏感信息,请修改后重试",
|
||||
"not found": "引用的素材不存在或已被删除,请检查素材库",
|
||||
"not valid": "请求参数无效,请检查输入内容",
|
||||
"audio duration": "音频总时长超过15秒限制,请缩短音频后重试",
|
||||
"audio": "音频不符合要求(支持MP3/WAV,单条2-15秒,总时长≤15秒)",
|
||||
}
|
||||
|
||||
# provider 创建阶段抛 RuntimeError("火山报错 [code] message"),从中抽 code/message
|
||||
_RUNTIME_ERROR_RE = re.compile(r"火山报错 \[([^\]]*)\]\s*(.*)", re.S)
|
||||
|
||||
|
||||
def map_video_error(code: str, message: str) -> str:
|
||||
"""error code / message → 用户友好中文。永远返回非空文案。"""
|
||||
friendly = ERROR_MESSAGES.get(code or "")
|
||||
if not friendly:
|
||||
msg_lower = (message or "").lower()
|
||||
for keyword, hint in _MESSAGE_KEYWORDS.items():
|
||||
if keyword in msg_lower:
|
||||
friendly = hint
|
||||
break
|
||||
return friendly or "生成失败,请重试"
|
||||
|
||||
|
||||
def parse_provider_error(exc: Exception) -> tuple[str, str]:
|
||||
"""从 provider 抛出的异常抽 (code, message)。抽不出则 code 为空、message 取异常文本。"""
|
||||
text = str(exc)
|
||||
m = _RUNTIME_ERROR_RE.search(text)
|
||||
if m:
|
||||
return m.group(1).strip(), m.group(2).strip()
|
||||
return "", text
|
||||
@@ -0,0 +1,136 @@
|
||||
"""自由创作视频 token 计费(移植自 jimeng-clone backend/utils/billing.py)。
|
||||
|
||||
Token 预估公式(火山官方):(输入视频时长 + 输出时长) × 宽 × 高 × 帧率 / 1024
|
||||
单价:元/百万tokens,存 ModelConfig.metadata["pricing"],按 分辨率档 × 是否含视频参考 取价。
|
||||
|
||||
⚠️ 预估仅用于前端展示与额度预留;真实费用以火山返回 usage.total_tokens 结算。
|
||||
预留额外加 RESERVE_BUFFER(ledger 禁超预留扣费,真实 tokens 可能高于预估),
|
||||
结算时 clamp 到预留额并对差额告警。
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
# 分辨率 → 像素映射(火山 Seedance 2.0 API 文档)
|
||||
RESOLUTION_MAP = {
|
||||
# 720p
|
||||
("720p", "16:9"): (1280, 720),
|
||||
("720p", "9:16"): (720, 1280),
|
||||
("720p", "4:3"): (1112, 834),
|
||||
("720p", "1:1"): (960, 960),
|
||||
("720p", "3:4"): (834, 1112),
|
||||
("720p", "21:9"): (1470, 630),
|
||||
# 480p
|
||||
("480p", "16:9"): (864, 496),
|
||||
("480p", "9:16"): (496, 864),
|
||||
("480p", "4:3"): (752, 560),
|
||||
("480p", "1:1"): (640, 640),
|
||||
("480p", "3:4"): (560, 752),
|
||||
("480p", "21:9"): (992, 432),
|
||||
# 1080p(仅标准档)
|
||||
("1080p", "16:9"): (1920, 1080),
|
||||
("1080p", "9:16"): (1080, 1920),
|
||||
("1080p", "4:3"): (1664, 1248),
|
||||
("1080p", "1:1"): (1440, 1440),
|
||||
("1080p", "3:4"): (1248, 1664),
|
||||
("1080p", "21:9"): (2206, 946),
|
||||
# 4k(仅标准档)
|
||||
("4k", "16:9"): (3840, 2160),
|
||||
("4k", "9:16"): (2160, 3840),
|
||||
("4k", "4:3"): (3326, 2494),
|
||||
("4k", "1:1"): (2880, 2880),
|
||||
("4k", "3:4"): (2494, 3326),
|
||||
("4k", "21:9"): (4398, 1886),
|
||||
}
|
||||
|
||||
DEFAULT_FPS = 24
|
||||
|
||||
# 预留 = 预估费用 × buffer。ledger 的 charge_reserved_credit 在 actual > reserved 时抛错,
|
||||
# 而火山真实 tokens 有最低用量限制/输入视频真实时长偏差,可能略高于预估。
|
||||
RESERVE_BUFFER = Decimal("1.10")
|
||||
|
||||
|
||||
def get_resolution(aspect_ratio: str, tier: str) -> tuple:
|
||||
"""(tier, aspect_ratio) → (width, height)。非法组合 KeyError fail loud,不静默降级。"""
|
||||
key = (tier, aspect_ratio)
|
||||
if key not in RESOLUTION_MAP:
|
||||
raise KeyError(
|
||||
f"不支持的分辨率组合: tier={tier!r}, aspect_ratio={aspect_ratio!r}. "
|
||||
f"仅支持 480p/720p/1080p/4k × 16:9/9:16/4:3/1:1/3:4/21:9"
|
||||
)
|
||||
return RESOLUTION_MAP[key]
|
||||
|
||||
|
||||
def estimate_tokens(
|
||||
width: int,
|
||||
height: int,
|
||||
duration: int,
|
||||
fps: int = DEFAULT_FPS,
|
||||
input_video_duration: float = 0,
|
||||
) -> int:
|
||||
total_duration = duration + (input_video_duration or 0)
|
||||
return round(width * height * fps * total_duration / 1024)
|
||||
|
||||
|
||||
def has_video_reference(references: list) -> bool:
|
||||
return any((ref or {}).get("type") == "video" for ref in references or [])
|
||||
|
||||
|
||||
def sum_video_duration(references: list) -> float:
|
||||
"""输入参考视频总时长(秒),计入 token 公式的输入时长项。"""
|
||||
return sum(
|
||||
float(ref.get("duration") or 0)
|
||||
for ref in references or []
|
||||
if (ref or {}).get("type") == "video"
|
||||
)
|
||||
|
||||
|
||||
def get_token_price(model_config, with_video_ref: bool, resolution: str) -> Decimal:
|
||||
"""从 ModelConfig.metadata["pricing"] 取单价(元/百万tokens)。
|
||||
|
||||
先按 resolution 精确键,无则回落 "default";缺 pricing/缺键抛 ValueError fail loud
|
||||
(1080p/4k 只有标准档配了键,fast/mini 在提交校验就被拒,不允许静默按 720p 计价)。
|
||||
"""
|
||||
pricing = (model_config.metadata or {}).get("pricing") or {}
|
||||
# 1080p/4k 有独立价:缺该档键 = 模型不支持该分辨率(fast/mini),fail loud,
|
||||
# 绝不按 default(480p/720p)价静默计费——那是欺骗用户(jimeng _get_token_price 同原则)。
|
||||
if resolution in ("1080p", "4k") and resolution not in pricing:
|
||||
raise ValueError(f"模型 {model_config.name} 不支持 {resolution}——提交校验应已拦截,不应进到计价")
|
||||
tier = pricing.get(resolution) or pricing.get("default")
|
||||
if not tier:
|
||||
raise ValueError(f"模型 {model_config.name} 未配置 pricing(resolution={resolution})")
|
||||
key = "with_ref_video" if with_video_ref else "no_ref_video"
|
||||
price = tier.get(key)
|
||||
if price is None:
|
||||
raise ValueError(f"模型 {model_config.name} pricing 缺 {resolution}/{key} 档单价")
|
||||
return Decimal(str(price))
|
||||
|
||||
|
||||
def calculate_cost(tokens: int, price: Decimal) -> Decimal:
|
||||
"""tokens × 单价(元/百万tokens),保留 2 位小数。"""
|
||||
cost = Decimal(str(tokens)) * Decimal(str(price)) / Decimal("1000000")
|
||||
return cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def estimate_video_cost(
|
||||
model_config,
|
||||
*,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
duration: int,
|
||||
references: list,
|
||||
) -> tuple[int, Decimal]:
|
||||
"""返回 (预估 tokens, 预估费用元)。供提交预留与前端预估口径对齐。"""
|
||||
width, height = get_resolution(aspect_ratio, resolution)
|
||||
tokens = estimate_tokens(
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
input_video_duration=sum_video_duration(references),
|
||||
)
|
||||
price = get_token_price(model_config, has_video_reference(references), resolution)
|
||||
return tokens, calculate_cost(tokens, price)
|
||||
|
||||
|
||||
def tokens_to_cost(model_config, tokens: int, *, with_video_ref: bool, resolution: str) -> Decimal:
|
||||
"""按真实 usage.total_tokens 计价(结算口径,与预估同一张价表)。"""
|
||||
price = get_token_price(model_config, with_video_ref, resolution)
|
||||
return calculate_cost(tokens, price)
|
||||
@@ -2,6 +2,7 @@ from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
@@ -308,6 +309,253 @@ class ImageConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return Response({"conversation_id": str(conversation.id), "tasks": data})
|
||||
|
||||
|
||||
def _free_video_task_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=False)
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoView(APIView):
|
||||
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
|
||||
|
||||
POST /api/ai/free-video/ 提交任务,秒回(火山 create 同步调、轮询交给 worker 兜底 + 前端主动 poll)
|
||||
GET /api/ai/free-video/ 任务流分页(offset/page_size,新→旧)
|
||||
"""
|
||||
|
||||
def post(self, request):
|
||||
require_worker() # 生成闸:无 worker 时任务提交火山后无人兜底轮询(额度冻结、结果丢失)
|
||||
from .free_video import serialize_free_video_task, submit_free_video
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
task = submit_free_video(team=team, user=request.user, params=request.data or {})
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 重取带 prefetch 的实例,序列化统一走同一条路
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def get(self, request):
|
||||
from .free_video import serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
offset = max(0, int(request.query_params.get("offset") or 0))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size") or 20)))
|
||||
except (TypeError, ValueError):
|
||||
page_size = 20
|
||||
qs = _free_video_task_queryset(team).order_by("-created_at")
|
||||
total = qs.count()
|
||||
tasks = list(qs[offset : offset + page_size])
|
||||
return Response(
|
||||
{
|
||||
"results": [serialize_free_video_task(t) for t in tasks],
|
||||
"total": total,
|
||||
"has_more": offset + page_size < total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreeVideoPollView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/poll/ —— web 进程内单次轮询+终态化(幂等)。
|
||||
前端渐进轮询打这里;本地无 worker 也能全程收尾(与 pipeline poll-video-segment 同模式)。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
from .free_video import finalize_free_video, serialize_free_video_task
|
||||
|
||||
team = get_current_team(request.user)
|
||||
task = _free_video_task_queryset(team).filter(id=task_id).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
try:
|
||||
task = finalize_free_video(task=task)
|
||||
except Exception: # noqa: BLE001 — 单次轮询失败(网络抖动)不终结任务,返回现状继续轮
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("free video poll failed for %s", task_id, exc_info=True)
|
||||
# 终态后重取(finalize 里可能新建了资产)
|
||||
task = _free_video_task_queryset(team).get(id=task.id)
|
||||
return Response({"task": serialize_free_video_task(task)})
|
||||
|
||||
|
||||
class FreeVideoFavoriteView(APIView):
|
||||
"""POST /api/ai/free-video/<id>/favorite/ —— 收藏开关。"""
|
||||
|
||||
def post(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
task.is_favorited = not task.is_favorited
|
||||
task.save(update_fields=["is_favorited", "updated_at"])
|
||||
return Response({"is_favorited": task.is_favorited})
|
||||
|
||||
|
||||
class FreeVideoDetailView(APIView):
|
||||
"""DELETE /api/ai/free-video/<id>/ —— 软删(在途任务拒删,等终态)。"""
|
||||
|
||||
def delete(self, request, task_id):
|
||||
team = get_current_team(request.user)
|
||||
task = AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, id=task_id, is_deleted=False).first()
|
||||
if task is None:
|
||||
return Response({"detail": "任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING):
|
||||
return Response({"detail": "任务生成中,请等待完成后再删除"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
task.is_deleted = True
|
||||
task.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# 上传参考素材的格式/尺寸限制(与 jimeng inputBar 校验对齐;后端兜底,前端也拦)
|
||||
_FREE_REF_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_FREE_REF_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
|
||||
_FREE_REF_AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/x-wav", "audio/wave"}
|
||||
_FREE_REF_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_FREE_REF_VIDEO_MAX = 50 * 1024 * 1024
|
||||
_FREE_REF_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
class FreeVideoUploadView(APIView):
|
||||
"""POST /api/ai/free-video/upload/ —— 参考素材上传(图/视频/音频)。
|
||||
|
||||
校验(图 300-6000px、比例(0.4,2.5)、≤30MB;视频 mp4/mov ≤50MB、2-15s;音频 mp3/wav ≤15MB、2-15s)
|
||||
→ TOS → Asset(source=UPLOAD, in_library=False) → {asset_id,url,type,duration,thumb_url}。
|
||||
视频顺带 ffmpeg 抽首帧缩略图。"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
import tempfile
|
||||
import uuid as _uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
from .media_probe import extract_video_poster, probe_duration
|
||||
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = get_current_team(request.user)
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
|
||||
if content_type in _FREE_REF_IMAGE_TYPES:
|
||||
kind, asset_type, suffix = "image", Asset.Type.IMAGE, {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]
|
||||
if size > _FREE_REF_IMAGE_MAX:
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_VIDEO_TYPES:
|
||||
kind, asset_type, suffix = "video", Asset.Type.VIDEO, ".mp4" if content_type == "video/mp4" else ".mov"
|
||||
if size > _FREE_REF_VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _FREE_REF_AUDIO_TYPES:
|
||||
kind, asset_type, suffix = "audio", Asset.Type.AUDIO, ".mp3" if content_type == "audio/mpeg" else ".wav"
|
||||
if size > _FREE_REF_AUDIO_MAX:
|
||||
return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
raw = upload.read()
|
||||
width = height = None
|
||||
duration = None
|
||||
poster_bytes = None
|
||||
|
||||
if kind == "image":
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
width, height = im.size
|
||||
except Exception: # noqa: BLE001
|
||||
return Response({"detail": "图片解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (300 <= width <= 6000 and 300 <= height <= 6000):
|
||||
return Response({"detail": "图片边长需在 300-6000 像素之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
ratio = width / height
|
||||
if not (0.4 <= ratio <= 2.5):
|
||||
return Response({"detail": "图片宽高比需在 0.4-2.5 之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-upload-") as tmp:
|
||||
tmp_path = Path(tmp) / f"in{suffix}"
|
||||
tmp_path.write_bytes(raw)
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
poster_bytes = extract_video_poster(str(tmp_path))
|
||||
|
||||
asset_id = _uuid.uuid4()
|
||||
storage = TosStorage()
|
||||
object_key = f"teams/{team.id}/free-create/uploads/{asset_id}{suffix}"
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
|
||||
name = (upload.name or f"素材{suffix}")[:255]
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
name=name,
|
||||
asset_type=asset_type,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.UPLOAD,
|
||||
in_library=False, # 仅作生成参考,不进资产库列表
|
||||
metadata={"feature": "free_video_reference"},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
width=width,
|
||||
height=height,
|
||||
duration_ms=int(duration * 1000) if duration else None,
|
||||
is_primary=True,
|
||||
)
|
||||
url = storage.public_url(object_key=stored.object_key)
|
||||
thumb_url = ""
|
||||
if poster_bytes:
|
||||
poster_key = f"teams/{team.id}/free-create/uploads/{asset_id}-poster.jpg"
|
||||
poster_stored = storage.upload_fileobj(
|
||||
fileobj=BytesIO(poster_bytes), object_key=poster_key, content_type="image/jpeg"
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key=poster_stored.object_key,
|
||||
bucket=poster_stored.bucket,
|
||||
content_type=poster_stored.content_type,
|
||||
size_bytes=poster_stored.size_bytes,
|
||||
is_primary=False,
|
||||
)
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"url": url,
|
||||
"type": kind,
|
||||
"name": name,
|
||||
"duration": duration,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
|
||||
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
|
||||
|
||||
@@ -59,7 +59,11 @@ def _get_service():
|
||||
Credentials(ak, sk, SERVICE, REGION),
|
||||
10, 30,
|
||||
)
|
||||
actions = ["CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset"]
|
||||
actions = [
|
||||
"CreateAssetGroup", "CreateAsset", "ListAssetGroups", "ListAssets", "GetAsset", "DeleteAsset",
|
||||
# 自由创作素材库(组/素材全生命周期)
|
||||
"GetAssetGroup", "UpdateAssetGroup", "UpdateAsset", "DeleteAssetGroup",
|
||||
]
|
||||
api_info = {a: ApiInfo("POST", "/", {"Action": a, "Version": API_VERSION}, {}, {}) for a in actions}
|
||||
return Service(service_info, api_info)
|
||||
|
||||
@@ -117,3 +121,49 @@ def list_asset_groups(page: int = 1, page_size: int = 20, name: str | None = Non
|
||||
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
|
||||
)
|
||||
return result.get("Items", []), result.get("TotalCount", 0)
|
||||
|
||||
|
||||
def list_assets(group_ids: list | None = None, status: str | None = None,
|
||||
name: str | None = None, page: int = 1, page_size: int = 20) -> tuple:
|
||||
"""列组内素材。返回 (items, total_count)。"""
|
||||
filter_dict: dict = {"GroupType": "AIGC"}
|
||||
if group_ids:
|
||||
filter_dict["GroupIds"] = group_ids
|
||||
if status:
|
||||
filter_dict["Statuses"] = [status]
|
||||
if name:
|
||||
filter_dict["Name"] = name
|
||||
result = _do_request(
|
||||
"ListAssets",
|
||||
{"Filter": filter_dict, "PageNumber": page, "PageSize": page_size, "ProjectName": _project()},
|
||||
)
|
||||
return result.get("Items", []), result.get("TotalCount", 0)
|
||||
|
||||
|
||||
def get_asset_group(group_id: str) -> dict:
|
||||
return _do_request("GetAssetGroup", {"Id": group_id, "ProjectName": _project()})
|
||||
|
||||
|
||||
def update_asset_group(group_id: str, name: str | None = None, description: str | None = None) -> None:
|
||||
body: dict = {"Id": group_id, "ProjectName": _project()}
|
||||
if name is not None:
|
||||
body["Name"] = name
|
||||
if description is not None:
|
||||
body["Description"] = description
|
||||
_do_request("UpdateAssetGroup", body)
|
||||
|
||||
|
||||
def update_asset(asset_id: str, name: str | None = None) -> None:
|
||||
body: dict = {"Id": asset_id, "ProjectName": _project()}
|
||||
if name is not None:
|
||||
body["Name"] = name
|
||||
_do_request("UpdateAsset", body)
|
||||
|
||||
|
||||
def delete_asset(asset_id: str) -> None:
|
||||
_do_request("DeleteAsset", {"Id": asset_id, "ProjectName": _project()})
|
||||
|
||||
|
||||
def delete_asset_group(group_id: str) -> None:
|
||||
"""删组(远程级联删组内素材)。"""
|
||||
_do_request("DeleteAssetGroup", {"Id": group_id, "ProjectName": _project()})
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""自由创作·人物素材库端点(FreeAssetGroup / FreeAsset ←→ 火山 Assets API)。
|
||||
|
||||
移植自 jimeng-clone 素材库:建组=火山 CreateAssetGroup+本地记录;传素材=TOS 上传→CreateAsset(URL)
|
||||
→FreeAsset(processing);状态轮询=GetAsset 刷新 active/failed;删除=远程 NotFound 幂等继续清本地。
|
||||
生成时以 asset://{remote_asset_id} 引用(见 apps/ai/free_video.build_content_items)。
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.common.api import get_current_team
|
||||
|
||||
from . import assets_client
|
||||
from .assets_client import AssetsAPIError
|
||||
from .models import FreeAsset, FreeAssetGroup
|
||||
from .storage import TosStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
_VIDEO_TYPES = {"video/mp4": ".mp4", "video/quicktime": ".mov"}
|
||||
_AUDIO_TYPES = {"audio/mpeg": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/wave": ".wav"}
|
||||
_IMAGE_MAX = 30 * 1024 * 1024
|
||||
_VIDEO_MAX = 50 * 1024 * 1024
|
||||
_AUDIO_MAX = 15 * 1024 * 1024
|
||||
|
||||
|
||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||
return {
|
||||
"id": str(fa.id),
|
||||
"name": fa.name,
|
||||
"url": fa.url,
|
||||
"type": fa.asset_type.lower(),
|
||||
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
|
||||
"duration": fa.duration,
|
||||
"status": fa.status,
|
||||
"error_message": fa.error_message,
|
||||
"created_at": fa.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_group(group: FreeAssetGroup, asset_count: int | None = None) -> dict:
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"thumbnail_url": group.thumbnail_url,
|
||||
"asset_count": asset_count if asset_count is not None else group.assets.count(),
|
||||
"created_at": group.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _assets_api_unavailable() -> Response:
|
||||
return Response({"detail": "素材库服务未配置,请联系管理员"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
|
||||
class FreeAssetGroupListView(APIView):
|
||||
"""GET 列素材组 / POST 建组。"""
|
||||
|
||||
def get(self, request):
|
||||
team = get_current_team(request.user)
|
||||
groups = FreeAssetGroup.objects.filter(team=team, is_deleted=False).order_by("-created_at")
|
||||
counts = {str(g.id): g.assets.count() for g in groups}
|
||||
return Response({"results": [_serialize_group(g, counts[str(g.id)]) for g in groups]})
|
||||
|
||||
def post(self, request):
|
||||
if not assets_client.is_enabled():
|
||||
return _assets_api_unavailable()
|
||||
team = get_current_team(request.user)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
description = str(request.data.get("description") or "").strip()
|
||||
if not name:
|
||||
return Response({"detail": "素材组名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
remote_group_id = assets_client.create_asset_group(name, description)
|
||||
except AssetsAPIError as exc:
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group = FreeAssetGroup.objects.create(
|
||||
team=team, created_by=request.user, name=name, description=description, remote_group_id=remote_group_id
|
||||
)
|
||||
return Response({"group": _serialize_group(group, 0)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FreeAssetGroupDetailView(APIView):
|
||||
"""GET 组详情+素材列表 / PATCH 改名 / DELETE 删组(远程级联,NotFound 幂等清本地)。"""
|
||||
|
||||
def _get_group(self, request, group_id) -> FreeAssetGroup | None:
|
||||
team = get_current_team(request.user)
|
||||
return FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||
|
||||
def get(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
assets = list(group.assets.order_by("-created_at"))
|
||||
return Response({"group": _serialize_group(group, len(assets)), "assets": [_serialize_asset(a) for a in assets]})
|
||||
|
||||
def patch(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = request.data.get("name")
|
||||
description = request.data.get("description")
|
||||
update_fields = ["updated_at"]
|
||||
if name is not None and str(name).strip():
|
||||
group.name = str(name).strip()
|
||||
update_fields.append("name")
|
||||
if description is not None:
|
||||
group.description = str(description)
|
||||
update_fields.append("description")
|
||||
if len(update_fields) == 1:
|
||||
return Response({"group": _serialize_group(group)})
|
||||
try:
|
||||
assets_client.update_asset_group(group.remote_group_id, name=group.name, description=group.description)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group.save(update_fields=update_fields)
|
||||
return Response({"group": _serialize_group(group)})
|
||||
|
||||
def delete(self, request, group_id):
|
||||
group = self._get_group(request, group_id)
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
assets_client.delete_asset_group(group.remote_group_id)
|
||||
except AssetsAPIError as exc:
|
||||
# 远程已不存在 → 幂等继续清本地;其它错误如实反馈
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
group.is_deleted = True
|
||||
group.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeAssetUploadView(APIView):
|
||||
"""POST /free-groups/<id>/assets/ —— 上传素材进组:TOS → 火山 CreateAsset → FreeAsset(processing)。"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request, group_id):
|
||||
if not assets_client.is_enabled():
|
||||
return _assets_api_unavailable()
|
||||
team = get_current_team(request.user)
|
||||
group = FreeAssetGroup.objects.filter(id=group_id, team=team, is_deleted=False).first()
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response({"detail": "缺少文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
content_type = (upload.content_type or "").lower()
|
||||
size = upload.size or 0
|
||||
if content_type in _IMAGE_TYPES:
|
||||
kind, suffix, asset_type = "image", _IMAGE_TYPES[content_type], FreeAsset.Type.IMAGE
|
||||
if size > _IMAGE_MAX:
|
||||
return Response({"detail": "图片大小不能超过 30MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _VIDEO_TYPES:
|
||||
kind, suffix, asset_type = "video", _VIDEO_TYPES[content_type], FreeAsset.Type.VIDEO
|
||||
if size > _VIDEO_MAX:
|
||||
return Response({"detail": "视频大小不能超过 50MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif content_type in _AUDIO_TYPES:
|
||||
kind, suffix, asset_type = "audio", _AUDIO_TYPES[content_type], FreeAsset.Type.AUDIO
|
||||
if size > _AUDIO_MAX:
|
||||
return Response({"detail": "音频大小不能超过 15MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(
|
||||
{"detail": "不支持的文件格式(图片 JPG/PNG/WebP,视频 MP4/MOV,音频 MP3/WAV)"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
raw = upload.read()
|
||||
duration = None
|
||||
thumb_url = ""
|
||||
storage = TosStorage()
|
||||
file_id = uuid.uuid4()
|
||||
|
||||
if kind != "image":
|
||||
from apps.ai.media_probe import extract_video_poster, probe_duration
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="airshelf-fc-lib-") as tmp:
|
||||
tmp_path = Path(tmp) / f"in{suffix}"
|
||||
tmp_path.write_bytes(raw)
|
||||
duration = probe_duration(str(tmp_path))
|
||||
if duration is None:
|
||||
return Response({"detail": "媒体文件解析失败,请更换文件"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not (2 <= duration <= 15):
|
||||
label = "视频" if kind == "video" else "音频"
|
||||
return Response({"detail": f"{label}时长需在 2-15 秒之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if kind == "video":
|
||||
poster = extract_video_poster(str(tmp_path))
|
||||
if poster:
|
||||
poster_key = f"teams/{team.id}/free-create/library/{file_id}-poster.jpg"
|
||||
storage.upload_fileobj(fileobj=BytesIO(poster), object_key=poster_key, content_type="image/jpeg")
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
object_key = f"teams/{team.id}/free-create/library/{file_id}{suffix}"
|
||||
stored = storage.upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type)
|
||||
url = storage.public_url(object_key=stored.object_key)
|
||||
|
||||
name = str(request.data.get("name") or "").strip() or (upload.name or f"素材{suffix}")
|
||||
name = name[:128]
|
||||
try:
|
||||
remote_asset_id = assets_client.create_asset(
|
||||
group.remote_group_id, url, name=name, asset_type=asset_type
|
||||
)
|
||||
except AssetsAPIError as exc:
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
fa = FreeAsset.objects.create(
|
||||
group=group,
|
||||
remote_asset_id=remote_asset_id,
|
||||
name=name,
|
||||
url=url,
|
||||
asset_type=asset_type,
|
||||
thumbnail_url=thumb_url or (url if kind == "image" else ""),
|
||||
duration=duration,
|
||||
status=FreeAsset.Status.PROCESSING,
|
||||
)
|
||||
if not group.thumbnail_url and fa.thumbnail_url:
|
||||
group.thumbnail_url = fa.thumbnail_url
|
||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
||||
return Response({"asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class FreeAssetDetailView(APIView):
|
||||
"""PATCH 改名 / DELETE 删素材(远程 NotFound 幂等清本地)。"""
|
||||
|
||||
def _get_asset(self, request, asset_id) -> FreeAsset | None:
|
||||
team = get_current_team(request.user)
|
||||
return FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).select_related("group").first()
|
||||
|
||||
def patch(self, request, asset_id):
|
||||
fa = self._get_asset(request, asset_id)
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
if not name:
|
||||
return Response({"detail": "素材名称不能为空"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
assets_client.update_asset(fa.remote_asset_id, name=name)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
fa.name = name[:128]
|
||||
fa.save(update_fields=["name", "updated_at"])
|
||||
return Response({"asset": _serialize_asset(fa)})
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
fa = self._get_asset(request, asset_id)
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
assets_client.delete_asset(fa.remote_asset_id)
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code != "NotFound":
|
||||
return Response({"detail": exc.user_message}, status=status.HTTP_400_BAD_REQUEST)
|
||||
fa.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class FreeAssetPollView(APIView):
|
||||
"""POST /free-assets/<id>/poll/ —— 查火山刷新审核状态(processing → active/failed)。"""
|
||||
|
||||
def post(self, request, asset_id):
|
||||
team = get_current_team(request.user)
|
||||
fa = FreeAsset.objects.filter(id=asset_id, group__team=team, group__is_deleted=False).first()
|
||||
if fa is None:
|
||||
return Response({"detail": "素材不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if fa.status == FreeAsset.Status.PROCESSING:
|
||||
try:
|
||||
result = assets_client.get_asset(fa.remote_asset_id)
|
||||
remote_status = str(result.get("Status") or "")
|
||||
if remote_status == "Active":
|
||||
fa.status = FreeAsset.Status.ACTIVE
|
||||
fa.url = result.get("Url") or fa.url
|
||||
fa.save(update_fields=["status", "url", "updated_at"])
|
||||
elif remote_status == "Failed":
|
||||
fa.status = FreeAsset.Status.FAILED
|
||||
fa.error_message = str(result.get("ErrorMessage") or "素材审核未通过")
|
||||
fa.save(update_fields=["status", "error_message", "updated_at"])
|
||||
except AssetsAPIError as exc:
|
||||
if exc.code == "NotFound":
|
||||
fa.status = FreeAsset.Status.FAILED
|
||||
fa.error_message = "素材在远程已不存在"
|
||||
fa.save(update_fields=["status", "error_message", "updated_at"])
|
||||
else:
|
||||
logger.warning("free asset %s poll failed: %s", asset_id, exc)
|
||||
return Response({"asset": _serialize_asset(fa)})
|
||||
@@ -0,0 +1,135 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-02 07:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0007_team_monthly_credit_limit"),
|
||||
("assets", "0008_backfill_workbench_in_library"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FreeAssetGroup",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=128)),
|
||||
("description", models.TextField(blank=True)),
|
||||
("remote_group_id", models.CharField(max_length=128)),
|
||||
("thumbnail_url", models.URLField(blank=True)),
|
||||
("is_deleted", models.BooleanField(default=False)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_%(class)s_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="%(class)s_set",
|
||||
to="accounts.team",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FreeAsset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("remote_asset_id", models.CharField(max_length=128)),
|
||||
("name", models.CharField(blank=True, max_length=128)),
|
||||
("url", models.URLField(blank=True, max_length=1024)),
|
||||
(
|
||||
"asset_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Image", "Image"),
|
||||
("Video", "Video"),
|
||||
("Audio", "Audio"),
|
||||
],
|
||||
default="Image",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("thumbnail_url", models.URLField(blank=True, max_length=1024)),
|
||||
("duration", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("processing", "Processing"),
|
||||
("active", "Active"),
|
||||
("failed", "Failed"),
|
||||
],
|
||||
default="processing",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="assets",
|
||||
to="assets.freeassetgroup",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeassetgroup",
|
||||
index=models.Index(
|
||||
fields=["team", "-created_at"], name="assets_free_team_id_c0cbb2_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeasset",
|
||||
index=models.Index(
|
||||
fields=["group", "-created_at"], name="assets_free_group_i_b2a9ec_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="freeasset",
|
||||
index=models.Index(
|
||||
fields=["remote_asset_id"], name="assets_free_remote__49909d_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -178,3 +178,61 @@ class AssetUsage(TimeStampedModel):
|
||||
usage_type = models.CharField(max_length=64)
|
||||
context = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
class FreeAssetGroup(TeamOwnedModel):
|
||||
"""自由创作·人物素材组(一角色一组)。
|
||||
|
||||
本质是「火山 Assets API 引用登记表」:组内素材登记到火山素材库拿 remote_asset_id,
|
||||
生成时以 asset://{remote_asset_id} 引用(免重复上传 + 火山侧预审)。
|
||||
与 AssetReviewGroup(一团队一审核组)职责不同,与资产库 Asset(TOS 文件资产)也不同,独立建表。
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
remote_group_id = models.CharField(max_length=128)
|
||||
thumbnail_url = models.URLField(blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["team", "-created_at"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"free-group:{self.name}"
|
||||
|
||||
|
||||
class FreeAsset(TimeStampedModel):
|
||||
"""自由创作·素材组内单素材(火山 Assets API 登记项)。"""
|
||||
|
||||
class Type(models.TextChoices):
|
||||
IMAGE = "Image", "Image"
|
||||
VIDEO = "Video", "Video"
|
||||
AUDIO = "Audio", "Audio"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PROCESSING = "processing", "Processing" # 火山侧处理/审核中
|
||||
ACTIVE = "active", "Active" # 可用,可 asset:// 引用
|
||||
FAILED = "failed", "Failed" # 审核/处理失败
|
||||
|
||||
group = models.ForeignKey(FreeAssetGroup, on_delete=models.CASCADE, related_name="assets")
|
||||
remote_asset_id = models.CharField(max_length=128)
|
||||
name = models.CharField(max_length=128, blank=True)
|
||||
url = models.URLField(max_length=1024, blank=True) # TOS 公读直链(火山从这里拉源文件)
|
||||
asset_type = models.CharField(max_length=16, choices=Type.choices, default=Type.IMAGE)
|
||||
thumbnail_url = models.URLField(max_length=1024, blank=True)
|
||||
duration = models.FloatField(null=True, blank=True) # 视频/音频时长(秒)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PROCESSING)
|
||||
error_message = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["group", "-created_at"]),
|
||||
models.Index(fields=["remote_asset_id"]),
|
||||
]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"free-asset:{self.name or self.remote_asset_id}"
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .free_library import (
|
||||
FreeAssetDetailView,
|
||||
FreeAssetGroupDetailView,
|
||||
FreeAssetGroupListView,
|
||||
FreeAssetPollView,
|
||||
FreeAssetUploadView,
|
||||
)
|
||||
from .views import AssetUploadView, AssetViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -8,4 +15,10 @@ router.register("", AssetViewSet, basename="asset")
|
||||
|
||||
urlpatterns = [
|
||||
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
|
||||
# 自由创作·人物素材库(火山 Assets API 引用登记)。必须在 router 通配之前注册。
|
||||
path("free-groups/", FreeAssetGroupListView.as_view(), name="free-asset-group-list"),
|
||||
path("free-groups/<uuid:group_id>/", FreeAssetGroupDetailView.as_view(), name="free-asset-group-detail"),
|
||||
path("free-groups/<uuid:group_id>/assets/", FreeAssetUploadView.as_view(), name="free-asset-upload"),
|
||||
path("free-assets/<uuid:asset_id>/", FreeAssetDetailView.as_view(), name="free-asset-detail"),
|
||||
path("free-assets/<uuid:asset_id>/poll/", FreeAssetPollView.as_view(), name="free-asset-poll"),
|
||||
] + router.urls
|
||||
|
||||
@@ -15,7 +15,7 @@ class CreditLedgerTests(TestCase):
|
||||
self.team = Team.objects.create(name="Billing Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
|
||||
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
|
||||
self.provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "Volcano"})
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider,
|
||||
name="doubao-seed-2-0-pro-260215",
|
||||
@@ -110,7 +110,7 @@ class MemberLimitTests(TestCase):
|
||||
self.team = Team.objects.create(name="Limit Team", owner=self.user)
|
||||
self.member = TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000"))
|
||||
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
|
||||
self.provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "Volcano"})
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE,
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ class ProductMaterialsTests(TestCase):
|
||||
mk("product_image", "正面主图", {"product_id": pid})
|
||||
mk("model_tryon", "上身图1", {"product_id": pid})
|
||||
# 项目包:场景 + 视频素材(走 origin_task→project)
|
||||
prov = ModelProvider.objects.create(name="volcengine", display_name="V", base_url="https://x")
|
||||
prov, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V", "base_url": "https://x"})
|
||||
mc = ModelConfig.objects.create(provider=prov, name="m", display_name="M", capability=ModelConfig.Capability.IMAGE)
|
||||
task = AITask.objects.create(team=self.team, project=self.proj, task_type=AITask.Type.SCENE_IMAGE, status=AITask.Status.SUCCEEDED, model_config=mc, idempotency_key="pm-1")
|
||||
mk("scene", "场景1", task=task)
|
||||
|
||||
@@ -32,10 +32,13 @@ class ProjectApiTests(TestCase):
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="Test Product")
|
||||
self.provider = ModelProvider.objects.create(
|
||||
# 迁移 0023(自由创作视频种子)已在测试库预建 volcengine provider,这里 get_or_create 避免撞唯一键
|
||||
self.provider, _ = ModelProvider.objects.get_or_create(
|
||||
name="volcengine",
|
||||
display_name="Volcano",
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||||
defaults={
|
||||
"display_name": "Volcano",
|
||||
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
)
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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":
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user