Files
yingqing/core/backend/apps/ai/video_pricing.py
T
zycandClaude Fable 5 c5f7eb2acc 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>
2026-07-06 10:58:36 +08:00

137 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自由创作视频 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)