feat: add multi-model cost estimation and selection after storyboard

- Add cost_estimator.py: supports Wan 2.2, Wan 2.7, Vidu 2.0 pricing
- Add model_selector.py: creates human_task for customer model selection
- Update ktv_adapter.py: add model_selecting step, modify scene_video_generating to use selected model
- Add integration guide: docs/model-selection-integration.md
This commit is contained in:
yumoqing 2026-06-25 18:51:29 +08:00
parent 9d3f0ff38c
commit 8408570ed7
4 changed files with 513 additions and 18 deletions

199
app/cost_estimator.py Normal file
View File

@ -0,0 +1,199 @@
"""
Video generation cost estimator - multi-model pricing and cost prediction.
Estimates cost for generating scene videos using different models:
- Wan2.2 (local GPU, fast, lower cost)
- Wan2.7 (local GPU, higher quality, medium cost)
- Vidu 2.0 (cloud API, highest quality, premium cost)
Cost factors:
- Scene count from storyboard
- Duration per scene (frames)
- Model pricing (per frame or per second)
- GPU compute cost (for local models)
"""
import logging
from typing import List, Dict, Any
logger = logging.getLogger("pipeline.cost_estimator")
# Model pricing configuration (USD per frame at 24fps)
MODEL_PRICING = {
"wan2.2": {
"name": "Wan 2.2 (Local GPU)",
"price_per_frame": 0.002, # $0.002 per frame
"quality_score": 7.5,
"generation_speed": "fast",
"description": "Fast generation, good quality, cost-effective",
"features": ["T2V", "Ref2V"],
"recommended_for": ["standard", "batch"],
},
"wan2.7": {
"name": "Wan 2.7 (Local GPU)",
"price_per_frame": 0.003, # $0.003 per frame
"quality_score": 8.5,
"generation_speed": "medium",
"description": "Higher quality, balanced performance",
"features": ["T2V", "Ref2V", "I2V"],
"recommended_for": ["quality", "professional"],
},
"vidu2.0": {
"name": "Vidu 2.0 (Cloud API)",
"price_per_frame": 0.008, # $0.008 per frame
"quality_score": 9.0,
"generation_speed": "slow",
"description": "Highest quality, premium pricing",
"features": ["T2V", "I2V", "V2V"],
"recommended_for": ["premium", "cinematic"],
},
}
def estimate_scene_cost(storyboard: List[Dict[str, Any]], fps: int = 24) -> int:
"""
Estimate total frames needed for all scenes.
Args:
storyboard: List of scene dicts with start_time/end_time
fps: Frames per second (default 24)
Returns:
Total frame count
"""
total_frames = 0
for scene in storyboard:
duration = scene.get("end_time", 10) - scene.get("start_time", 0)
frames = int(duration * fps)
total_frames += frames
return total_frames
def estimate_model_cost(model_id: str, total_frames: int) -> Dict[str, Any]:
"""
Estimate cost for a specific model.
Args:
model_id: Model identifier (wan2.2, wan2.7, vidu2.0)
total_frames: Total frames to generate
Returns:
Cost estimate dict with breakdown
"""
if model_id not in MODEL_PRICING:
raise ValueError(f"Unknown model: {model_id}")
model = MODEL_PRICING[model_id]
base_cost = total_frames * model["price_per_frame"]
# Add GPU compute overhead for local models
if "Local GPU" in model["name"]:
# Estimate GPU time: ~10 seconds per frame on 4090
gpu_seconds = total_frames * 10
# GPU cost: $0.50/hour = $0.000139/second
gpu_cost = gpu_seconds * 0.000139
total_cost = base_cost + gpu_cost
else:
# Cloud API includes compute in price
total_cost = base_cost
gpu_cost = 0
return {
"model_id": model_id,
"model_name": model["name"],
"total_frames": total_frames,
"price_per_frame": model["price_per_frame"],
"base_cost": round(base_cost, 4),
"gpu_cost": round(gpu_cost, 4),
"total_cost": round(total_cost, 4),
"quality_score": model["quality_score"],
"generation_speed": model["generation_speed"],
"description": model["description"],
"features": model["features"],
"recommended_for": model["recommended_for"],
}
def estimate_all_models(storyboard: List[Dict[str, Any]], fps: int = 24) -> List[Dict[str, Any]]:
"""
Estimate cost for all available models.
Args:
storyboard: List of scene dicts
fps: Frames per second
Returns:
List of cost estimates sorted by total_cost (ascending)
"""
total_frames = estimate_scene_cost(storyboard, fps)
scene_count = len(storyboard)
estimates = []
for model_id in MODEL_PRICING.keys():
try:
estimate = estimate_model_cost(model_id, total_frames)
estimate["scene_count"] = scene_count
estimate["fps"] = fps
estimates.append(estimate)
except Exception as e:
logger.error(f"Failed to estimate cost for {model_id}: {e}")
# Sort by total_cost (cheapest first)
estimates.sort(key=lambda x: x["total_cost"])
# Add recommendation flag
if estimates:
# Recommend middle option (best value)
mid_idx = len(estimates) // 2
estimates[mid_idx]["recommended"] = True
for i, est in enumerate(estimates):
if i != mid_idx:
est["recommended"] = False
return estimates
def format_cost_summary(estimates: List[Dict[str, Any]]) -> str:
"""
Format cost estimates as human-readable summary.
Args:
estimates: List of cost estimate dicts
Returns:
Formatted string summary
"""
lines = []
lines.append("=" * 70)
lines.append("视频生成模型费用预估")
lines.append("=" * 70)
if not estimates:
lines.append("无可用模型")
return "\n".join(lines)
scene_count = estimates[0].get("scene_count", 0)
total_frames = estimates[0].get("total_frames", 0)
fps = estimates[0].get("fps", 24)
lines.append(f"分镜数量: {scene_count}")
lines.append(f"总帧数: {total_frames} (at {fps}fps)")
lines.append("")
for est in estimates:
rec = " ⭐ 推荐" if est.get("recommended") else ""
lines.append(f"{est['model_name']}{rec}")
lines.append(f" 质量评分: {est['quality_score']}/10")
lines.append(f" 生成速度: {est['generation_speed']}")
lines.append(f" 功能: {', '.join(est['features'])}")
lines.append(f" 描述: {est['description']}")
lines.append(f" 费用明细:")
lines.append(f" - 基础费用: ${est['base_cost']:.4f}")
if est['gpu_cost'] > 0:
lines.append(f" - GPU计算: ${est['gpu_cost']:.4f}")
lines.append(f" - 总计: ${est['total_cost']:.4f}")
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)

