feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库

后端:
- free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022)
- video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测
- catalog/volcano: Seedance free-video 模型接入+seed(migration 0023)
- 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口
- settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关)
- 测试: test_free_video.py 新增; billing/products/projects tests 配套调整

前端:
- /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗)
- api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入

bug/: 测试清单 (11)(12) 与截图归档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-07-06 10:58:36 +08:00
co-authored by Claude Fable 5
parent 94202e7bce
commit c5f7eb2acc
45 changed files with 5186 additions and 21 deletions
+136
View File
@@ -0,0 +1,136 @@
"""自由创作视频 token 计费(移植自 jimeng-clone backend/utils/billing.py)。
Token 预估公式(火山官方):(输入视频时长 + 输出时长) × 宽 × 高 × 帧率 / 1024
单价:元/百万tokens,存 ModelConfig.metadata["pricing"],按 分辨率档 × 是否含视频参考 取价。
⚠️ 预估仅用于前端展示与额度预留;真实费用以火山返回 usage.total_tokens 结算。
预留额外加 RESERVE_BUFFER(ledger 禁超预留扣费,真实 tokens 可能高于预估),
结算时 clamp 到预留额并对差额告警。
"""
from decimal import Decimal, ROUND_HALF_UP
# 分辨率 → 像素映射(火山 Seedance 2.0 API 文档)
RESOLUTION_MAP = {
# 720p
("720p", "16:9"): (1280, 720),
("720p", "9:16"): (720, 1280),
("720p", "4:3"): (1112, 834),
("720p", "1:1"): (960, 960),
("720p", "3:4"): (834, 1112),
("720p", "21:9"): (1470, 630),
# 480p
("480p", "16:9"): (864, 496),
("480p", "9:16"): (496, 864),
("480p", "4:3"): (752, 560),
("480p", "1:1"): (640, 640),
("480p", "3:4"): (560, 752),
("480p", "21:9"): (992, 432),
# 1080p(仅标准档)
("1080p", "16:9"): (1920, 1080),
("1080p", "9:16"): (1080, 1920),
("1080p", "4:3"): (1664, 1248),
("1080p", "1:1"): (1440, 1440),
("1080p", "3:4"): (1248, 1664),
("1080p", "21:9"): (2206, 946),
# 4k(仅标准档)
("4k", "16:9"): (3840, 2160),
("4k", "9:16"): (2160, 3840),
("4k", "4:3"): (3326, 2494),
("4k", "1:1"): (2880, 2880),
("4k", "3:4"): (2494, 3326),
("4k", "21:9"): (4398, 1886),
}
DEFAULT_FPS = 24
# 预留 = 预估费用 × buffer。ledger 的 charge_reserved_credit 在 actual > reserved 时抛错,
# 而火山真实 tokens 有最低用量限制/输入视频真实时长偏差,可能略高于预估。
RESERVE_BUFFER = Decimal("1.10")
def get_resolution(aspect_ratio: str, tier: str) -> tuple:
"""(tier, aspect_ratio) → (width, height)。非法组合 KeyError fail loud,不静默降级。"""
key = (tier, aspect_ratio)
if key not in RESOLUTION_MAP:
raise KeyError(
f"不支持的分辨率组合: tier={tier!r}, aspect_ratio={aspect_ratio!r}. "
f"仅支持 480p/720p/1080p/4k × 16:9/9:16/4:3/1:1/3:4/21:9"
)
return RESOLUTION_MAP[key]
def estimate_tokens(
width: int,
height: int,
duration: int,
fps: int = DEFAULT_FPS,
input_video_duration: float = 0,
) -> int:
total_duration = duration + (input_video_duration or 0)
return round(width * height * fps * total_duration / 1024)
def has_video_reference(references: list) -> bool:
return any((ref or {}).get("type") == "video" for ref in references or [])
def sum_video_duration(references: list) -> float:
"""输入参考视频总时长(秒),计入 token 公式的输入时长项。"""
return sum(
float(ref.get("duration") or 0)
for ref in references or []
if (ref or {}).get("type") == "video"
)
def get_token_price(model_config, with_video_ref: bool, resolution: str) -> Decimal:
"""从 ModelConfig.metadata["pricing"] 取单价(元/百万tokens)。
先按 resolution 精确键,无则回落 "default";缺 pricing/缺键抛 ValueError fail loud
(1080p/4k 只有标准档配了键,fast/mini 在提交校验就被拒,不允许静默按 720p 计价)。
"""
pricing = (model_config.metadata or {}).get("pricing") or {}
# 1080p/4k 有独立价:缺该档键 = 模型不支持该分辨率(fast/mini),fail loud,
# 绝不按 default(480p/720p)价静默计费——那是欺骗用户(jimeng _get_token_price 同原则)。
if resolution in ("1080p", "4k") and resolution not in pricing:
raise ValueError(f"模型 {model_config.name} 不支持 {resolution}——提交校验应已拦截,不应进到计价")
tier = pricing.get(resolution) or pricing.get("default")
if not tier:
raise ValueError(f"模型 {model_config.name} 未配置 pricing(resolution={resolution})")
key = "with_ref_video" if with_video_ref else "no_ref_video"
price = tier.get(key)
if price is None:
raise ValueError(f"模型 {model_config.name} pricing 缺 {resolution}/{key} 档单价")
return Decimal(str(price))
def calculate_cost(tokens: int, price: Decimal) -> Decimal:
"""tokens × 单价(元/百万tokens),保留 2 位小数。"""
cost = Decimal(str(tokens)) * Decimal(str(price)) / Decimal("1000000")
return cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def estimate_video_cost(
model_config,
*,
aspect_ratio: str,
resolution: str,
duration: int,
references: list,
) -> tuple[int, Decimal]:
"""返回 (预估 tokens, 预估费用元)。供提交预留与前端预估口径对齐。"""
width, height = get_resolution(aspect_ratio, resolution)
tokens = estimate_tokens(
width,
height,
duration,
input_video_duration=sum_video_duration(references),
)
price = get_token_price(model_config, has_video_reference(references), resolution)
return tokens, calculate_cost(tokens, price)
def tokens_to_cost(model_config, tokens: int, *, with_video_ref: bool, resolution: str) -> Decimal:
"""按真实 usage.total_tokens 计价(结算口径,与预估同一张价表)。"""
price = get_token_price(model_config, with_video_ref, resolution)
return calculate_cost(tokens, price)