1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
- 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL
2. pyflakes 警告清零 (apps/ + packages/ + tests/):
- 移除 17 处未使用的 import (F401)
- 修复 26 处未使用的局部变量 (F841):
* 有副作用的赋值转为裸调用
* 无副作用的赋值直接删除
- 修复 1 处未使用的异常变量 (F841)
- 修复 1 处空 except 块
3. 测试文件冗余清理:
- 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
- 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
626 lines
26 KiB
Python
626 lines
26 KiB
Python
"""灰度对比测试 Runner — 新旧引擎批量对比 + 报告生成.
|
||
|
||
使用方法:
|
||
# 配置环境变量
|
||
export STAGING_API_URL=https://api.staging.example.com
|
||
export STAGING_API_KEY=your_key
|
||
|
||
# 运行全部 P0 场景
|
||
python -m tests.render_compare.runner --priority P0 --output ./report/
|
||
|
||
# 只跑指定场景
|
||
python -m tests.render_compare.runner --scenario simple_pass_through,subtitle_rendering
|
||
|
||
对比流程:
|
||
1. 对每个场景,分别提交到 legacy 和 unified 引擎(通过 Feature Flag 白名单/百分比控制)
|
||
- 方式A:通过内部 API 临时切换 flag(需要 admin key)
|
||
- 方式B:提交任务时指定 engine 参数(如果 API 支持)
|
||
2. 等待任务完成,下载输出视频
|
||
3. 像素对比(SSIM + PSNR)+ 音频对比(差值RMS)
|
||
4. 生成 HTML 对比报告
|
||
|
||
注意:默认假设 API 支持 `engine` 参数来指定渲染引擎。
|
||
如果不支持,需要先通过内部 API 切换 Feature Flag,然后提交任务。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
# 确保项目根目录在 path 中
|
||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||
|
||
from .audio_diff import AudioDiffResult, compute_audio_diff
|
||
from .scenarios import SCENARIOS, CompareScenario, get_scenarios_by_priority
|
||
from .video_diff import VideoDiffResult, compute_video_diff
|
||
|
||
|
||
@dataclass
|
||
class ScenarioResult:
|
||
"""单个场景的对比结果."""
|
||
|
||
scenario: CompareScenario
|
||
legacy_task_id: str = ""
|
||
unified_task_id: str = ""
|
||
legacy_video_path: str = ""
|
||
unified_video_path: str = ""
|
||
legacy_duration_sec: float = 0.0
|
||
unified_duration_sec: float = 0.0
|
||
video_diff: VideoDiffResult | None = None
|
||
audio_diff: AudioDiffResult | None = None
|
||
legacy_success: bool = False
|
||
unified_success: bool = False
|
||
error: str = ""
|
||
|
||
@property
|
||
def passed(self) -> bool:
|
||
if not (self.legacy_success and self.unified_success):
|
||
return False
|
||
if self.video_diff and not self.video_diff.passed:
|
||
return False
|
||
if self.audio_diff and not self.audio_diff.passed:
|
||
return False
|
||
return True
|
||
|
||
|
||
class StagingAPI:
|
||
"""Staging 环境 API 客户端."""
|
||
|
||
def __init__(self, base_url: str, api_key: str, internal_api_key: str = ""):
|
||
self.base_url = base_url.rstrip("/")
|
||
self.api_key = api_key
|
||
self.internal_api_key = internal_api_key
|
||
self.client = httpx.Client(timeout=30.0)
|
||
|
||
def _headers(self, internal: bool = False) -> dict[str, str]:
|
||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||
if internal and self.internal_api_key:
|
||
headers["X-API-Key"] = self.internal_api_key
|
||
return headers
|
||
|
||
def submit_render_task(self, plan_payload: dict[str, Any], engine: str = "") -> str:
|
||
"""提交渲染任务,返回 task_id.
|
||
|
||
Args:
|
||
plan_payload: EditPlan payload
|
||
engine: 可选,指定引擎("legacy" / "unified")
|
||
|
||
Returns:
|
||
task_id
|
||
"""
|
||
url = f"{self.base_url}/api/v1/render/compose"
|
||
payload = dict(plan_payload)
|
||
if engine:
|
||
payload["engine"] = engine
|
||
resp = self.client.post(url, json=payload, headers=self._headers())
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return data.get("task_id") or data.get("id", "")
|
||
|
||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||
"""获取任务状态."""
|
||
url = f"{self.base_url}/api/v1/tasks/{task_id}"
|
||
resp = self.client.get(url, headers=self._headers())
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
def wait_for_task(self, task_id: str, timeout: float = 300.0, poll_interval: float = 3.0) -> dict[str, Any]:
|
||
"""等待任务完成.
|
||
|
||
Returns:
|
||
最终任务状态
|
||
|
||
Raises:
|
||
TimeoutError: 超时
|
||
"""
|
||
start = time.time()
|
||
while time.time() - start < timeout:
|
||
status = self.get_task_status(task_id)
|
||
state = status.get("status", "")
|
||
if state in ("completed", "success", "done", "failed", "error"):
|
||
return status
|
||
time.sleep(poll_interval)
|
||
raise TimeoutError(f"Task {task_id} timed out after {timeout}s")
|
||
|
||
def set_feature_flag(self, flag_name: str, enabled: bool, percentage: int = 0, whitelist: list[str] | None = None):
|
||
"""通过内部 API 设置 Feature Flag.
|
||
|
||
用于不支持 engine 参数的场景,切换全局灰度比例。
|
||
"""
|
||
if not self.internal_api_key:
|
||
raise ValueError("internal_api_key is required for feature flag operations")
|
||
url = f"{self.base_url}/api/v1/internal/feature-flags/{flag_name}"
|
||
body: dict[str, Any] = {"enabled": enabled, "percentage": percentage}
|
||
if whitelist is not None:
|
||
body["whitelist"] = whitelist
|
||
resp = self.client.put(url, json=body, headers=self._headers(internal=True))
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
def get_feature_flag(self, flag_name: str) -> dict[str, Any]:
|
||
"""获取 Feature Flag 配置."""
|
||
if not self.internal_api_key:
|
||
raise ValueError("internal_api_key is required")
|
||
url = f"{self.base_url}/api/v1/internal/feature-flags/{flag_name}"
|
||
resp = self.client.get(url, headers=self._headers(internal=True))
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
def download_video(self, video_url: str, output_path: str | Path) -> Path:
|
||
"""下载视频文件."""
|
||
output_path = Path(output_path)
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with self.client.stream("GET", video_url, timeout=60.0) as resp:
|
||
resp.raise_for_status()
|
||
with open(output_path, "wb") as f:
|
||
for chunk in resp.iter_bytes():
|
||
f.write(chunk)
|
||
return output_path
|
||
|
||
|
||
class CompareRunner:
|
||
"""新旧引擎对比 Runner."""
|
||
|
||
# 全局默认阈值(唯一真实来源,所有入口统一引用)
|
||
DEFAULT_SSIM_THRESHOLD: float = 0.95
|
||
DEFAULT_PSNR_THRESHOLD: float = 28.0
|
||
DEFAULT_AUDIO_SIMILARITY_THRESHOLD: float = 0.90
|
||
DEFAULT_DURATION_TOLERANCE: float = 0.1
|
||
DEFAULT_TASK_TIMEOUT: float = 300.0
|
||
|
||
def __init__(
|
||
self,
|
||
api: StagingAPI,
|
||
output_dir: Path,
|
||
*,
|
||
ssim_threshold: float | None = None,
|
||
psnr_threshold: float | None = None,
|
||
audio_similarity_threshold: float | None = None,
|
||
task_timeout: float | None = None,
|
||
flag_mode: bool = False, # 是否使用 Feature Flag 方式切换引擎
|
||
duration_tolerance: float | None = None,
|
||
):
|
||
self.api = api
|
||
self.output_dir = output_dir
|
||
self.ssim_threshold = ssim_threshold if ssim_threshold is not None else self.DEFAULT_SSIM_THRESHOLD
|
||
self.psnr_threshold = psnr_threshold if psnr_threshold is not None else self.DEFAULT_PSNR_THRESHOLD
|
||
self.audio_similarity_threshold = (
|
||
audio_similarity_threshold
|
||
if audio_similarity_threshold is not None
|
||
else self.DEFAULT_AUDIO_SIMILARITY_THRESHOLD
|
||
)
|
||
self.duration_tolerance = (
|
||
duration_tolerance if duration_tolerance is not None else self.DEFAULT_DURATION_TOLERANCE
|
||
)
|
||
self.task_timeout = task_timeout if task_timeout is not None else self.DEFAULT_TASK_TIMEOUT
|
||
self.flag_mode = flag_mode
|
||
self.results: list[ScenarioResult] = []
|
||
# flag_mode 下保存原始配置,测试结束后恢复(防污染线上)
|
||
self._original_flag_config: dict[str, Any] | None = None
|
||
|
||
def run_scenario(self, scenario: CompareScenario) -> ScenarioResult:
|
||
"""运行单个场景对比."""
|
||
print(f"\n{'='*60}")
|
||
print(f"[{scenario.priority}] {scenario.id}: {scenario.name}")
|
||
print(f" {scenario.description}")
|
||
|
||
result = ScenarioResult(scenario=scenario)
|
||
scenario_dir = self.output_dir / scenario.id
|
||
scenario_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
try:
|
||
# 1. 提交两个引擎的任务
|
||
legacy_task_id = self._submit_with_engine(scenario, "legacy")
|
||
unified_task_id = self._submit_with_engine(scenario, "unified")
|
||
result.legacy_task_id = legacy_task_id
|
||
result.unified_task_id = unified_task_id
|
||
print(f" legacy task: {legacy_task_id}")
|
||
print(f" unified task: {unified_task_id}")
|
||
|
||
# 2. 等待完成
|
||
print(" waiting for legacy...", end="", flush=True)
|
||
legacy_status = self.api.wait_for_task(legacy_task_id, timeout=self.task_timeout)
|
||
result.legacy_success = legacy_status.get("status") in ("completed", "success", "done")
|
||
legacy_video_url = legacy_status.get("output_url", "") or legacy_status.get("video_url", "")
|
||
print(f" {'✅' if result.legacy_success else '❌'} ({legacy_status.get('duration_sec', '?')}s)")
|
||
|
||
print(" waiting for unified...", end="", flush=True)
|
||
unified_status = self.api.wait_for_task(unified_task_id, timeout=self.task_timeout)
|
||
result.unified_success = unified_status.get("status") in ("completed", "success", "done")
|
||
unified_video_url = unified_status.get("output_url", "") or unified_status.get("video_url", "")
|
||
print(f" {'✅' if result.unified_success else '❌'} ({unified_status.get('duration_sec', '?')}s)")
|
||
|
||
result.legacy_duration_sec = float(legacy_status.get("duration_sec", 0))
|
||
result.unified_duration_sec = float(unified_status.get("duration_sec", 0))
|
||
|
||
if not (result.legacy_success and result.unified_success):
|
||
result.error = f"Legacy success={result.legacy_success}, Unified success={result.unified_success}"
|
||
print(" ⚠️ 任务未全部成功,跳过对比")
|
||
return result
|
||
|
||
# 3. 下载视频
|
||
print(" downloading...", end="", flush=True)
|
||
legacy_path = self.api.download_video(legacy_video_url, scenario_dir / "legacy.mp4")
|
||
unified_path = self.api.download_video(unified_video_url, scenario_dir / "unified.mp4")
|
||
result.legacy_video_path = str(legacy_path)
|
||
result.unified_video_path = str(unified_path)
|
||
print(" ✅")
|
||
|
||
# 4. 像素对比
|
||
print(" computing video diff...", end="", flush=True)
|
||
result.video_diff = compute_video_diff(
|
||
legacy_path,
|
||
unified_path,
|
||
ssim_threshold=self.ssim_threshold,
|
||
psnr_threshold=self.psnr_threshold,
|
||
duration_tolerance=self.duration_tolerance,
|
||
)
|
||
print(
|
||
f" SSIM={result.video_diff.avg_ssim:.4f} PSNR={result.video_diff.avg_psnr:.2f}dB {'✅' if result.video_diff.passed else '❌'}"
|
||
)
|
||
|
||
# 5. 音频对比(仅当都有音频时)
|
||
from .audio_diff import probe_has_audio
|
||
|
||
legacy_has_audio = probe_has_audio(legacy_path)
|
||
unified_has_audio = probe_has_audio(unified_path)
|
||
|
||
if legacy_has_audio and unified_has_audio:
|
||
print(" computing audio diff...", end="", flush=True)
|
||
result.audio_diff = compute_audio_diff(
|
||
legacy_path,
|
||
unified_path,
|
||
similarity_threshold=self.audio_similarity_threshold,
|
||
)
|
||
print(
|
||
f" similarity={result.audio_diff.similarity_score:.4f} {'✅' if result.audio_diff.passed else '❌'}"
|
||
)
|
||
elif legacy_has_audio != unified_has_audio:
|
||
result.error = f"音频不一致: legacy_has_audio={legacy_has_audio}, unified_has_audio={unified_has_audio}"
|
||
print(f" ⚠️ 音频不一致: legacy={legacy_has_audio}, unified={unified_has_audio}")
|
||
else:
|
||
print(" audio: both silent (skip)")
|
||
|
||
except Exception as e:
|
||
result.error = str(e)
|
||
print(f" ❌ 错误: {e}")
|
||
|
||
self.results.append(result)
|
||
return result
|
||
|
||
def _submit_with_engine(self, scenario: CompareScenario, engine: str) -> str:
|
||
"""提交指定引擎的任务.
|
||
|
||
如果 flag_mode=True,通过 Feature Flag 切换,否则通过 engine 参数。
|
||
"""
|
||
if self.flag_mode:
|
||
# 先设置 flag(用白名单方式,确保只有当前测试用户命中)
|
||
percentage = 0 if engine == "legacy" else 100
|
||
self.api.set_feature_flag("render_engine", enabled=True, percentage=percentage)
|
||
time.sleep(1) # 给 worker 一点时间刷新配置
|
||
return self.api.submit_render_task(scenario.plan_payload)
|
||
else:
|
||
return self.api.submit_render_task(scenario.plan_payload, engine=engine)
|
||
|
||
def run_all(self, scenarios: list[CompareScenario]) -> list[ScenarioResult]:
|
||
"""运行所有场景.
|
||
|
||
flag_mode=True 时,测试开始前保存原始 Feature Flag 配置,
|
||
结束后(无论成功失败)自动恢复,避免污染线上环境。
|
||
"""
|
||
print(f"\n灰度对比测试开始 - {len(scenarios)} 个场景")
|
||
print(f"输出目录: {self.output_dir}")
|
||
print(f"视频阈值: SSIM>={self.ssim_threshold}, PSNR>={self.psnr_threshold}dB")
|
||
print(f"音频阈值: similarity>={self.audio_similarity_threshold}")
|
||
|
||
# flag_mode:保存原始配置,测试结束后恢复(防污染)
|
||
if self.flag_mode:
|
||
try:
|
||
self._original_flag_config = self.api.get_feature_flag("render_engine")
|
||
print(f" [flag_mode] 已保存原始配置: {self._original_flag_config}")
|
||
except Exception as e:
|
||
print(f" ⚠️ [flag_mode] 保存原始配置失败: {e}")
|
||
print(" 为避免污染线上,将中止测试。请检查 internal_api_key 配置。")
|
||
return self.results
|
||
|
||
try:
|
||
for i, scenario in enumerate(scenarios):
|
||
print(f"\n进度: {i+1}/{len(scenarios)}")
|
||
self.run_scenario(scenario)
|
||
finally:
|
||
# 始终恢复原始 flag 配置
|
||
if self.flag_mode and self._original_flag_config:
|
||
try:
|
||
orig = self._original_flag_config
|
||
self.api.set_feature_flag(
|
||
"render_engine",
|
||
enabled=orig.get("enabled", False),
|
||
percentage=orig.get("percentage", 0),
|
||
whitelist=orig.get("whitelist"),
|
||
)
|
||
print("\n[flag_mode] ✅ 已恢复原始 Feature Flag 配置")
|
||
except Exception as e:
|
||
print(f"\n[flag_mode] ❌ 恢复 Feature Flag 失败: {e}")
|
||
print(" 请手动检查并恢复 render_engine flag 配置!")
|
||
|
||
return self.results
|
||
|
||
def summary(self) -> dict[str, Any]:
|
||
"""生成汇总统计."""
|
||
total = len(self.results)
|
||
passed = sum(1 for r in self.results if r.passed)
|
||
failed = total - passed
|
||
|
||
# 性能对比
|
||
perf_diffs = []
|
||
for r in self.results:
|
||
if r.legacy_success and r.unified_success and r.legacy_duration_sec > 0:
|
||
diff_pct = (r.unified_duration_sec - r.legacy_duration_sec) / r.legacy_duration_sec * 100
|
||
perf_diffs.append(diff_pct)
|
||
avg_perf_diff = sum(perf_diffs) / len(perf_diffs) if perf_diffs else 0.0
|
||
|
||
return {
|
||
"total": total,
|
||
"passed": passed,
|
||
"failed": failed,
|
||
"pass_rate": f"{passed/total*100:.1f}%" if total > 0 else "0%",
|
||
"avg_perf_diff_pct": round(avg_perf_diff, 2),
|
||
"scenarios": [self._result_to_dict(r) for r in self.results],
|
||
"timestamp": datetime.now().isoformat(),
|
||
"ssim_threshold": self.ssim_threshold,
|
||
"psnr_threshold": self.psnr_threshold,
|
||
"audio_threshold": self.audio_similarity_threshold,
|
||
}
|
||
|
||
def _result_to_dict(self, r: ScenarioResult) -> dict[str, Any]:
|
||
return {
|
||
"id": r.scenario.id,
|
||
"name": r.scenario.name,
|
||
"priority": r.scenario.priority,
|
||
"passed": r.passed,
|
||
"legacy_success": r.legacy_success,
|
||
"unified_success": r.unified_success,
|
||
"legacy_duration_sec": r.legacy_duration_sec,
|
||
"unified_duration_sec": r.unified_duration_sec,
|
||
"video_diff": r.video_diff.to_dict() if r.video_diff else None,
|
||
"audio_diff": r.audio_diff.to_dict() if r.audio_diff else None,
|
||
"error": r.error,
|
||
}
|
||
|
||
|
||
def generate_html_report(summary: dict[str, Any], output_path: Path):
|
||
"""生成 HTML 对比报告."""
|
||
scenarios = summary["scenarios"]
|
||
|
||
# 按通过/失败分组
|
||
# 构建场景卡片
|
||
scenario_cards = ""
|
||
for s in scenarios:
|
||
status_class = "pass" if s["passed"] else "fail"
|
||
status_text = "✅ 通过" if s["passed"] else "❌ 失败"
|
||
|
||
vdiff = s.get("video_diff") or {}
|
||
adiff = s.get("audio_diff") or {}
|
||
|
||
video_info = ""
|
||
if vdiff:
|
||
video_info = f"""
|
||
<div class="metric-row">
|
||
<span>SSIM:</span>
|
||
<span class="{'good' if vdiff.get('avg_ssim', 0) >= 0.95 else 'warn'}">{vdiff.get('avg_ssim', 0):.4f}</span>
|
||
</div>
|
||
<div class="metric-row">
|
||
<span>PSNR:</span>
|
||
<span>{vdiff.get('avg_psnr', 0):.2f} dB</span>
|
||
</div>
|
||
<div class="metric-row">
|
||
<span>时长差:</span>
|
||
<span>{vdiff.get('duration_diff', 0):.3f}s</span>
|
||
</div>
|
||
"""
|
||
|
||
audio_info = ""
|
||
if adiff:
|
||
audio_info = f"""
|
||
<div class="metric-row">
|
||
<span>音频相似度:</span>
|
||
<span class="{'good' if adiff.get('similarity_score', 0) >= 0.9 else 'warn'}">{adiff.get('similarity_score', 0):.4f}</span>
|
||
</div>
|
||
<div class="metric-row">
|
||
<span>差值 RMS:</span>
|
||
<span>{adiff.get('diff_rms_db', 0):.2f} dB</span>
|
||
</div>
|
||
"""
|
||
|
||
perf_info = ""
|
||
if s["legacy_duration_sec"] and s["unified_duration_sec"]:
|
||
diff = s["unified_duration_sec"] - s["legacy_duration_sec"]
|
||
pct = diff / s["legacy_duration_sec"] * 100 if s["legacy_duration_sec"] else 0
|
||
trend = "🔴" if pct > 10 else ("🟡" if pct > 0 else "🟢")
|
||
perf_info = f"""
|
||
<div class="perf-row">
|
||
<span>Legacy: {s['legacy_duration_sec']:.2f}s</span>
|
||
<span>Unified: {s['unified_duration_sec']:.2f}s</span>
|
||
<span>{trend} {pct:+.1f}%</span>
|
||
</div>
|
||
"""
|
||
|
||
error_info = f'<div class="error-box">{s["error"]}</div>' if s["error"] else ""
|
||
|
||
scenario_cards += f"""
|
||
<div class="card {status_class}">
|
||
<div class="card-header">
|
||
<span class="badge">{s['priority']}</span>
|
||
<span class="scenario-name">{s['name']}</span>
|
||
<span class="status {status_class}">{status_text}</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="grid-2">
|
||
<div>
|
||
<h4>视频质量</h4>
|
||
{video_info or '<p class="muted">无数据</p>'}
|
||
</div>
|
||
<div>
|
||
<h4>音频质量</h4>
|
||
{audio_info or '<p class="muted">无音频或跳过</p>'}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h4>性能对比</h4>
|
||
{perf_info or '<p class="muted">无数据</p>'}
|
||
</div>
|
||
{error_info}
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>统一渲染引擎灰度对比报告</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 20px; }}
|
||
.container {{ max-width: 1200px; margin: 0 auto; }}
|
||
h1 {{ margin-bottom: 20px; font-size: 24px; }}
|
||
.summary {{ background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; display: flex; gap: 32px; flex-wrap: wrap; }}
|
||
.summary-item {{ text-align: center; }}
|
||
.summary-item .value {{ font-size: 32px; font-weight: bold; margin-bottom: 4px; }}
|
||
.summary-item .label {{ color: #666; font-size: 14px; }}
|
||
.pass .value {{ color: #10b981; }}
|
||
.fail .value {{ color: #ef4444; }}
|
||
.card {{ background: white; border-radius: 12px; margin-bottom: 16px; overflow: hidden; border-left: 4px solid #10b981; }}
|
||
.card.fail {{ border-left-color: #ef4444; }}
|
||
.card-header {{ padding: 16px 20px; background: #fafafa; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #eee; }}
|
||
.badge {{ background: #e5e7eb; color: #374151; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }}
|
||
.scenario-name {{ flex: 1; font-weight: 600; }}
|
||
.status {{ font-weight: 600; }}
|
||
.status.pass {{ color: #10b981; }}
|
||
.status.fail {{ color: #ef4444; }}
|
||
.card-body {{ padding: 20px; }}
|
||
.grid-2 {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 16px; }}
|
||
h4 {{ margin-bottom: 12px; color: #374151; font-size: 14px; }}
|
||
.metric-row {{ display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }}
|
||
.metric-row .good {{ color: #10b981; font-weight: 600; }}
|
||
.metric-row .warn {{ color: #f59e0b; font-weight: 600; }}
|
||
.perf-row {{ display: flex; gap: 24px; padding: 8px 0; font-size: 14px; background: #f9fafb; padding: 12px; border-radius: 8px; }}
|
||
.error-box {{ background: #fef2f2; color: #dc2626; padding: 12px; border-radius: 8px; margin-top: 12px; font-size: 13px; }}
|
||
.muted {{ color: #9ca3af; font-size: 14px; }}
|
||
.timestamp {{ text-align: center; color: #9ca3af; font-size: 12px; margin-top: 24px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🎬 统一渲染引擎灰度对比报告</h1>
|
||
<div class="summary">
|
||
<div class="summary-item">
|
||
<div class="value">{summary['total']}</div>
|
||
<div class="label">总场景数</div>
|
||
</div>
|
||
<div class="summary-item pass">
|
||
<div class="value">{summary['passed']}</div>
|
||
<div class="label">通过</div>
|
||
</div>
|
||
<div class="summary-item fail">
|
||
<div class="value">{summary['failed']}</div>
|
||
<div class="label">失败</div>
|
||
</div>
|
||
<div class="summary-item">
|
||
<div class="value">{summary['pass_rate']}</div>
|
||
<div class="label">通过率</div>
|
||
</div>
|
||
<div class="summary-item">
|
||
<div class="value {'good' if summary['avg_perf_diff_pct'] <= 0 else 'warn'}" style="font-size: 24px; color: {'#10b981' if summary['avg_perf_diff_pct'] <= 0 else '#f59e0b'}">{summary['avg_perf_diff_pct']:+.1f}%</div>
|
||
<div class="label">平均性能差异</div>
|
||
</div>
|
||
</div>
|
||
{scenario_cards}
|
||
<div class="timestamp">生成时间: {summary['timestamp']}</div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
output_path.write_text(html, encoding="utf-8")
|
||
return output_path
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="统一渲染引擎灰度对比测试")
|
||
parser.add_argument("--priority", default="P0", choices=["P0", "P1", "P2"], help="最低优先级")
|
||
parser.add_argument("--scenarios", default="", help="指定场景ID,逗号分隔")
|
||
parser.add_argument("--output", default="./gray_compare_report", help="输出目录")
|
||
parser.add_argument("--ssim-threshold", type=float, default=None, help="SSIM阈值(默认0.95)")
|
||
parser.add_argument("--psnr-threshold", type=float, default=None, help="PSNR阈值(dB)(默认28.0)")
|
||
parser.add_argument("--audio-threshold", type=float, default=None, help="音频相似度阈值(默认0.90)")
|
||
parser.add_argument("--flag-mode", action="store_true", help="使用Feature Flag方式切换引擎")
|
||
parser.add_argument("--task-timeout", type=float, default=300.0, help="单任务超时时间(秒)")
|
||
args = parser.parse_args()
|
||
|
||
base_url = os.environ.get("STAGING_API_URL", "")
|
||
api_key = os.environ.get("STAGING_API_KEY", "")
|
||
internal_key = os.environ.get("STAGING_INTERNAL_API_KEY", "")
|
||
|
||
if not base_url or not api_key:
|
||
print("❌ 请设置环境变量 STAGING_API_URL 和 STAGING_API_KEY")
|
||
sys.exit(1)
|
||
|
||
# 选择场景
|
||
if args.scenarios:
|
||
scenario_ids = [s.strip() for s in args.scenarios.split(",")]
|
||
selected = [s for s in SCENARIOS if s.id in scenario_ids]
|
||
if not selected:
|
||
print(f"❌ 未找到匹配的场景: {scenario_ids}")
|
||
print(f"可用场景: {[s.id for s in SCENARIOS]}")
|
||
sys.exit(1)
|
||
else:
|
||
selected = get_scenarios_by_priority(args.priority)
|
||
|
||
output_dir = Path(args.output).resolve()
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
api = StagingAPI(base_url, api_key, internal_key)
|
||
runner = CompareRunner(
|
||
api,
|
||
output_dir,
|
||
ssim_threshold=args.ssim_threshold,
|
||
psnr_threshold=args.psnr_threshold,
|
||
audio_similarity_threshold=args.audio_threshold,
|
||
flag_mode=args.flag_mode,
|
||
task_timeout=args.task_timeout,
|
||
)
|
||
|
||
runner.run_all(selected)
|
||
|
||
# 生成报告
|
||
summary = runner.summary()
|
||
|
||
# JSON 报告
|
||
json_path = output_dir / "report.json"
|
||
json_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
|
||
# HTML 报告
|
||
html_path = output_dir / "report.html"
|
||
generate_html_report(summary, html_path)
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"对比完成: {summary['passed']}/{summary['total']} 通过 ({summary['pass_rate']})")
|
||
print(f"报告: {html_path}")
|
||
print(f"JSON: {json_path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|