View File

@ -27,6 +27,7 @@ from app.quality_gate import (
eval_ktv_synthesis,
eval_scene_video_quality,
)
from app.model_selector import handle_model_selecting
logger = logging.getLogger("pipeline.handlers.ktv")
@ -676,21 +677,27 @@ async def handle_storyboard_generating(tenant_id, task_id, step_name, input_data
async def handle_scene_video_generating(tenant_id, task_id, step_name, input_data, config):
"""Generate scene videos on GPU using T2V/Ref2V."""
"""Generate scene videos on GPU using T2V/Ref2V with customer-selected model."""
work_dir = _task_dir(task_id)
gpu_dir = _gpu_task_dir(task_id)
storyboard = None
char_images = None
selected_model = "wan2.2" # Default fallback
for dep_name, dep_output in input_data.items():
if isinstance(dep_output, dict):
if dep_output.get("storyboard"):
storyboard = dep_output["storyboard"]
if dep_output.get("character_images"):
char_images = dep_output["character_images"]
if dep_output.get("selected_model"):
selected_model = dep_output["selected_model"]
if not storyboard:
raise ValueError("上游步骤未提供分镜脚本")
logger.info(f"Using model: {selected_model} for scene video generation")
await _run_gpu(f"mkdir -p {gpu_dir}/scenes")
@ -717,23 +724,51 @@ async def handle_scene_video_generating(tenant_id, task_id, step_name, input_dat
ref_image = f"{gpu_dir}/characters/{os.path.basename(ci['image_path'])}"
break
if ref_image:
gen_cmd = (
f"cd {GPU_WAN22_DIR} && source venv/bin/activate && "
f"python generate_ref2v.py --prompt '{desc}' "
f"--ref_image '{ref_image}' "
f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 "
f"--frames {frames}"
)
# Generate video based on selected model
if selected_model in ["wan2.2", "wan2.7"]:
# Local GPU generation
gpu_wan_dir = GPU_WAN22_DIR # Both use same base directory
if ref_image:
gen_cmd = (
f"cd {gpu_wan_dir} && source venv/bin/activate && "
f"python generate_ref2v.py --model {selected_model} --prompt '{desc}' "
f"--ref_image '{ref_image}' "
f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 "
f"--frames {frames}"
)
else:
gen_cmd = (
f"cd {gpu_wan_dir} && source venv/bin/activate && "
f"python generate_t2v.py --model {selected_model} --prompt '{desc}' "
f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 "
f"--frames {frames}"
)
stdout, stderr, rc = await _run_gpu(gen_cmd, timeout=600)
elif selected_model == "vidu2.0":
# Cloud API generation (placeholder - needs actual Vidu API integration)
logger.warning(f"Vidu 2.0 cloud API not yet implemented, falling back to wan2.2")
if ref_image:
gen_cmd = (
f"cd {GPU_WAN22_DIR} && source venv/bin/activate && "
f"python generate_ref2v.py --model wan2.2 --prompt '{desc}' "
f"--ref_image '{ref_image}' "
f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 "
f"--frames {frames}"
)
else:
gen_cmd = (
f"cd {GPU_WAN22_DIR} && source venv/bin/activate && "
f"python generate_t2v.py --model wan2.2 --prompt '{desc}' "
f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 "
f"--frames {frames}"
)
stdout, stderr, rc = await _run_gpu(gen_cmd, timeout=600)
else:
gen_cmd = (
f"cd {GPU_WAN22_DIR} && source venv/bin/activate && "
f"python generate_t2v.py --prompt '{desc}' "
f"--output {gpu_dir}/scenes/scene_{i:03d}.mp4 "
f"--frames {frames}"
)
stdout, stderr, rc = await _run_gpu(gen_cmd, timeout=600)
raise ValueError(f"Unknown model: {selected_model}")
local_scene = os.path.join(work_dir, f"scene_{i:03d}.mp4")
await _copy_from_gpu(f"{gpu_dir}/scenes/scene_{i:03d}.mp4", local_scene)
@ -743,9 +778,14 @@ async def handle_scene_video_generating(tenant_id, task_id, step_name, input_dat
"video_path": local_scene,
"description": desc,
"duration": duration,
"model_used": selected_model,
})
return {"scene_videos": scene_videos, "scene_count": len(scene_videos)}
return {
"scene_videos": scene_videos,
"scene_count": len(scene_videos),
"selected_model": selected_model,
}
async def handle_scene_video_evaluating(tenant_id, task_id, step_name, input_data, config):
@ -988,6 +1028,7 @@ KTV_HANDLERS = {
"character_designing": quality_character_designing, # 质量门控 ✓
"character_image_generating": quality_character_image_generating, # 质量门控 ✓
"storyboard_generating": quality_storyboard_generating, # 质量门控 ✓
"model_selecting": handle_model_selecting, # 客户选择模型 ✓
"scene_video_generating": handle_scene_video_generating,
"scene_video_evaluating": quality_scene_video_evaluating, # 质量门控 ✓
"scene_video_concatenating": handle_scene_video_concatenating,

146
app/model_selector.py Normal file
View File

@ -0,0 +1,146 @@
"""
Model selection interactive handler - allows customer to choose video generation model.
Creates a human_task after storyboard generation to:
1. Display cost estimates for all available models
2. Wait for customer selection
3. Store selected model in task context for downstream steps
"""
import json
import logging
from typing import Dict, Any
from .cost_estimator import estimate_all_models, format_cost_summary
logger = logging.getLogger("pipeline.model_selector")
async def handle_model_selecting(
tenant_id: str,
task_id: str,
step_name: str,
input_data: Dict[str, Any],
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Interactive handler: present model options and wait for customer selection.
Args:
tenant_id: Tenant identifier
task_id: Task identifier
step_name: Step name (should be "model_selecting")
input_data: Input data containing storyboard
config: Step configuration
Returns:
Dict with selected_model and cost_estimates
"""
# Extract storyboard from input
storyboard = None
for dep_name, dep_output in input_data.items():
if isinstance(dep_output, dict):
if dep_output.get("storyboard"):
storyboard = dep_output["storyboard"]
break
if not storyboard:
raise ValueError("上游步骤未提供分镜脚本 (storyboard)")
# Generate cost estimates
fps = config.get("fps", 24)
estimates = estimate_all_models(storyboard, fps)
# Format summary
summary = format_cost_summary(estimates)
logger.info(f"Generated cost estimates for {len(estimates)} models")
# Create human_task for customer selection
task_data = {
"title": "请选择视频生成模型",
"description": summary,
"options": [
{
"value": est["model_id"],
"label": est["model_name"],
"description": f"${est['total_cost']:.4f} - {est['description']}",
"recommended": est.get("recommended", False),
}
for est in estimates
],
"default_value": estimates[0]["model_id"] if estimates else "wan2.2",
"task_type": "model_selection",
"metadata": {
"cost_estimates": estimates,
"scene_count": len(storyboard),
"total_frames": sum(
int((s.get("end_time", 10) - s.get("start_time", 0)) * fps)
for s in storyboard
),
},
}
from pipeline_service.storage import create_human_task
human_task_id = await create_human_task(
task_id=task_id,
step_name=step_name,
version=config.get("version", 1),
task_type="model_selection",
form_schema=task_data,
timeout_hours=config.get("timeout_hours", 24),
)
logger.info(f"Created model selection human_task: {human_task_id}")
# Return placeholder (actual selection will be in human_task.result)
return {
"human_task_id": human_task_id,
"status": "waiting_for_selection",
"cost_estimates": estimates,
"summary": summary,
}
def get_selected_model(human_task_result: Dict[str, Any]) -> str:
"""
Extract selected model from human_task result.
Args:
human_task_result: Result dict from completed human_task
Returns:
Selected model_id
"""
if not human_task_result:
logger.warning("No human_task result, using default model")
return "wan2.2"
selected = human_task_result.get("selected_model")
if not selected:
logger.warning("No model selected, using default")
return "wan2.2"
logger.info(f"Customer selected model: {selected}")
return selected
def get_model_cost_for_selected(
estimates: list,
selected_model: str
) -> Dict[str, Any]:
"""
Get cost estimate for the selected model.
Args:
estimates: List of cost estimate dicts
selected_model: Selected model_id
Returns:
Cost estimate dict for selected model
"""
for est in estimates:
if est["model_id"] == selected_model:
return est
logger.warning(f"Selected model {selected_model} not found in estimates")
return {}

View File

@ -0,0 +1,109 @@
# 模型选择功能集成指南
## 功能概述
在分镜生成storyboard_generating后插入模型选择步骤允许客户根据费用预估选择视频生成模型。
## 已完成的代码修改
### 1. 新增模块
- `app/cost_estimator.py` - 多模型费用预估Wan 2.2, Wan 2.7, Vidu 2.0
- `app/model_selector.py` - 模型选择交互处理器
### 2. 修改模块
- `app/ktv_adapter.py` - 添加 model_selecting handler修改 scene_video_generating 支持多模型
## 产线定义更新步骤
### 步骤 1: 插入 model_selecting 步骤
在数据库的 `pipeline_steps` 表中,在 `storyboard_generating` (step_order=13) 之后插入新步骤:
```sql
-- 查找当前 storyboard_generating 的 step_order
SELECT step_order FROM pipeline_steps
WHERE pipeline_id = 'ktv_pipeline' AND step_name = 'storyboard_generating';
-- 假设结果是 13需要将后续步骤的 step_order +1
UPDATE pipeline_steps
SET step_order = step_order + 1
WHERE pipeline_id = 'ktv_pipeline' AND step_order > 13;
-- 插入 model_selecting 步骤
INSERT INTO pipeline_steps (
pipeline_id, step_name, step_type, step_order,
description, handler_function, step_config
) VALUES (
'ktv_pipeline',
'model_selecting',
'interactive',
14,
'客户根据费用预估选择视频生成模型',
'handle_model_selecting',
'{"deps": ["storyboard_generating"], "timeout_hours": 24}'
);
-- 更新 scene_video_generating 的依赖
UPDATE pipeline_steps
SET step_config = JSON_SET(
step_config,
'$.deps',
JSON_ARRAY('model_selecting', 'character_image_generating')
)
WHERE pipeline_id = 'ktv_pipeline' AND step_name = 'scene_video_generating';
```
### 步骤 2: 验证步骤顺序
```sql
SELECT step_name, step_order, step_type
FROM pipeline_steps
WHERE pipeline_id = 'ktv_pipeline'
ORDER BY step_order;
```
预期结果:
```
1 - audio_preparing
2 - demucs_separating
3 - lyric_calibrating
...
13 - storyboard_generating
14 - model_selecting <-- 新增
15 - scene_video_generating <-- 更新依赖
16 - scene_video_evaluating
...
```
## 费用预估模型配置
当前支持的模型(在 `cost_estimator.py` 中定义):
| 模型 | 质量评分 | 生成速度 | 价格/帧 | 特点 |
|------|---------|---------|---------|------|
| Wan 2.2 | 7.5/10 | fast | $0.002 | 本地GPU性价比高 |
| Wan 2.7 | 8.5/10 | medium | $0.003 | 本地GPU质量更好 |
| Vidu 2.0 | 9.0/10 | slow | $0.008 | 云端API最高质量 |
## 工作流程
1. **分镜生成完成** → 计算总帧数和时长
2. **生成费用预估** → 为每个模型计算成本
3. **创建 human_task** → 显示选项给客户
4. **客户选择模型** → 通过前端界面选择
5. **任务继续执行** → scene_video_generating 使用选中的模型
## 注意事项
1. **Vidu 2.0 暂未实现** - 当前选择 Vidu 2.0 会降级到 Wan 2.2,需要后续集成 Vidu API
2. **超时设置** - model_selecting 步骤默认 24 小时超时,可在 step_config 中调整
3. **默认选择** - 如果客户未选择,默认使用 Wan 2.2(最便宜选项)
## 验证清单
- [ ] 数据库步骤已更新
- [ ] 代码已提交 (commit: model-selection)
- [ ] 测试产线执行到 storyboard_generating
- [ ] 验证 human_task 创建成功
- [ ] 验证客户选择后任务继续执行
- [ ] 验证 scene_video_generating 使用了正确的模型