14f39054ac
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m36s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m29s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m29s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 10m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m1s
- seedance-2.5: 15→30s (官方4-30s) - seedance-2.0-fast: 10→15s (官方4-15s) - seedance-2.0-mini: 10→15s (官方4-15s) - wan-3.0: 10→30s (官方5-30s) - seedance-2.0 保持15s不变 - display_name统一格式,纯文字不带emoji
415 lines
15 KiB
Python
415 lines
15 KiB
Python
"""积分消耗规则配置 (#1895)
|
||
|
||
v1.6.1: 按产品决策,智能混剪/AI数字人/AI配音/抖音解析/改写/标题/封面 全部免费,
|
||
仅保留声音克隆合成(voice_clone_synth)的扣点逻辑;声音克隆训练保持免费。
|
||
爆款视频(viral_video)走动态定价,见本文件 VIRAL_VIDEO_MODEL_PRICES + calculate_viral_video_credits。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
# ============ 爆款视频动态定价 (#2151) ============
|
||
# key = (model_id, resolution, has_video_input),单位:
|
||
# - billing_mode=token: 元/百万tokens(输出)
|
||
# - billing_mode=per_second: 元/秒(视频时长)
|
||
VIRAL_VIDEO_MODEL_PRICES: dict[tuple[str, str, bool], float] = {
|
||
("seedance-2.5", "480p", False): 70.0,
|
||
("seedance-2.5", "720p", False): 70.0,
|
||
("seedance-2.5", "1080p", False): 77.0,
|
||
("seedance-2.5", "480p", True): 42.0,
|
||
("seedance-2.5", "720p", True): 42.0,
|
||
("seedance-2.5", "1080p", True): 46.0,
|
||
("seedance-2.0", "480p", False): 46.0,
|
||
("seedance-2.0", "720p", False): 46.0,
|
||
("seedance-2.0", "1080p", False): 51.0,
|
||
("seedance-2.0", "4k", False): 80.0,
|
||
("seedance-2.0-fast", "480p", False): 28.0,
|
||
("seedance-2.0-fast", "720p", False): 28.0,
|
||
("seedance-2.0-mini", "480p", False): 9.2,
|
||
("seedance-2.0-mini", "720p", False): 9.2,
|
||
("wan-3.0", "480p", False): 0.3,
|
||
("wan-3.0", "720p", False): 0.6,
|
||
("wan-3.0", "1080p", False): 1.2,
|
||
}
|
||
|
||
# 固定成本(元):VLM 分析 + LLM 文案 + TTS + OSS + 服务器
|
||
VIRAL_VIDEO_FIXED_COST = 0.15
|
||
# 利润系数
|
||
VIRAL_VIDEO_PROFIT_MULTIPLIER = 1.3
|
||
# Seedance 输出帧率
|
||
VIRAL_VIDEO_FPS = 24
|
||
|
||
# 分辨率别名映射 -> 标准 key
|
||
_RESOLUTION_ALIASES: dict[str, str] = {
|
||
"480p": "480p",
|
||
"普清": "480p",
|
||
"default": "480p",
|
||
"low": "480p",
|
||
"sd": "480p",
|
||
"720p": "720p",
|
||
"高清": "720p",
|
||
"medium": "720p",
|
||
"hd": "720p",
|
||
"1080p": "1080p",
|
||
"超清": "1080p",
|
||
"high": "1080p",
|
||
"ultra": "1080p",
|
||
"全能": "1080p",
|
||
"fhd": "1080p",
|
||
}
|
||
# 分辨率 -> 短边像素数(p 值代表短边,不是 height)
|
||
_RESOLUTION_SHORT_SIDE: dict[str, int] = {"480p": 480, "720p": 720, "1080p": 1080, "4k": 2160}
|
||
_RESOLUTION_ALIASES["4k"] = "4k"
|
||
_RESOLUTION_ALIASES["2160p"] = "4k"
|
||
_RESOLUTION_ALIASES["uhd"] = "4k"
|
||
|
||
|
||
def resolve_video_dimensions(resolution: str, ratio: str) -> tuple[int, int]:
|
||
"""把 (resolution, ratio) 解析为 (width, height)。
|
||
|
||
resolution 数字代表短边像素数(480p/720p/1080p 等):
|
||
- 横屏 16:9:短边是 height,width = short * 16/9
|
||
- 竖屏 9:16:短边是 width,height = short * 16/9
|
||
- 方屏 1:1:width = height = short
|
||
"""
|
||
key = str(resolution or "").strip()
|
||
key_l = key.lower()
|
||
res_key = _RESOLUTION_ALIASES.get(key_l) or _RESOLUTION_ALIASES.get(key) or "720p"
|
||
short = _RESOLUTION_SHORT_SIDE.get(res_key, 720)
|
||
r = str(ratio or "").strip().lower()
|
||
if r == "16:9":
|
||
# 横屏:短边是 height,width 向上取整并对齐偶数
|
||
w = math.ceil(short * 16 / 9)
|
||
h = short
|
||
elif r == "1:1":
|
||
w, h = short, short
|
||
else:
|
||
# 9:16 竖屏(默认):短边是 width,height 向上取整并对齐偶数
|
||
w = short
|
||
h = math.ceil(short * 16 / 9)
|
||
# 对齐到偶数(视频编码要求)
|
||
w = w + (w % 2)
|
||
h = h + (h % 2)
|
||
return int(w), int(h)
|
||
|
||
|
||
# ── 爆款视频多模型元数据 (#2159) ──────────────────────────────────────
|
||
VIRAL_VIDEO_MODEL_CONFIG: dict[str, dict] = {
|
||
"seedance-2.5": {
|
||
"key": "seedance-2.5",
|
||
"display_name": "Seedance 2.5 — 最新最强",
|
||
"model_id": "doubao-seedance-2-5-260628",
|
||
"provider": "doubao",
|
||
"supports_audio": True,
|
||
"supported_resolutions": ["480p", "720p", "1080p"],
|
||
"max_duration": 30,
|
||
"billing_mode": "token",
|
||
"is_default": True,
|
||
},
|
||
"seedance-2.0": {
|
||
"key": "seedance-2.0",
|
||
"display_name": "Seedance 2.0 — 正式首选",
|
||
"model_id": "doubao-seedance-2-0-260128",
|
||
"provider": "doubao",
|
||
"supports_audio": True,
|
||
"supported_resolutions": ["480p", "720p", "1080p", "4k"],
|
||
"max_duration": 15,
|
||
"billing_mode": "token",
|
||
"is_default": False,
|
||
},
|
||
"seedance-2.0-fast": {
|
||
"key": "seedance-2.0-fast",
|
||
"display_name": "Seedance 2.0 Fast — 快速低成本",
|
||
"model_id": "doubao-seedance-2-0-fast-260128",
|
||
"provider": "doubao",
|
||
"supports_audio": True,
|
||
"supported_resolutions": ["480p", "720p"],
|
||
"max_duration": 15,
|
||
"billing_mode": "token",
|
||
"is_default": False,
|
||
},
|
||
"seedance-2.0-mini": {
|
||
"key": "seedance-2.0-mini",
|
||
"display_name": "Seedance 2.0 Mini — 低成本测试",
|
||
"model_id": "doubao-seedance-2-0-mini-260615",
|
||
"provider": "doubao",
|
||
"supports_audio": True,
|
||
"supported_resolutions": ["480p", "720p"],
|
||
"max_duration": 15,
|
||
"billing_mode": "token",
|
||
"is_default": False,
|
||
},
|
||
"wan-3.0": {
|
||
"key": "wan-3.0",
|
||
"display_name": "Wan 3.0 — 通义万相(阿里云)",
|
||
"model_id": "wan3.0-video",
|
||
"provider": "dashscope",
|
||
"supports_audio": True,
|
||
"supported_resolutions": ["480p", "720p", "1080p"],
|
||
"max_duration": 30,
|
||
"billing_mode": "per_second",
|
||
"is_default": False,
|
||
},
|
||
}
|
||
|
||
|
||
def get_viral_video_model_config(model_key: str | None) -> dict:
|
||
"""获取模型配置,未知 key 回落到默认 seedance-2.5。"""
|
||
key = (model_key or "").strip().lower()
|
||
if key and key in VIRAL_VIDEO_MODEL_CONFIG:
|
||
return VIRAL_VIDEO_MODEL_CONFIG[key]
|
||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.5"]
|
||
|
||
|
||
def list_viral_video_models(
|
||
include_placeholder: bool = False,
|
||
dashscope_available: bool = False,
|
||
) -> list[dict]:
|
||
"""返回前端可用的模型列表(供 GET /api/v1/viral-video/models 端点用)。"""
|
||
out: list[dict] = []
|
||
for _k, cfg in VIRAL_VIDEO_MODEL_CONFIG.items():
|
||
if cfg.get("_placeholder") and not include_placeholder:
|
||
continue
|
||
if cfg.get("provider") == "dashscope" and not dashscope_available:
|
||
continue
|
||
out.append(
|
||
{
|
||
"key": cfg["key"],
|
||
"display_name": cfg["display_name"],
|
||
"supports_audio": bool(cfg.get("supports_audio", True)),
|
||
"supported_resolutions": list(cfg.get("supported_resolutions", ["720p"])),
|
||
"max_duration": int(cfg.get("max_duration", 15)),
|
||
"billing_mode": cfg.get("billing_mode", "token"),
|
||
"is_default": bool(cfg.get("is_default", False)),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _match_model_prefix(model: str | None) -> str:
|
||
"""匹配 model key(支持全部内部别名,未知回落到 seedance-2.5)。
|
||
|
||
按 key 长度从长到短匹配,避免 "seedance-2.0-fast" 被 "seedance-2.0" 前缀命中。
|
||
"""
|
||
mm = (model or "").strip().lower()
|
||
for k in sorted(VIRAL_VIDEO_MODEL_CONFIG.keys(), key=len, reverse=True):
|
||
if mm == k or mm.startswith(k):
|
||
return k
|
||
return "seedance-2.5"
|
||
|
||
|
||
def _infer_resolution_key(width: int, height: int) -> str:
|
||
"""从实际 (width, height) 用短边推断 resolution key。"""
|
||
short = min(int(width or 720), int(height or 720))
|
||
if short >= 1900:
|
||
return "4k"
|
||
if short >= 1000:
|
||
return "1080p"
|
||
if short >= 650:
|
||
return "720p"
|
||
return "480p"
|
||
|
||
|
||
def calculate_viral_video_credits_with_breakdown(
|
||
duration_seconds: int,
|
||
width: int,
|
||
height: int,
|
||
model: str = "seedance-2.5",
|
||
has_video_input: bool = False,
|
||
actual_tokens: int | None = None,
|
||
fps: int = VIRAL_VIDEO_FPS,
|
||
) -> tuple[float, dict]:
|
||
"""计算爆款视频所需积分(1 积分 = 1 元),并返回计费公式明细。
|
||
|
||
公式:
|
||
tokens = duration * width * height * fps / 1024
|
||
video_cost = tokens / 1_000_000 * model_token_price
|
||
total = round((video_cost + fixed_cost) * profit_multiplier, 2)
|
||
若传入 actual_tokens 则用它替代计算值。
|
||
|
||
Returns:
|
||
(credits, breakdown) 二元组:
|
||
- credits: 四舍五入保留两位小数的最终积分
|
||
- breakdown: dict,包含 tokens / video_cost / fixed_cost / profit_multiplier /
|
||
model_price / width / height / fps 字段,便于前端展示计费明细。
|
||
"""
|
||
w = max(1, int(width or 1))
|
||
h = max(1, int(height or 1))
|
||
effective_fps = int(fps or VIRAL_VIDEO_FPS)
|
||
|
||
prefix = _match_model_prefix(model)
|
||
cfg = get_viral_video_model_config(prefix)
|
||
res_key = _infer_resolution_key(w, h)
|
||
billing = cfg.get("billing_mode", "token")
|
||
key = (prefix, res_key, bool(has_video_input))
|
||
price = VIRAL_VIDEO_MODEL_PRICES.get(key)
|
||
if price is None:
|
||
price = VIRAL_VIDEO_MODEL_PRICES.get(("seedance-2.5", res_key, False), 70.0)
|
||
dur = max(1, int(duration_seconds or 15))
|
||
if billing == "per_second":
|
||
tokens = 0.0
|
||
video_cost = dur * float(price)
|
||
billing_unit = "second"
|
||
else:
|
||
if actual_tokens is not None and actual_tokens > 0:
|
||
tokens = float(actual_tokens)
|
||
else:
|
||
tokens = dur * w * h * effective_fps / 1024.0
|
||
video_cost = tokens / 1_000_000.0 * float(price)
|
||
billing_unit = "token"
|
||
|
||
total = (video_cost + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER
|
||
credits = round(float(total), 2)
|
||
breakdown = {
|
||
"tokens": float(tokens),
|
||
"video_cost": float(video_cost),
|
||
"fixed_cost": float(VIRAL_VIDEO_FIXED_COST),
|
||
"profit_multiplier": float(VIRAL_VIDEO_PROFIT_MULTIPLIER),
|
||
"model_price": float(price),
|
||
"model_key": prefix,
|
||
"billing_mode": billing,
|
||
"billing_unit": billing_unit,
|
||
"width": int(w),
|
||
"height": int(h),
|
||
"fps": int(effective_fps),
|
||
"duration": dur,
|
||
}
|
||
return credits, breakdown
|
||
|
||
|
||
def calculate_viral_video_credits(
|
||
duration_seconds: int,
|
||
width: int,
|
||
height: int,
|
||
model: str = "seedance-2.5",
|
||
has_video_input: bool = False,
|
||
actual_tokens: int | None = None,
|
||
fps: int = VIRAL_VIDEO_FPS,
|
||
) -> float:
|
||
"""计算爆款视频所需积分(1 积分 = 1 元),仅返回积分值(向后兼容包装器)。
|
||
|
||
内部调用 calculate_viral_video_credits_with_breakdown,仅返回 credits 部分,
|
||
保持旧调用方签名与返回值类型不变。
|
||
|
||
公式:
|
||
tokens = duration * width * height * fps / 1024
|
||
video_cost = tokens / 1_000_000 * model_token_price
|
||
total = round((video_cost + fixed_cost) * profit_multiplier, 2)
|
||
若传入 actual_tokens 则用它替代计算值。
|
||
"""
|
||
credits, _ = calculate_viral_video_credits_with_breakdown(
|
||
duration_seconds=duration_seconds,
|
||
width=width,
|
||
height=height,
|
||
model=model,
|
||
has_video_input=has_video_input,
|
||
actual_tokens=actual_tokens,
|
||
fps=fps,
|
||
)
|
||
return credits
|
||
|
||
|
||
# ============ 场景定义 ============
|
||
# 每个场景: base_points(基础积分), unit(计费单位), name(显示名称), dynamic(是否动态定价)
|
||
# 说明:爆款视频(viral_video)走动态定价(预扣→结算多退少补),因此不使用 @points_gate
|
||
# 装饰器,base_points=0,dynamic=True;前端展示场景列表时仍可看到。
|
||
|
||
POINTS_SCENES: dict[str, dict] = {
|
||
"voice_clone_train": {
|
||
"base_points": 0,
|
||
"unit": "次",
|
||
"name": "声音克隆训练",
|
||
"description": "声音克隆训练免费(每用户限 1 个声音)",
|
||
},
|
||
"voice_clone_synth": {
|
||
"base_points": 1,
|
||
"unit": "分钟",
|
||
"name": "声音克隆合成",
|
||
"description": "克隆音色合成每分钟消耗 1 积分",
|
||
},
|
||
"viral_video": {
|
||
"base_points": 0,
|
||
"unit": "次",
|
||
"name": "爆款视频",
|
||
"dynamic": True,
|
||
"description": "爆款视频动态定价(按视频时长/分辨率/模型计算,预扣→结算多退少补)",
|
||
},
|
||
}
|
||
|
||
# 免费用户积分消耗上浮系数(仅对 voice_clone_synth 生效)
|
||
FREE_USER_MULTIPLIER = 1.15
|
||
|
||
# ============ 积分包定义 ============
|
||
|
||
POINTS_PACKAGES: dict[str, dict] = {
|
||
"starter_pack": {"name": "体验包", "points": 100, "price_cents": 990},
|
||
"basic_pack": {"name": "基础包", "points": 500, "price_cents": 3900},
|
||
"pro_pack": {"name": "专业包", "points": 2000, "price_cents": 12900},
|
||
}
|
||
|
||
# ============ 会员定价 ============
|
||
|
||
MEMBERSHIP_PRICES: dict[str, dict] = {
|
||
"monthly": {"name": "月卡", "price_cents": 1990, "duration_days": 30},
|
||
"quarterly": {"name": "季卡", "price_cents": 3990, "duration_days": 90},
|
||
"yearly": {"name": "年卡", "price_cents": 15900, "duration_days": 365},
|
||
}
|
||
|
||
# 会员积分折扣(付费会员按此系数打折)
|
||
MEMBER_DISCOUNT: dict[str, float] = {
|
||
"monthly": 0.9,
|
||
"quarterly": 0.87,
|
||
"yearly": 0.8,
|
||
}
|
||
|
||
|
||
def calculate_points_cost(
|
||
scene_key: str,
|
||
is_member: bool,
|
||
quantity: int = 1,
|
||
duration_minutes: float = 0,
|
||
member_type: str | None = None,
|
||
) -> float:
|
||
"""计算指定场景的积分消耗。
|
||
|
||
Args:
|
||
scene_key: 场景标识(当前支持 voice_clone_train/voice_clone_synth/viral_video;
|
||
viral_video 为动态定价场景,此处返回 0,由业务侧调用
|
||
calculate_viral_video_credits 手动计算)
|
||
is_member: 是否付费会员
|
||
quantity: 数量(按次计费场景)
|
||
duration_minutes: 时长分钟数(按时长计费场景)
|
||
member_type: 会员类型 (monthly/quarterly/yearly),用于折扣
|
||
|
||
Returns:
|
||
实际消耗积分(float;已含免费用户 ×1.15 上浮或会员折扣);免费/动态/已下线场景统一返回 0。
|
||
"""
|
||
scene = POINTS_SCENES.get(scene_key)
|
||
if not scene:
|
||
# 已下线/未注册的场景统一返回 0(免费),保持向后兼容
|
||
return 0.0
|
||
|
||
# 动态定价场景(如 viral_video)由业务侧手动计算,这里统一返回 0
|
||
if scene.get("dynamic"):
|
||
return 0.0
|
||
|
||
base = scene["base_points"]
|
||
if base == 0:
|
||
return 0.0
|
||
|
||
unit = scene["unit"]
|
||
if unit == "分钟":
|
||
total_base = base * max(1, math.ceil(duration_minutes))
|
||
elif unit in ("次", "张"):
|
||
total_base = base * quantity
|
||
else:
|
||
total_base = base
|
||
|
||
if is_member and member_type and member_type in MEMBER_DISCOUNT:
|
||
total_base = max(1, math.floor(total_base * MEMBER_DISCOUNT[member_type]))
|
||
elif not is_member:
|
||
total_base = math.ceil(total_base * FREE_USER_MULTIPLIER)
|
||
|
||
return float(total_base)
|