Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a76161ed6a | |||
| 9feac537ad | |||
| 937f751f68 | |||
| afcfcf45ee | |||
| ffb40038d3 | |||
| 30094bc591 | |||
| 939a49d1d4 | |||
| 8ad44ad045 | |||
| 26f3abab72 | |||
| 4f377d4fd3 | |||
| 35b18e5e16 | |||
| e9dd33e2f6 | |||
| 7f18b1b35a | |||
| 880a7106d4 | |||
| d478a9ffbc | |||
| 628fc6c4e6 | |||
| f651422582 | |||
| 14580f536a | |||
| 6ff8d48c15 | |||
| da1d55ef4e | |||
| 00f8c0b566 | |||
| 1f01d6df25 |
@@ -1022,12 +1022,22 @@ jobs:
|
||||
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
|
||||
|
||||
# develop 分支额外推送 :dev tag(Watchtower 监听的滚动更新 tag)
|
||||
if [ "${GITHUB_REF_NAME}" = "develop" ]; then
|
||||
DEV_TAG="${IMAGE_FULL}:dev"
|
||||
EXTRA_TAGS="$BRANCH_TAG $DEV_TAG"
|
||||
PUSHED_TAGS_SUMMARY="${BRANCH_TAG} + ${DEV_TAG}"
|
||||
else
|
||||
EXTRA_TAGS="$BRANCH_TAG"
|
||||
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
|
||||
fi
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
||||
|
||||
NO_CACHE_FLAG=""
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
|
||||
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
if EXTRA_TAGS="$EXTRA_TAGS" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
|
||||
echo "✅ Docker build 成功"
|
||||
break
|
||||
fi
|
||||
@@ -1040,7 +1050,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
|
||||
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${PUSHED_TAGS_SUMMARY})"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1244,9 +1254,11 @@ jobs:
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
run: |
|
||||
set -eux
|
||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
|
||||
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
|
||||
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
|
||||
staging_user="${STAGING_SSH_USER:-root}"
|
||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
||||
staging_port="${STAGING_SSH_PORT:-22}"
|
||||
echo "Host: $staging_host"
|
||||
echo "Port: $staging_port"
|
||||
|
||||
@@ -1470,7 +1482,6 @@ jobs:
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
|
||||
strategy:
|
||||
@@ -2124,3 +2135,4 @@ jobs:
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
# CI retry trigger
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
retrigger3
|
||||
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
<!-- CI trigger: 1788229339 -->
|
||||
@@ -766,7 +766,12 @@ def generate_cover(
|
||||
storage_svc = get_shared_storage_service()
|
||||
mk_client = get_mediakit_client()
|
||||
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
|
||||
# #1901 统一读 "title",兼容老数据 "title_config"
|
||||
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(_e2_title_cfg, dict) or not (_e2_title_cfg.get("text") or "").strip():
|
||||
_alt = (plan.config or {}).get("title_config", {}) or {}
|
||||
if isinstance(_alt, dict):
|
||||
_e2_title_cfg = _alt
|
||||
if not isinstance(_e2_title_cfg, dict):
|
||||
_e2_title_cfg = {}
|
||||
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
|
||||
|
||||
@@ -58,41 +58,10 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
|
||||
|
||||
|
||||
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
|
||||
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
|
||||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.query_voice_durations。"""
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
|
||||
|
||||
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
|
||||
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
|
||||
"""
|
||||
# 先去重查询(IN 查询性能优化),但最终按原始 voice_ids 顺序返回
|
||||
raw_ids = list(voice_ids or [])
|
||||
if not raw_ids:
|
||||
return []
|
||||
# 去重且保序,用于 SQL IN 查询;空字符串/None 视为无效id → 0.0
|
||||
unique_ids: list[str] = []
|
||||
_seen: set[str] = set()
|
||||
for v in raw_ids:
|
||||
if v and v not in _seen:
|
||||
_seen.add(v)
|
||||
unique_ids.append(v)
|
||||
if not unique_ids:
|
||||
return [0.0 for _ in raw_ids]
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
|
||||
dur_map: dict[str, float] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
dur_map[row[0]] = float(row[1] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
dur_map[row[0]] = 0.0
|
||||
# 按原始 voice_ids 顺序返回,保持长度一致;空/None/未查到 → 0.0
|
||||
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
|
||||
return [0.0 for _ in raw_ids]
|
||||
return query_voice_durations(db, voice_ids)
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
@@ -198,61 +167,10 @@ def _writeback_edit_plan_config(
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
|
||||
"""[已下沉] 路由层兼容别名 → app.services.generation_common.writeback_edit_plan_config。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
|
||||
失败只记日志,不影响任务创建。
|
||||
"""
|
||||
if not plan_id:
|
||||
return
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
|
||||
if plan_model is None:
|
||||
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
|
||||
return
|
||||
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
|
||||
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
|
||||
if title_config:
|
||||
old_title_config = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_config.get("text") or "").strip()
|
||||
new_title_text = (title_config.get("text") or "").strip()
|
||||
if old_title_text != new_title_text:
|
||||
# 标题变化,清除旧封面
|
||||
if "cover" in merged:
|
||||
del merged["cover"]
|
||||
logger.info(
|
||||
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
|
||||
plan_id,
|
||||
old_title_text,
|
||||
new_title_text,
|
||||
)
|
||||
merged["title_config"] = title_config
|
||||
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
|
||||
plan_id,
|
||||
task_id,
|
||||
list(merged.keys()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return writeback_edit_plan_config(plan_id, task_id, title_config, db)
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
@@ -503,25 +421,14 @@ def create_generation_task(
|
||||
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
|
||||
voice_durations = _query_voice_durations(db, variant_voices)
|
||||
|
||||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
|
||||
batch_source_plan_id = request.source_edit_plan_id
|
||||
if not batch_source_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(公共函数)
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id,
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
batch_source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
|
||||
batch_source_plan_id = (
|
||||
request.source_edit_plan_id
|
||||
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
|
||||
or ""
|
||||
)
|
||||
|
||||
if not batch_source_plan_id and not request.variant_plan_ids:
|
||||
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
|
||||
@@ -566,24 +473,10 @@ def create_generation_task(
|
||||
) from clone_err
|
||||
variant_plan_ids.append(_plan0.id)
|
||||
|
||||
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值
|
||||
def _collect_segments(pid):
|
||||
segs = {}
|
||||
_sk, _pg = 0, 500
|
||||
while True:
|
||||
_b = _plan_svc._clip_repo.list_by_plan(pid, skip=_sk, limit=_pg)
|
||||
if not _b:
|
||||
break
|
||||
for _c in _b:
|
||||
if _c.asset_id and float(_c.duration or 0) > 0:
|
||||
_st = float(_c.start_time or 0.0)
|
||||
segs.setdefault(_c.asset_id, []).append((_st, _st + float(_c.duration)))
|
||||
if len(_b) < _pg:
|
||||
break
|
||||
_sk += _pg
|
||||
return segs
|
||||
# #1855 P0:批次区间避让表,从变体0实际clips构建初始值(公共函数)
|
||||
from app.services.generation_common import collect_plan_segments as _collect_segments
|
||||
|
||||
_batch_segments = _collect_segments(_plan0.id)
|
||||
_batch_segments = _collect_segments(_plan0.id, _plan_svc._clip_repo)
|
||||
|
||||
# 变体 1..N-1 独立选片(传入累积batch_segments做素材区间避让)
|
||||
for task_index in range(1, count):
|
||||
@@ -631,7 +524,7 @@ def create_generation_task(
|
||||
|
||||
# #1855 P0:把新变体的clips区间追加到batch_segments,供下一变体避让
|
||||
try:
|
||||
_new_segs = _collect_segments(variant.id)
|
||||
_new_segs = _collect_segments(variant.id, _plan_svc._clip_repo)
|
||||
for _aid, _ivs in _new_segs.items():
|
||||
_batch_segments.setdefault(_aid, []).extend(_ivs)
|
||||
except Exception:
|
||||
@@ -659,24 +552,13 @@ def create_generation_task(
|
||||
)
|
||||
_single_vd: list[float] = _query_voice_durations(db, _voices)
|
||||
_single_dur = _single_vd[0] if _single_vd else 0.0
|
||||
_single_plan = request.source_edit_plan_id
|
||||
if not _single_plan and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id,
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
_single_plan = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
|
||||
_single_plan = (
|
||||
request.source_edit_plan_id
|
||||
or resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id)
|
||||
or ""
|
||||
)
|
||||
if _single_dur > 0 and _single_plan:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
|
||||
@@ -90,25 +90,12 @@ def create_variant_plans(
|
||||
except VariantVoiceError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
|
||||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新(公共函数)
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id.strip(),
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.exception("[variant-plans] 源 plan 解析失败")
|
||||
source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or ""
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -287,10 +287,15 @@ def retry_voice_clone(
|
||||
return _to_response(profile)
|
||||
|
||||
|
||||
_ALLOWED_PREVIEW_EMOTIONS = {"", "natural", "excited", "calm", "friendly"}
|
||||
|
||||
|
||||
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
||||
def get_voice_clone_preview(
|
||||
clone_id: str,
|
||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
||||
speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"),
|
||||
emotion: str = Query("", description="情绪:natural/excited/calm/friendly,空字符串为默认自然"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
@@ -298,11 +303,17 @@ def get_voice_clone_preview(
|
||||
"""获取克隆音色试听音频(实时 TTS 合成)。
|
||||
|
||||
- 克隆音色必须处于 ready 状态
|
||||
- 使用默认试听文本时,结果缓存 7 天
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
- 使用默认试听文本时,结果缓存 7 天(仅默认 text+speed=1.0+emotion=空 组合缓存)
|
||||
- 可传入自定义 text/speed/emotion 试听不同效果
|
||||
"""
|
||||
import time
|
||||
|
||||
if emotion not in _ALLOWED_PREVIEW_EMOTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的 emotion 值: {emotion},可选: natural/excited/calm/friendly 或留空",
|
||||
)
|
||||
|
||||
use_case = GetVoiceCloneUseCase(repository)
|
||||
try:
|
||||
profile = use_case.execute(clone_id, authenticated_user.user.id)
|
||||
@@ -315,8 +326,8 @@ def get_voice_clone_preview(
|
||||
detail=f"Voice clone is not ready (current status: {profile.status})",
|
||||
)
|
||||
|
||||
# 有自定义文本时不缓存
|
||||
use_cache = not text.strip()
|
||||
# 仅默认试听文本 + 默认 speed + 默认 emotion 时使用缓存
|
||||
use_cache = (not text.strip()) and abs(speed - 1.0) < 1e-6 and (not emotion)
|
||||
|
||||
if use_cache and clone_id in _clone_preview_cache:
|
||||
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
|
||||
@@ -337,12 +348,15 @@ def get_voice_clone_preview(
|
||||
text=preview_text,
|
||||
voice_id=profile.voice_id,
|
||||
format="mp3",
|
||||
speed=1.0,
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 缓存(仅默认试听文本)
|
||||
# 缓存(仅默认参数组合)
|
||||
if use_cache:
|
||||
_clone_preview_cache[clone_id] = (
|
||||
result.audio_url,
|
||||
|
||||
@@ -1003,25 +1003,10 @@ class EditPlanService:
|
||||
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
|
||||
plan_ids.append(plan0.id)
|
||||
|
||||
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值
|
||||
def _collect_plan_segments(pid: str) -> dict[str, list[tuple[float, float]]]:
|
||||
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 区间表。"""
|
||||
segs: dict[str, list[tuple[float, float]]] = {}
|
||||
_sk2, _pg2 = 0, 500
|
||||
while True:
|
||||
_b2 = self._clip_repo.list_by_plan(pid, skip=_sk2, limit=_pg2)
|
||||
if not _b2:
|
||||
break
|
||||
for _c in _b2:
|
||||
if _c.asset_id and float(_c.duration or 0) > 0:
|
||||
_st = float(_c.start_time or 0.0)
|
||||
segs.setdefault(_c.asset_id, []).append((_st, _st + float(_c.duration)))
|
||||
if len(_b2) < _pg2:
|
||||
break
|
||||
_sk2 += _pg2
|
||||
return segs
|
||||
# #1855 P0:批次内素材区间避让表——从变体0实际落库的clips构建初始值(公共函数)
|
||||
from app.services.generation_common import collect_plan_segments as _collect_plan_segments
|
||||
|
||||
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id)
|
||||
batch_segments_acc: dict[str, list[tuple[float, float]]] = _collect_plan_segments(plan0.id, self._clip_repo)
|
||||
|
||||
# 变体 1..N-1:独立选片(传入累积的 batch_segments 做区间避让)
|
||||
for i in range(1, count):
|
||||
@@ -1061,7 +1046,7 @@ class EditPlanService:
|
||||
|
||||
# #1855 P0:把当前新变体的 clips 区间追加到 batch_segments,供下一变体避让
|
||||
try:
|
||||
_new_segs = _collect_plan_segments(variant.id)
|
||||
_new_segs = _collect_plan_segments(variant.id, self._clip_repo)
|
||||
for _aid, _ivs in _new_segs.items():
|
||||
batch_segments_acc.setdefault(_aid, []).extend(_ivs)
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""智能剪辑公共服务辅助函数(从 route 层下沉)。
|
||||
|
||||
集中管理:
|
||||
- query_voice_durations:批量查询配音素材时长
|
||||
- writeback_edit_plan_config:任务入队后回写 EditPlan.config
|
||||
- collect_plan_segments:分页读取 plan clips 构建素材区间表(变体避让用)
|
||||
- resolve_latest_plan_by_template:按 template_id + user_id 查最新 EditPlan
|
||||
|
||||
设计原则:
|
||||
- 无副作用的纯查询 / 幂等写回;失败一律不阻断主流程(记日志 + 返回安全默认值)
|
||||
- 不依赖 FastAPI / HTTPException,便于 service 层和 worker 复用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
|
||||
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
|
||||
|
||||
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
|
||||
|
||||
#1855 P0修复:不再对 voice_ids 去重,保持与调用方传入顺序/长度一致,
|
||||
允许同配音id多次出现时返回相同时长(支持"同配音N变体"的时长对齐)。
|
||||
"""
|
||||
raw_ids = list(voice_ids or [])
|
||||
if not raw_ids:
|
||||
return []
|
||||
unique_ids: list[str] = []
|
||||
_seen: set[str] = set()
|
||||
for v in raw_ids:
|
||||
if v and v not in _seen:
|
||||
_seen.add(v)
|
||||
unique_ids.append(v)
|
||||
if not unique_ids:
|
||||
return [0.0 for _ in raw_ids]
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(unique_ids)).all()
|
||||
dur_map: dict[str, float] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
dur_map[row[0]] = float(row[1] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
dur_map[row[0]] = 0.0
|
||||
return [dur_map.get(v, 0.0) if v else 0.0 for v in raw_ids]
|
||||
except Exception:
|
||||
logger.warning("[generation_common] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
|
||||
return [0.0 for _ in raw_ids]
|
||||
|
||||
|
||||
def writeback_edit_plan_config(
|
||||
plan_id: str,
|
||||
task_id: str,
|
||||
title_config: dict | None,
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""任务入队成功后,回写 EditPlan.config:generation_task_id + title_config。
|
||||
|
||||
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
|
||||
失败只记日志,不影响任务创建。
|
||||
"""
|
||||
if not plan_id:
|
||||
return
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
|
||||
if plan_model is None:
|
||||
logger.warning("[generation_common] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
|
||||
return
|
||||
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
|
||||
if title_config:
|
||||
# #1901 统一字段名为 "title"(worker sync_configs_to_plan 写的是 "title")
|
||||
# 先读取新旧两个 key,判断标题文字是否变化
|
||||
old_title_cfg = merged.get("title", {}) or {}
|
||||
if not isinstance(old_title_cfg, dict) or not (old_title_cfg.get("text") or "").strip():
|
||||
old_title_cfg = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_cfg.get("text") or "").strip() if isinstance(old_title_cfg, dict) else ""
|
||||
new_title_text = (title_config.get("text") or "").strip()
|
||||
if old_title_text != new_title_text:
|
||||
if "cover" in merged:
|
||||
del merged["cover"]
|
||||
logger.info(
|
||||
"[generation_common] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
|
||||
plan_id,
|
||||
old_title_text,
|
||||
new_title_text,
|
||||
)
|
||||
# 字段名归一化(font_size→size, font_preset→font, font_color→color),与 worker sync_configs_to_plan 保持一致
|
||||
normalized = dict(title_config)
|
||||
if "font_size" in normalized and "size" not in normalized:
|
||||
normalized["size"] = normalized["font_size"]
|
||||
if "font_preset" in normalized and "font" not in normalized:
|
||||
normalized["font"] = normalized["font_preset"]
|
||||
if "font_color" in normalized and "color" not in normalized:
|
||||
normalized["color"] = normalized["font_color"]
|
||||
merged["title"] = normalized
|
||||
# 清掉旧 key,避免双字段并存
|
||||
merged.pop("title_config", None)
|
||||
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
"[generation_common] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
|
||||
plan_id,
|
||||
task_id,
|
||||
list(merged.keys()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[generation_common] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def collect_plan_segments(
|
||||
plan_id: str,
|
||||
clip_repo: Any,
|
||||
*,
|
||||
page_size: int = 500,
|
||||
) -> dict[str, list[tuple[float, float]]]:
|
||||
"""分页读取 plan 所有 clips,构建 {asset_id: [(start, end), ...]} 素材区间表。
|
||||
|
||||
用于 #1855 P0 批次内素材区间避让(变体间素材片段重叠控制)。
|
||||
"""
|
||||
segs: dict[str, list[tuple[float, float]]] = {}
|
||||
sk, pg = 0, page_size
|
||||
while True:
|
||||
batch = clip_repo.list_by_plan(plan_id, skip=sk, limit=pg)
|
||||
if not batch:
|
||||
break
|
||||
for c in batch:
|
||||
if c.asset_id and float(c.duration or 0) > 0:
|
||||
st = float(c.start_time or 0.0)
|
||||
segs.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
if len(batch) < pg:
|
||||
break
|
||||
sk += pg
|
||||
return segs
|
||||
|
||||
|
||||
def resolve_latest_plan_by_template(
|
||||
db: Session,
|
||||
*,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
) -> Optional[str]:
|
||||
"""按 template_id + user_id 查找最新的 EditPlan.id(模板兜底用)。找不到返回 None。"""
|
||||
if not (template_id or "").strip():
|
||||
return None
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == template_id.strip(),
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
return latest.id if latest else None
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[generation_common] 按template查找最新plan失败: template=%s user=%s",
|
||||
template_id,
|
||||
user_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -26,6 +26,10 @@ export interface BatchVariantPlansRequest {
|
||||
count: number
|
||||
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
|
||||
source_edit_plan_id?: string
|
||||
/** 统一配音 ID(共用配音模式);独立配音模式不传,改传 voice_library_ids */
|
||||
voice_library_id?: string
|
||||
/** 独立配音 ID 列表(长度=count,按变体序号一一对应);共用配音模式不传 */
|
||||
voice_library_ids?: string[]
|
||||
}
|
||||
|
||||
/** 单个变体的计划片段 */
|
||||
@@ -36,6 +40,8 @@ export interface VariantPlan {
|
||||
plan_id: string
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
/** 该变体实际配音时长(秒),用于前端预览按配音时长对齐音画;后端暂未返回时缺省 */
|
||||
voice_duration?: number
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface TTSSynthesizeRequest {
|
||||
voice_id?: string
|
||||
output_name?: string
|
||||
language?: string
|
||||
emotion?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
@@ -103,6 +104,7 @@ export interface TTSPreviewRequest {
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
language?: string
|
||||
emotion?: string // 情绪参数:natural/excited/calm/friendly
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,12 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
|
||||
export const getVoiceClonePreview = async (
|
||||
cloneId: string,
|
||||
text?: string,
|
||||
options?: { speed?: number; emotion?: string },
|
||||
): Promise<VoiceClonePreviewResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (text) searchParams.set("text", text)
|
||||
if (options?.speed !== undefined) searchParams.set("speed", String(options.speed))
|
||||
if (options?.emotion) searchParams.set("emotion", options.emotion)
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<VoiceClonePreviewResponse>(
|
||||
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
|
||||
|
||||
@@ -25,15 +25,6 @@ body {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ── 自定义字体 ── */
|
||||
@font-face {
|
||||
font-family: "华康俪金黑";
|
||||
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 滚动条 - V21 样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -227,11 +227,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
setLipsyncStatus("generating")
|
||||
setLipsyncErrorMessage("")
|
||||
|
||||
console.log("[对口型] 开始生成:", {
|
||||
videoId: video.id,
|
||||
mode: isPreSynth ? "pre-synth" : "tts-direct",
|
||||
textLen: state.scriptText.length,
|
||||
})
|
||||
const asset = await getAssetById(video.id)
|
||||
const videoUrl = asset?.file_url
|
||||
if (!videoUrl) {
|
||||
@@ -262,9 +257,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
emotion: normalizeEmotion(state.emotion),
|
||||
}
|
||||
}
|
||||
console.log("[对口型] createLipsyncJob 请求:", payload)
|
||||
const job = await createLipsyncJob(payload)
|
||||
console.log("[对口型] createLipsyncJob 响应:", { id: job.id, status: job.status })
|
||||
state.setLipsyncJob(job)
|
||||
|
||||
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
|
||||
@@ -274,11 +267,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
console.log("[对口型] 轮询状态:", {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
error: updated.error_message,
|
||||
})
|
||||
if (updated.status === "completed") {
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
setLipsyncStatus("completed")
|
||||
|
||||
@@ -139,13 +139,6 @@ export function PanelVoiceSelector({
|
||||
}
|
||||
const targetId = voice.voice_clone_profile_id || voice.id
|
||||
// DEBUG: 打印请求参数,帮助定位 /tts/preview 失败原因
|
||||
console.log("[AI数字人-克隆试听] previewTts 请求:", {
|
||||
voice_id: targetId,
|
||||
voice_name: voice.name,
|
||||
voice_type: voice.type,
|
||||
voice_clone_profile_id: voice.voice_clone_profile_id,
|
||||
voice_id_field: voice.voice_id,
|
||||
})
|
||||
setPreviewingId(voice.id)
|
||||
try {
|
||||
const res = await previewTts({
|
||||
@@ -154,10 +147,6 @@ export function PanelVoiceSelector({
|
||||
speed: speed, // 透传用户选择的语速(#1822)
|
||||
emotion: normalizeEmotion(emotion), // 情绪中文→英文枚举
|
||||
})
|
||||
console.log("[AI数字人-克隆试听] previewTts 响应:", {
|
||||
audio_url: res.audio_url?.substring(0, 80),
|
||||
duration: res.duration,
|
||||
})
|
||||
if (!res.audio_url) {
|
||||
setPreviewingId(null)
|
||||
message.error("合成试听失败:未返回音频")
|
||||
|
||||
@@ -22,8 +22,8 @@ export interface ScriptSentence {
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/** 句子分隔符:中英文句号/问号/感叹号/分号/逗号/换行(覆盖中文短视频常用断句) */
|
||||
const SENTENCE_SPLIT_RE = /[。!?!??!;;,,\n\r]+/
|
||||
/** 句子分隔符:中英文句号/问号/感叹号/分号/换行(按句断,不在逗号处切;保持与后端一致) */
|
||||
const SENTENCE_SPLIT_RE = /[。!?!??!;;\n\r]+/
|
||||
|
||||
/**
|
||||
* 分句并计算每句的起止时间。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import { getFontFamily } from "@/pages/generate/constants"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
@@ -80,7 +81,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
className="ep-preview-title"
|
||||
style={{
|
||||
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
|
||||
fontFamily: titleConfig.font_preset,
|
||||
fontFamily: getFontFamily(titleConfig.font_preset),
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
||||
@@ -106,7 +107,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
className="ep-preview-subtitle"
|
||||
style={{
|
||||
fontSize: `${Math.min(subtitleSettings.size, 14)}px`,
|
||||
fontFamily: subtitleSettings.font,
|
||||
fontFamily: getFontFamily(subtitleSettings.font),
|
||||
top:
|
||||
subtitleSettings.position === "top"
|
||||
? "8px"
|
||||
|
||||
@@ -9,15 +9,8 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "bottom", label: "底部" },
|
||||
]
|
||||
|
||||
export const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
]
|
||||
// FONT_OPTIONS 统一从 generate/constants 导入,避免多处维护遗漏
|
||||
export { FONT_OPTIONS } from "@/pages/generate/constants"
|
||||
|
||||
export const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
|
||||
@@ -7,15 +7,8 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "bottom", label: "底部" },
|
||||
]
|
||||
|
||||
export const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
]
|
||||
// FONT_OPTIONS 统一从 generate/constants 导入
|
||||
export { FONT_OPTIONS } from "@/pages/generate/constants"
|
||||
|
||||
export const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
|
||||
@@ -231,9 +231,13 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
|
||||
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
|
||||
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
|
||||
// P0 fix:批量变体计划请求需携带配音参数,避免后端按"无配音"选片导致 clips 时长与配音错位
|
||||
const batchVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
voiceDurationsByVariant: variantVoiceDurations,
|
||||
loading: variantClipsLoading,
|
||||
error: variantClipsError,
|
||||
retry: retryVariantClips,
|
||||
@@ -243,6 +247,9 @@ const GeneratePage: React.FC = () => {
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
|
||||
voiceLibraryId: batchVoiceLibraryId,
|
||||
voiceLibraryIds: voiceLibraryIds || [],
|
||||
voiceModePerVideo,
|
||||
})
|
||||
|
||||
/* ── 批量变体配音预览 URL(#1750):独立模式每变体挂各自配音,共用模式全挂同一条;
|
||||
@@ -476,6 +483,7 @@ const GeneratePage: React.FC = () => {
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
voiceDurations={variantVoiceDurations}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
|
||||
@@ -31,6 +31,12 @@ interface CanvasPreviewGridProps {
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
|
||||
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
|
||||
* 长度=count,undefined 项表示该变体未提供后端时长。
|
||||
*/
|
||||
voiceDurations?: (number | undefined)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
@@ -56,6 +62,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrls,
|
||||
voiceDurations,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
@@ -120,6 +127,7 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
voiceDurationHint={voiceDurations?.[i]}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
/**
|
||||
* 前端预览播放器 — Canvas + WebCodecs 方案
|
||||
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
*
|
||||
* 架构:
|
||||
* - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
|
||||
* - 浏览器不支持 → fallback 到多 video 元素方案
|
||||
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
|
||||
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
|
||||
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
|
||||
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
|
||||
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
|
||||
*
|
||||
* 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
|
||||
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
LoadingOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
import { usePreviewAudio } from "../hooks/usePreviewAudio"
|
||||
import { PreviewControls } from "./PreviewControls"
|
||||
import { getFontFamily } from "../constants"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
@@ -58,12 +56,11 @@ interface FrontendPreviewPlayerProps {
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
/**
|
||||
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
|
||||
* 未提供则在 audio loadedmetadata 后自测兜底。
|
||||
*/
|
||||
voiceDurationHint?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +113,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
playToken,
|
||||
@@ -124,26 +122,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
// #1754:测量配音时长,计算缩放因子
|
||||
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
setVoiceDuration(0)
|
||||
return
|
||||
}
|
||||
const audio = new Audio()
|
||||
audio.preload = "metadata"
|
||||
const onLoaded = () => {
|
||||
if (audio.duration && isFinite(audio.duration)) {
|
||||
setVoiceDuration(audio.duration)
|
||||
}
|
||||
}
|
||||
audio.addEventListener("loadedmetadata", onLoaded)
|
||||
audio.src = voiceAudioUrl
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
// #1754→P0:配音时长作为音画时长锚点。
|
||||
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
|
||||
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
|
||||
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
|
||||
)
|
||||
|
||||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||||
const rawClipsDuration = useMemo(() => {
|
||||
@@ -189,9 +172,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
? (titleSettings.posY / playRes.height) * 100
|
||||
: null
|
||||
|
||||
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
|
||||
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
|
||||
const draggingTitleRef = useRef(false)
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const handleTitlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !playerContainerRef.current) return
|
||||
@@ -207,7 +191,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
|
||||
if (titleDragRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
@@ -222,7 +205,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
|
||||
if (onTitlePositionChange && playerContainerRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
@@ -243,7 +225,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
[onTitlePositionChange, playRes.width, playRes.height],
|
||||
)
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = playerContainerRef.current
|
||||
@@ -268,234 +249,157 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
|
||||
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
|
||||
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
|
||||
// 描边/阴影也要按缩放比例放大
|
||||
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
|
||||
const titleStrokeWidth = Math.max(1, 2 * titleScale)
|
||||
const titleShadowBlur = 4 * titleScale
|
||||
const titleShadowOffset = 2 * titleScale
|
||||
|
||||
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
|
||||
const useWebCodecs = false
|
||||
|
||||
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// ── Canvas 播放器(WebCodecs 路径) ──
|
||||
const canvasTitle = titleSettings
|
||||
? {
|
||||
text: effectiveTitle || "标题预览",
|
||||
fontSize: titleSettings.size,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
color: titleSettings.color || "#ffffff",
|
||||
position: titleSettings.position || "top",
|
||||
bold: titleSettings.bold,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const canvasSegments = useMemo(
|
||||
() =>
|
||||
segments.map((s) => ({
|
||||
assetId: s.assetId,
|
||||
videoUrl: s.videoUrl,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
})),
|
||||
[segments],
|
||||
)
|
||||
|
||||
// WebCodecs 解码失败后强制走 video fallback
|
||||
const [forceVideoFallback, setForceVideoFallback] = useState(false)
|
||||
|
||||
const handleCanvasError = useCallback((err: Error) => {
|
||||
console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err)
|
||||
setForceVideoFallback(true)
|
||||
}, [])
|
||||
|
||||
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
|
||||
canvasRef,
|
||||
useWebCodecs && !forceVideoFallback ? canvasSegments : [],
|
||||
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
|
||||
handleCanvasError,
|
||||
useWebCodecs && !forceVideoFallback,
|
||||
)
|
||||
|
||||
// WebCodecs 报告解码失败时自动切换到 video fallback
|
||||
useEffect(() => {
|
||||
if (canvasState.hasDecodeError && !forceVideoFallback) {
|
||||
console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback")
|
||||
setForceVideoFallback(true)
|
||||
}
|
||||
}, [canvasState.hasDecodeError, forceVideoFallback])
|
||||
|
||||
// ── Video 播放器(fallback 路径) ──
|
||||
// ── Video 播放器(默认路径,浏览器原生硬件解码) ──
|
||||
const {
|
||||
isPlaying: videoIsPlaying,
|
||||
currentTime: videoCurrentTime,
|
||||
totalDuration: videoTotalDuration,
|
||||
currentSegmentIndex: videoCurrentSegIdx,
|
||||
canPlay: videoCanPlay,
|
||||
togglePlayPause: videoTogglePlayPause,
|
||||
seekTo: videoSeekTo,
|
||||
pause: videoPause,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
pause,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback)
|
||||
const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
|
||||
const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
||||
const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
|
||||
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
|
||||
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
|
||||
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
|
||||
// P0 fix:以配音时长为音画同步锚点。
|
||||
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
|
||||
const effectiveTotalDuration =
|
||||
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
|
||||
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||||
const [muted, setMuted] = useState(false)
|
||||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||||
const hasVoice = !!voiceAudioUrl
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
audioRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
}
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
audioRef.current.muted = muted
|
||||
}, [voiceAudioUrl, muted])
|
||||
// 音频 ended:兜底触发暂停与释放播放权
|
||||
const handleAudioEnded = useCallback(() => {
|
||||
if (!isPlaying) return
|
||||
pause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [isPlaying, pause, playToken, onPlayTokenChange])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
audio.currentTime = currentTime
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
const {
|
||||
seekTo: audioSeekTo,
|
||||
ensurePlayingAt: audioEnsurePlayingAt,
|
||||
pause: audioPause,
|
||||
} = usePreviewAudio({
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
muted,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onVoiceDurationChange: setVoiceDuration,
|
||||
onEnded: handleAudioEnded,
|
||||
})
|
||||
|
||||
// 片段切换时同步音频(仅 fallback 路径需要)
|
||||
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
|
||||
// 片段切换时同步音频时间(video fallback)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src || !isPlaying) return
|
||||
audio.currentTime = currentTime
|
||||
if (!isPlaying) return
|
||||
audioSeekTo(currentTime)
|
||||
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segmentSyncKey, isPlaying])
|
||||
}, [currentSegmentIndex, isPlaying])
|
||||
|
||||
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
|
||||
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
|
||||
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
|
||||
const tailStartRef = useRef<number>(0)
|
||||
const tailBaseRef = useRef<number>(0)
|
||||
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
|
||||
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
|
||||
|
||||
useEffect(() => {
|
||||
const needTail =
|
||||
!!voiceAudioUrl &&
|
||||
voiceDuration > 0 &&
|
||||
!isPlaying &&
|
||||
typeof currentTime === "number" &&
|
||||
currentTime >= totalDuration - 0.1 &&
|
||||
currentTime < voiceDuration - 0.1
|
||||
if (needTail && tailCurrentTime === null) {
|
||||
tailBaseRef.current = currentTime
|
||||
tailStartRef.current = performance.now()
|
||||
setTailCurrentTime(currentTime)
|
||||
tailAudioRef.current.ensurePlayingAt(currentTime)
|
||||
return
|
||||
}
|
||||
if (!needTail && tailCurrentTime !== null) {
|
||||
setTailCurrentTime(null)
|
||||
}
|
||||
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (tailCurrentTime === null) return
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - tailStartRef.current) / 1000
|
||||
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
|
||||
setTailCurrentTime(t)
|
||||
tailAudioRef.current.ensurePlayingAt(t)
|
||||
if (t >= (voiceDuration || 0) - 0.05) {
|
||||
tailAudioRef.current.pause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
setTailCurrentTime(null)
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
|
||||
|
||||
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
|
||||
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
canvasControls.seek(time)
|
||||
} else {
|
||||
videoSeekTo(time)
|
||||
}
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
audio.currentTime = time
|
||||
}
|
||||
setTailCurrentTime(null)
|
||||
seekTo(time)
|
||||
audioSeekTo(time)
|
||||
},
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
[seekTo, audioSeekTo],
|
||||
)
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) canvasControls.pause()
|
||||
} else if (isPlaying) {
|
||||
videoPause()
|
||||
if (isPlaying) {
|
||||
pause()
|
||||
}
|
||||
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
|
||||
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
|
||||
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, playToken, effectiveUseWebCodecs])
|
||||
}, [activePlayToken, playToken])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
onPlayTokenChange?.(null)
|
||||
} else {
|
||||
if (playToken != null) onPlayTokenChange?.(playToken)
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
|
||||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||||
togglePlayPause()
|
||||
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
|
||||
|
||||
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
if (!voiceAudioUrl || voiceDuration <= 0) return
|
||||
if (displayCurrentTime < voiceDuration - 0.08) return
|
||||
pause()
|
||||
audioPause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [
|
||||
effectiveUseWebCodecs,
|
||||
canvasState.isPlaying,
|
||||
canvasControls,
|
||||
videoTogglePlayPause,
|
||||
isPlaying,
|
||||
displayCurrentTime,
|
||||
voiceAudioUrl,
|
||||
voiceDuration,
|
||||
pause,
|
||||
audioPause,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleProgressClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, handleSeekTo],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true)
|
||||
handleProgressClick(e)
|
||||
},
|
||||
[handleProgressClick],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
handleSeekTo(ratio * totalDuration)
|
||||
}
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, totalDuration, handleSeekTo])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
// ── Canvas 容器 ref(保留声明,WebCodecs 兜底路径仍引用) ──
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
@@ -529,7 +433,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -549,48 +452,15 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
{isBuffering ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ fontSize: 40, color: "#fff", marginBottom: 12 }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14, margin: 0 }}>加载中...</p>
|
||||
</>
|
||||
) : showDecodeError ? (
|
||||
<>
|
||||
<PlayCircleOutlined style={{ fontSize: 40, color: "#ef4444", marginBottom: 12 }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 14,
|
||||
margin: "0 0 4px",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
视频解码失败
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 12,
|
||||
margin: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
|
||||
/>
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
暂无可播放素材
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
请先在左侧选择素材
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
|
||||
/>
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
暂无可播放素材
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
请先在左侧选择素材
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -612,53 +482,30 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
||||
{effectiveUseWebCodecs && (
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
opacity: i === currentSegmentIndex ? 1 : 0,
|
||||
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{!effectiveUseWebCodecs &&
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
muted={hasVoice || muted}
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
opacity: i === videoCurrentSegIdx ? 1 : 0,
|
||||
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
|
||||
}}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
muted={hasVoice || muted}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||||
{titleSettings?.title && (
|
||||
@@ -710,7 +557,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${titleFontSizePx}px`,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
|
||||
color: titleSettings.color || "#ffffff",
|
||||
fontWeight: titleSettings.bold ? 700 : 400,
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
@@ -735,200 +582,19 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 中央播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "50%",
|
||||
width: 52,
|
||||
height: 52,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 26,
|
||||
zIndex: 10,
|
||||
transition: "transform 0.2s ease, background 0.2s ease",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
|
||||
{segments.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontWeight: 500,
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{`${videoCurrentSegIdx + 1} / ${segments.length}`}
|
||||
</div>
|
||||
|
||||
{/* 控制条 — 手机风格毛玻璃 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleTogglePlay}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: compact ? 14 : 16,
|
||||
cursor: "pointer",
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: compact ? 10 : 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: compact ? 58 : 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${progressPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewControls
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
muted={muted}
|
||||
onToggleMute={() => setMuted((m) => !m)}
|
||||
hasSegments={segments.length > 0}
|
||||
segmentIndex={currentSegmentIndex}
|
||||
segmentCount={segments.length}
|
||||
currentTime={displayCurrentTime}
|
||||
totalDuration={effectiveTotalDuration}
|
||||
onSeek={handleSeekTo}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { PreviewProgressBar } from "./PreviewProgressBar"
|
||||
|
||||
interface PreviewControlsProps {
|
||||
isPlaying: boolean
|
||||
onTogglePlay: () => void
|
||||
muted: boolean
|
||||
onToggleMute: () => void
|
||||
hasSegments: boolean
|
||||
segmentIndex: number
|
||||
segmentCount: number
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
onSeek: (time: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
|
||||
*/
|
||||
export const PreviewControls: React.FC<PreviewControlsProps> = ({
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
muted,
|
||||
onToggleMute,
|
||||
hasSegments,
|
||||
segmentIndex,
|
||||
segmentCount,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
compact = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 静音/有声切换(#1741):左上角 */}
|
||||
{hasSegments && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={onToggleMute}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontWeight: 500,
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{`${segmentIndex + 1} / ${segmentCount}`}
|
||||
</div>
|
||||
|
||||
{/* 中央播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "50%",
|
||||
width: 52,
|
||||
height: 52,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 26,
|
||||
zIndex: 10,
|
||||
transition: "transform 0.2s ease, background 0.2s ease",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 控制条 — 手机风格毛玻璃 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: compact ? 14 : 16,
|
||||
cursor: "pointer",
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
<PreviewProgressBar
|
||||
currentTime={currentTime}
|
||||
totalDuration={totalDuration}
|
||||
onSeek={onSeek}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
|
||||
interface PreviewProgressBarProps {
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
onSeek: (time: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条组件:点击/拖拽 seek
|
||||
*/
|
||||
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
|
||||
currentTime,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
compact = false,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const seekByClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
onSeek(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, onSeek],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true)
|
||||
seekByClientX(e.clientX)
|
||||
},
|
||||
[seekByClientX],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, seekByClientX])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontSize: compact ? 10 : 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: compact ? 58 : 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${progressPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -50,15 +50,7 @@ export const POSITION_OPTIONS = [
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
export const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
]
|
||||
export const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "微软雅黑", "楷体"]
|
||||
|
||||
/* ── 标题字体 CSS font-family 映射(中文显示名 → 浏览器可识别的字体栈) ── */
|
||||
export const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
@@ -68,7 +60,6 @@ export const FONT_FAMILY_MAP: Record<string, string> = {
|
||||
PingFang: '"PingFang SC", -apple-system, "Helvetica Neue", sans-serif',
|
||||
微软雅黑: '"Microsoft YaHei", "PingFang SC", sans-serif',
|
||||
楷体: '"KaiTi", "STKaiti", "DFKai-SB", serif',
|
||||
华康俪金黑: '"华康俪金黑", "DFLiJinHei-W8", "Source Han Sans SC", "Microsoft YaHei", sans-serif',
|
||||
}
|
||||
|
||||
export function getFontFamily(font: string): string {
|
||||
|
||||
@@ -171,7 +171,6 @@ export function useBatchCovers({
|
||||
let okCount = 0
|
||||
let failCount = 0
|
||||
for (const i of pending) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const ok = await generateOne(i)
|
||||
if (ok) okCount += 1
|
||||
else failCount += 1
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface BatchVariantClipsState {
|
||||
clipsByVariant: EditPlanClip[][]
|
||||
/** 各变体的 plan_id(正式生成回传,保证预览即成片);未就绪为空串 */
|
||||
planIdsByVariant: string[]
|
||||
/** 各变体的后端返回配音时长(秒);未就绪/未返回为 undefined */
|
||||
voiceDurationsByVariant: (number | undefined)[]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
loading: boolean
|
||||
/** 后端真实片段是否全部可用(每个变体都有 ≥1 条片段) */
|
||||
@@ -44,6 +46,12 @@ interface UseBatchVariantPlansOptions {
|
||||
assetIds: string[]
|
||||
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
|
||||
sourcePlanId?: string
|
||||
/** 统一配音 ID(共用配音模式),参考 useGenerateVideo voiceLibraryId 计算 */
|
||||
voiceLibraryId?: string
|
||||
/** 独立配音 ID 列表(每变体一条),voiceModePerVideo=true 时使用 */
|
||||
voiceLibraryIds?: string[]
|
||||
/** 是否启用独立配音模式(每变体各自一条配音) */
|
||||
voiceModePerVideo?: boolean
|
||||
}
|
||||
|
||||
export function useBatchVariantPlans({
|
||||
@@ -52,9 +60,13 @@ export function useBatchVariantPlans({
|
||||
templateId,
|
||||
assetIds,
|
||||
sourcePlanId = "",
|
||||
voiceLibraryId = "",
|
||||
voiceLibraryIds = [],
|
||||
voiceModePerVideo = false,
|
||||
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
|
||||
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
|
||||
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
|
||||
const [voiceDurationsByVariant, setVoiceDurationsByVariant] = useState<(number | undefined)[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
@@ -70,17 +82,30 @@ export function useBatchVariantPlans({
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
// 配音参数:与 useGenerateVideo 保持一致的传参逻辑
|
||||
// - 独立配音模式 + voiceLibraryIds 非空:传 voice_library_ids
|
||||
// - 统一配音:传 voice_library_id
|
||||
// - 都没选:不传
|
||||
const voiceParam: { voice_library_id?: string; voice_library_ids?: string[] } = {}
|
||||
if (voiceModePerVideo && voiceLibraryIds.length > 0) {
|
||||
voiceParam.voice_library_ids = voiceLibraryIds
|
||||
} else if (voiceLibraryId) {
|
||||
voiceParam.voice_library_id = voiceLibraryId
|
||||
}
|
||||
|
||||
const resp = await createBatchVariantPlans({
|
||||
template_id: templateId,
|
||||
asset_ids: assetIds,
|
||||
count,
|
||||
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
|
||||
...voiceParam,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
|
||||
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
|
||||
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
|
||||
const planIds: string[] = Array.from({ length: count }, () => "")
|
||||
const voiceDurs: (number | undefined)[] = Array.from({ length: count }, () => undefined)
|
||||
for (const item of items) {
|
||||
const idx = item.variant_index
|
||||
if (idx < 0 || idx >= count) continue
|
||||
@@ -88,6 +113,9 @@ export function useBatchVariantPlans({
|
||||
clips[idx] = (item.clips || [])
|
||||
.filter((c) => c && c.asset_id && c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
if (typeof item.voice_duration === "number" && item.voice_duration > 0) {
|
||||
voiceDurs[idx] = item.voice_duration
|
||||
}
|
||||
}
|
||||
// 数据完整性校验:每个变体都必须有真实片段,否则视为失败(不允许假数据冒充)
|
||||
const incomplete = clips.some((list) => list.length === 0)
|
||||
@@ -95,10 +123,12 @@ export function useBatchVariantPlans({
|
||||
console.warn("[useBatchVariantPlans] 变体计划数据不完整(存在空片段变体),标记加载失败")
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setVoiceDurationsByVariant([])
|
||||
setError(true)
|
||||
} else {
|
||||
setClipsByVariant(clips)
|
||||
setPlanIdsByVariant(planIds)
|
||||
setVoiceDurationsByVariant(voiceDurs)
|
||||
setError(false)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -107,11 +137,20 @@ export function useBatchVariantPlans({
|
||||
console.warn("[useBatchVariantPlans] 申请变体计划失败,预览加载失败:", err)
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
setVoiceDurationsByVariant([])
|
||||
setError(true)
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [templateId, count, sourcePlanId, assetIds])
|
||||
}, [
|
||||
templateId,
|
||||
count,
|
||||
sourcePlanId,
|
||||
assetIds,
|
||||
voiceLibraryId,
|
||||
voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
])
|
||||
|
||||
/** 用户点击「重试」:nonce +1 驱动 effect 重新发起请求(effect 内 lastKey 校验保证只发一次) */
|
||||
const retry = useCallback(() => {
|
||||
@@ -125,24 +164,40 @@ export function useBatchVariantPlans({
|
||||
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
|
||||
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setVoiceDurationsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setLoading((prev) => (prev === false ? prev : false))
|
||||
setError((prev) => (prev === false ? prev : false))
|
||||
lastKeyRef.current = ""
|
||||
return
|
||||
}
|
||||
const voiceKey = voiceModePerVideo
|
||||
? `per:${[...voiceLibraryIds].sort().join(",")}`
|
||||
: `one:${voiceLibraryId}`
|
||||
const key = `${retryNonce}|${templateId}|${count}|${sourcePlanId}|${[...assetIds]
|
||||
.sort()
|
||||
.join(",")}`
|
||||
.join(",")}|${voiceKey}`
|
||||
if (key === lastKeyRef.current) return
|
||||
lastKeyRef.current = key
|
||||
load()
|
||||
}, [enabled, templateId, count, sourcePlanId, assetIds, load, retryNonce])
|
||||
}, [
|
||||
enabled,
|
||||
templateId,
|
||||
count,
|
||||
sourcePlanId,
|
||||
assetIds,
|
||||
load,
|
||||
retryNonce,
|
||||
voiceLibraryId,
|
||||
voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
])
|
||||
|
||||
const ready = !error && !loading && clipsByVariant.every((list) => list.length > 0)
|
||||
|
||||
return {
|
||||
clipsByVariant,
|
||||
planIdsByVariant,
|
||||
voiceDurationsByVariant,
|
||||
loading,
|
||||
ready,
|
||||
error,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,6 @@ export function useTitleCoverSync({
|
||||
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
}))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTemplate, setTitleSettings, setCoverSettings])
|
||||
// ↑ 移除 userTemplates,只在 selectedTemplate 真正变化时触发
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
interface UsePreviewAudioOptions {
|
||||
voiceAudioUrl: string | undefined
|
||||
voiceDurationHint: number | undefined
|
||||
muted: boolean
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
onVoiceDurationChange: (d: number) => void
|
||||
onEnded: () => void
|
||||
}
|
||||
|
||||
interface UsePreviewAudioReturn {
|
||||
seekTo: (time: number) => void
|
||||
ensurePlayingAt: (time: number) => void
|
||||
pause: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
|
||||
* ended 事件回调、seek 同步、末帧冻结期间续播。
|
||||
*/
|
||||
export function usePreviewAudio({
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
muted,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onVoiceDurationChange,
|
||||
onEnded,
|
||||
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
|
||||
// 外部 hint 初始化(自测值前的兜底)
|
||||
useEffect(() => {
|
||||
if (voiceDurationHint && voiceDurationHint > 0) {
|
||||
onVoiceDurationChange(voiceDurationHint)
|
||||
}
|
||||
}, [voiceDurationHint, onVoiceDurationChange])
|
||||
|
||||
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
audioRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
}
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
audioRef.current.muted = muted
|
||||
|
||||
const audio = audioRef.current
|
||||
const onLoaded = () => {
|
||||
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
|
||||
onVoiceDurationChange(audio.duration)
|
||||
}
|
||||
}
|
||||
const onEndedHandler = () => onEnded()
|
||||
audio.addEventListener("loadedmetadata", onLoaded)
|
||||
audio.addEventListener("ended", onEndedHandler)
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
audio.removeEventListener("ended", onEndedHandler)
|
||||
}
|
||||
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
|
||||
|
||||
// mute 变化即时同步
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.muted = muted
|
||||
}, [muted])
|
||||
|
||||
// 播放/暂停同步(跟随视频 isPlaying)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
|
||||
try {
|
||||
audio.currentTime = currentTime
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
try {
|
||||
audio.currentTime = time
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensurePlayingAt = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
try {
|
||||
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
|
||||
if (audio.paused) audio.play().catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
try {
|
||||
audioRef.current?.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { seekTo, ensurePlayingAt, pause }
|
||||
}
|
||||
@@ -125,7 +125,6 @@ export function useVariantVoicePreview({
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
const audioUrl = res.audio_url || ""
|
||||
|
||||
@@ -11,6 +11,7 @@ import CloneModal from "@/components/voice/CloneModal"
|
||||
import { VoiceCloneCard } from "./components/VoiceCloneCard"
|
||||
import { VoiceCloneEmpty, VoiceCloneSkeleton, ToastContainer } from "./components/States"
|
||||
import { EditNameDialog } from "./components/EditNameDialog"
|
||||
import VoiceClonePreviewPanel from "./components/VoiceClonePreviewPanel"
|
||||
import { useVoiceCloneList } from "./hooks/useVoiceCloneList"
|
||||
import "./voice-clone.css"
|
||||
|
||||
@@ -47,6 +48,9 @@ const VoiceClone: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 音色试听面板(自定义文本 + 语速/情绪) */}
|
||||
{!isLoading && voices.length > 0 && <VoiceClonePreviewPanel voices={voices} />}
|
||||
|
||||
{/* 加载状态 — 骨架屏 */}
|
||||
{isLoading && <VoiceCloneSkeleton />}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 克隆音色试听面板
|
||||
* - 选择就绪音色、输入试听文本、调节语速/情绪,点试听
|
||||
* - 复用配音库 TTS 弹窗的 SpeedControl / EmotionControl 组件
|
||||
*/
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { SoundOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import { getVoiceClonePreview, type VoiceClone } from "@/api/voice-clone"
|
||||
import SpeedControl from "@/pages/voices/components/tts-modal/SpeedControl"
|
||||
import EmotionControl from "@/pages/voices/components/tts-modal/EmotionControl"
|
||||
import { DEFAULT_TTS_EMOTION, type TtsEmotion } from "@/pages/voices/components/tts-modal/constants"
|
||||
import { TTS_CONFIG } from "@/pages/voices/components/tts-modal/types"
|
||||
|
||||
interface VoiceClonePreviewPanelProps {
|
||||
voices: VoiceClone[]
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的声音效果,希望你喜欢。"
|
||||
|
||||
const VoiceClonePreviewPanel: React.FC<VoiceClonePreviewPanelProps> = ({ voices }) => {
|
||||
const readyVoices = voices.filter((v) => v.status === "ready")
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>(readyVoices[0]?.id ?? "")
|
||||
const [previewText, setPreviewText] = useState(DEFAULT_PREVIEW_TEXT)
|
||||
const [speed, setSpeed] = useState<number>(TTS_CONFIG.DEFAULT_SPEED)
|
||||
const [emotion, setEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!selectedId) {
|
||||
message.warning("请先选择要试听的音色")
|
||||
return
|
||||
}
|
||||
const text = previewText.trim()
|
||||
if (!text) {
|
||||
message.warning("请输入试听文本")
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current = null
|
||||
}
|
||||
setPreviewing(true)
|
||||
setAudioUrl(null)
|
||||
try {
|
||||
const res = await getVoiceClonePreview(selectedId, text, { speed, emotion })
|
||||
setAudioUrl(res.audio_url)
|
||||
const audio = new Audio(res.audio_url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {
|
||||
message.error("播放失败,请重试")
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "试听失败"
|
||||
message.error(msg)
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}, [selectedId, previewText, speed, emotion])
|
||||
|
||||
if (readyVoices.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="vc-preview-panel">
|
||||
<div className="vc-preview-header">
|
||||
<SoundOutlined style={{ color: "var(--primary-color)" }} />
|
||||
<span className="vc-preview-title">音色试听</span>
|
||||
</div>
|
||||
|
||||
<div className="vc-preview-body">
|
||||
<div className="vc-preview-field">
|
||||
<label className="vc-preview-label">选择音色</label>
|
||||
<select
|
||||
className="vc-preview-select"
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
>
|
||||
{readyVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="vc-preview-field">
|
||||
<label className="vc-preview-label">试听文本</label>
|
||||
<textarea
|
||||
className="vc-preview-textarea"
|
||||
value={previewText}
|
||||
onChange={(e) => setPreviewText(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
placeholder="输入试听文本(最多200字)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vc-preview-grid">
|
||||
<div className="vc-preview-field">
|
||||
<SpeedControl speed={speed} onChange={setSpeed} />
|
||||
</div>
|
||||
<div className="vc-preview-field">
|
||||
<EmotionControl emotion={emotion} onChange={setEmotion} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="vc-preview-btn"
|
||||
onClick={handlePreview}
|
||||
disabled={previewing || !selectedId || !previewText.trim()}
|
||||
>
|
||||
{previewing ? (
|
||||
<>
|
||||
<LoadingOutlined /> 合成中...
|
||||
</>
|
||||
) : (
|
||||
<>▶ 开始试听</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{audioUrl && !previewing && (
|
||||
<audio controls src={audioUrl} style={{ width: "100%", marginTop: 4 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceClonePreviewPanel
|
||||
@@ -464,3 +464,93 @@
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 试听面板 ─────────────────────────────────────────── */
|
||||
.vc-preview-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.vc-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vc-preview-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vc-preview-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vc-preview-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vc-preview-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vc-preview-select,
|
||||
.vc-preview-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vc-preview-select:focus,
|
||||
.vc-preview-textarea:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.vc-preview-textarea {
|
||||
resize: vertical;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.vc-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vc-preview-btn {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.vc-preview-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -132,12 +132,16 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsLanguage,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsEmotion,
|
||||
setTtsLanguage,
|
||||
setTtsOpen,
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
@@ -363,6 +367,8 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsText={ttsText}
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsSpeed={ttsSpeed}
|
||||
ttsEmotion={ttsEmotion}
|
||||
ttsLanguage={ttsLanguage}
|
||||
ttsStatus={ttsStatus}
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
@@ -374,6 +380,8 @@ const VoiceLibrary: React.FC = () => {
|
||||
onTtsTextChange={setTtsText}
|
||||
onTtsVoiceChange={setTtsVoiceId}
|
||||
onTtsSpeedChange={setTtsSpeed}
|
||||
onTtsEmotionChange={setTtsEmotion}
|
||||
onTtsLanguageChange={setTtsLanguage}
|
||||
onTtsSynthesize={handleTtsSynthesize}
|
||||
onTtsSave={handleTtsSave}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { type TtsModalProps, type TtsStatus } from "./tts-modal/types"
|
||||
import TextInputSection from "./tts-modal/TextInputSection"
|
||||
import VoiceSelector from "./tts-modal/VoiceSelector"
|
||||
import SpeedControl from "./tts-modal/SpeedControl"
|
||||
import EmotionControl from "./tts-modal/EmotionControl"
|
||||
import LanguageControl from "./tts-modal/LanguageControl"
|
||||
import SynthesizeButton from "./tts-modal/SynthesizeButton"
|
||||
import ErrorAlert from "./tts-modal/ErrorAlert"
|
||||
import ResultPanel from "./tts-modal/ResultPanel"
|
||||
@@ -14,6 +16,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsLanguage,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
@@ -23,6 +27,8 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
onSpeedChange,
|
||||
onEmotionChange,
|
||||
onLanguageChange,
|
||||
onSynthesize,
|
||||
onSave,
|
||||
}) => {
|
||||
@@ -43,6 +49,16 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<EmotionControl emotion={ttsEmotion} onChange={onEmotionChange} />
|
||||
<LanguageControl language={ttsLanguage} onChange={onLanguageChange} />
|
||||
</div>
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
|
||||
import type { TtsEmotion, TtsLanguage } from "./tts-modal/constants"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import CloneDetailModal from "./CloneDetailModal"
|
||||
import UploadVoiceModal from "./UploadVoiceModal"
|
||||
@@ -42,6 +43,8 @@ export interface VoiceModalsProps {
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsEmotion: TtsEmotion
|
||||
ttsLanguage: TtsLanguage
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
@@ -52,6 +55,8 @@ export interface VoiceModalsProps {
|
||||
onTtsTextChange: (text: string) => void
|
||||
onTtsVoiceChange: (id: string) => void
|
||||
onTtsSpeedChange: (speed: number) => void
|
||||
onTtsEmotionChange: (emotion: TtsEmotion) => void
|
||||
onTtsLanguageChange: (language: TtsLanguage) => void
|
||||
onTtsSynthesize: () => void
|
||||
onTtsSave: () => void
|
||||
}
|
||||
@@ -80,6 +85,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsLanguage,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
@@ -89,6 +96,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
onTtsTextChange,
|
||||
onTtsVoiceChange,
|
||||
onTtsSpeedChange,
|
||||
onTtsEmotionChange,
|
||||
onTtsLanguageChange,
|
||||
onTtsSynthesize,
|
||||
onTtsSave,
|
||||
}) => {
|
||||
@@ -129,6 +138,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsText={ttsText}
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsSpeed={ttsSpeed}
|
||||
ttsEmotion={ttsEmotion}
|
||||
ttsLanguage={ttsLanguage}
|
||||
ttsStatus={ttsStatus}
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
@@ -138,6 +149,8 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
onTextChange={onTtsTextChange}
|
||||
onVoiceChange={onTtsVoiceChange}
|
||||
onSpeedChange={onTtsSpeedChange}
|
||||
onEmotionChange={onTtsEmotionChange}
|
||||
onLanguageChange={onTtsLanguageChange}
|
||||
onSynthesize={onTtsSynthesize}
|
||||
onSave={onTtsSave}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react"
|
||||
import { TTS_EMOTION_OPTIONS, type TtsEmotion } from "./constants"
|
||||
|
||||
interface EmotionControlProps {
|
||||
emotion: TtsEmotion
|
||||
onChange: (emotion: TtsEmotion) => void
|
||||
}
|
||||
|
||||
/** 情绪选择下拉 */
|
||||
const EmotionControl: React.FC<EmotionControlProps> = ({ emotion, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
情绪
|
||||
</div>
|
||||
<select
|
||||
value={emotion}
|
||||
onChange={(e) => onChange(e.target.value as TtsEmotion)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border-color, #e5e7eb)",
|
||||
background: "var(--bg-primary, #fff)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{TTS_EMOTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmotionControl
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react"
|
||||
import { TTS_LANGUAGE_OPTIONS, type TtsLanguage } from "./constants"
|
||||
|
||||
interface LanguageControlProps {
|
||||
language: TtsLanguage
|
||||
onChange: (language: TtsLanguage) => void
|
||||
}
|
||||
|
||||
/** 语言选择下拉 */
|
||||
const LanguageControl: React.FC<LanguageControlProps> = ({ language, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语言
|
||||
</div>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => onChange(e.target.value as TtsLanguage)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "7px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border-color, #e5e7eb)",
|
||||
background: "var(--bg-primary, #fff)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{TTS_LANGUAGE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageControl
|
||||
@@ -0,0 +1,22 @@
|
||||
/** TTS 情绪选项(对齐后端 CosyVoice 支持:natural/excited/calm/friendly) */
|
||||
export const TTS_EMOTION_OPTIONS = [
|
||||
{ value: "natural", label: "自然" },
|
||||
{ value: "excited", label: "兴奋" },
|
||||
{ value: "calm", label: "沉稳" },
|
||||
{ value: "friendly", label: "亲切" },
|
||||
] as const
|
||||
|
||||
export type TtsEmotion = (typeof TTS_EMOTION_OPTIONS)[number]["value"]
|
||||
|
||||
/** TTS 语言选项 */
|
||||
export const TTS_LANGUAGE_OPTIONS = [
|
||||
{ value: "zh-CN", label: "中文" },
|
||||
{ value: "en", label: "英文" },
|
||||
{ value: "ja", label: "日文" },
|
||||
{ value: "ko", label: "韩文" },
|
||||
] as const
|
||||
|
||||
export type TtsLanguage = (typeof TTS_LANGUAGE_OPTIONS)[number]["value"]
|
||||
|
||||
export const DEFAULT_TTS_EMOTION: TtsEmotion = "natural"
|
||||
export const DEFAULT_TTS_LANGUAGE: TtsLanguage = "zh-CN"
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import type { TtsClonedVoiceOption } from "./VoiceSelector"
|
||||
import type { TtsEmotion, TtsLanguage } from "./constants"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -8,6 +9,8 @@ export interface TtsModalProps {
|
||||
ttsText: string
|
||||
ttsVoiceId: string
|
||||
ttsSpeed: number
|
||||
ttsEmotion: TtsEmotion
|
||||
ttsLanguage: TtsLanguage
|
||||
ttsStatus: TtsStatus
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
@@ -18,6 +21,8 @@ export interface TtsModalProps {
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
onSpeedChange: (speed: number) => void
|
||||
onEmotionChange: (emotion: TtsEmotion) => void
|
||||
onLanguageChange: (language: TtsLanguage) => void
|
||||
onSynthesize: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { type PresetVoiceDisplay } from "../types"
|
||||
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
|
||||
import {
|
||||
DEFAULT_TTS_EMOTION,
|
||||
DEFAULT_TTS_LANGUAGE,
|
||||
type TtsEmotion,
|
||||
type TtsLanguage,
|
||||
} from "../components/tts-modal/constants"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -29,6 +35,8 @@ export function useTtsSynthesize({
|
||||
const [ttsText, setTtsText] = useState("")
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0)
|
||||
const [ttsEmotion, setTtsEmotion] = useState<TtsEmotion>(DEFAULT_TTS_EMOTION)
|
||||
const [ttsLanguage, setTtsLanguage] = useState<TtsLanguage>(DEFAULT_TTS_LANGUAGE)
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>("idle")
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null)
|
||||
@@ -51,6 +59,8 @@ export function useTtsSynthesize({
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
emotion: ttsEmotion,
|
||||
language: ttsLanguage,
|
||||
})
|
||||
setTtsJobId(resp.job_id)
|
||||
|
||||
@@ -81,7 +91,7 @@ export function useTtsSynthesize({
|
||||
setTtsStatus("error")
|
||||
setTtsError(msg)
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed])
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed, ttsEmotion, ttsLanguage])
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
@@ -103,6 +113,8 @@ export function useTtsSynthesize({
|
||||
setTtsText("")
|
||||
setTtsVoiceId("")
|
||||
setTtsSpeed(1.0)
|
||||
setTtsEmotion(DEFAULT_TTS_EMOTION)
|
||||
setTtsLanguage(DEFAULT_TTS_LANGUAGE)
|
||||
setTtsStatus("idle")
|
||||
setTtsAudioUrl(null)
|
||||
setTtsError(null)
|
||||
@@ -132,6 +144,8 @@ export function useTtsSynthesize({
|
||||
ttsText,
|
||||
ttsVoiceId,
|
||||
ttsSpeed,
|
||||
ttsEmotion,
|
||||
ttsLanguage,
|
||||
ttsJobId,
|
||||
ttsStatus,
|
||||
ttsAudioUrl,
|
||||
@@ -143,6 +157,8 @@ export function useTtsSynthesize({
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
setTtsSpeed,
|
||||
setTtsEmotion,
|
||||
setTtsLanguage,
|
||||
setTtsOpen,
|
||||
// Actions
|
||||
handleTtsSynthesize,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750)
|
||||
*
|
||||
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
|
||||
* useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑:
|
||||
* - 有配音时 video 保持 muted(素材原声不与配音混音)
|
||||
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
|
||||
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
|
||||
|
||||
@@ -321,8 +321,14 @@ def build_subtitles_from_plan(
|
||||
|
||||
has_any = False
|
||||
|
||||
# 1. 标题
|
||||
title_cfg = plan_config.get("title_config") or {}
|
||||
# 1. 标题(#1901 统一读 "title",兼容老数据 "title_config")
|
||||
title_cfg = plan_config.get("title") or {}
|
||||
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
|
||||
_alt = plan_config.get("title_config") or {}
|
||||
if isinstance(_alt, dict):
|
||||
title_cfg = _alt
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
if isinstance(title_cfg, dict):
|
||||
title_text = str(title_cfg.get("text", ""))
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
|
||||
@@ -572,7 +572,12 @@ class UnifiedRenderService:
|
||||
ASS 文件路径,没有字幕时返回 None
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
# #1901 统一读 "title",兼容老数据 "title_config"
|
||||
title_cfg = config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
|
||||
_alt = config.get("title_config") or {}
|
||||
if isinstance(_alt, dict):
|
||||
title_cfg = _alt
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
# 会员制 + 积分方案设计文档(v2)
|
||||
|
||||
> **Issue**: [#1895](https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/issues/1895)
|
||||
> **版本**: v2.0
|
||||
> **状态**: 方案设计(待确认)
|
||||
> **创建时间**: 2026-09-14
|
||||
> **更新**: 2026-09-14 — 基于用户确认方案重写,简化为两档会员 + 基于真实 AI 调用成本定价
|
||||
|
||||
---
|
||||
|
||||
## 一、方案概述
|
||||
|
||||
### 1.1 核心思路
|
||||
|
||||
**两档会员 + 积分消耗制**:
|
||||
|
||||
- **免费会员**:每天可免费混剪 2 条视频;其他 AI 功能可购买积分包使用(消耗按会员价 ×1.15)
|
||||
- **付费会员**:解锁全部功能 + 积分购买折扣
|
||||
|
||||
### 1.2 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| **极简** | 只分免费/付费两档,不搞多梯度会员 |
|
||||
| **成本驱动** | 积分定价严格基于真实 AI 调用成本,毛利率 60-75% |
|
||||
| **可扩展** | 新增消耗场景只需注册,不改动核心逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 二、真实成本基准
|
||||
|
||||
> 数据来源:《小虾 SaaS AI 能力成本核算报告》(生产实测 + API 官方定价)
|
||||
|
||||
| 能力 | 调用方式 | 真实成本 |
|
||||
|------|---------|----------|
|
||||
| AI 配音(TTS) | 阿里云 cosyvoice-v3-flash | ¥0.024/分钟 |
|
||||
| AI 数字人(口型同步) | 火山 MediaKit lip-sync | ¥1.028/分钟(含 TTS) |
|
||||
| 智能混剪渲染 | 本地 FFmpeg(CPU) | ¥0.001/分钟 |
|
||||
| 声音克隆训练 | 阿里云 voice-enrollment | 免费(合成才收费) |
|
||||
| 声音克隆合成 | 同 cosyvoice TTS | ¥0.024/分钟 |
|
||||
| AI 文案改写 / 标题生成 | 火山方舟 doubao-seed-1-6 | <¥0.001/条 |
|
||||
| 抖音链接提取文案 | yt-dlp + 火山 ASR | ¥0.03/分钟 |
|
||||
| AI 封面生成 | 本地 ffmpeg 抽帧 + PIL | <¥0.001/张 |
|
||||
| OSS 存储 | 阿里云杭州标准 | ¥0.001/条/月 |
|
||||
|
||||
**典型场景成本:**
|
||||
- 1 分钟基础混剪(TTS + FFmpeg + 封面 + 存储 1 月):**≈ ¥0.03/条**
|
||||
- 1 分钟数字人口播(TTS + lip-sync + 封装):**≈ ¥1.03/条**(是混剪的 35 倍)
|
||||
- 免费用户每天 2 条混剪:单活跃用户月成本 **≈ ¥2.3**(完全可承受)
|
||||
|
||||
---
|
||||
|
||||
## 三、会员等级设计
|
||||
|
||||
### 3.1 两档会员对照
|
||||
|
||||
| 权益项 | 免费会员 | 付费会员 |
|
||||
|--------|---------|---------|
|
||||
| **每日免费混剪** | 2 条/天(含 TTS + 渲染) | 不限次数 |
|
||||
| **AI 数字人** | ✓ 可用(消耗×1.15) | ✓ 可用 |
|
||||
| **声音克隆** | ✓ 可用(消耗×1.15) | ✓ 可用 |
|
||||
| **AI 文案改写** | ✓ 可用(消耗×1.15) | ✓ 可用 |
|
||||
| **AI 标题生成** | ✓ 可用(消耗×1.15) | ✓ 可用 |
|
||||
| **抖音链接提取** | ✓ 可用(消耗×1.15) | ✓ 可用 |
|
||||
| **AI 封面生成** | ✓ 可用(消耗×1.15) | ✓ 可用 |
|
||||
| **导出分辨率** | 720p | 最高 1080p |
|
||||
| **积分购买折扣** | 无 | 见下表 |
|
||||
| **批量导出** | ✗ | ✓ |
|
||||
| **多平台发布** | ✗ | ✓ |
|
||||
| **去重检测报告** | ✗ | ✓ |
|
||||
|
||||
### 3.2 付费会员定价
|
||||
|
||||
| 套餐 | 价格 | 折合月价 | 积分购买折扣 |
|
||||
|------|------|---------|-------------|
|
||||
| 月卡 | ¥19.9/月 | ¥19.9 | 9 折 |
|
||||
| 季卡 | ¥39.9/季 | ≈ ¥13.3 | 8.7 折 |
|
||||
| 年卡 | ¥159/年 | ≈ ¥13.25 | 8 折 |
|
||||
|
||||
> 年卡和季卡的核心优惠体现在**积分折扣更低**,适合长期高频使用的用户。
|
||||
|
||||
### 3.3 与现有代码的映射
|
||||
|
||||
**废弃** `subscription.py` 中的 4 档 `PLAN_QUOTAS`(free/standard/pro/enterprise),替换为:
|
||||
|
||||
```python
|
||||
MEMBER_TIERS = {
|
||||
"free": {
|
||||
"name": "免费会员",
|
||||
"daily_free_clips": 2, # 每日免费混剪数
|
||||
"max_resolution": "720p",
|
||||
"monthly_credits": 0,
|
||||
"points_discount": 1.0, # 无折扣
|
||||
"points_multiplier": 1.15, # 积分消耗上浮 15%
|
||||
},
|
||||
"paid": {
|
||||
"name": "付费会员",
|
||||
"daily_free_clips": -1, # 不限
|
||||
"max_resolution": "1080p",
|
||||
"monthly_credits": 0,
|
||||
"points_discount": 1.0, # 默认月卡 9 折通过购买折扣体现
|
||||
"points_multiplier": 1.0,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
付费会员的积分购买折扣通过订单系统实现,不在 `MEMBER_TIERS` 中硬编码:
|
||||
|
||||
```python
|
||||
MEMBER_DISCOUNT = {
|
||||
"monthly": 0.9, # 月卡 9 折
|
||||
"quarterly": 0.87, # 季卡 8.7 折
|
||||
"yearly": 0.8, # 年卡 8 折
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、积分体系设计
|
||||
|
||||
### 4.1 积分获取
|
||||
|
||||
| 获取方式 | 说明 | 频率 |
|
||||
|----------|------|------|
|
||||
| **积分包充值** | 用户按需购买,付费会员享受折扣 | 随时 |
|
||||
| **任务奖励** | 注册送 50、首次生成视频送 20、邀请好友送 30/人(月上限 10 人) | 一次性/周期性 |
|
||||
|
||||
### 4.2 积分包定价
|
||||
|
||||
积分定价核心:**1 积分 ≈ ¥0.10(会员价)**,免费用户同操作消耗 ×1.15 向上取整。
|
||||
|
||||
| 积分包 | 积分数量 | 会员价 | 折合单价 | 免费用户等量价格 |
|
||||
|--------|----------|--------|---------|----------------|
|
||||
| 体验包 | 100 | ¥9.9 | ¥0.099/积分 | ¥10.89 |
|
||||
| 基础包 | 500 | ¥39 | ¥0.078/积分 | ¥42.9 |
|
||||
| 专业包 | 2,000 | ¥129 | ¥0.065/积分 | ¥141.9 |
|
||||
|
||||
> 简化为 3 档,覆盖轻度、中度、重度用户。
|
||||
|
||||
### 4.3 积分消耗场景清单
|
||||
|
||||
| 功能模块 | 消耗场景 | 会员消耗 | 免费用户消耗(×1.15↑) | 真实成本 | 毛利率 |
|
||||
|----------|----------|---------|----------------------|----------|--------|
|
||||
| **AI 配音** | 生成 1 分钟配音 | 1 积分 | 2 积分 | ¥0.024 | ~76% |
|
||||
| **智能混剪** | 生成 1 条视频(≤30s) | 3 积分 | 4 积分 | ¥0.03 | >85% |
|
||||
| **智能混剪(加长)** | 每增加 30s | +1 积分 | +2 积分 | — | — |
|
||||
| **AI 数字人** | 生成 1 分钟口播视频 | 15 积分 | 18 积分 | ¥1.03 | ~30% |
|
||||
| **声音克隆训练** | 训练一个声音模型 | **免费** | **免费** | ¥0 | — |
|
||||
| **声音克隆合成** | 用克隆声音生成 1 分钟配音 | 1 积分 | 2 积分 | ¥0.024 | ~76% |
|
||||
| **抖音链接提取** | 提取 1 次文案 | 1 积分 | 2 积分 | ¥0.03 | ~70% |
|
||||
| **AI 改写文案** | 改写 1 次 | 1 积分 | 2 积分 | <¥0.001 | ~99% |
|
||||
| **AI 标题生成** | 生成 1 次(≤10 条) | 1 积分 | 2 积分 | <¥0.001 | ~99% |
|
||||
| **AI 封面生成** | 生成 1 张 | 1 积分 | 2 积分 | <¥0.001 | ~99% |
|
||||
|
||||
> **免费用户规则**:每天可免费混剪 2 条视频(含 TTS),不消耗积分。使用其他 AI 功能需购买积分包,积分消耗按会员价的 1.15 倍向上取整计算。
|
||||
|
||||
### 4.4 积分有效期
|
||||
|
||||
- 用户付费购买的积分:**永久有效**,不清零
|
||||
- 任务奖励赠送的积分:永久有效
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库设计
|
||||
|
||||
### 5.1 users 表新增字段
|
||||
|
||||
```sql
|
||||
-- 在现有 users 表上新增字段
|
||||
ALTER TABLE users ADD COLUMN is_member BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE users ADD COLUMN member_type VARCHAR(20) DEFAULT NULL; -- monthly / quarterly / yearly
|
||||
ALTER TABLE users ADD COLUMN member_expires_at TIMESTAMP DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN points_balance INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- 保留但可废弃的字段(兼容期)
|
||||
-- subscription_plan → 迁移期间映射为 is_member + member_type
|
||||
-- subscription_status → 迁移期间保留
|
||||
-- max_projects / max_storage_gb → 不再限制,保留字段但不再使用
|
||||
```
|
||||
|
||||
### 5.2 积分账户表 `points_accounts`
|
||||
|
||||
每个用户一条记录,记录余额和累计值。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS points_accounts (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
balance INTEGER NOT NULL DEFAULT 0, -- 当前可用积分
|
||||
total_earned INTEGER NOT NULL DEFAULT 0, -- 累计获得
|
||||
total_spent INTEGER NOT NULL DEFAULT 0, -- 累计消耗
|
||||
total_purchased INTEGER NOT NULL DEFAULT 0, -- 累计购买
|
||||
total_gifted INTEGER NOT NULL DEFAULT 0, -- 累计赠送(任务奖励)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_points_accounts_user ON points_accounts(user_id);
|
||||
```
|
||||
|
||||
### 5.3 积分流水表 `points_transactions`
|
||||
|
||||
每笔积分变动一条记录,只追加不修改,保证审计完整性。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS points_transactions (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
account_id VARCHAR(36) NOT NULL REFERENCES points_accounts(id) ON DELETE CASCADE,
|
||||
type VARCHAR(20) NOT NULL, -- earn(获得) / spend(消耗) / refund(退还)
|
||||
source VARCHAR(50) NOT NULL, -- recharge / task_reward / ai_voice / ai_digital_human / ai_video / ai_rewrite / ai_title / ai_cover / douyin_extract / voice_clone
|
||||
amount INTEGER NOT NULL, -- 变动数量(正数)
|
||||
balance_after INTEGER NOT NULL, -- 变动后余额
|
||||
description VARCHAR(255) DEFAULT '',
|
||||
ref_id VARCHAR(100) DEFAULT '', -- 关联业务 ID(订单号、任务 ID 等)
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_points_tx_user ON points_transactions(user_id);
|
||||
CREATE INDEX idx_points_tx_type ON points_transactions(type);
|
||||
CREATE INDEX idx_points_tx_source ON points_transactions(source);
|
||||
CREATE INDEX idx_points_tx_created ON points_transactions(created_at);
|
||||
```
|
||||
|
||||
### 5.4 积分订单表 `points_orders`
|
||||
|
||||
记录积分充值订单。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS points_orders (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
package_name VARCHAR(50) NOT NULL, -- 体验包/基础包/专业包
|
||||
points_amount INTEGER NOT NULL, -- 积分数量
|
||||
price_cents INTEGER NOT NULL, -- 支付金额(分)
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
|
||||
discount REAL NOT NULL DEFAULT 1.0, -- 享受的折扣
|
||||
original_price_cents INTEGER NOT NULL, -- 原价(分)
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / paid / failed / refunded
|
||||
payment_method VARCHAR(50), -- wechat_pay / alipay
|
||||
payment_id VARCHAR(100), -- 第三方支付流水号
|
||||
paid_at TIMESTAMP,
|
||||
expire_at TIMESTAMP, -- 未支付自动过期时间
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_points_orders_user ON points_orders(user_id);
|
||||
CREATE INDEX idx_points_orders_status ON points_orders(status);
|
||||
```
|
||||
|
||||
### 5.5 每日免费混剪计数 `daily_usage_records`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS daily_usage_records (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
usage_date DATE NOT NULL, -- 使用日期(UTC+8)
|
||||
usage_type VARCHAR(50) NOT NULL, -- free_clip(免费混剪)
|
||||
count INTEGER NOT NULL DEFAULT 0, -- 当日已使用次数
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, usage_date, usage_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_daily_usage_user_date ON daily_usage_records(user_id, usage_date);
|
||||
```
|
||||
|
||||
> **Redis 辅助**:用 Redis `INCR` 做实时计数(key: `daily_usage:{user_id}:{date}:{usage_type}`),TTL 48 小时自动过期;DB 作为持久化兜底,定期同步。
|
||||
|
||||
### 5.6 表关系图
|
||||
|
||||
```
|
||||
users (1) ──── (1) points_accounts
|
||||
│
|
||||
└── (1:N) points_transactions
|
||||
│
|
||||
└── ref_id → generation_tasks / points_orders / ...
|
||||
|
||||
users (1) ──── (1:N) points_orders
|
||||
users (1) ──── (1:N) daily_usage_records
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### 6.1 积分账户
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/points/balance` | 查询积分余额 + 会员状态 |
|
||||
| GET | `/api/v1/points/transactions` | 积分流水(分页 + 筛选) |
|
||||
|
||||
#### `GET /api/v1/points/balance` 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 580,
|
||||
"total_earned": 1200,
|
||||
"total_spent": 620,
|
||||
"is_member": true,
|
||||
"member_type": "yearly",
|
||||
"member_expires_at": "2027-09-14T00:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/points/transactions` 请求参数
|
||||
|
||||
```
|
||||
?page=1&page_size=20&type=spend&source=ai_voice&start_date=2026-09-01&end_date=2026-09-30
|
||||
```
|
||||
|
||||
### 6.2 积分充值
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/points/packages` | 查询可购买的积分包列表 |
|
||||
| POST | `/api/v1/points/recharge` | 创建充值订单 |
|
||||
| POST | `/api/v1/points/payment-callback` | 支付回调(内部) |
|
||||
|
||||
#### `GET /api/v1/points/packages` 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"id": "starter_pack",
|
||||
"name": "体验包",
|
||||
"points": 100,
|
||||
"price": 990,
|
||||
"discounted_price_for_monthly": 891,
|
||||
"discounted_price_for_quarterly": 861,
|
||||
"discounted_price_for_yearly": 792
|
||||
},
|
||||
{
|
||||
"id": "basic_pack",
|
||||
"name": "基础包",
|
||||
"points": 500,
|
||||
"price": 3900,
|
||||
"discounted_price_for_monthly": 3510,
|
||||
"discounted_price_for_quarterly": 3393,
|
||||
"discounted_price_for_yearly": 3120
|
||||
},
|
||||
{
|
||||
"id": "pro_pack",
|
||||
"name": "专业包",
|
||||
"points": 2000,
|
||||
"price": 12900,
|
||||
"discounted_price_for_monthly": 11610,
|
||||
"discounted_price_for_quarterly": 11223,
|
||||
"discounted_price_for_yearly": 10320
|
||||
}
|
||||
],
|
||||
"user_discount": "yearly",
|
||||
"unit_price_yuan": 0.10
|
||||
}
|
||||
```
|
||||
|
||||
> 价格单位为**分**(避免浮点精度问题)。折扣根据用户当前会员类型自动计算。
|
||||
|
||||
### 6.3 积分消耗(内部服务接口)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/v1/points/check` | 消费前余额检查 |
|
||||
| POST | `/api/v1/points/deduct` | 消费扣减(事务性) |
|
||||
| POST | `/api/v1/points/refund` | 消费退还(业务失败时) |
|
||||
|
||||
#### `POST /api/v1/points/check` 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"scene_key": "ai_digital_human",
|
||||
"duration_minutes": 1,
|
||||
"user_id": "xxx"
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/v1/points/check` 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"allowed": true,
|
||||
"required_points": 15,
|
||||
"current_balance": 580,
|
||||
"remaining_after": 565,
|
||||
"is_free_quota": false
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 每日免费额度
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/usage/daily` | 查询今日免费额度使用情况 |
|
||||
|
||||
#### `GET /api/v1/usage/daily` 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"free_clips_used": 1,
|
||||
"free_clips_limit": 2,
|
||||
"free_clips_remaining": 1,
|
||||
"reset_at": "2026-09-15T00:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 会员订阅(改造现有接口)
|
||||
|
||||
| 方法 | 路径 | 说明 | 改动 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/v1/subscription/current` | 当前会员信息 | 增加积分余额、会员类型 |
|
||||
| POST | `/api/v1/subscription/subscribe` | 开通/续费会员 | 替换原 change-plan |
|
||||
| POST | `/api/v1/subscription/cancel` | 取消自动续费 | 保留 |
|
||||
| POST | `/api/v1/subscription/payment-callback` | 支付回调 | 改造 |
|
||||
|
||||
#### `POST /api/v1/subscription/subscribe` 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"member_type": "yearly",
|
||||
"payment_method": "wechat_pay"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 消耗规则查询
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/points/rules` | 查询所有积分消耗规则 |
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{ "scene_key": "ai_voice", "scene_name": "AI 配音", "points_per_use": 1, "unit": "分钟" },
|
||||
{ "scene_key": "ai_video", "scene_name": "智能混剪", "points_per_use": 3, "unit": "条", "extra_per_30s": 1 },
|
||||
{ "scene_key": "ai_digital_human", "scene_name": "AI 数字人", "points_per_use": 15, "unit": "分钟" },
|
||||
{ "scene_key": "voice_clone_train", "scene_name": "声音克隆训练", "points_per_use": 0 },
|
||||
{ "scene_key": "voice_clone_synth", "scene_name": "声音克隆合成", "points_per_use": 1, "unit": "分钟" },
|
||||
{ "scene_key": "douyin_extract", "scene_name": "抖音链接提取", "points_per_use": 1, "unit": "次" },
|
||||
{ "scene_key": "ai_rewrite", "scene_name": "AI 改写文案", "points_per_use": 1, "unit": "次" },
|
||||
{ "scene_key": "ai_title", "scene_name": "AI 标题生成", "points_per_use": 1, "unit": "次" },
|
||||
{ "scene_key": "ai_cover", "scene_name": "AI 封面生成", "points_per_use": 1, "unit": "张" }
|
||||
],
|
||||
"free_user_multiplier": 1.15,
|
||||
"note": "免费用户消耗 = 会员消耗 × 1.15,向上取整"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、前端页面改造要点
|
||||
|
||||
### 7.1 会员购买页(改造 Plans.tsx)
|
||||
|
||||
**现状**:写死 4 档(free/standard/pro/enterprise),与后端不一致。
|
||||
|
||||
**改造为**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 选择适合您的方案 │
|
||||
├────────────────────┬─────────────────────────┤
|
||||
│ 免费会员 │ 付费会员 ★ 推荐 │
|
||||
│ ¥0 │ ¥19.9/月 │
|
||||
│ │ ¥39.9/季(省33%) │
|
||||
│ ✓ 每日2条免费混剪 │ ¥159/年(省33%) │
|
||||
│ ✓ 720p 导出 │ │
|
||||
│ ✓ AI功能(积分×1.15)│ ✓ 无限混剪 │
|
||||
│ │ ✓ 全部AI功能(标准积分) │
|
||||
│ │ ✓ 积分购买最高 8 折 │
|
||||
│ │ ✓ 最高 1080p 导出 │
|
||||
│ │ │
|
||||
│ [当前方案] │ [立即开通] │
|
||||
├────────────────────┴─────────────────────────┤
|
||||
│ 💰 积分充值(付费会员专享折扣) │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │100积分 │ │500积分 │ │2000积分│ │
|
||||
│ │ ¥9.9 │ │ ¥39 │ │ ¥129 │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**改动要点**:
|
||||
- 移除 4 档卡片,改为左右对比布局
|
||||
- 付费会员区增加月卡/季卡/年卡切换
|
||||
- 底部增加积分包快捷购买区
|
||||
|
||||
### 7.2 新增积分中心页 `/app/points`
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 积分中心 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 💎 580 积分 会员到期: 2027-09-14 │
|
||||
│ 本月获得 +200 本月消耗 -120 │
|
||||
│ │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ [积分明细] [充值记录] [消耗规则] │
|
||||
│ ───────── │
|
||||
│ 时间 类型 场景 数量 余额 │
|
||||
│ 09-14 10:30 消耗 AI配音 -1 579 │
|
||||
│ 09-14 09:15 消耗 智能混剪 -5 580 │
|
||||
│ 09-01 00:00 获得 任务奖励 +50 585 │
|
||||
│ 08-28 14:20 获得 充值+500 +500 385 │
|
||||
│ ... │
|
||||
│ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.3 Header 积分徽章
|
||||
|
||||
- 位置:顶部导航栏用户头像左侧
|
||||
- 显示:💎 + 积分余额
|
||||
- 余额 < 10 时变橙色警告
|
||||
- 点击展开快捷面板:余额、本月消耗、充值入口
|
||||
|
||||
### 7.4 功能入口消耗提示
|
||||
|
||||
在每个 AI 功能操作按钮旁显示:
|
||||
|
||||
```
|
||||
[生成配音] 💎 -1 积分
|
||||
[生成视频] 💎 -5 积分(约1分钟)
|
||||
[数字人口播] 💎 -15 积分/分钟
|
||||
```
|
||||
|
||||
免费用户看到 AI 功能时显示消耗提示(×1.15 价格),并提示"开通会员享更低积分消耗"。
|
||||
|
||||
### 7.5 免费额度提示
|
||||
|
||||
免费用户在工作台顶部显示:
|
||||
|
||||
```
|
||||
今日免费混剪:已用 1/2 条 [升级会员享无限混剪 →]
|
||||
```
|
||||
|
||||
### 7.6 前端文件改动清单
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `Plans.tsx` | 重写 | 两档会员 + 积分包购买 |
|
||||
| `pages/points/Center.tsx` | 新增 | 积分中心主页 |
|
||||
| `pages/points/Transactions.tsx` | 新增 | 积分明细 |
|
||||
| `pages/points/Packages.tsx` | 新增 | 积分包充值 |
|
||||
| `components/common/PointsBadge/` | 新增 | Header 积分徽章 |
|
||||
| `components/common/PointsCost/` | 新增 | 消耗提示组件 |
|
||||
| `api/points.ts` | 新增 | 积分相关 API 封装 |
|
||||
| `api/subscription.ts` | 改造 | 对接新会员接口 |
|
||||
| `store/pointsStore.ts` | 新增 | 积分状态管理 |
|
||||
|
||||
---
|
||||
|
||||
## 八、配额检查中间件设计
|
||||
|
||||
### 8.1 整体流程
|
||||
|
||||
```
|
||||
用户请求 → API Route
|
||||
│
|
||||
├─ 1. 检查会员状态(is_member + member_expires_at)
|
||||
│
|
||||
├─ 2. 免费混剪额度检查(仅混剪场景)
|
||||
│ ├─ 免费用户 + 混剪功能 → 检查每日免费额度(Redis)
|
||||
│ │ ├─ 未超限 → 放行,不扣积分
|
||||
│ │ └─ 已超限 → 检查积分余额,按 ×1.15 扣减
|
||||
│ └─ 付费用户 + 混剪功能 → 不限次数,不扣免费额度
|
||||
│
|
||||
├─ 3. 积分扣费检查(所有 AI 功能)
|
||||
│ ├─ 免费用户:消耗 = 基础积分 × 1.15(向上取整)
|
||||
│ └─ 付费用户:消耗 = 基础积分(享受会员价)
|
||||
│ ├─ 余额充足 → 事务扣减 → 放行
|
||||
│ └─ 余额不足 → 返回 402,提示充值
|
||||
│
|
||||
└─ 4. 业务执行失败 → 自动退还积分
|
||||
```
|
||||
|
||||
### 8.2 装饰器实现
|
||||
|
||||
```python
|
||||
# packages/middleware/points_gate.py
|
||||
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException
|
||||
|
||||
def points_gate(scene_key: str, per_unit: int = None, unit_field: str = None):
|
||||
"""
|
||||
AI 功能入口的积分扣费装饰器
|
||||
|
||||
Args:
|
||||
scene_key: 消耗场景标识
|
||||
per_unit: 每单位消耗积分(固定值时直接传)
|
||||
unit_field: 动态计算时,从 request 中取数量的字段名
|
||||
如 "duration_minutes" 表示按时长计费
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# 提取用户
|
||||
current_user = _extract_user(args, kwargs)
|
||||
request = _extract_request(args, kwargs)
|
||||
|
||||
# 计算消耗量
|
||||
if per_unit is not None:
|
||||
base_points = per_unit
|
||||
elif unit_field is not None:
|
||||
units = getattr(request, unit_field, 1)
|
||||
base_points = get_scene_points(scene_key) * units
|
||||
else:
|
||||
base_points = get_scene_points(scene_key)
|
||||
|
||||
# 混剪功能:先检查免费额度
|
||||
if scene_key == "free_clip":
|
||||
if check_and_incr_daily_usage(current_user.user.id, "free_clip"):
|
||||
# 免费额度内,不扣积分
|
||||
kwargs["_points_deducted"] = 0
|
||||
return await func(*args, **kwargs)
|
||||
# 免费额度用完,继续走积分扣费逻辑
|
||||
|
||||
# 根据会员类型计算实际消耗
|
||||
is_member = current_user.user.is_member
|
||||
if is_member:
|
||||
total_points = base_points # 会员标准价
|
||||
else:
|
||||
# 免费用户消耗 ×1.15,向上取整
|
||||
import math
|
||||
total_points = math.ceil(base_points * 1.15)
|
||||
|
||||
# 检查并扣减积分
|
||||
points_service = get_points_service()
|
||||
result = points_service.check_and_deduct(
|
||||
user_id=current_user.user.id,
|
||||
scene_key=scene_key,
|
||||
amount=total_points,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {total_points} 积分,当前余额 {result.balance}",
|
||||
"recharge_url": "/app/points/recharge"
|
||||
}
|
||||
)
|
||||
|
||||
kwargs["_points_deduct_id"] = result.transaction_id
|
||||
kwargs["_points_deducted"] = total_points
|
||||
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# 业务失败,退还积分
|
||||
points_service.refund(
|
||||
user_id=current_user.user.id,
|
||||
transaction_id=result.transaction_id,
|
||||
reason=f"业务执行失败: {scene_key}"
|
||||
)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
### 8.3 每日免费额度检查(Redis)
|
||||
|
||||
```python
|
||||
# packages/middleware/daily_usage.py
|
||||
|
||||
import redis
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
# Redis key: daily_usage:{user_id}:{date}:{usage_type}
|
||||
# TTL: 48 小时自动过期
|
||||
|
||||
def check_and_incr_daily_usage(user_id: str, usage_type: str, limit: int = 2) -> bool:
|
||||
"""
|
||||
检查并递增每日使用次数
|
||||
返回 True 表示允许,False 表示已超限
|
||||
"""
|
||||
tz = timezone(timedelta(hours=8))
|
||||
today = datetime.now(tz).strftime("%Y%m%d")
|
||||
key = f"daily_usage:{user_id}:{today}:{usage_type}"
|
||||
|
||||
r = get_redis_client()
|
||||
current = r.get(key)
|
||||
|
||||
if current and int(current) >= limit:
|
||||
return False
|
||||
|
||||
pipe = r.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, 48 * 3600) # 48 小时过期
|
||||
results = pipe.execute()
|
||||
|
||||
# 同步到 DB(异步或定时任务)
|
||||
sync_daily_usage_to_db(user_id, today, usage_type)
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### 8.4 与现有 QuotaChecker 的集成
|
||||
|
||||
现有 `QuotaChecker` 保留,负责检查存储、并发等基础配额。新增 `PointsGate` 负责积分扣费。两者在中间件层协作:
|
||||
|
||||
```python
|
||||
# 统一中间件入口
|
||||
async def unified_quota_check(user, scene_key, **kwargs):
|
||||
# Step 1: 基础配额(存储、并发等)
|
||||
plan = "paid" if user.is_member else "free"
|
||||
base_check = quota_checker.check_multiple(plan, {
|
||||
QuotaDimension.STORAGE_GB.value: get_used_storage(user.id),
|
||||
QuotaDimension.MAX_CONCURRENT.value: get_concurrent_count(user.id),
|
||||
})
|
||||
for result in base_check:
|
||||
if not result.allowed:
|
||||
raise QuotaExceededError(result)
|
||||
|
||||
# Step 2: 积分扣费
|
||||
await points_gate_check(user, scene_key, **kwargs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、支付接入
|
||||
|
||||
### 9.1 支付渠道
|
||||
|
||||
| 渠道 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| **微信支付** | P0(首期) | 覆盖绝大多数用户 |
|
||||
| **支付宝** | P1(后续) | 二期接入 |
|
||||
|
||||
### 9.2 支付流程
|
||||
|
||||
```
|
||||
用户选择套餐/积分包
|
||||
→ POST /api/v1/subscription/subscribe 或 /api/v1/points/recharge
|
||||
→ 后端创建订单,调用微信支付统一下单 API
|
||||
→ 返回支付参数(prepay_id / code_url)
|
||||
→ 前端展示二维码 or 调起支付
|
||||
→ 用户支付完成
|
||||
→ 微信回调 POST /api/v1/subscription/payment-callback
|
||||
→ 验签 → 更新订单状态 → 发放积分/开通会员
|
||||
```
|
||||
|
||||
### 9.3 发票
|
||||
|
||||
- 初期不做自助开票
|
||||
- 用户有需求时通过客服人工处理
|
||||
|
||||
### 9.4 退款策略
|
||||
|
||||
- 虚拟商品原则上不退
|
||||
- 特殊情况(如系统故障导致积分异常消耗)由客服人工处理
|
||||
|
||||
---
|
||||
|
||||
## 十、文件改动总览
|
||||
|
||||
| 类型 | 文件路径 | 说明 |
|
||||
|------|----------|------|
|
||||
| **新增** | `migrations/007_membership_points.sql` | 新建积分相关表 |
|
||||
| **新增** | `packages/domain/points_service.py` | 积分核心服务 |
|
||||
| **新增** | `packages/middleware/points_gate.py` | 积分扣费中间件 |
|
||||
| **新增** | `packages/middleware/daily_usage.py` | 每日免费额度检查 |
|
||||
| **新增** | `apps/api/app/api/routes/points.py` | 积分 API 路由 |
|
||||
| **新增** | `apps/api/app/schemas/points.py` | 积分 Schema |
|
||||
| **新增** | `apps/web/src/pages/points/Center.tsx` | 积分中心页 |
|
||||
| **新增** | `apps/web/src/pages/points/Transactions.tsx` | 积分明细页 |
|
||||
| **新增** | `apps/web/src/pages/points/Packages.tsx` | 积分包充值页 |
|
||||
| **新增** | `apps/web/src/components/common/PointsBadge/` | Header 积分徽章 |
|
||||
| **新增** | `apps/web/src/components/common/PointsCost/` | 消耗提示组件 |
|
||||
| **新增** | `apps/web/src/api/points.ts` | 积分 API 封装 |
|
||||
| **新增** | `apps/web/src/store/pointsStore.ts` | 积分状态管理 |
|
||||
| **修改** | `packages/domain/quota.py` | 简化配额维度 |
|
||||
| **修改** | `packages/adapters/sqlalchemy_impl/models.py` | 新增积分 Model + users 字段 |
|
||||
| **修改** | `apps/api/app/api/routes/subscription.py` | 改造为两档会员 |
|
||||
| **修改** | `apps/api/app/schemas/subscription.py` | 简化 Schema |
|
||||
| **修改** | `apps/web/src/pages/subscription/Plans.tsx` | 两档会员 UI |
|
||||
| **修改** | 各 AI 功能路由 | 添加 `@points_gate` 装饰器 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、实施排期
|
||||
|
||||
| 阶段 | 内容 | 预估工时 |
|
||||
|------|------|----------|
|
||||
| P1 | 数据库迁移 + Model + Repository | 2 天 |
|
||||
| P2 | PointsService 核心逻辑 + 每日额度 | 2 天 |
|
||||
| P3 | 积分 API + 微信支付接入 | 3 天 |
|
||||
| P4 | 中间件改造(各 AI 功能接入) | 2 天 |
|
||||
| P5 | 前端页面改造 + 新增页面 | 4 天 |
|
||||
| P6 | 集成测试 + 端到端测试 | 2 天 |
|
||||
| **合计** | | **~15 天** |
|
||||
|
||||
---
|
||||
|
||||
## 十二、开放问题(待后续讨论)
|
||||
|
||||
| # | 问题 | 当前决策 |
|
||||
|---|------|---------|
|
||||
| 1 | 积分是否需要有效期? | **永久有效**(已确认) |
|
||||
| 2 | 退款策略? | **虚拟商品不退**,特殊情况人工处理(已确认) |
|
||||
| 3 | 企业版定制需求? | **暂不做**,后续按需(已确认) |
|
||||
| 4 | 支付渠道优先级? | **微信优先**,支付宝二期(已确认) |
|
||||
| 5 | 发票需求? | **暂不做**,后续按需(已确认) |
|
||||
| 6 | 免费用户每日 2 条混剪是否限制分辨率? | 建议限制 720p |
|
||||
|
||||
---
|
||||
|
||||
*本方案基于真实 AI 调用成本核算,经用户确认后制定。待确认后即可进入开发阶段。*
|
||||
@@ -1,6 +1,6 @@
|
||||
"""共享的句子时间戳计算工具 — 供 Celery TTS 任务和 /lipsync/tts-preview 同步接口复用.
|
||||
|
||||
- `_split_script_into_sentences`: 按标点分句(中英文逗号/句号/问号/感叹号/分号/换行)
|
||||
- `split_script_into_sentences`: 按标点分句(中英文句号/问号/感叹号/分号/换行,不含逗号,与前端 sentences.ts 保持一致)
|
||||
- `_estimate_sentence_timings_by_chars`: 按字数比例估算(静音检测失败时降级)
|
||||
- `_probe_audio_duration`: ffprobe 读取音频时长
|
||||
- `compute_sentence_timings`: 基于 ffmpeg silencedetect 精确计算每句起止时间
|
||||
@@ -19,15 +19,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def split_script_into_sentences(script_text: str) -> list[str]:
|
||||
"""按句号/问号/感叹号/分号/逗号/换行分句(与前端 SENTENCE_SPLIT_RE 一致).
|
||||
"""按句号/问号/感叹号/分号/换行分句(与前端 sentences.ts 的 SENTENCE_SPLIT_RE 一致).
|
||||
|
||||
中文短视频文案习惯用「,」断小句(如"卖花的叫花无缺,卖姜的叫姜子牙"),
|
||||
必须把逗号也纳入分隔符,否则多句文案会被识别成一整句,导致 B-roll 时间戳错位。
|
||||
仅在句末标点(。!?!?;;)和换行处分句,**不再用逗号(,,)切分**。
|
||||
按逗号切分会把连贯句子拆得过碎,导致 B-roll/口播画面按句插入时句数过多、
|
||||
时长过短,效果不符合预期(Issue #1892)。
|
||||
"""
|
||||
text = (script_text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
parts = re.split(r"[。!?!??!;;,,\n\r]+", text)
|
||||
parts = re.split(r"[。!?!??!;;\n\r]+", text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
|
||||
@@ -36,14 +36,20 @@ CACHE_TAG_PRIMARY="${CACHE_TAG:-develop}"
|
||||
API_IMAGE="xiaoxia-saas-api:$VERSION"
|
||||
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
|
||||
WEB_IMAGE="xiaoxia-saas-web:$VERSION"
|
||||
API_LATEST="xiaoxia-saas-api:dev"
|
||||
WORKER_LATEST="xiaoxia-saas-worker:dev"
|
||||
API_DEV="xiaoxia-saas-api:dev"
|
||||
WORKER_DEV="xiaoxia-saas-worker:dev"
|
||||
WEB_DEV="xiaoxia-saas-web:dev"
|
||||
|
||||
# Registry 上的完整镜像名
|
||||
# Registry 上的完整镜像名(SHA/版本 tag)
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$VERSION"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$VERSION"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$VERSION"
|
||||
|
||||
# Registry 上的 dev floating tag(仅 staging/develop 构建时推送,供 Watchtower 监听自动更新)
|
||||
REGISTRY_API_DEV="${REGISTRY}/xiaoxia-saas-api:dev"
|
||||
REGISTRY_WORKER_DEV="${REGISTRY}/xiaoxia-saas-worker:dev"
|
||||
REGISTRY_WEB_DEV="${REGISTRY}/xiaoxia-saas-web:dev"
|
||||
|
||||
USE_CACHE=0
|
||||
USE_PUSH=0
|
||||
CACHE_WRITE=0
|
||||
@@ -56,23 +62,34 @@ if docker buildx version >/dev/null 2>&1; then
|
||||
docker buildx use default 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ---- 是否需要推送 dev tag ----
|
||||
# staging 构建 或 develop 分支构建时才推送 :dev tag,供 Watchtower 自动更新;
|
||||
# production / release tag 构建不推送 dev tag,避免污染生产 tag 指向。
|
||||
PUSH_DEV_TAG=0
|
||||
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
|
||||
if [ "$BUILD_ENV" = "staging" ] || [ "$BRANCH_NAME" = "develop" ]; then
|
||||
PUSH_DEV_TAG=1
|
||||
echo "Dev tag push: ENABLED (BUILD_ENV=$BUILD_ENV, BRANCH=$BRANCH_NAME) — Watchtower will pick up new :dev"
|
||||
else
|
||||
echo "Dev tag push: disabled (BUILD_ENV=$BUILD_ENV, BRANCH=$BRANCH_NAME)"
|
||||
fi
|
||||
|
||||
# ---- 缓存读写策略(按分支隔离)----
|
||||
# 默认只读不写,防止 feature 分支污染主缓存
|
||||
# 只有 develop/main 分支才写回缓存
|
||||
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
|
||||
# 清理本地旧镜像
|
||||
docker rmi -f "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
|
||||
docker rmi -f "$API_IMAGE" "$API_DEV" 2>/dev/null || true
|
||||
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||
-f infra/docker/api.Dockerfile \
|
||||
-t "$API_IMAGE" -t "$API_LATEST" \
|
||||
-t "$API_IMAGE" -t "$API_DEV" \
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
|
||||
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_DEV" .
|
||||
fi
|
||||
|
||||
build_with_cache() {
|
||||
@@ -122,22 +139,22 @@ build_with_cache() {
|
||||
echo "=== Building API image ==="
|
||||
build_with_cache "api" "infra/docker/api.Dockerfile" \
|
||||
"--build-arg APP_VERSION=$VERSION"
|
||||
docker tag "$API_IMAGE" "$API_LATEST"
|
||||
docker tag "$API_IMAGE" "$API_DEV"
|
||||
|
||||
echo "=== Building Worker image ==="
|
||||
# 清理本地旧镜像
|
||||
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
|
||||
docker rmi -f "$WORKER_IMAGE" "$WORKER_DEV" 2>/dev/null || true
|
||||
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--build-arg APP_VERSION="$VERSION" \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||
-f infra/docker/worker.Dockerfile \
|
||||
-t "$WORKER_IMAGE" -t "$WORKER_LATEST" \
|
||||
-t "$WORKER_IMAGE" -t "$WORKER_DEV" \
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
|
||||
docker build --pull=false --build-arg APP_VERSION="$VERSION" -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_DEV" .
|
||||
fi
|
||||
|
||||
echo "=== Building Web image (with buildx cache) ==="
|
||||
@@ -158,37 +175,52 @@ docker run --rm \
|
||||
test -f apps/web/dist/index.html
|
||||
|
||||
# 清理本地旧镜像
|
||||
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
|
||||
docker rmi -f "$WEB_IMAGE" "$WEB_DEV" 2>/dev/null || true
|
||||
|
||||
if [ "$USE_CACHE" -eq 1 ]; then
|
||||
docker buildx build \
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
|
||||
-t "$WEB_IMAGE" \
|
||||
-t "$WEB_IMAGE" -t "$WEB_DEV" \
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
|
||||
-t "$WEB_IMAGE" \
|
||||
-t "$WEB_IMAGE" -t "$WEB_DEV" \
|
||||
.
|
||||
fi
|
||||
|
||||
# Push 到 Registry
|
||||
if [ "$USE_PUSH" -eq 1 ]; then
|
||||
echo "=== Pushing images to Registry ==="
|
||||
echo "=== Pushing SHA/version-tagged images to Registry ==="
|
||||
docker tag "$API_IMAGE" "$REGISTRY_API"
|
||||
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER"
|
||||
docker tag "$WEB_IMAGE" "$REGISTRY_WEB"
|
||||
docker push "$REGISTRY_API"
|
||||
docker push "$REGISTRY_WORKER"
|
||||
docker push "$REGISTRY_WEB"
|
||||
echo "All images pushed to $REGISTRY"
|
||||
echo "SHA-tagged images pushed to $REGISTRY"
|
||||
|
||||
# ---- 额外推送 :dev floating tag(仅 staging/develop)----
|
||||
# :dev tag 指向最新一次 develop/staging 构建,供 Watchtower 通过 WATCHTOWER_ROLLING_RESTART 等方式
|
||||
# 监听镜像更新并自动 pull + restart;部署脚本 (ci_staging_deploy.sh) 仍使用 SHA tag 做确定性部署,
|
||||
# dev tag 仅作为"最新 develop 成功构建"的可滚动标识,避免并发构建互相覆盖导致的不可重现问题。
|
||||
if [ "$PUSH_DEV_TAG" -eq 1 ]; then
|
||||
echo "=== Pushing :dev floating tags to Registry (for Watchtower auto-update) ==="
|
||||
docker tag "$API_IMAGE" "$REGISTRY_API_DEV"
|
||||
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER_DEV"
|
||||
docker tag "$WEB_IMAGE" "$REGISTRY_WEB_DEV"
|
||||
docker push "$REGISTRY_API_DEV"
|
||||
docker push "$REGISTRY_WORKER_DEV"
|
||||
docker push "$REGISTRY_WEB_DEV"
|
||||
echo ":dev tags pushed to $REGISTRY"
|
||||
fi
|
||||
else
|
||||
echo "Registry push skipped (no auth token available)"
|
||||
fi
|
||||
|
||||
echo "=== Build complete ==="
|
||||
docker images | grep "xiaoxia-saas" | grep "$VERSION" || true
|
||||
docker images | grep "xiaoxia-saas" | grep -E "($VERSION|dev)" || true
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
|
||||
# 用法:
|
||||
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
|
||||
# ============================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
ACR_USERNAME="${ACR_USERNAME:-}"
|
||||
ACR_PASSWORD="${ACR_PASSWORD:-}"
|
||||
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
|
||||
|
||||
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
|
||||
IMAGES=(
|
||||
"python:3.12-slim-bookworm"
|
||||
"python:3.12-slim"
|
||||
"node:20"
|
||||
"nginx:alpine"
|
||||
)
|
||||
|
||||
echo "============================================"
|
||||
echo " 基础镜像同步到 ACR"
|
||||
echo " ACR: $ACR_REGISTRY"
|
||||
echo " 源: $SOURCE_PREFIX"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 登录 ACR
|
||||
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
|
||||
echo "登录 ACR..."
|
||||
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
|
||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
|
||||
echo "ACR 登录成功"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
success=0
|
||||
failed=0
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
source_image="${SOURCE_PREFIX}/${image}"
|
||||
target_image="${ACR_REGISTRY}/base/${image}"
|
||||
|
||||
echo "--- 同步: $image ---"
|
||||
echo " 源: $source_image"
|
||||
echo " 目标: $target_image"
|
||||
|
||||
# Pull 源镜像(带重试)
|
||||
pulled=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Pull 尝试 $attempt/3..."
|
||||
if docker pull "$source_image"; then
|
||||
pulled=1
|
||||
break
|
||||
fi
|
||||
echo " Pull 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pulled" -eq 0 ]; then
|
||||
echo " ❌ Pull 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tag
|
||||
docker tag "$source_image" "$target_image"
|
||||
echo " Tag 完成"
|
||||
|
||||
# Push 到 ACR
|
||||
pushed=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Push 尝试 $attempt/3..."
|
||||
if docker push "$target_image"; then
|
||||
pushed=1
|
||||
break
|
||||
fi
|
||||
echo " Push 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pushed" -eq 1 ]; then
|
||||
echo " ✅ 同步成功: $image"
|
||||
success=$((success + 1))
|
||||
else
|
||||
echo " ❌ Push 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "============================================"
|
||||
echo " 同步完成"
|
||||
echo " 成功: $success"
|
||||
echo " 失败: $failed"
|
||||
echo "============================================"
|
||||
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
results = data.get('results', {})
|
||||
total = sum(len(v) for v in results.values())
|
||||
print(total)
|
||||
except Exception:
|
||||
print('error')
|
||||
")
|
||||
|
||||
echo "Secrets detected: $FOUND"
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
for fpath, items in data.get('results', {}).items():
|
||||
for item in items:
|
||||
line = item.get('line_number', '?')
|
||||
stype = item.get('type', '?')
|
||||
hashed = item.get('hashed_secret', '')[:16]
|
||||
print(f' {fpath}:{line} [{stype}] {hashed}...')
|
||||
"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] CI & shell scripts syntax validation ==="
|
||||
SYNTAX_ERROR=0
|
||||
# 检查所有 CI shell 脚本
|
||||
for script in scripts/ci/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查所有 CI Python 脚本语法
|
||||
for script in scripts/ci/*.py; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! python3 -m py_compile "$script" 2>&1; then
|
||||
echo "❌ Python语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查 .gitea/workflows 下的脚本(如果有)
|
||||
for script in .gitea/workflows/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$SYNTAX_ERROR" -ne 0 ]; then
|
||||
echo "❌ CI脚本语法校验失败,见上方错误"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All CI scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
@@ -170,16 +170,17 @@ rollback() {
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
--network-alias xiaoxia-api \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
@@ -196,26 +197,29 @@ rollback() {
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
--network-alias xiaoxia-worker \
|
||||
--network-alias xiaoxia-api \
|
||||
--env-file "$ENV_FILE" \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
|
||||
-e PYTHONPATH=/app:/app/apps/api:/app/packages \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
-v "$LEGACY_ASSETS_DIR:/app/legacy-assets" \
|
||||
-w /app/apps/worker \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--cpus 2 \
|
||||
--memory 3g \
|
||||
--health-cmd "sh -c 'PYTHONPATH=/app:/app/apps/api:/app/packages celery -A worker_app.celery_app inspect ping -t 5 2>&1 | grep -q pong'" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-timeout 15s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WORKER_IMAGE"
|
||||
--health-start-period 60s \
|
||||
--log-driver json-file --log-opt max-size=200m --log-opt max-file=5 \
|
||||
"$PREV_WORKER_IMAGE" \
|
||||
/usr/local/bin/entrypoint-worker.sh
|
||||
else
|
||||
echo "No previous Worker image to roll back to"
|
||||
fi
|
||||
@@ -230,12 +234,15 @@ rollback() {
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
--network-alias xiaoxia-web \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
-e APP_ENV=production \
|
||||
-e API_BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
|
||||
$LEGACY_VOLUME \
|
||||
--restart unless-stopped \
|
||||
--cpus 1 \
|
||||
--memory 512m \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
@@ -373,12 +380,13 @@ docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
--network-alias xiaoxia-api \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
@@ -395,26 +403,29 @@ docker run -d \
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
--network-alias xiaoxia-worker \
|
||||
--network-alias xiaoxia-api \
|
||||
--env-file "$ENV_FILE" \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://production-api.xiaoxiajianji.com \
|
||||
-e GENERATED_FILES_URL_PREFIX=https://saas-api.xiaoxiajianji.com/generated-files \
|
||||
-e PYTHONPATH=/app:/app/apps/api:/app/packages \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
-v "$LEGACY_ASSETS_DIR:/app/legacy-assets" \
|
||||
-w /app/apps/worker \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"for pid in /proc/[0-9]*/cmdline; do if grep -ql celery \"$pid\" 2>/dev/null; then exit 0; fi; done; exit 1\"" \
|
||||
--memory 3g \
|
||||
--health-cmd "sh -c 'PYTHONPATH=/app:/app/apps/api:/app/packages celery -A worker_app.celery_app inspect ping -t 5 2>&1 | grep -q pong'" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-timeout 15s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WORKER" || rollback
|
||||
--health-start-period 60s \
|
||||
--log-driver json-file --log-opt max-size=200m --log-opt max-file=5 \
|
||||
"$REGISTRY_WORKER" \
|
||||
/usr/local/bin/entrypoint-worker.sh || rollback
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
@@ -429,12 +440,15 @@ echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
--network-alias xiaoxia-web \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
-e APP_ENV=production \
|
||||
-e API_BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
-v "$NGINX_CONF_FILE:/etc/nginx/conf.d/default.conf:ro" \
|
||||
$LEGACY_VOLUME \
|
||||
--restart unless-stopped \
|
||||
--cpus 1 \
|
||||
--memory 512m \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
@@ -487,7 +501,7 @@ docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Production deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "API: http://127.0.0.1:8001"
|
||||
echo "Web: http://127.0.0.1:3002"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
|
||||
@@ -264,6 +264,9 @@ if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
fi
|
||||
|
||||
# ---- 并行 Pull 三个镜像 ----
|
||||
# 注意:这里必须使用 IMAGE_TAG(commit SHA)做确定性部署,不要改成 :dev。
|
||||
# :dev 是 floating tag,可能被并发构建覆盖,导致部署版本不可重现、回滚混乱。
|
||||
# Watchtower 可监听 :dev 做非关键路径的自动同步;正式部署/回滚一律锚定 SHA。
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||
#
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
|
||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 127.0.0.1,CI runner 在 staging 本机)
|
||||
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
|
||||
# STAGING_SSH_PORT - SSH 端口 (默认 22)
|
||||
# STAGING_SSH_KEY - SSH 私钥内容
|
||||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||||
#
|
||||
@@ -40,9 +40,9 @@ HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||||
|
||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-127.0.0.1}"
|
||||
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
|
||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22}"
|
||||
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""generation_common 公共服务辅助函数单元测试。
|
||||
|
||||
覆盖 query_voice_durations / writeback_edit_plan_config / collect_plan_segments /
|
||||
resolve_latest_plan_by_template 四个下沉函数的主路径、边界与容错路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# query_voice_durations
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestQueryVoiceDurations:
|
||||
def _make_db_with_rows(self, rows):
|
||||
"""构造 MagicMock db,query().filter().all() 返回 rows。"""
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.all.return_value = list(rows)
|
||||
return db
|
||||
|
||||
def test_empty_input_returns_empty_list(self):
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
db = MagicMock()
|
||||
assert query_voice_durations(db, []) == []
|
||||
assert query_voice_durations(db, None) == []
|
||||
db.query.assert_not_called()
|
||||
|
||||
def test_all_empty_or_falsy_ids_returns_zero_list(self):
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
db = MagicMock()
|
||||
assert query_voice_durations(db, ["", None, ""]) == [0.0, 0.0, 0.0]
|
||||
|
||||
def test_normal_lookup_returns_durations_in_input_order(self):
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
db = self._make_db_with_rows([("v1", 3.5), ("v2", 7.2)])
|
||||
result = query_voice_durations(db, ["v1", "v2", "v-missing"])
|
||||
assert result == [3.5, 7.2, 0.0]
|
||||
|
||||
def test_duplicate_ids_returns_consistent_durations_preserves_order(self):
|
||||
"""#1855:同配音 id 多次出现应返回相同时长,保持输入顺序/长度。"""
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
db = self._make_db_with_rows([("v1", 4.0)])
|
||||
result = query_voice_durations(db, ["v1", "v1", "v1"])
|
||||
assert result == [4.0, 4.0, 4.0]
|
||||
|
||||
def test_non_numeric_duration_coerced_to_zero(self):
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
db = self._make_db_with_rows([("v1", None), ("v2", "not-a-number"), ("v3", 2.0)])
|
||||
result = query_voice_durations(db, ["v1", "v2", "v3"])
|
||||
assert result == [0.0, 0.0, 2.0]
|
||||
|
||||
def test_db_exception_returns_zeros_and_logs(self, caplog):
|
||||
from app.services.generation_common import query_voice_durations
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = RuntimeError("DB boom")
|
||||
with caplog.at_level("WARNING"):
|
||||
result = query_voice_durations(db, ["v1", "v2"])
|
||||
assert result == [0.0, 0.0]
|
||||
assert any("配音时长查询失败" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# writeback_edit_plan_config
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_plan_model(config=None):
|
||||
plan = MagicMock()
|
||||
plan.config = config if config is not None else {}
|
||||
return plan
|
||||
|
||||
|
||||
class TestWritebackEditPlanConfig:
|
||||
def test_empty_plan_id_returns_immediately(self):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
db = MagicMock()
|
||||
writeback_edit_plan_config("", "task1", None, db)
|
||||
db.query.assert_not_called()
|
||||
|
||||
def test_plan_not_found_logs_and_returns(self, caplog):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
with caplog.at_level("WARNING"):
|
||||
writeback_edit_plan_config("p999", "task1", None, db)
|
||||
db.commit.assert_not_called()
|
||||
assert any("plan不存在" in rec.message for rec in caplog.records)
|
||||
|
||||
def test_writes_task_id_preserves_existing_config(self):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"other": "keep-me"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "task-xyz", None, db)
|
||||
assert plan.config["generation_task_id"] == "task-xyz"
|
||||
assert plan.config["other"] == "keep-me"
|
||||
assert "title" not in plan.config or not plan.config.get("title")
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_merges_title_without_title_change(self):
|
||||
"""#1901: 写 'title' 字段,未变标题保留 cover。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
|
||||
assert plan.config["title"] == {"text": "old"}
|
||||
# 旧 key 不应残留
|
||||
assert "title_config" not in plan.config
|
||||
# 标题未变 → cover 保留
|
||||
assert plan.config.get("cover") == "x"
|
||||
|
||||
def test_merges_title_fallback_to_old_title_config_key(self):
|
||||
"""#1901: 老数据存在 title_config(无 title)时,也能正确识别旧标题文字。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
|
||||
# 写入新 key "title",旧 key 被清除
|
||||
assert plan.config["title"] == {"text": "old"}
|
||||
assert "title_config" not in plan.config
|
||||
assert plan.config.get("cover") == "x"
|
||||
|
||||
def test_title_change_clears_cover(self):
|
||||
"""#1901: 标题变化时清 cover,新配置写到 'title'。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "new-title"}, db)
|
||||
assert "cover" not in plan.config
|
||||
assert plan.config["title"] == {"text": "new-title"}
|
||||
assert "title_config" not in plan.config
|
||||
|
||||
def test_title_config_normalizes_legacy_keys(self):
|
||||
"""#1901: 写入时归一化 font_size/font_preset/font_color → size/font/color,与 worker 对齐。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config(
|
||||
"p1",
|
||||
"t1",
|
||||
{"text": "hi", "font_size": 32, "font_preset": "楷体", "font_color": "#ff0000", "bold": True},
|
||||
db,
|
||||
)
|
||||
title = plan.config["title"]
|
||||
assert title["text"] == "hi"
|
||||
assert title["size"] == 32
|
||||
assert title["font"] == "楷体"
|
||||
assert title["color"] == "#ff0000"
|
||||
# 原始 key 保留(方便调用方排查,但归一化后的 key 必须存在)
|
||||
assert title["font_size"] == 32
|
||||
|
||||
def test_config_not_dict_treated_as_empty(self):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model(config=None)
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "hi"}, db)
|
||||
assert plan.config["generation_task_id"] == "t1"
|
||||
assert plan.config["title"] == {"text": "hi"}
|
||||
assert "title_config" not in plan.config
|
||||
|
||||
def test_exception_triggers_rollback_and_logs(self, caplog):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
|
||||
with caplog.at_level("WARNING"):
|
||||
writeback_edit_plan_config("p1", "t1", None, db)
|
||||
db.rollback.assert_called_once()
|
||||
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
|
||||
|
||||
def test_exception_with_rollback_also_failing_is_safe(self, caplog):
|
||||
"""外层异常后,db.rollback() 自己也抛异常时也不应中断(pass 兜底)。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
|
||||
db.rollback.side_effect = RuntimeError("rollback boom")
|
||||
with caplog.at_level("WARNING"):
|
||||
# 不应抛出异常
|
||||
writeback_edit_plan_config("p1", "t1", None, db)
|
||||
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# collect_plan_segments
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_clip(asset_id, start, duration):
|
||||
c = MagicMock()
|
||||
c.asset_id = asset_id
|
||||
c.start_time = start
|
||||
c.duration = duration
|
||||
return c
|
||||
|
||||
|
||||
class TestCollectPlanSegments:
|
||||
def test_empty_plan_returns_empty(self):
|
||||
from app.services.generation_common import collect_plan_segments
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_by_plan.return_value = []
|
||||
assert collect_plan_segments("p1", repo) == {}
|
||||
|
||||
def test_single_page_collects_segments(self):
|
||||
from app.services.generation_common import collect_plan_segments
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_by_plan.side_effect = [
|
||||
[_make_clip("a1", 0.0, 5.0), _make_clip("a1", 10.0, 3.0), _make_clip("a2", 2.0, 4.0)],
|
||||
[],
|
||||
]
|
||||
segs = collect_plan_segments("p1", repo, page_size=500)
|
||||
assert segs["a1"] == [(0.0, 5.0), (10.0, 13.0)]
|
||||
assert segs["a2"] == [(2.0, 6.0)]
|
||||
|
||||
def test_pagination_walks_all_batches(self):
|
||||
from app.services.generation_common import collect_plan_segments
|
||||
|
||||
repo = MagicMock()
|
||||
page1 = [_make_clip("a1", 0.0, 1.0)] * 2
|
||||
page2 = [_make_clip("a2", 0.0, 2.0)] * 2
|
||||
page3 = [_make_clip("a3", 0.0, 1.0)] # short final batch → stop
|
||||
repo.list_by_plan.side_effect = [page1, page2, page3]
|
||||
segs = collect_plan_segments("p1", repo, page_size=2)
|
||||
assert set(segs.keys()) == {"a1", "a2", "a3"}
|
||||
assert repo.list_by_plan.call_count == 3
|
||||
|
||||
def test_skips_zero_or_negative_duration_clips(self):
|
||||
from app.services.generation_common import collect_plan_segments
|
||||
|
||||
repo = MagicMock()
|
||||
repo.list_by_plan.side_effect = [
|
||||
[_make_clip(None, 0.0, 5.0), _make_clip("a1", 0.0, 0.0), _make_clip("a1", 1.0, -1.0)],
|
||||
[],
|
||||
]
|
||||
assert collect_plan_segments("p1", repo) == {}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# resolve_latest_plan_by_template
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestResolveLatestPlanByTemplate:
|
||||
@pytest.mark.parametrize("tid", ["", None, " "])
|
||||
def test_empty_template_returns_none(self, tid):
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
db = MagicMock()
|
||||
assert resolve_latest_plan_by_template(db, template_id=tid, user_id="u1") is None
|
||||
db.query.assert_not_called()
|
||||
|
||||
def test_returns_latest_plan_id(self):
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
db = MagicMock()
|
||||
latest = MagicMock(id="plan-xyz")
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest
|
||||
assert resolve_latest_plan_by_template(db, template_id=" tpl1 ", user_id="u1") == "plan-xyz"
|
||||
|
||||
def test_no_plan_returns_none(self):
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
|
||||
|
||||
def test_db_exception_returns_none_and_logs(self, caplog):
|
||||
from app.services.generation_common import resolve_latest_plan_by_template
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = RuntimeError("boom")
|
||||
with caplog.at_level("WARNING"):
|
||||
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
|
||||
assert any("查找最新plan失败" in rec.message for rec in caplog.records)
|
||||
@@ -46,6 +46,21 @@ class TestSplitScriptIntoSentences(unittest.TestCase):
|
||||
result = _split_script_into_sentences("没有标点的句子")
|
||||
self.assertEqual(result, ["没有标点的句子"])
|
||||
|
||||
def test_chinese_comma_not_split(self):
|
||||
"""Issue #1892:中文逗号「,」不应分句,保持完整小句。"""
|
||||
result = _split_script_into_sentences("卖花的叫花无缺,卖姜的叫姜子牙。")
|
||||
self.assertEqual(result, ["卖花的叫花无缺,卖姜的叫姜子牙"])
|
||||
|
||||
def test_english_comma_not_split(self):
|
||||
"""Issue #1892:英文逗号「,」不应分句。"""
|
||||
result = _split_script_into_sentences("Hello, how are you?")
|
||||
self.assertEqual(result, ["Hello, how are you"])
|
||||
|
||||
def test_comma_between_sentences_kept_in_sentence(self):
|
||||
"""两个句号间带逗号的长句:逗号不切,只按句号切。"""
|
||||
result = _split_script_into_sentences("第一句,带逗号。第二句,也带逗号!")
|
||||
self.assertEqual(result, ["第一句,带逗号", "第二句,也带逗号"])
|
||||
|
||||
|
||||
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
|
||||
"""Tests for _estimate_sentence_timings_by_chars."""
|
||||
|
||||
@@ -50,7 +50,7 @@ def _make_auth_user(user_id: str = "user_001") -> MagicMock:
|
||||
class TestVoiceClonePreview:
|
||||
"""克隆音色试听接口测试。"""
|
||||
|
||||
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001"):
|
||||
def _call_preview(self, profile, cosyvoice_mock, text="", user_id="user_001", speed=1.0, emotion=""):
|
||||
"""调用路由函数,模拟 FastAPI 注入依赖。"""
|
||||
from app.api.routes.voice_clones import get_voice_clone_preview
|
||||
|
||||
@@ -93,6 +93,8 @@ class TestVoiceClonePreview:
|
||||
result = get_voice_clone_preview(
|
||||
clone_id=profile.id if profile else "nonexistent",
|
||||
text=text,
|
||||
speed=speed,
|
||||
emotion=emotion,
|
||||
authenticated_user=_make_auth_user(user_id),
|
||||
repository=repo,
|
||||
cosyvoice=cosyvoice_mock,
|
||||
@@ -129,6 +131,8 @@ class TestVoiceClonePreview:
|
||||
call_kwargs = cosyvoice.synthesize_speech.call_args
|
||||
assert call_kwargs.kwargs["voice_id"] == "clone_voice_001"
|
||||
assert call_kwargs.kwargs["format"] == "mp3"
|
||||
assert call_kwargs.kwargs["speed"] == 1.0
|
||||
assert call_kwargs.kwargs.get("emotion", "") == ""
|
||||
|
||||
def test_preview_custom_text(self) -> None:
|
||||
"""自定义试听文本。"""
|
||||
@@ -161,6 +165,76 @@ class TestVoiceClonePreview:
|
||||
assert exc_info.value.status_code == 404
|
||||
cosyvoice.synthesize_speech.assert_not_called()
|
||||
|
||||
def test_preview_custom_speed_emotion_passed_through(self) -> None:
|
||||
"""Issue #1897: speed/emotion 参数透传到 cosyvoice.synthesize_speech。"""
|
||||
from app.api.routes.voice_clones import _clone_preview_cache
|
||||
|
||||
_clone_preview_cache.clear()
|
||||
|
||||
profile = _make_profile()
|
||||
cosyvoice = MagicMock()
|
||||
cosyvoice.synthesize_speech.return_value = SynthesizeResult(
|
||||
audio_url="https://oss.example.com/preview/fast.mp3",
|
||||
duration=2.0,
|
||||
file_size=32000,
|
||||
)
|
||||
|
||||
result = self._call_preview(profile, cosyvoice, speed=1.3, emotion="excited")
|
||||
|
||||
assert result.audio_url == "https://oss.example.com/preview/fast.mp3"
|
||||
call_kwargs = cosyvoice.synthesize_speech.call_args.kwargs
|
||||
assert call_kwargs["speed"] == 1.3
|
||||
assert call_kwargs["emotion"] == "excited"
|
||||
|
||||
def test_preview_invalid_emotion_rejected(self) -> None:
|
||||
"""Issue #1897: 非法 emotion 值返回 400。"""
|
||||
from app.api.routes.voice_clones import _clone_preview_cache
|
||||
|
||||
_clone_preview_cache.clear()
|
||||
|
||||
profile = _make_profile()
|
||||
cosyvoice = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
self._call_preview(profile, cosyvoice, emotion="angry")
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
cosyvoice.synthesize_speech.assert_not_called()
|
||||
|
||||
def test_preview_non_default_speed_no_cache(self) -> None:
|
||||
"""Issue #1897: 自定义 speed/emotion 不走缓存。"""
|
||||
from app.api.routes.voice_clones import _clone_preview_cache
|
||||
|
||||
_clone_preview_cache.clear()
|
||||
|
||||
profile = _make_profile()
|
||||
cosyvoice = MagicMock()
|
||||
cosyvoice.synthesize_speech.side_effect = [
|
||||
SynthesizeResult(audio_url="https://example.com/fast.mp3", duration=2.0, file_size=32000),
|
||||
SynthesizeResult(audio_url="https://example.com/slow.mp3", duration=4.0, file_size=60000),
|
||||
]
|
||||
|
||||
# 非默认speed — 不应缓存
|
||||
result1 = self._call_preview(profile, cosyvoice, speed=1.5)
|
||||
result2 = self._call_preview(profile, cosyvoice, speed=0.7, emotion="calm")
|
||||
assert cosyvoice.synthesize_speech.call_count == 2
|
||||
assert result1.audio_url != result2.audio_url
|
||||
|
||||
def test_preview_valueerror_from_cosyvoice_returns_400(self) -> None:
|
||||
"""Issue #1897: cosyvoice 因 speed 非法等抛 ValueError 时返回 400(与 TTS preview 一致)。"""
|
||||
from app.api.routes.voice_clones import _clone_preview_cache
|
||||
|
||||
_clone_preview_cache.clear()
|
||||
|
||||
profile = _make_profile()
|
||||
cosyvoice = MagicMock()
|
||||
cosyvoice.synthesize_speech.side_effect = ValueError("speed out of range")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
self._call_preview(profile, cosyvoice)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
def test_preview_not_ready_pending(self) -> None:
|
||||
"""pending 状态的克隆音色不能试听。"""
|
||||
from app.api.routes.voice_clones import _clone_preview_cache
|
||||
|
||||
@@ -57,7 +57,7 @@ class TestWritebackEditPlanConfig:
|
||||
mock_db.query.assert_called_once()
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# ---- 行 170-182: 正常写入 + title_config ----
|
||||
# ---- 行 170-182: 正常写入 + title(#1901 统一字段名) ----
|
||||
def test_success_with_title_config(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
@@ -69,7 +69,11 @@ class TestWritebackEditPlanConfig:
|
||||
)
|
||||
|
||||
assert mock_plan.config["generation_task_id"] == "task_456"
|
||||
assert mock_plan.config["title_config"] == {"text": "标题", "font_size": 36}
|
||||
# #1901: 统一写到 "title" 字段,且 font_size 已归一化为 size
|
||||
assert mock_plan.config["title"]["text"] == "标题"
|
||||
assert mock_plan.config["title"]["size"] == 36
|
||||
assert mock_plan.config["title"]["font_size"] == 36
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert mock_plan.config["existing_key"] == "existing_value"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@@ -80,7 +84,7 @@ class TestWritebackEditPlanConfig:
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_789", title_config=None, db=mock_db)
|
||||
|
||||
assert mock_plan.config["generation_task_id"] == "task_789"
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ---- 行 170: config 不是 dict → 兜底空 dict ----
|
||||
@@ -115,12 +119,12 @@ class TestWritebackEditPlanConfig:
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
# ---- 行 173: title_config 为空 dict → 不写入 title_config ----
|
||||
# ---- 行 173: title_config 为空 dict → 不写入 title ----
|
||||
def test_empty_title_config_not_written(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config={}, db=mock_db)
|
||||
|
||||
# 空 dict 为 falsy,不写入
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
|
||||
assert mock_plan.config["generation_task_id"] == "task_1"
|
||||
|
||||
Reference in New Issue
Block a user