Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4364bc951d | |||
| 495506e2a1 | |||
| 749cee9838 | |||
| 35f04f746b | |||
| 8ea3e26ab6 | |||
| 769590a525 | |||
| 3df88c48fd | |||
| 6ab953ae2a | |||
| 73294c6440 | |||
| c96b6de053 | |||
| 02660b063c | |||
| fd977991bd | |||
| ce9da8cbfe | |||
| fae974b533 | |||
| 9b5f92a05d | |||
| e9128449ef | |||
| 90ec76ce92 | |||
| 32f105b3cc | |||
| 17f0f9682b | |||
| c822cfe69b | |||
| 3b71a96b0a | |||
| bee1cdc060 | |||
| 50ef39a7ef | |||
| 88fa27e3aa | |||
| 74d23895d5 | |||
| da5e9d4e56 | |||
| e8c23d6ffa | |||
| 599ae3fdc8 | |||
| cfd37a39d1 | |||
| 0d20c82145 | |||
| dfb81a803e | |||
| 7f661c6594 | |||
| 7642ca9177 | |||
| faf4573fea | |||
| 457362a55c | |||
| f4ea5435a6 | |||
| 35063a691f | |||
| 941e70f95b |
@@ -1195,6 +1195,7 @@ jobs:
|
||||
DOUBAO_FAST_MODEL: "${{ secrets.DOUBAO_FAST_MODEL }}"
|
||||
DOUBAO_TIMEOUT: "${{ secrets.DOUBAO_TIMEOUT }}"
|
||||
DOUBAO_MAX_RETRIES: "${{ secrets.DOUBAO_MAX_RETRIES }}"
|
||||
DITTO_EMOTION_TIMEOUT: "${{ secrets.DITTO_EMOTION_TIMEOUT }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""viral_video_param_guides 表:运营可配置的人设/结构/营销目的/目标客群/语言指导文案(PR3 P2-h)。
|
||||
|
||||
Revision ID: 109_add_viral_video_param_guides
|
||||
Revises: 108_storyboard_v4_multivoice
|
||||
Create Date: 2026-10-12
|
||||
|
||||
表用途:
|
||||
- 替代 worker 中 ~130 行硬编码 if/elif mapping;
|
||||
- 5 类 guide_type: persona / structure / marketing_purpose / target_customer / language;
|
||||
- 启动时由 seed_viral_video_param_guides.py 灌入代码默认值,运营可在后台改,30s TTL 缓存自动热加载。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "109_add_viral_video_param_guides"
|
||||
down_revision = "108_storyboard_v4_multivoice"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS viral_video_param_guides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guide_type VARCHAR(32) NOT NULL,
|
||||
guide_key VARCHAR(64) NOT NULL,
|
||||
guide_text TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (guide_type, guide_key)
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_viral_video_param_guides_type_key
|
||||
ON viral_video_param_guides (guide_type, guide_key)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_viral_video_param_guides_type_key")
|
||||
op.execute("DROP TABLE IF EXISTS viral_video_param_guides")
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
v1.6 三步分步流水线端点(单次 Seedance 出片版):
|
||||
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
|
||||
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
|
||||
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
|
||||
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后文案+分镜一步生成+审核,暂停在 copy_generated
|
||||
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染(TTS+Seedance+上传),直到完成
|
||||
|
||||
旧端点(兼容保留,旧前端/一键生成模式):
|
||||
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
|
||||
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
|
||||
意图分析已并入脚本生成一步完成,不再有独立的 intent_parsing 阶段或 wait_user_confirm 状态。
|
||||
|
||||
通用:
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
|
||||
@@ -30,8 +28,6 @@ from app.schemas.viral_video import (
|
||||
AnalyzeStyleRequest,
|
||||
AnalyzeStyleResponse,
|
||||
ConfirmCopyRequest,
|
||||
ConfirmIntentRequest,
|
||||
CreateViralVideoRequest,
|
||||
CreditsFormulaBreakdown,
|
||||
EstimateCreditsRequest,
|
||||
EstimateCreditsResponse,
|
||||
@@ -49,7 +45,17 @@ from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
SQLAlchemyViralVideoStyleTemplateRepository,
|
||||
)
|
||||
from packages.domain.points_rules import list_viral_video_models
|
||||
from packages.application.viral_video.constants import (
|
||||
API_FALLBACK_HARD_CONSTRAINTS,
|
||||
API_FALLBACK_NEGATIVE_PROMPTS,
|
||||
)
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits,
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
list_viral_video_models,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
from packages.domain.points_service import PointsService
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
from packages.shared.dashscope_client import get_dashscope_client
|
||||
|
||||
@@ -65,7 +71,7 @@ def _build_copy_result(job) -> dict | None:
|
||||
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
|
||||
|
||||
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
|
||||
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
|
||||
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表)拼装兼容结构。
|
||||
"""
|
||||
cr = getattr(job, "copy_result", None)
|
||||
if isinstance(cr, dict) and cr:
|
||||
@@ -79,12 +85,9 @@ def _build_copy_result(job) -> dict | None:
|
||||
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
|
||||
copy_text = getattr(job, "generated_copy_text", "") or ""
|
||||
sb = getattr(job, "storyboard", None) or []
|
||||
intent = getattr(job, "intent_result", None) or {}
|
||||
if not copy_text and not sb:
|
||||
return None
|
||||
title = ""
|
||||
if isinstance(intent, dict):
|
||||
title = intent.get("suggested_title") or intent.get("intent", "") or ""
|
||||
shots = []
|
||||
for seg in sb:
|
||||
if isinstance(seg, dict):
|
||||
@@ -105,8 +108,8 @@ def _build_copy_result(job) -> dict | None:
|
||||
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
|
||||
"scene_and_lighting": "",
|
||||
"shots": shots,
|
||||
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
|
||||
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
|
||||
"hard_constraints": list(API_FALLBACK_HARD_CONSTRAINTS),
|
||||
"negative_prompts": list(API_FALLBACK_NEGATIVE_PROMPTS),
|
||||
"voiceover_script": copy_text,
|
||||
"final_copy": copy_text,
|
||||
"suggested_copy": copy_text,
|
||||
@@ -145,7 +148,6 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
language=getattr(job, "language", "zh-CN") or "zh-CN",
|
||||
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(job, "video_model", "") or "",
|
||||
intent_result=job.intent_result,
|
||||
result_video_url=job.result_video_url,
|
||||
pre_trusted_images=getattr(job, "pre_trusted_images", None) or None,
|
||||
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
|
||||
@@ -168,58 +170,157 @@ def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateReposi
|
||||
return SQLAlchemyViralVideoStyleTemplateRepository(session)
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
def _settle_viral_video_credits(
|
||||
user_id: int,
|
||||
job,
|
||||
session: Session,
|
||||
*,
|
||||
action: str,
|
||||
old_duration: int | None = None,
|
||||
old_resolution: str | None = None,
|
||||
old_ratio: str | None = None,
|
||||
old_model: str | None = None,
|
||||
new_duration: int | None = None,
|
||||
new_resolution: str | None = None,
|
||||
new_ratio: str | None = None,
|
||||
new_model: str | None = None,
|
||||
) -> dict:
|
||||
"""爆款视频积分结算统一入口(confirm-copy / retry 共用)。
|
||||
|
||||
action 取值:
|
||||
- "deduct_first" 首次扣费(confirm 时还没付过)
|
||||
- "recompute" 参数变更重算:退旧扣新(confirm_copy 时传 old/new)
|
||||
- "delta" 参数变更差额结算:多退少补(retry 时传 old/new)
|
||||
返回 credits_info dict,包含 old_est / new_est / action / diff 等。
|
||||
积分不足时 raise HTTPException(402),调用方无需再捕获。
|
||||
"""
|
||||
from app.config import settings as _settings
|
||||
|
||||
@router.post("/generate", response_model=ViralVideoJobResponse)
|
||||
def create_viral_video(
|
||||
request: CreateViralVideoRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""创建爆款视频任务,入队 Celery 编排器。"""
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
info: dict = {"action": action, "old_est": 0.0, "new_est": 0.0, "diff": 0.0, "settled": False}
|
||||
if not _settings.points_enabled:
|
||||
return info
|
||||
|
||||
repo = _get_job_repo(session)
|
||||
|
||||
# 创建领域实体
|
||||
job = ViralVideoJob(
|
||||
user_id=authenticated_user.user.id,
|
||||
images=list(request.images),
|
||||
industry=request.industry,
|
||||
target_customer=request.target_customer,
|
||||
persona_id=request.persona_id,
|
||||
viral_structure=request.viral_structure,
|
||||
marketing_purpose=request.marketing_purpose,
|
||||
bgm_preference=request.bgm_preference,
|
||||
duration=request.duration or 15,
|
||||
user_copy_text=request.user_copy_text,
|
||||
fusion_level=request.fusion_level,
|
||||
reference_audio_path=request.reference_audio_path,
|
||||
reference_video_url=request.reference_video_url,
|
||||
style_strength=request.style_strength,
|
||||
style_template_id=request.style_template_id,
|
||||
voice_id=getattr(request, "voice_id", "") or "",
|
||||
voice_source=getattr(request, "voice_source", "") or "",
|
||||
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(request, "video_model", "") or "",
|
||||
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
|
||||
copy_result=None,
|
||||
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
|
||||
float(getattr(job, "credits_cost", 0) or 0) > 0
|
||||
)
|
||||
|
||||
# 持久化
|
||||
repo.save(job)
|
||||
if action == "deduct_first":
|
||||
if already_paid:
|
||||
return info
|
||||
w, h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
|
||||
)
|
||||
est = calculate_viral_video_credits(int(job.duration or 15), w, h, job.video_model or "seedance-2.5")
|
||||
info["new_est"] = est
|
||||
svc = PointsService()
|
||||
res = svc.deduct_viral_video(user_id, est, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {est} 积分,当前余额 {balance}",
|
||||
"required": est,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = est
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
info["settled"] = True
|
||||
info["diff"] = est
|
||||
return info
|
||||
|
||||
# 入队 Celery 任务
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
|
||||
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"任务入队失败: {e}")
|
||||
repo.update(job)
|
||||
if action == "recompute":
|
||||
if not already_paid:
|
||||
# 等价于首次扣费
|
||||
return _settle_viral_video_credits(user_id, job, session, action="deduct_first")
|
||||
old_w, old_h = resolve_video_dimensions(old_resolution or "720p", old_ratio or "9:16")
|
||||
old_est = calculate_viral_video_credits(
|
||||
int(old_duration or 15), old_w, old_h, old_model or "seedance-2.5"
|
||||
)
|
||||
new_w, new_h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
|
||||
)
|
||||
new_est = calculate_viral_video_credits(
|
||||
int(job.duration or 15), new_w, new_h, job.video_model or "seedance-2.5"
|
||||
)
|
||||
info.update({"old_est": old_est, "new_est": new_est, "diff": new_est - old_est})
|
||||
svc = PointsService()
|
||||
if getattr(job, "credits_transaction_id", None):
|
||||
svc.refund_points(
|
||||
user_id=user_id,
|
||||
amount=float(job.credits_prepaid),
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=job.credits_transaction_id,
|
||||
description="confirm-copy 参数变更退还旧预扣",
|
||||
)
|
||||
if new_est > 0:
|
||||
res = svc.deduct_viral_video(user_id, new_est, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {new_est} 积分,当前余额 {balance}",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = new_est
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
info["settled"] = True
|
||||
return info
|
||||
|
||||
return _to_response(job)
|
||||
if action == "delta":
|
||||
eff_dur = int(new_duration if new_duration is not None else old_duration or 15)
|
||||
eff_res = new_resolution if new_resolution is not None else (old_resolution or "720p")
|
||||
eff_ratio = new_ratio if new_ratio is not None else (old_ratio or "9:16")
|
||||
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
|
||||
w, h = resolve_video_dimensions(eff_res, eff_ratio)
|
||||
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model)
|
||||
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
diff = round(new_est - old_prepaid, 2)
|
||||
info.update({"old_est": old_prepaid, "new_est": new_est, "diff": diff})
|
||||
if abs(diff) < 0.01:
|
||||
return info
|
||||
svc = PointsService()
|
||||
if diff > 0:
|
||||
res = svc.deduct_viral_video(user_id, diff, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
"delta": diff,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid + diff, 2)
|
||||
else:
|
||||
refund = round(-diff, 2)
|
||||
txn_id = getattr(job, "credits_transaction_id", "") or ""
|
||||
svc.refund_points(
|
||||
user_id=user_id,
|
||||
amount=refund,
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=txn_id or job.id,
|
||||
description="爆款视频重试参数变更退费",
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid - refund, 2)
|
||||
info["settled"] = True
|
||||
return info
|
||||
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
|
||||
@@ -383,84 +484,27 @@ def confirm_copy(
|
||||
or (request.video_model is not None and request.video_model != old_model)
|
||||
)
|
||||
|
||||
# 积分预扣(已扣过/重试任务跳过)
|
||||
from app.config import settings as _settings
|
||||
|
||||
if _settings.points_enabled:
|
||||
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
|
||||
float(getattr(job, "credits_cost", 0) or 0) > 0
|
||||
# 积分预扣(PR2:统一走 _settle_viral_video_credits)
|
||||
if param_changed:
|
||||
credits_info = _settle_viral_video_credits(
|
||||
authenticated_user.user.id,
|
||||
job,
|
||||
session,
|
||||
action="recompute",
|
||||
old_duration=old_duration,
|
||||
old_resolution=old_resolution,
|
||||
old_ratio=old_ratio,
|
||||
old_model=old_model,
|
||||
)
|
||||
if param_changed and already_paid:
|
||||
# 参数变更:回退旧预扣,按新参数重新预扣
|
||||
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
old_w, old_h = resolve_video_dimensions(old_resolution, old_ratio)
|
||||
old_est = calculate_viral_video_credits(old_duration, old_w, old_h, old_model)
|
||||
new_w, new_h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p",
|
||||
job.video_ratio or "9:16",
|
||||
)
|
||||
new_est = calculate_viral_video_credits(
|
||||
int(job.duration or 15), new_w, new_h, job.video_model or "seedance-2.5"
|
||||
)
|
||||
svc = PointsService()
|
||||
# 退回旧预扣
|
||||
if getattr(job, "credits_transaction_id", None):
|
||||
svc.refund_points(
|
||||
user_id=authenticated_user.user.id,
|
||||
amount=float(job.credits_prepaid),
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=job.credits_transaction_id,
|
||||
description="confirm-copy 参数变更退还旧预扣",
|
||||
)
|
||||
# 预扣新金额
|
||||
if new_est > 0:
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, new_est, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {new_est} 积分,当前余额 {balance}",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = new_est
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
logger.info(
|
||||
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%d new=%d job_id=%s", old_est, new_est, job.id
|
||||
)
|
||||
elif not already_paid:
|
||||
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
w, h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p",
|
||||
job.video_ratio or "9:16",
|
||||
)
|
||||
est_credits = calculate_viral_video_credits(
|
||||
int(job.duration or 15), w, h, job.video_model or "seedance-2.5"
|
||||
)
|
||||
svc = PointsService()
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, est_credits, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {est_credits} 积分,当前余额 {balance}",
|
||||
"required": est_credits,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = est_credits
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
repo.update(job)
|
||||
logger.info(
|
||||
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%s new=%s job_id=%s",
|
||||
credits_info.get("old_est"),
|
||||
credits_info.get("new_est"),
|
||||
job.id,
|
||||
)
|
||||
else:
|
||||
_settle_viral_video_credits(authenticated_user.user.id, job, session, action="deduct_first")
|
||||
repo.update(job)
|
||||
|
||||
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
|
||||
repo.update(job)
|
||||
@@ -470,6 +514,7 @@ def confirm_copy(
|
||||
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %s", e, exc_info=True)
|
||||
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200,避免前端收到"成功"但 job 已 failed 的不一致状态
|
||||
job.mark_failed(f"任务入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
@@ -487,11 +532,6 @@ def estimate_credits(
|
||||
profit_multiplier / model_price / width / height / fps),便于前端展示计费明细。
|
||||
同时兼容前端传 model 或 video_model、resolution 或 video_resolution、ratio 或 video_ratio。
|
||||
"""
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
model = (request.model or "").strip() or "seedance-2.5"
|
||||
resolution = (request.resolution or "").strip() or "720p"
|
||||
ratio = (request.ratio or "").strip() or "9:16"
|
||||
@@ -623,25 +663,7 @@ def retry_viral_video_job(
|
||||
or (new_model is not None and new_model != old_model)
|
||||
)
|
||||
|
||||
from app.config import settings as _settings
|
||||
|
||||
need_points_settle = False
|
||||
new_est = 0.0
|
||||
if _settings.points_enabled and param_changed:
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
eff_dur = int(new_duration if new_duration is not None else old_duration)
|
||||
eff_res = new_resolution if new_resolution is not None else old_resolution
|
||||
eff_ratio = new_ratio if new_ratio is not None else old_ratio
|
||||
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
|
||||
w, h = resolve_video_dimensions(eff_res, eff_ratio)
|
||||
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model or "seedance-2.5")
|
||||
need_points_settle = True
|
||||
|
||||
# 写入新参数(即使不开 points 也要允许用户重试时改参数)
|
||||
# 写入新参数(即使不开 points 也要允许用户重试时改参数;delta 结算依赖这些新值)
|
||||
if new_duration is not None:
|
||||
job.duration = max(5, min(30, int(new_duration)))
|
||||
if new_resolution is not None:
|
||||
@@ -651,55 +673,29 @@ def retry_viral_video_job(
|
||||
if new_model is not None:
|
||||
job.video_model = new_model
|
||||
|
||||
if need_points_settle:
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
svc = PointsService()
|
||||
diff = round(new_est - old_prepaid, 2)
|
||||
if abs(diff) >= 0.01:
|
||||
if diff > 0:
|
||||
# 新预扣更多:补扣差额
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, diff, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
"delta": diff,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid + diff, 2)
|
||||
logger.info(
|
||||
"[爆款视频][retry] 补扣差额 job_id=%s diff=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
diff,
|
||||
job.credits_prepaid,
|
||||
)
|
||||
else:
|
||||
# 新预扣更少:退还差额
|
||||
refund = round(-diff, 2)
|
||||
txn_id = getattr(job, "credits_transaction_id", "") or ""
|
||||
svc.refund_points(
|
||||
user_id=authenticated_user.user.id,
|
||||
amount=refund,
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=txn_id or job.id,
|
||||
description="爆款视频重试参数变更退费",
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid - refund, 2)
|
||||
logger.info(
|
||||
"[爆款视频][retry] 退还差额 job_id=%s refund=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
refund,
|
||||
job.credits_prepaid,
|
||||
)
|
||||
# 差额为 0 则不调整
|
||||
# PR2:统一走 _settle_viral_video_credits(delta 多退少补)
|
||||
if param_changed:
|
||||
credits_info = _settle_viral_video_credits(
|
||||
authenticated_user.user.id,
|
||||
job,
|
||||
session,
|
||||
action="delta",
|
||||
old_duration=old_duration,
|
||||
old_resolution=old_resolution,
|
||||
old_ratio=old_ratio,
|
||||
old_model=old_model,
|
||||
new_duration=new_duration,
|
||||
new_resolution=new_resolution,
|
||||
new_ratio=new_ratio,
|
||||
new_model=new_model,
|
||||
)
|
||||
if credits_info.get("settled"):
|
||||
logger.info(
|
||||
"[爆款视频][retry] 积分差额结算 job_id=%s diff=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
credits_info["diff"],
|
||||
job.credits_prepaid,
|
||||
)
|
||||
|
||||
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
|
||||
job.retry_count += 1
|
||||
@@ -711,7 +707,6 @@ def retry_viral_video_job(
|
||||
job.phase_message = ""
|
||||
job.heartbeat_at = None
|
||||
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
|
||||
job.intent_result = None
|
||||
job.copy_result = None
|
||||
job.storyboard = None
|
||||
job.generated_copy_text = ""
|
||||
@@ -720,8 +715,7 @@ def retry_viral_video_job(
|
||||
job.credits_cost = 0.0
|
||||
repo.update(job)
|
||||
|
||||
# 重新入队:从图片分析阶段1重新跑(旧 run_viral_video_pipeline 只跑到 wait_user_confirm 就停,
|
||||
# 会导致前端永远转圈或文案确认页卡死后 confirm-copy 返回 409)
|
||||
# 重新入队:从图片分析阶段1重新跑
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
|
||||
logger.info(
|
||||
@@ -733,49 +727,13 @@ def retry_viral_video_job(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
|
||||
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200
|
||||
job.mark_failed(f"重试入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
|
||||
def confirm_intent(
|
||||
job_id: str,
|
||||
request: ConfirmIntentRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
|
||||
|
||||
# 更新文案
|
||||
if request.confirmed_copy:
|
||||
job.user_copy_text = request.confirmed_copy
|
||||
|
||||
# 恢复流水线
|
||||
job.resume_from_confirm()
|
||||
repo.update(job)
|
||||
|
||||
# 从断点恢复 Celery 任务
|
||||
try:
|
||||
celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id])
|
||||
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"恢复流水线失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
|
||||
def analyze_style(
|
||||
job_id: str,
|
||||
@@ -1042,7 +1000,6 @@ _STATUS_STAGE = {
|
||||
"running": "",
|
||||
"image_analyzed": "image_analysis",
|
||||
"copy_generated": "review",
|
||||
"wait_user_confirm": "intent_parsing",
|
||||
"completed": "uploading",
|
||||
"failed": "",
|
||||
"cancelled": "",
|
||||
@@ -1053,7 +1010,6 @@ _STATUS_PROGRESS = {
|
||||
"running": 5.0,
|
||||
"image_analyzed": 15.0,
|
||||
"copy_generated": 70.0,
|
||||
"wait_user_confirm": 35.0,
|
||||
"completed": 100.0,
|
||||
"failed": 0.0,
|
||||
"cancelled": 0.0,
|
||||
@@ -1064,7 +1020,6 @@ _STATUS_MESSAGE = {
|
||||
"running": "任务执行中",
|
||||
"image_analyzed": "图片分析完成,等待填写营销参数",
|
||||
"copy_generated": "文案与分镜已生成,等待确认文案",
|
||||
"wait_user_confirm": "等待用户确认意图文案",
|
||||
"completed": "视频生成完成",
|
||||
"failed": "任务失败",
|
||||
"cancelled": "任务已取消",
|
||||
|
||||
@@ -13,7 +13,6 @@ VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
|
||||
VALID_STAGES = (
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"script_generation",
|
||||
"review",
|
||||
"tts",
|
||||
@@ -68,47 +67,6 @@ class CopyResult(BaseModel):
|
||||
# -- Request Schemas --
|
||||
|
||||
|
||||
class CreateViralVideoRequest(BaseModel):
|
||||
"""旧接口:一键创建(保留兼容)。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=20)
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = Field(default=15, ge=5, le=30, description="视频时长(秒),5-30")
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = "medium"
|
||||
style_template_id: str = ""
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
def _v_fl(cls, v: str) -> str:
|
||||
if v == "full_ai":
|
||||
return "ai_full"
|
||||
if v not in VALID_FUSION_LEVELS:
|
||||
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
|
||||
return v
|
||||
|
||||
@field_validator("style_strength")
|
||||
@classmethod
|
||||
def _v_ss(cls, v: str) -> str:
|
||||
if v not in VALID_STYLE_STRENGTHS:
|
||||
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
|
||||
return v
|
||||
|
||||
|
||||
class AnalyzeImagesRequest(BaseModel):
|
||||
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
|
||||
|
||||
@@ -179,13 +137,6 @@ class ConfirmCopyRequest(BaseModel):
|
||||
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
|
||||
|
||||
|
||||
class ConfirmIntentRequest(BaseModel):
|
||||
"""旧 confirm-intent(兼容)。"""
|
||||
|
||||
confirmed_copy: str = ""
|
||||
adjustments: str = ""
|
||||
|
||||
|
||||
class AnalyzeStyleRequest(BaseModel):
|
||||
reference_video_url: str = Field(..., description="参考视频 URL")
|
||||
style_template_id: str = ""
|
||||
@@ -231,7 +182,6 @@ class ViralVideoJobResponse(BaseModel):
|
||||
language: str = "zh-CN"
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
pre_trusted_images: list[str] | None = None
|
||||
video_resolution: str = "720p"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import apiClient from "@/api/client"
|
||||
import type {
|
||||
GenerateViralVideoRequest,
|
||||
HistoryResponse,
|
||||
StyleTemplate,
|
||||
ViralVideoJob,
|
||||
@@ -13,26 +12,11 @@ import type {
|
||||
ViralVideoModelsResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 创建爆款视频任务 */
|
||||
export function generateViralVideo(payload: GenerateViralVideoRequest) {
|
||||
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 查询单个任务 */
|
||||
export function getViralVideoJob(id: string) {
|
||||
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 用户确认/修改 AI 理解的意图后继续 */
|
||||
export function confirmViralVideoIntent(
|
||||
id: string,
|
||||
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
|
||||
) {
|
||||
return apiClient
|
||||
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 重试失败任务 */
|
||||
export function retryViralVideo(id: string) {
|
||||
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
|
||||
|
||||
@@ -21,23 +21,15 @@ export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
|
||||
export type VideoRatio = (typeof VALID_RATIOS)[number]
|
||||
|
||||
export type ViralVideoStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "wait_user_confirm"
|
||||
| "image_analyzed"
|
||||
| "copy_generated"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
"pending" | "running" | "image_analyzed" | "copy_generated" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/**
|
||||
* v1.6 后端流水线阶段。单次 Seedance 出片版:
|
||||
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
|
||||
* image_analysis → video_analysis(可选) → script_generation(含意图理解) → review → tts → rendering → uploading
|
||||
*/
|
||||
export type ViralVideoStage =
|
||||
| "image_analysis"
|
||||
| "video_analysis"
|
||||
| "intent_parsing"
|
||||
| "script_generation"
|
||||
| "review"
|
||||
| "tts"
|
||||
@@ -47,7 +39,7 @@ export type ViralVideoStage =
|
||||
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
|
||||
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
|
||||
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
|
||||
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
|
||||
const COPY_STAGES = new Set<ViralVideoStage>(["script_generation", "review"])
|
||||
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
|
||||
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
|
||||
|
||||
@@ -161,22 +153,6 @@ export interface StyleTemplate {
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface IntentResult {
|
||||
intent?: string
|
||||
key_messages?: string[]
|
||||
tone?: string
|
||||
target_emotion?: string
|
||||
call_to_action?: string
|
||||
suggested_title?: string
|
||||
/** v1.5 旧字段兼容 */
|
||||
product?: string
|
||||
selling_points?: string[]
|
||||
target_audience?: string
|
||||
structure?: string
|
||||
duration?: number
|
||||
suggested_copy?: string
|
||||
}
|
||||
|
||||
export interface ViralVideoJob {
|
||||
id: string
|
||||
status: ViralVideoStatus
|
||||
@@ -194,8 +170,6 @@ export interface ViralVideoJob {
|
||||
voice_mode?: "global" | "per_video"
|
||||
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
|
||||
bgm_preference?: string
|
||||
intent_result?: IntentResult
|
||||
intent_text?: string
|
||||
/** v1.6 编导分镜脚本(核心产物) */
|
||||
copy_result?: CopyResult
|
||||
/** 向后兼容:= copy_result.shots */
|
||||
@@ -225,31 +199,6 @@ export interface ViralVideoJob {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface GenerateViralVideoRequest {
|
||||
images: string[]
|
||||
reference_video_url?: string
|
||||
douyin_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
|
||||
bgm_preference?: string
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
language?: string
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
/** 视频时长(5-30秒,默认15) */
|
||||
duration?: number
|
||||
video_model?: string
|
||||
video_ratio?: string
|
||||
/** 三步拆分:step 控制后端执行到哪一步暂停 */
|
||||
step?: "analyze" | "generate_copy" | "generate_video"
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: ViralVideoJob[]
|
||||
total: number
|
||||
@@ -325,6 +274,18 @@ export interface ConfirmCopyRequest {
|
||||
edited_copy?: string
|
||||
/** 视频模型 key,覆盖默认 */
|
||||
video_model?: string
|
||||
/** 分辨率,如 480p/720p/1080p */
|
||||
video_resolution?: string
|
||||
/** 画面比例,如 9:16/16:9/1:1 */
|
||||
video_ratio?: string
|
||||
/** 成片时长秒数 */
|
||||
duration?: number
|
||||
/** 音色 ID(CosyVoice voice_id 或 preset id) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset/library/upload/clone/my_voice */
|
||||
voice_source?: string
|
||||
/** 口播语言 */
|
||||
language?: string
|
||||
}
|
||||
|
||||
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ViralVideoModel } from "@/api/viral-video/types"
|
||||
|
||||
export const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
|
||||
export const TARGET_CUSTOMERS = [
|
||||
"小镇青年",
|
||||
"小镇中老年",
|
||||
"Z世代",
|
||||
"精致妈妈",
|
||||
"新锐白领",
|
||||
"资深中产",
|
||||
"都市蓝领",
|
||||
"都市银发",
|
||||
]
|
||||
export const PERSONAS = [
|
||||
"通用个人IP",
|
||||
"老板型IP",
|
||||
"专家型IP",
|
||||
"顾问型IP",
|
||||
"创始人IP",
|
||||
"创业者IP",
|
||||
"从业者经验派",
|
||||
"避坑顾问型",
|
||||
"知识科普型",
|
||||
"测评种草型",
|
||||
]
|
||||
export const STRUCTURES = [
|
||||
"反差破局+亮明观点+还原现状",
|
||||
"痛点场景植入+给出通关秘籍",
|
||||
"反常识开场+扎心现状+制造焦虑",
|
||||
"核心难题+化解焦虑+描绘愿景",
|
||||
"痛点前置+前后对比+给出期许",
|
||||
"悬念拉满+揭露危害+明确目的",
|
||||
"情绪共鸣+勾起好奇+拔高价值",
|
||||
"颠覆认知+现状拆解+赋予价值",
|
||||
"结果先行+消除顾虑+保留悬念",
|
||||
"真实困境+认知反转+传授秘籍",
|
||||
"反差事实+案例佐证+戳破真相",
|
||||
"预埋悬念+标杆案例+引出核心",
|
||||
"危机渲染+放大痛点+输出干货",
|
||||
"逆袭开场+成功诀窍+低价引流",
|
||||
"反常识观点+分析危害+描述现状",
|
||||
"正反对比+解析原因+简易方法",
|
||||
"真实场景+内心刻画+共情引导+展望未来",
|
||||
"连续否定+代入感受+打破认知+精准戳痛",
|
||||
"揭秘行业+类比施压+放大焦虑+精准锁客",
|
||||
]
|
||||
export const PURPOSES = [
|
||||
"获客引流",
|
||||
"账号涨粉",
|
||||
"活动通知",
|
||||
"口播带货",
|
||||
"促销转化",
|
||||
"场景种草",
|
||||
"痛点解决",
|
||||
"功能演示",
|
||||
"门店发现",
|
||||
"到店实录",
|
||||
"招牌体验",
|
||||
"同城团购",
|
||||
"品牌主张",
|
||||
"品牌故事",
|
||||
"生活方式",
|
||||
"创意概念",
|
||||
"对话短剧",
|
||||
"反转短剧",
|
||||
"悬念短剧",
|
||||
"情绪短片",
|
||||
]
|
||||
export const RATIOS = [
|
||||
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
|
||||
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
|
||||
{ v: "1:1", label: "1:1 方形(小红书)" },
|
||||
]
|
||||
export const DURATIONS = Array.from({ length: 16 }, (_, i) => 15 + i)
|
||||
/** 兜底模型列表(接口未返回时使用) */
|
||||
export const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
|
||||
{
|
||||
key: "seedance-2.5",
|
||||
display_name: "Seedance 2.5 — 最新最强",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 30,
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
key: "seedance-2.0",
|
||||
display_name: "Seedance 2.0 — 正式首选",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
{
|
||||
key: "seedance-2.0-fast",
|
||||
display_name: "Seedance 2.0 Fast — 快速测试",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
{
|
||||
key: "seedance-2.0-mini",
|
||||
display_name: "Seedance 2.0 Mini — 低成本",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
{
|
||||
key: "wan-3.0",
|
||||
display_name: "Wan 3.0 — 通义万相全能",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 15,
|
||||
},
|
||||
]
|
||||
export const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"]
|
||||
export const QUALITY_OPTIONS = [
|
||||
{ v: "480p", label: "480p(快速)" },
|
||||
{ v: "720p", label: "720p(清晰)" },
|
||||
{ v: "1080p", label: "1080p(高清)" },
|
||||
]
|
||||
export const UPSCALE_OPTIONS = [
|
||||
{ v: "default", label: "默认" },
|
||||
{ v: "sd", label: "普清" },
|
||||
{ v: "hd", label: "高清" },
|
||||
{ v: "uhd", label: "超清" },
|
||||
{ v: "omnipotent", label: "全能" },
|
||||
]
|
||||
export const VOICE_TIPS =
|
||||
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
|
||||
@@ -138,27 +138,6 @@
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid #f0f0f5;
|
||||
gap: 8px;
|
||||
}
|
||||
.vv-section.collapsed .vv-section-head {
|
||||
border-bottom: none;
|
||||
}
|
||||
.vv-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-step-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -310,11 +289,6 @@
|
||||
|
||||
/* antd Select dropdown light */
|
||||
.vv-page .ant-select-dropdown,
|
||||
.vv-select-dropdown {
|
||||
background: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 4px !important;
|
||||
}
|
||||
.vv-page .ant-select-item {
|
||||
color: #1f2937 !important;
|
||||
border-radius: 6px !important;
|
||||
@@ -655,10 +629,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.vv-douyin-input {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vv-ref-video-box {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -669,44 +639,6 @@
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.vv-audio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.vv-audio-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 14px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vv-audio-cell:hover:not(:disabled) {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-audio-cell.selected,
|
||||
.vv-audio-cell.active {
|
||||
background: #f5f0ff;
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-audio-cell:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.vv-audio-ic {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.vv-selected-audio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -857,12 +789,6 @@
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-recog-v {
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-recog-meta {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* progress mini */
|
||||
.vv-progress-mini-inline {
|
||||
@@ -1002,11 +928,6 @@
|
||||
.vv-form-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vv-form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.vv-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1026,29 +947,6 @@
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-copy-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.vv-copy-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-copy-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.vv-copy-tag-preview {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.vv-copy-loading {
|
||||
display: flex;
|
||||
@@ -1080,15 +978,6 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.vv-copy-locked {
|
||||
background: #fafafe;
|
||||
border-color: #d8cafc;
|
||||
}
|
||||
.vv-copy-sub {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Storyboard (linear doc style) ── */
|
||||
.vv-storyboard {
|
||||
display: flex;
|
||||
@@ -1789,84 +1678,10 @@
|
||||
}
|
||||
|
||||
/* intent (keyword chips) - if used */
|
||||
.vv-intent {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.vv-intent-title {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vv-intent-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.vv-chip {
|
||||
padding: 4px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vv-chip.picked {
|
||||
background: #f5f0ff;
|
||||
color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* misc helpers */
|
||||
.vv-k {
|
||||
color: #6b7280;
|
||||
}
|
||||
.vv-v {
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-hint-yellow {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vv-hint-gray {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Segmented tabs (legacy) */
|
||||
.vv-seg-tabs {
|
||||
display: inline-flex;
|
||||
background: #f5f6fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.vv-seg-tab {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vv-seg-tab:hover {
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
}
|
||||
.vv-seg-tab.active {
|
||||
background: #f3f0ff;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── v1.6 我的音色(默认主路径) ── */
|
||||
.vv-voice-section {
|
||||
@@ -2272,3 +2087,94 @@
|
||||
.vv-file-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* ── P2 新增 utility / 组件类 ── */
|
||||
/* 短剧模式提示条(放在 Step2 区域就地显示) */
|
||||
.vv-shortdrama-hint {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
/* speaker 标签(主角 / 配角) */
|
||||
.vv-sb-speaker-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.vv-sb-speaker-tag-main {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
.vv-sb-speaker-tag-guest {
|
||||
background-color: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
.vv-sb-speaker-main {
|
||||
color: #1890ff;
|
||||
}
|
||||
.vv-sb-speaker-guest {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
/* 间距 / 排版 helpers */
|
||||
.vv-mt-4 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.vv-mt-6 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.vv-mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vv-mb-6 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vv-mr-6 {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.vv-fs-12 {
|
||||
font-size: 12px;
|
||||
}
|
||||
.vv-fs-13 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.vv-fs-22 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.vv-p-12 {
|
||||
padding: 12px;
|
||||
}
|
||||
.vv-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.vv-flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.vv-w-170 {
|
||||
width: 170px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vv-inline-spinner {
|
||||
margin-right: 6px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vv-progress-bar-sm {
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
|
||||
.vv-loading-ic {
|
||||
font-size: 36px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -152,8 +152,7 @@ export default function AssetPickerModal({
|
||||
<div className="vv-modal-body">
|
||||
<div className="vv-asset-search">
|
||||
<select
|
||||
className="vv-input"
|
||||
style={{ width: 170, flex: "0 0 auto" }}
|
||||
className="vv-input vv-w-170"
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
@@ -201,8 +200,8 @@ export default function AssetPickerModal({
|
||||
</div>
|
||||
{kind === "voice" ? (
|
||||
<>
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
|
||||
<div className="vv-mt-8 vv-fs-13">暂无配音素材</div>
|
||||
<div className="vv-muted vv-mt-4 vv-fs-12">
|
||||
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
|
||||
export interface EmptyBlockProps {
|
||||
icon?: React.ReactNode
|
||||
title?: string
|
||||
desc?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一空态组件:icon + title + desc + action,避免散落的 vv-muted 空文案
|
||||
*/
|
||||
export const EmptyBlock: React.FC<EmptyBlockProps> = ({
|
||||
icon = "🎬",
|
||||
title,
|
||||
desc,
|
||||
action,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`vv-empty-block ${className || ""}`} style={style}>
|
||||
<div className="vv-empty-icon">{icon}</div>
|
||||
{title && <div className="vv-empty-title">{title}</div>}
|
||||
{desc && <div className="vv-empty-desc vv-muted">{desc}</div>}
|
||||
{action && <div className="vv-empty-action">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyBlock
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
import { WarningFilled, ReloadOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface ErrorRetryBarProps {
|
||||
label?: string
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
retryText?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一错误条:警告图标 + 标签前缀 + 错误消息 + 可选重试按钮
|
||||
* 替换主页面里散落的 .vv-error 块
|
||||
*/
|
||||
export const ErrorRetryBar: React.FC<ErrorRetryBarProps> = ({
|
||||
label,
|
||||
message,
|
||||
onRetry,
|
||||
retryText = "重试",
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`vv-error ${className || ""}`} style={style}>
|
||||
<WarningFilled style={{ marginRight: 6 }} />
|
||||
{label ? `${label}:` : ""}
|
||||
{message}
|
||||
{onRetry && (
|
||||
<button
|
||||
className="vv-btn vv-btn-warn vv-btn-sm"
|
||||
style={{ marginLeft: 10 }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
<ReloadOutlined /> {retryText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorRetryBar
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react"
|
||||
|
||||
export interface InlineTextProps {
|
||||
value: string
|
||||
onChange?: (v: string) => void // 受控模式
|
||||
onCommit: (v: string) => void // blur/Enter 提交
|
||||
onCancel?: () => void
|
||||
multiline?: boolean
|
||||
textareaClass?: string
|
||||
textClass?: string
|
||||
placeholder?: string
|
||||
editing: boolean
|
||||
onStartEdit: () => void
|
||||
rows?: number
|
||||
maxLength?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用"点文字→textarea→blur/Enter保存→Esc取消"内联编辑组件
|
||||
* 替换 ViralVideoPage 里 11 处反射式 textarea/span 复制粘贴
|
||||
*/
|
||||
export const InlineText: React.FC<InlineTextProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
multiline,
|
||||
textareaClass,
|
||||
textClass,
|
||||
placeholder,
|
||||
editing,
|
||||
onStartEdit,
|
||||
rows = 2,
|
||||
maxLength,
|
||||
disabled,
|
||||
}) => {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [local, setLocal] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && taRef.current) {
|
||||
taRef.current.focus()
|
||||
taRef.current.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const commit = useCallback(() => {
|
||||
const v = (onChange ? value : local).trim()
|
||||
const cur = (value ?? "").trim()
|
||||
if (v !== cur) onCommit(v)
|
||||
else onCancel?.()
|
||||
}, [local, value, onCommit, onCancel, onChange])
|
||||
|
||||
const handleKey = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onCancel?.()
|
||||
}
|
||||
if (!multiline && e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
commit()
|
||||
}
|
||||
},
|
||||
[multiline, commit, onCancel],
|
||||
)
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className={textareaClass}
|
||||
value={onChange ? value : local}
|
||||
onChange={(e) => (onChange ? onChange(e.target.value) : setLocal(e.target.value))}
|
||||
onBlur={commit}
|
||||
onKeyDown={handleKey}
|
||||
rows={rows}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const displayText = value || placeholder || "点击编辑"
|
||||
const isPlaceholder = !value
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`${textClass || ""} ${isPlaceholder ? "vv-inline-placeholder" : ""} ${
|
||||
disabled ? "vv-inline-disabled" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!disabled) onStartEdit()
|
||||
}}
|
||||
style={
|
||||
isPlaceholder
|
||||
? { color: "#9ca3af", cursor: disabled ? "not-allowed" : "text" }
|
||||
: { cursor: disabled ? "not-allowed" : "text" }
|
||||
}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlineText
|
||||
@@ -106,10 +106,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
const GENDER_LABEL = (v: PresetVoice) => {
|
||||
if (v.gender_label) return v.gender_label
|
||||
const g = v.gender
|
||||
if (g === "female") return "女声·女声"
|
||||
if (g === "male") return "男声·男声"
|
||||
if (g === "child") return "童声·童声"
|
||||
return "性别未标注·其他"
|
||||
if (g === "female") return "女声"
|
||||
if (g === "male") return "男声"
|
||||
if (g === "child") return "童声"
|
||||
return "其他"
|
||||
}
|
||||
|
||||
const AVATAR_BG = (gender?: string) => {
|
||||
@@ -303,7 +303,7 @@ const PresetVoicePickerModal: React.FC<Props> = ({
|
||||
{v.avatar_url ? (
|
||||
<img src={v.avatar_url} alt={v.name} />
|
||||
) : (
|
||||
<UserOutlined style={{ fontSize: 22 }} />
|
||||
<UserOutlined className="vv-fs-22" />
|
||||
)}
|
||||
</div>
|
||||
<div className="vv-voice-card-name" title={v.name}>
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import React, { useMemo } from "react"
|
||||
import {
|
||||
UploadOutlined,
|
||||
FolderOpenOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { renderMarkdown } from "../utils/renderMarkdown"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
import { VoicePickerSection } from "./VoicePickerSection"
|
||||
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, ImageAnalysisResult } from "../types"
|
||||
import type { ImageProductAnalysis, StyleStrength } from "@/api/viral-video/types"
|
||||
import { STYLE_STRENGTHS } from "@/api/viral-video/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export interface Step1UploadPanelProps {
|
||||
images: UploadedImage[]
|
||||
refVideo: RefVideo
|
||||
voicePanel: VoicePanel
|
||||
refAudio: RefAudio
|
||||
voiceTipsOpen: boolean
|
||||
myVoices: VoiceClone[]
|
||||
myVoicesLoading: boolean
|
||||
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
|
||||
playingVoiceId: string | null
|
||||
styleStrength: StyleStrength
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
isAnalyzing: boolean
|
||||
analysisError?: string
|
||||
canAnalyze: boolean
|
||||
analyzeBtnText: React.ReactNode
|
||||
// callbacks - images
|
||||
onPickLocalImages: () => void
|
||||
onPickAssetImages: () => void
|
||||
onRemoveImage: (localId: string) => void
|
||||
onMoveImage: (idx: number, dir: -1 | 1) => void
|
||||
onClearImages: () => void
|
||||
onImagesSelected: (files: FileList) => void
|
||||
onAnalyze: () => void
|
||||
// callbacks - ref video
|
||||
onPickLocalVideo: () => void
|
||||
onVideoSelected: (file: File) => void
|
||||
onRemoveRefVideo: () => void
|
||||
onStyleStrengthChange: (v: StyleStrength) => void
|
||||
// callbacks - voice (delegated to VoicePickerSection)
|
||||
onPickLocalAudio: () => void
|
||||
onPickAssetAudio: () => void
|
||||
onRemoveRefAudio: () => void
|
||||
onToggleVoicePanel: (p: VoicePanel) => void
|
||||
onSelectMyVoice: (v: VoiceClone) => void
|
||||
onOpenCloneModal: () => void
|
||||
onSelectVoiceAsset: (a: AssetItem) => void
|
||||
onToggleVoicePlay: (v: { id: string; url: string }) => void
|
||||
onVoiceSelected: (file: File) => void
|
||||
onToggleVoiceTips: () => void
|
||||
// refs
|
||||
imgInputRef: React.Ref<HTMLInputElement>
|
||||
videoInputRef: React.Ref<HTMLInputElement>
|
||||
voiceInputRef: React.Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
const fmtSize = (bytes: number | undefined) => {
|
||||
if (!bytes || bytes <= 0) return "--"
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function RecognitionBlock({
|
||||
imageAnalysis,
|
||||
isAnalyzing,
|
||||
}: {
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
isAnalyzing: boolean
|
||||
}) {
|
||||
const images: ImageProductAnalysis[] = useMemo(() => {
|
||||
return (
|
||||
(imageAnalysis?.images as ImageProductAnalysis[] | undefined) ||
|
||||
(imageAnalysis?.products as ImageProductAnalysis[] | undefined) ||
|
||||
[]
|
||||
)
|
||||
}, [imageAnalysis])
|
||||
|
||||
if (isAnalyzing) {
|
||||
return (
|
||||
<div className="vv-recog">
|
||||
<div className="vv-recog-title">
|
||||
<VVSpinner color="#7c3aed" className="vv-inline-spinner" />
|
||||
识别描述汇览
|
||||
</div>
|
||||
<div className="vv-muted">AI 正在识别画面…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className="vv-recog">
|
||||
<div className="vv-recog-title">
|
||||
<CheckCircleFilled style={{ color: "#10b981" }} />
|
||||
识别描述汇览
|
||||
</div>
|
||||
{images.map((p, i) => {
|
||||
const meta = [p.name || "未识别", p.brand, p.category].filter(Boolean)
|
||||
return (
|
||||
<div key={i} className="vv-recog-item vv-recog-md">
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">图片{i + 1}:</span>
|
||||
<span>{meta.join(" · ")}</span>
|
||||
</div>
|
||||
{p.summary_markdown ? (
|
||||
<div
|
||||
className="vv-md-body"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(p.summary_markdown) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="vv-muted">(暂无叙述描述)</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
|
||||
return (
|
||||
<div className="vv-section">
|
||||
<div className="vv-step1-head">
|
||||
<span className="vv-step-badge">1</span>
|
||||
<span className="vv-step1-title">STEP 1 上传素材并分析图片</span>
|
||||
</div>
|
||||
<div className="vv-section-body vv-section-body-scroll">
|
||||
<div className="vv-scroll-inner">
|
||||
{/* 图片按钮 */}
|
||||
<div className="vv-action-row">
|
||||
<button className="vv-action-btn" onClick={p.onPickLocalImages}>
|
||||
<UploadOutlined /> 本地上传
|
||||
</button>
|
||||
<button className="vv-action-btn" onClick={p.onPickAssetImages}>
|
||||
<FolderOpenOutlined /> 从素材库选择图片
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={p.imgInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files) p.onImagesSelected(e.target.files)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="vv-count-row">
|
||||
<span className="vv-muted">
|
||||
已选 <strong style={{ color: "#111827" }}>{p.images.length}</strong>/9
|
||||
</span>
|
||||
{p.images.length > 0 && (
|
||||
<button className="vv-link-btn" onClick={p.onClearImages}>
|
||||
清空图片
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.images.length > 0 && (
|
||||
<div className="vv-file-list">
|
||||
{p.images.map((im, i) => (
|
||||
<div key={im.localId} className="vv-file-item">
|
||||
<div className="vv-file-thumb">
|
||||
<span className="vv-img-badge">{i + 1}</span>
|
||||
<img src={im.ossUrl || im.url} alt={im.name} />
|
||||
</div>
|
||||
<div className="vv-file-info">
|
||||
<div className="vv-file-name" title={im.name}>
|
||||
{im.name}
|
||||
</div>
|
||||
<div className="vv-file-sub">
|
||||
{im.uploading ? (
|
||||
<>
|
||||
<span className="vv-progress-mini-inline" />
|
||||
上传中 {im.pct}%
|
||||
</>
|
||||
) : (
|
||||
<>{fmtSize(im.size)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vv-file-actions">
|
||||
<button
|
||||
className="vv-icon-btn"
|
||||
onClick={() => p.onMoveImage(i, -1)}
|
||||
disabled={i === 0 || im.uploading}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="vv-icon-btn"
|
||||
onClick={() => p.onMoveImage(i, 1)}
|
||||
disabled={i === p.images.length - 1 || im.uploading}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
</button>
|
||||
<button
|
||||
className="vv-icon-btn vv-icon-btn-danger"
|
||||
onClick={() => p.onRemoveImage(im.localId)}
|
||||
disabled={!!im.uploading}
|
||||
title="删除"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考视频 */}
|
||||
<div className="vv-subblock">
|
||||
<div className="vv-subblock-head">
|
||||
<span className="vv-subblock-title">参考视频</span>
|
||||
<span className="vv-opt-tag">选填</span>
|
||||
<span className="vv-subblock-hint">最大 200MB · 最长 120 秒</span>
|
||||
</div>
|
||||
<div className="vv-action-row">
|
||||
<button className="vv-action-btn" onClick={p.onPickLocalVideo}>
|
||||
<UploadOutlined /> 本地上传视频
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={p.videoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) p.onVideoSelected(e.target.files[0])
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{p.refVideo && (
|
||||
<div className="vv-ref-video-box">
|
||||
<video
|
||||
src={p.refVideo.ossUrl || p.refVideo.url}
|
||||
controls
|
||||
className="vv-ref-video-el"
|
||||
/>
|
||||
<div className="vv-file-info vv-mt-6">
|
||||
<div className="vv-file-name">{p.refVideo.name}</div>
|
||||
{p.refVideo.uploading && (
|
||||
<div className="vv-progress-bar vv-progress-bar-sm">
|
||||
<div className="vv-progress-fill" style={{ width: `${p.refVideo.pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="vv-link-btn" onClick={p.onRemoveRefVideo}>
|
||||
删除重传
|
||||
</button>
|
||||
{p.refVideo.ossUrl && (
|
||||
<div className="vv-mt-8">
|
||||
<div className="vv-label vv-mb-6">风格借鉴强度</div>
|
||||
<div className="vv-pill-row">
|
||||
{STYLE_STRENGTHS.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
className={`vv-pill ${p.styleStrength === s.value ? "active" : ""}`}
|
||||
onClick={() => p.onStyleStrengthChange(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VoicePickerSection
|
||||
refAudio={p.refAudio}
|
||||
voicePanel={p.voicePanel}
|
||||
voiceTipsOpen={p.voiceTipsOpen}
|
||||
myVoices={p.myVoices}
|
||||
myVoicesLoading={p.myVoicesLoading}
|
||||
voiceAssets={p.voiceAssets}
|
||||
playingVoiceId={p.playingVoiceId}
|
||||
voiceInputRef={p.voiceInputRef}
|
||||
onOpenCloneModal={p.onOpenCloneModal}
|
||||
onToggleVoicePanel={p.onToggleVoicePanel}
|
||||
onSelectMyVoice={p.onSelectMyVoice}
|
||||
onSelectVoiceAsset={p.onSelectVoiceAsset}
|
||||
onToggleVoicePlay={p.onToggleVoicePlay}
|
||||
onPickLocalAudio={p.onPickLocalAudio}
|
||||
onPickAssetAudio={p.onPickAssetAudio}
|
||||
onVoiceSelected={p.onVoiceSelected}
|
||||
onRemoveRefAudio={p.onRemoveRefAudio}
|
||||
onToggleVoiceTips={p.onToggleVoiceTips}
|
||||
/>
|
||||
|
||||
<RecognitionBlock imageAnalysis={p.imageAnalysis} isAnalyzing={p.isAnalyzing} />
|
||||
</div>
|
||||
|
||||
<button className="vv-btn vv-btn-primary" disabled={!p.canAnalyze} onClick={p.onAnalyze}>
|
||||
{p.analyzeBtnText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step1UploadPanel
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from "react"
|
||||
import { EditOutlined } from "@ant-design/icons"
|
||||
import { Select } from "antd"
|
||||
import { FUSION_LEVELS } from "@/api/viral-video/types"
|
||||
import type { FusionLevel } from "@/api/viral-video/types"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { ErrorRetryBar } from "./ErrorRetryBar"
|
||||
import { StoryboardCard } from "./StoryboardCard"
|
||||
import type { SbDoc, StoryboardEditor } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage, UIStep, ViralVideoJob } from "../types"
|
||||
import {
|
||||
LANGUAGES,
|
||||
TARGET_CUSTOMERS,
|
||||
PERSONAS,
|
||||
STRUCTURES,
|
||||
PURPOSES,
|
||||
DURATIONS,
|
||||
} from "../ViralVideoPage.constants"
|
||||
import { getJobPhaseMessage } from "@/api/viral-video/types"
|
||||
|
||||
export interface Step2CopyPanelProps {
|
||||
// 启用状态
|
||||
step2Enabled: boolean
|
||||
uiStep: UIStep
|
||||
// 文案参数
|
||||
fusionLevel: FusionLevel
|
||||
userCopy: string
|
||||
industry: string
|
||||
targetCustomer: string
|
||||
persona: string
|
||||
language: string
|
||||
viralStructure: string
|
||||
marketingPurpose: string
|
||||
duration: number
|
||||
// 回调
|
||||
onFusionLevelChange: (v: FusionLevel) => void
|
||||
onUserCopyChange: (v: string) => void
|
||||
onParamChange: (patch: {
|
||||
industry?: string
|
||||
targetCustomer?: string
|
||||
persona?: string
|
||||
language?: string
|
||||
viralStructure?: string
|
||||
marketingPurpose?: string
|
||||
duration?: number
|
||||
}) => void
|
||||
onGenerateCopy: () => void
|
||||
// 分镜/流式预览
|
||||
storyboard: SbDoc | null
|
||||
scriptStreamText: string
|
||||
scriptStreamMessage?: string
|
||||
editor: StoryboardEditor
|
||||
images: UploadedImage[]
|
||||
locked: boolean
|
||||
job: ViralVideoJob | null
|
||||
copyError?: string
|
||||
}
|
||||
|
||||
export const Step2CopyPanel: React.FC<Step2CopyPanelProps> = (p) => {
|
||||
const isGenerating = p.uiStep === "step2_generating"
|
||||
const showGenerateBtn =
|
||||
p.uiStep !== "step2_copy_ready" && p.uiStep !== "step3_generating" && p.uiStep !== "step3_done"
|
||||
|
||||
return (
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead">
|
||||
<span className={`vv-step-badge ${p.step2Enabled ? "" : "vv-step-badge-dim"}`}>2</span>
|
||||
<span className="vv-step-subtitle">STEP 2 生成视频文案</span>
|
||||
{!p.step2Enabled && <span className="vv-step-lock">请先完成 STEP 1</span>}
|
||||
</div>
|
||||
<div className="vv-section-body vv-section-body-scroll">
|
||||
<div className="vv-scroll-inner">
|
||||
{/* 文案融合方式 */}
|
||||
<div className="vv-section-subtitle">文案融合方式</div>
|
||||
<div className="vv-fusion-grid">
|
||||
{FUSION_LEVELS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={`vv-fusion-btn ${p.fusionLevel === f.value ? "active" : ""}`}
|
||||
onClick={() => p.onFusionLevelChange(f.value)}
|
||||
>
|
||||
<strong>{f.label}</strong>
|
||||
{f.desc}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(p.fusionLevel === "ai_polish" || p.fusionLevel === "user_primary") && (
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">
|
||||
{p.fusionLevel === "ai_polish" ? "写下你的文案草稿" : "写下你的文案/草稿"}
|
||||
</label>
|
||||
<textarea
|
||||
key={p.fusionLevel}
|
||||
className="vv-textarea"
|
||||
placeholder={
|
||||
p.fusionLevel === "ai_polish"
|
||||
? "请输入你的文案草稿,AI将在此基础上润色优化..."
|
||||
: "请输入你想要的原始文案,AI将严格保留你的表达..."
|
||||
}
|
||||
value={p.userCopy}
|
||||
onChange={(e) => p.onUserCopyChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参数(2列网格) */}
|
||||
<div className="vv-form-grid">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">
|
||||
行业与产品 <span className="vv-label-optional">(选填)</span>
|
||||
</label>
|
||||
<input
|
||||
className="vv-input"
|
||||
placeholder="如:家具店、宠物店、装修公司"
|
||||
value={p.industry}
|
||||
onChange={(e) => p.onParamChange({ industry: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">目标客户</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择目标客户"
|
||||
value={p.targetCustomer || undefined}
|
||||
onChange={(v) => p.onParamChange({ targetCustomer: v })}
|
||||
options={TARGET_CUSTOMERS.map((i) => ({ value: i, label: i }))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">台词语言</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={p.language}
|
||||
onChange={(v) => p.onParamChange({ language: v })}
|
||||
options={LANGUAGES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">我的人设</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择人设类型"
|
||||
optionFilterProp="children"
|
||||
value={p.persona || undefined}
|
||||
onChange={(v) => p.onParamChange({ persona: v })}
|
||||
options={PERSONAS.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">爆款结构</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={p.viralStructure}
|
||||
onChange={(v) => p.onParamChange({ viralStructure: v })}
|
||||
options={STRUCTURES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">营销目的</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择营销目的"
|
||||
value={p.marketingPurpose || undefined}
|
||||
onChange={(v) => p.onParamChange({ marketingPurpose: v })}
|
||||
options={PURPOSES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">文案视频时长</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={p.duration}
|
||||
onChange={(v) => p.onParamChange({ duration: v })}
|
||||
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 短剧模式提示 */}
|
||||
{p.marketingPurpose && p.marketingPurpose.includes("短剧") && (
|
||||
<div className="vv-shortdrama-hint">
|
||||
💡 短剧模式已选中,对手戏角色将自动分配配角音色
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文案审核错误(持久显示) */}
|
||||
{p.copyError && <ErrorRetryBar label="文案审核未通过" message={p.copyError} />}
|
||||
|
||||
{/* 分镜结果区 / 流式预览 */}
|
||||
<StoryboardCard
|
||||
uiStep={p.uiStep}
|
||||
scriptStreamText={p.scriptStreamText}
|
||||
scriptStreamMessage={p.scriptStreamMessage}
|
||||
jobPhaseMessage={getJobPhaseMessage(p.job)}
|
||||
storyboard={p.storyboard}
|
||||
editor={p.editor}
|
||||
images={p.images}
|
||||
locked={p.locked}
|
||||
onRegenerateCopy={p.onGenerateCopy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showGenerateBtn && (
|
||||
<button
|
||||
className={`vv-btn vv-btn-primary ${!p.step2Enabled ? "vv-btn-disabled-gray" : ""}`}
|
||||
onClick={p.onGenerateCopy}
|
||||
disabled={isGenerating || !p.step2Enabled}
|
||||
title={!p.step2Enabled ? "请先完成图片分析" : ""}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<VVSpinner /> AI 生成文案中…
|
||||
</>
|
||||
) : !p.step2Enabled ? (
|
||||
<>
|
||||
<EditOutlined /> 生成文案(请先完成图片分析)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EditOutlined /> 生成文案
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step2CopyPanel
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
ReloadOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Select } from "antd"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { ErrorRetryBar } from "./ErrorRetryBar"
|
||||
import type { UIStep, ViralVideoJob, ViralVideoModel } from "../types"
|
||||
import { getJobPhaseMessage } from "@/api/viral-video/types"
|
||||
import { RATIOS, QUALITY_OPTIONS, UPSCALE_OPTIONS } from "../ViralVideoPage.constants"
|
||||
|
||||
export interface Step3VideoPanelProps {
|
||||
step3Enabled: boolean
|
||||
uiStep: UIStep
|
||||
videoRatio: string
|
||||
videoModel: string
|
||||
quality: string
|
||||
upscale: string
|
||||
duration: number
|
||||
estimatedCredits: number | null
|
||||
estimatedCreditsError: boolean
|
||||
isGenerating: boolean
|
||||
outputUrl: string | null
|
||||
videoError?: string
|
||||
creditsReady: boolean
|
||||
job: ViralVideoJob | null
|
||||
videoModels: ViralVideoModel[]
|
||||
locked: boolean
|
||||
// callbacks
|
||||
onRatioChange: (v: string) => void
|
||||
onModelChange: (v: string) => void
|
||||
onQualityChange: (v: string) => void
|
||||
onUpscaleChange: (v: string) => void
|
||||
onGenerate: () => void
|
||||
onReset: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export const Step3VideoPanel: React.FC<Step3VideoPanelProps> = (p) => {
|
||||
const isDone = p.uiStep === "step3_done"
|
||||
const isGenerating = p.uiStep === "step3_generating"
|
||||
const isFailed = p.uiStep === "failed"
|
||||
|
||||
const curM = p.videoModels.find((m) => m && m.key === p.videoModel)
|
||||
const supportedRes = Array.isArray(curM?.supported_resolutions)
|
||||
? curM!.supported_resolutions
|
||||
: QUALITY_OPTIONS.map((o) => o.v)
|
||||
const qualityPool =
|
||||
supportedRes && supportedRes.length > 0 ? supportedRes : QUALITY_OPTIONS.map((o) => o.v)
|
||||
const qualityOptions = QUALITY_OPTIONS.filter((o) => qualityPool.includes(o.v))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead vv-step3-head">
|
||||
<div className="vv-step3-head-left">
|
||||
<span className={`vv-step-badge ${p.step3Enabled ? "" : "vv-step-badge-dim"}`}>3</span>
|
||||
<span className="vv-step-subtitle">STEP 3 生成视频</span>
|
||||
</div>
|
||||
{p.step3Enabled && (
|
||||
<button
|
||||
className="vv-btn vv-btn-ghost vv-btn-xs vv-history-btn"
|
||||
onClick={() => {
|
||||
// 历史作品功能待开发,此处保留原行为(message 由父组件触发)
|
||||
}}
|
||||
title="历史作品"
|
||||
>
|
||||
<HistoryOutlined /> 历史作品
|
||||
</button>
|
||||
)}
|
||||
{!p.step3Enabled && <span className="vv-step-lock">请先完成 STEP 2</span>}
|
||||
</div>
|
||||
<div className="vv-step3-divider" />
|
||||
<div className="vv-section-body vv-section-body-scroll vv-step3-config-body">
|
||||
<div className="vv-scroll-inner">
|
||||
<div className="vv-step3-grid">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频比例</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.videoRatio}
|
||||
onChange={p.onRatioChange}
|
||||
disabled={p.locked}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频模型</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.videoModel}
|
||||
disabled={p.locked}
|
||||
onChange={(v) => p.onModelChange(v)}
|
||||
options={p.videoModels
|
||||
.filter((m) => m && typeof m === "object" && m.key)
|
||||
.map((m) => ({ value: m.key, label: m.display_name || m.key }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">清晰度</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.quality}
|
||||
onChange={p.onQualityChange}
|
||||
disabled={p.locked}
|
||||
options={qualityOptions.map((o) => ({ value: o.v, label: o.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">成片超分</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={p.upscale}
|
||||
onChange={p.onUpscaleChange}
|
||||
disabled={p.locked}
|
||||
options={UPSCALE_OPTIONS.map((m) => ({ value: m.v, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.creditsReady && (
|
||||
<div className="vv-credits">
|
||||
<span>
|
||||
<SoundOutlined style={{ marginRight: 4 }} />
|
||||
预计消耗
|
||||
</span>
|
||||
<strong>
|
||||
{p.estimatedCreditsError
|
||||
? "-- 积分"
|
||||
: p.estimatedCredits === null
|
||||
? "… 积分"
|
||||
: `${p.estimatedCredits.toFixed(2)} 积分`}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频预览 */}
|
||||
<div className="vv-section vv-section-preview">
|
||||
<div className="vv-section-body vv-preview-body">
|
||||
<div className="vv-preview">
|
||||
{isDone && p.outputUrl ? (
|
||||
<video src={p.outputUrl} controls />
|
||||
) : isGenerating ? (
|
||||
<div className="vv-preview-placeholder">
|
||||
<div className="ph-icon">
|
||||
<LoadingOutlined className="vv-loading-ic" />
|
||||
</div>
|
||||
<div className="ph-txt">
|
||||
AI 正在生成视频中…
|
||||
<br />
|
||||
预计 1-3 分钟,请勿关闭页面
|
||||
</div>
|
||||
{getJobPhaseMessage(p.job) && (
|
||||
<div className="vv-muted vv-mt-6 vv-fs-12">{getJobPhaseMessage(p.job)}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="vv-preview-placeholder">
|
||||
<div className="ph-icon">🎬</div>
|
||||
<div className="ph-txt">完成前面步骤后视频将在此处预览</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误条 */}
|
||||
{isFailed && p.videoError && (
|
||||
<ErrorRetryBar label="视频生成失败" message={p.videoError} onRetry={p.onRetry} />
|
||||
)}
|
||||
|
||||
{/* 主按钮 */}
|
||||
{!isDone ? (
|
||||
<button
|
||||
className={`vv-btn vv-btn-primary ${!p.step3Enabled ? "vv-btn-disabled-gray" : ""}`}
|
||||
onClick={p.onGenerate}
|
||||
disabled={isGenerating || !p.step3Enabled}
|
||||
title={!p.step3Enabled ? "请先在 STEP 2 生成文案" : ""}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<VVSpinner /> AI 正在生成视频中…
|
||||
</>
|
||||
) : !p.step3Enabled ? (
|
||||
<>
|
||||
<VideoCameraOutlined /> 开始生成视频(请先生成文案)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VideoCameraOutlined /> 开始生成视频
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
p.outputUrl && (
|
||||
<button className="vv-btn vv-btn-ghost vv-btn-block" onClick={p.onReset}>
|
||||
<ReloadOutlined /> 再生成一个
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3VideoPanel
|
||||
@@ -0,0 +1,327 @@
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { FileTextOutlined, SoundOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { InlineText } from "./InlineEditTextarea"
|
||||
import { StoryboardShotItem } from "./StoryboardShotItem"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import type { StoryboardEditor, SbDoc } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage, UIStep } from "../types"
|
||||
import { RATIOS } from "../ViralVideoPage.constants"
|
||||
|
||||
export interface StoryboardCardProps {
|
||||
/** 当前 uiStep(仅用于按钮/锁定判定) */
|
||||
uiStep: UIStep
|
||||
/** 当前流式预览文本(step2_generating 时优先展示) */
|
||||
scriptStreamText: string
|
||||
scriptStreamMessage?: string
|
||||
jobPhaseMessage?: string
|
||||
/** 真实 storyboard(已补默认值的 SbDoc) */
|
||||
storyboard: SbDoc | null
|
||||
editor: StoryboardEditor
|
||||
images: UploadedImage[]
|
||||
/** 是否锁定编辑(视频生成中/完成时为 true) */
|
||||
locked: boolean
|
||||
onRegenerateCopy?: () => void
|
||||
}
|
||||
|
||||
import { renderMarkdown } from "../utils/renderMarkdown"
|
||||
|
||||
/** 解析 voiceover_script 中的 [speaker:xxx] 标记,渲染为带颜色的 React 片段 */
|
||||
function renderVoiceoverWithSpeakers(text: string) {
|
||||
if (!text) return null
|
||||
const parts = text.split(/\[speaker:([^\]]+)\]/)
|
||||
if (parts.length === 1) return <span>{text}</span>
|
||||
const elements: React.ReactNode[] = []
|
||||
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const speaker = parts[i]
|
||||
const segText = parts[i + 1] || ""
|
||||
const isMain = MAIN_SPEAKERS.includes(speaker)
|
||||
elements.push(
|
||||
<span key={i}>
|
||||
<strong className={isMain ? "vv-sb-speaker-main" : "vv-sb-speaker-guest"}>{speaker}</strong>
|
||||
<span>{segText}</span>
|
||||
</span>,
|
||||
)
|
||||
}
|
||||
return <>{elements}</>
|
||||
}
|
||||
|
||||
export const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
||||
uiStep,
|
||||
scriptStreamText,
|
||||
scriptStreamMessage,
|
||||
jobPhaseMessage,
|
||||
storyboard,
|
||||
editor,
|
||||
images,
|
||||
locked,
|
||||
onRegenerateCopy,
|
||||
}) => {
|
||||
const {
|
||||
editingField,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
commitField,
|
||||
updateOverview,
|
||||
updateConstraint,
|
||||
addConstraint,
|
||||
removeConstraint,
|
||||
updateSbField,
|
||||
} = editor
|
||||
|
||||
/* ── 生成中:流式预览 or 骨架 ── */
|
||||
if (uiStep === "step2_generating") {
|
||||
const hasStream = scriptStreamText.trim().length > 0
|
||||
const stageMsg = scriptStreamMessage || jobPhaseMessage || "🎬 AI 正在创作视频分镜脚本..."
|
||||
if (!hasStream) {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<VVSpinner />
|
||||
<span className="vv-copy-loading-text">{stageMsg}</span>
|
||||
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="vv-copy-box vv-storyboard vv-streaming-box">
|
||||
<div className="vv-streaming-header">
|
||||
<VVSpinner />
|
||||
<span className="vv-streaming-title">{stageMsg}</span>
|
||||
<span className="vv-streaming-count">{scriptStreamText.length} 字</span>
|
||||
</div>
|
||||
<div className="vv-sb-doc vv-streaming-doc">
|
||||
<pre className="vv-streaming-pre">
|
||||
{scriptStreamText
|
||||
.replace(/<\/?[a-zA-Z_][\w:-]*(?:\s[^>]*)?>/g, "")
|
||||
.replace(/\s{3,}/g, "\n\n")
|
||||
.trim()}
|
||||
<span className="vv-streaming-caret" aria-hidden="true" />
|
||||
</pre>
|
||||
</div>
|
||||
<div className="vv-sb-actions vv-streaming-actions">
|
||||
<span className="vv-streaming-hint">AI 实时输出中,完成后可编辑分镜与口播文案</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 没有 storyboard 时的空态 ── */
|
||||
if (!storyboard) {
|
||||
if (uiStep === "step2_copy_ready" || uiStep === "step3_generating" || uiStep === "step3_done") {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<span className="vv-muted">暂无分镜数据</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sb = storyboard
|
||||
const textCls = "vv-sb-field-val"
|
||||
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
|
||||
|
||||
return (
|
||||
<div className="vv-copy-box vv-storyboard">
|
||||
<div className="vv-sb-doc">
|
||||
{/* 文案预览 markdown(只读) */}
|
||||
{sb.copy_display_markdown && (
|
||||
<div className="vv-copy-preview">
|
||||
<h4 className="vv-sb-h vv-copy-preview-h">
|
||||
<FileTextOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
|
||||
文案预览
|
||||
</h4>
|
||||
<div
|
||||
className="vv-md-body"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(sb.copy_display_markdown) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频总览 */}
|
||||
<h4 className="vv-sb-h">视频总览</h4>
|
||||
<p className="vv-sb-inline-row">
|
||||
<strong className="vv-sb-field-k">整体主题:</strong>
|
||||
<InlineText
|
||||
value={sb.overview.theme}
|
||||
editing={!locked && editingField === "overview.theme"}
|
||||
onStartEdit={() => startEdit("overview.theme")}
|
||||
onCommit={(v) => commitField("overview.theme", v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
|
||||
disabled={locked}
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
<p className="vv-sb-inline-row">
|
||||
<strong className="vv-sb-field-k">总时长:</strong>
|
||||
<InlineText
|
||||
value={sb.overview.total_duration}
|
||||
editing={!locked && editingField === "overview.total_duration"}
|
||||
onStartEdit={() => startEdit("overview.total_duration")}
|
||||
onCommit={(v) => commitField("overview.total_duration", v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
|
||||
disabled={locked}
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
<p className="vv-sb-inline-row">
|
||||
<strong className="vv-sb-field-k">画幅:</strong>
|
||||
<Select
|
||||
className="vv-select vv-sb-inline-select"
|
||||
style={{ width: 90 }}
|
||||
value={sb.overview.aspect_ratio}
|
||||
disabled={locked}
|
||||
onChange={(v) => updateOverview({ aspect_ratio: v })}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
|
||||
variant="borderless"
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 场景与光线 */}
|
||||
<h4 className="vv-sb-h">场景与光线</h4>
|
||||
<p className="vv-sb-para">
|
||||
<InlineText
|
||||
value={sb.scene_and_lighting}
|
||||
editing={!locked && editingField === "scene_and_lighting"}
|
||||
onStartEdit={() => startEdit("scene_and_lighting")}
|
||||
onCommit={(v) => commitField("scene_and_lighting", v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
placeholder="描述整体场景氛围、光线方向与色温…"
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 逐镜头 */}
|
||||
<h4 className="vv-sb-h">逐秒镜头拆解</h4>
|
||||
<div className="vv-sb-doc-shots">
|
||||
{sb.shots.map((sh, idx) => (
|
||||
<StoryboardShotItem
|
||||
key={idx}
|
||||
index={idx}
|
||||
shot={sh}
|
||||
editor={editor}
|
||||
images={images}
|
||||
locked={locked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 硬性约束 */}
|
||||
<h4 className="vv-sb-h">硬性约束</h4>
|
||||
<div className="vv-sb-tags-block">
|
||||
<div className="vv-sb-taglist">
|
||||
{sb.hard_constraints.map((c, i) => (
|
||||
<span key={i} className="vv-sb-tag vv-sb-tag-hard">
|
||||
<input
|
||||
className="vv-sb-tag-input"
|
||||
value={c}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateConstraint("hard_constraints", i, e.target.value)}
|
||||
/>
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-tag-del"
|
||||
onClick={() => removeConstraint("hard_constraints", i)}
|
||||
aria-label="删除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!locked && (
|
||||
<button className="vv-sb-tag-add" onClick={() => addConstraint("hard_constraints")}>
|
||||
+ 添加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 负面提示词 */}
|
||||
<h4 className="vv-sb-h">负面提示词</h4>
|
||||
<div className="vv-sb-tags-block">
|
||||
<div className="vv-sb-taglist">
|
||||
{sb.negative_prompts.map((c, i) => (
|
||||
<span key={i} className="vv-sb-tag vv-sb-tag-neg">
|
||||
<input
|
||||
className="vv-sb-tag-input"
|
||||
value={c}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateConstraint("negative_prompts", i, e.target.value)}
|
||||
/>
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-tag-del"
|
||||
onClick={() => removeConstraint("negative_prompts", i)}
|
||||
aria-label="删除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!locked && (
|
||||
<button className="vv-sb-tag-add" onClick={() => addConstraint("negative_prompts")}>
|
||||
+ 添加
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完整口播稿 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">
|
||||
<SoundOutlined style={{ color: "#7c3aed", marginRight: 4 }} />
|
||||
口播稿:
|
||||
</strong>
|
||||
{editingField === "voiceover_script" ? (
|
||||
<textarea
|
||||
className={taCls}
|
||||
value={sb.voiceover_script}
|
||||
autoFocus
|
||||
onChange={(e) => updateSbField("voiceover_script", e.target.value)}
|
||||
onBlur={() => {
|
||||
// 受控模式:blur 即退出编辑(内容已实时同步)
|
||||
if (editingField === "voiceover_script") cancelEdit()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit()
|
||||
}}
|
||||
placeholder="AI 合成配音用的完整口播稿…"
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={textCls}
|
||||
onClick={() => {
|
||||
if (!locked) startEdit("voiceover_script")
|
||||
}}
|
||||
style={{ cursor: locked ? "not-allowed" : "text" }}
|
||||
>
|
||||
{renderVoiceoverWithSpeakers(sb.voiceover_script)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="vv-sb-actions">
|
||||
{uiStep === "step2_copy_ready" && onRegenerateCopy && (
|
||||
<button className="vv-btn vv-btn-ghost" onClick={onRegenerateCopy}>
|
||||
<ReloadOutlined /> 重新生成文案
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoryboardCard
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from "react"
|
||||
import { InlineText } from "./InlineEditTextarea"
|
||||
import type { StoryboardEditor, SbShot } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage } from "../types"
|
||||
|
||||
export interface StoryboardShotItemProps {
|
||||
index: number
|
||||
shot: SbShot
|
||||
editor: StoryboardEditor
|
||||
images: UploadedImage[]
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
|
||||
|
||||
const SHOT_FIELD_KEY = (idx: number, field: string) => `shot.${idx}.${field}`
|
||||
|
||||
/** 单个镜头行:时间/说话人/景别/场景/动作/BGM/转场/参考图 */
|
||||
export const StoryboardShotItem: React.FC<StoryboardShotItemProps> = ({
|
||||
index,
|
||||
shot,
|
||||
editor,
|
||||
images,
|
||||
locked,
|
||||
}) => {
|
||||
const { editingField, startEdit, cancelEdit, commitField, updateShot } = editor
|
||||
const refImg =
|
||||
typeof shot.reference_image_index === "number" ? images[shot.reference_image_index] : undefined
|
||||
|
||||
const textCls = "vv-sb-field-val"
|
||||
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
|
||||
|
||||
const isMainSpeaker = shot.speaker ? MAIN_SPEAKERS.includes(shot.speaker) : false
|
||||
|
||||
return (
|
||||
<div className="vv-sb-doc-shot">
|
||||
{/* 时间 */}
|
||||
<InlineText
|
||||
value={shot.time_range}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "time_range")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "time_range"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "time_range"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass="vv-sb-time-doc"
|
||||
textareaClass={`${taCls} vv-sb-time-ta`}
|
||||
placeholder="0-3秒"
|
||||
disabled={locked}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* 说话人标签(有 speaker 时展示,纯展示不加编辑) */}
|
||||
{shot.speaker && (
|
||||
<span
|
||||
className={`vv-sb-speaker-tag ${isMainSpeaker ? "vv-sb-speaker-tag-main" : "vv-sb-speaker-tag-guest"}`}
|
||||
>
|
||||
{shot.speaker}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 景别/角度与运镜 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">景别/角度与运镜:</strong>
|
||||
<InlineText
|
||||
value={shot.shot_type_angle_movement}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "shot_type_angle_movement")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "shot_type_angle_movement"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "shot_type_angle_movement"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 场景与对白 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">场景与对白:</strong>
|
||||
<InlineText
|
||||
value={shot.scene_and_dialogue}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "scene_and_dialogue")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "scene_and_dialogue"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "scene_and_dialogue"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 动作与真人细节 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">动作与真人细节:</strong>
|
||||
<InlineText
|
||||
value={shot.action_details}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "action_details")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "action_details"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "action_details"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 音效/BGM */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">音效/BGM:</strong>
|
||||
<InlineText
|
||||
value={shot.audio_bgm}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "audio_bgm")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "audio_bgm"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "audio_bgm"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 转场 */}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">转场:</strong>
|
||||
<InlineText
|
||||
value={shot.transition}
|
||||
editing={!locked && editingField === SHOT_FIELD_KEY(index, "transition")}
|
||||
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "transition"))}
|
||||
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "transition"), v)}
|
||||
onCancel={cancelEdit}
|
||||
textClass={textCls}
|
||||
textareaClass={taCls}
|
||||
disabled={locked}
|
||||
multiline
|
||||
rows={1}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* 参考图片 */}
|
||||
<p className="vv-sb-field vv-sb-ref-row">
|
||||
<strong className="vv-sb-field-k">参考图片:</strong>
|
||||
{refImg?.url ? (
|
||||
<span className="vv-sb-ref-badge">
|
||||
<img src={refImg.url} alt={`ref-${index}`} className="vv-sb-ref-thumb" />
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-ref-x"
|
||||
onClick={() => updateShot(index, { reference_image_index: undefined })}
|
||||
aria-label="移除参考图"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
) : !locked && images.length > 0 ? (
|
||||
<span className="vv-sb-ref-pick">
|
||||
{images.map((im, i) => (
|
||||
<button
|
||||
key={im.localId}
|
||||
className="vv-sb-ref-opt"
|
||||
onClick={() => updateShot(index, { reference_image_index: i })}
|
||||
title={`@图片${i + 1}`}
|
||||
>
|
||||
<img src={im.url} alt={`img${i}`} />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="vv-sb-muted">未指定</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoryboardShotItem
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
export interface VVSpinnerProps {
|
||||
size?: number
|
||||
color?: string
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 spinner 组件:复用 vv-spinner CSS 动画类,避免多处散落的 LoadingOutlined
|
||||
*/
|
||||
export const VVSpinner: React.FC<VVSpinnerProps> = ({ size, color, style, className }) => {
|
||||
return (
|
||||
<span
|
||||
className={`vv-spinner ${className || ""}`}
|
||||
style={{
|
||||
...(size ? { width: size, height: size, borderWidth: Math.max(2, size / 8) } : {}),
|
||||
...(color ? { borderTopColor: color } : {}),
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default VVSpinner
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from "react"
|
||||
import {
|
||||
UserOutlined,
|
||||
AudioMutedOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
FileTextOutlined,
|
||||
FolderOpenOutlined,
|
||||
UploadOutlined,
|
||||
CaretDownOutlined,
|
||||
CaretRightOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { message } from "antd"
|
||||
import type { RefAudio, VoicePanel } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { VOICE_TIPS } from "../ViralVideoPage.constants"
|
||||
|
||||
export interface VoicePickerSectionProps {
|
||||
refAudio: RefAudio
|
||||
voicePanel: VoicePanel
|
||||
voiceTipsOpen: boolean
|
||||
myVoices: VoiceClone[]
|
||||
myVoicesLoading: boolean
|
||||
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
|
||||
playingVoiceId: string | null
|
||||
voiceInputRef: React.Ref<HTMLInputElement>
|
||||
onOpenCloneModal: () => void
|
||||
onToggleVoicePanel: (p: VoicePanel) => void
|
||||
onSelectMyVoice: (v: VoiceClone) => void
|
||||
onSelectVoiceAsset: (a: AssetItem) => void
|
||||
onToggleVoicePlay: (v: { id: string; url: string }) => void
|
||||
onPickLocalAudio: () => void
|
||||
onPickAssetAudio: () => void
|
||||
onVoiceSelected: (file: File) => void
|
||||
onRemoveRefAudio: () => void
|
||||
onToggleVoiceTips: () => void
|
||||
}
|
||||
|
||||
export const VoicePickerSection: React.FC<VoicePickerSectionProps> = (p) => {
|
||||
return (
|
||||
<div className="vv-subblock">
|
||||
<div className="vv-subblock-head">
|
||||
<span className="vv-subblock-title">AI 配音</span>
|
||||
<span className="vv-opt-tag">选填</span>
|
||||
<span className="vv-subblock-hint">默认使用我的克隆音色,也可选择内置音色或上传配音</span>
|
||||
</div>
|
||||
|
||||
{/* 我的音色(主路径) */}
|
||||
<div className="vv-voice-section">
|
||||
<div className="vv-voice-section-head">
|
||||
<span className="vv-voice-section-title">
|
||||
<UserOutlined /> 我的音色
|
||||
</span>
|
||||
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onOpenCloneModal}>
|
||||
<PlusOutlined /> 录制新音色
|
||||
</button>
|
||||
</div>
|
||||
{p.myVoicesLoading ? (
|
||||
<div className="vv-muted vv-voice-empty">加载中…</div>
|
||||
) : p.myVoices.length === 0 ? (
|
||||
<div className="vv-voice-empty">
|
||||
<AudioMutedOutlined className="vv-voice-empty-ic" />
|
||||
<div className="vv-voice-empty-text">还没有克隆音色</div>
|
||||
<button className="vv-btn vv-btn-primary vv-btn-sm" onClick={p.onOpenCloneModal}>
|
||||
去录制一个
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vv-my-voice-list">
|
||||
{p.myVoices.map((v) => {
|
||||
const selected = p.refAudio?.source === "my_voice" && p.refAudio.id === v.id
|
||||
const playing = p.playingVoiceId === `my-${v.id}`
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vv-my-voice-item ${selected ? "selected" : ""}`}
|
||||
onClick={() => p.onSelectMyVoice(v)}
|
||||
>
|
||||
<div className="vv-voice-radio" />
|
||||
<div className="vv-voice-info">
|
||||
<div className="vv-voice-name">{v.name}</div>
|
||||
<div className="vv-voice-desc">
|
||||
AI 克隆声音
|
||||
{v.gender
|
||||
? ` · ${v.gender === "male" ? "男" : v.gender === "female" ? "女" : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`vv-voice-play ${playing ? "playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (v.sample_url) {
|
||||
p.onToggleVoicePlay({ id: `my-${v.id}`, url: v.sample_url })
|
||||
} else {
|
||||
message.info("正在获取试听音频…")
|
||||
getVoiceClonePreview(v.id)
|
||||
.then((r) => {
|
||||
if (r.audio_url)
|
||||
p.onToggleVoicePlay({ id: `my-${v.id}`, url: r.audio_url })
|
||||
})
|
||||
.catch(() => message.error("试听获取失败"))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{playing ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 次要选项 */}
|
||||
<div className="vv-voice-alt-row">
|
||||
<button
|
||||
className={`vv-voice-alt-btn ${p.refAudio?.source === "preset" ? "selected" : ""}`}
|
||||
onClick={() => p.onToggleVoicePanel("preset")}
|
||||
>
|
||||
<FileTextOutlined /> 内置音色
|
||||
</button>
|
||||
<button
|
||||
className={`vv-voice-alt-btn ${p.refAudio?.source === "upload" || p.refAudio?.source === "library" ? "selected" : ""}`}
|
||||
onClick={() => p.onToggleVoicePanel("library")}
|
||||
>
|
||||
<FolderOpenOutlined /> 上传配音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={p.voiceInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) p.onVoiceSelected(e.target.files[0])
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{p.refAudio && (
|
||||
<div className="vv-selected-audio">
|
||||
<span className="vv-mr-6">🔊</span>
|
||||
<span className="vv-flex-1">
|
||||
已选:
|
||||
{p.refAudio.source === "my_voice"
|
||||
? `我的音色 · ${p.refAudio.name}`
|
||||
: p.refAudio.source === "preset"
|
||||
? `内置 · ${p.refAudio.name}`
|
||||
: p.refAudio.name}
|
||||
</span>
|
||||
<button className="vv-link-btn" onClick={p.onRemoveRefAudio}>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.voicePanel === "library" && (
|
||||
<div className="vv-voice-panel">
|
||||
<div className="vv-voice-panel-actions">
|
||||
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickLocalAudio}>
|
||||
<UploadOutlined /> 本地上传音频文件
|
||||
</button>
|
||||
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickAssetAudio}>
|
||||
<FolderOpenOutlined /> 从素材库选择
|
||||
</button>
|
||||
</div>
|
||||
{p.voiceAssets.length === 0 ? (
|
||||
<div className="vv-muted vv-text-center vv-p-12">暂无已上传的配音文件</div>
|
||||
) : (
|
||||
<div className="vv-voice-list">
|
||||
{p.voiceAssets.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vv-voice-item ${p.refAudio?.id === v.id && (p.refAudio.source === "library" || p.refAudio.source === "upload") ? "selected" : ""}`}
|
||||
onClick={() =>
|
||||
p.onSelectVoiceAsset({ id: v.id, name: v.name, file_url: v.url } as AssetItem)
|
||||
}
|
||||
>
|
||||
<div className="vv-voice-radio" />
|
||||
<div className="vv-voice-info">
|
||||
<div className="vv-voice-name">{v.name}</div>
|
||||
<div className="vv-voice-desc">{v.desc || ""}</div>
|
||||
</div>
|
||||
{v.url && (
|
||||
<button
|
||||
className={`vv-voice-play ${p.playingVoiceId === v.id ? "playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
p.onToggleVoicePlay(v)
|
||||
}}
|
||||
>
|
||||
{p.playingVoiceId === v.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vv-tips">
|
||||
<button className="vv-tips-head" onClick={p.onToggleVoiceTips}>
|
||||
{p.voiceTipsOpen ? <CaretDownOutlined /> : <CaretRightOutlined />}
|
||||
支持格式与录音建议
|
||||
</button>
|
||||
{p.voiceTipsOpen && <div className="vv-tips-body">{VOICE_TIPS}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoicePickerSection
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
// 最小化的 Storyboard 类型,避免循环依赖(字段名与 api/viral-video/types 对齐)
|
||||
export interface SbShot {
|
||||
time_range: string
|
||||
speaker?: string
|
||||
shot_type_angle_movement: string
|
||||
scene_and_dialogue: string
|
||||
action_details: string
|
||||
audio_bgm: string
|
||||
transition: string
|
||||
reference_image_index?: number
|
||||
}
|
||||
export interface SbOverview {
|
||||
theme: string
|
||||
total_duration: string
|
||||
aspect_ratio: string
|
||||
}
|
||||
export interface SbDoc {
|
||||
overview: SbOverview
|
||||
scene_and_lighting: string
|
||||
shots: SbShot[]
|
||||
hard_constraints: string[]
|
||||
negative_prompts: string[]
|
||||
voiceover_script: string
|
||||
copy_display_markdown: string
|
||||
}
|
||||
|
||||
export type SbUpdater = (patch: (sb: SbDoc) => SbDoc) => void
|
||||
|
||||
export interface StoryboardEditor {
|
||||
editingField: string | null
|
||||
startEdit: (field: string) => void
|
||||
cancelEdit: () => void
|
||||
updateShot: (idx: number, patch: Partial<SbShot>) => void
|
||||
updateOverview: (patch: Partial<SbOverview>) => void
|
||||
updateSbField: <K extends keyof SbDoc>(key: K, value: SbDoc[K]) => void
|
||||
updateConstraint: (list: "hard_constraints" | "negative_prompts", idx: number, v: string) => void
|
||||
addConstraint: (list: "hard_constraints" | "negative_prompts") => void
|
||||
removeConstraint: (list: "hard_constraints" | "negative_prompts", idx: number) => void
|
||||
commitField: (field: string, value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 分镜脚本编辑器 hook:管理 editingField 状态,以及 storyboard 字段级更新方法。
|
||||
* 父组件传入 updateSb(fn) 即可,fn 接收当前 sb 返回新 sb(immer 风格)。
|
||||
*/
|
||||
export function useStoryboardEditor(updateSb: SbUpdater): StoryboardEditor {
|
||||
const [editingField, setEditingField] = useState<string | null>(null)
|
||||
|
||||
const startEdit = useCallback((field: string) => {
|
||||
setEditingField(field)
|
||||
}, [])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingField(null)
|
||||
}, [])
|
||||
|
||||
const updateShot = useCallback(
|
||||
(idx: number, patch: Partial<SbShot>) => {
|
||||
updateSb((sb) => ({
|
||||
...sb,
|
||||
shots: sb.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
|
||||
}))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const updateOverview = useCallback(
|
||||
(patch: Partial<SbOverview>) => {
|
||||
updateSb((sb) => ({ ...sb, overview: { ...sb.overview, ...patch } }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const updateSbField = useCallback(
|
||||
<K extends keyof SbDoc>(key: K, value: SbDoc[K]) => {
|
||||
updateSb((sb) => ({ ...sb, [key]: value }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const updateConstraint = useCallback(
|
||||
(list: "hard_constraints" | "negative_prompts", idx: number, v: string) => {
|
||||
updateSb((sb) => {
|
||||
const arr = [...sb[list]]
|
||||
arr[idx] = v
|
||||
return { ...sb, [list]: arr }
|
||||
})
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const addConstraint = useCallback(
|
||||
(list: "hard_constraints" | "negative_prompts") => {
|
||||
updateSb((sb) => ({ ...sb, [list]: [...sb[list], ""] }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const removeConstraint = useCallback(
|
||||
(list: "hard_constraints" | "negative_prompts", idx: number) => {
|
||||
updateSb((sb) => ({ ...sb, [list]: sb[list].filter((_, i) => i !== idx) }))
|
||||
},
|
||||
[updateSb],
|
||||
)
|
||||
|
||||
const commitField = useCallback(
|
||||
(field: string, value: string) => {
|
||||
if (field.startsWith("shot.")) {
|
||||
const parts = field.split(".")
|
||||
const idx = Number(parts[1])
|
||||
const shotField = parts.slice(2).join(".") as keyof SbShot
|
||||
updateShot(idx, { [shotField]: value } as Partial<SbShot>)
|
||||
} else if (field.startsWith("overview.")) {
|
||||
const f = field.split(".")[1] as keyof SbOverview
|
||||
updateOverview({ [f]: value } as Partial<SbOverview>)
|
||||
} else if (field === "scene_and_lighting" || field === "voiceover_script") {
|
||||
updateSbField(field, value)
|
||||
}
|
||||
setEditingField(null)
|
||||
},
|
||||
[updateShot, updateOverview, updateSbField],
|
||||
)
|
||||
|
||||
return {
|
||||
editingField,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
updateShot,
|
||||
updateOverview,
|
||||
updateSbField,
|
||||
updateConstraint,
|
||||
addConstraint,
|
||||
removeConstraint,
|
||||
commitField,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStoryboardEditor
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useCallback, useEffect } from "react"
|
||||
import axios from "axios"
|
||||
import { message } from "antd"
|
||||
import { isVideoStage, isCopyStage, getJobStage } from "@/api/viral-video/types"
|
||||
import type { UIStep, TabTask } from "../types"
|
||||
import {
|
||||
retryViralVideo,
|
||||
analyzeViralImages,
|
||||
generateViralCopy,
|
||||
confirmViralCopy,
|
||||
} from "@/api/viral-video"
|
||||
import type { CopyResult } from "@/api/viral-video/types"
|
||||
import type { ViralVideoJob } from "@/api/viral-video/types"
|
||||
import { applyPollUpdate } from "../utils/applyPollUpdate"
|
||||
import {
|
||||
useViralVideoMedia,
|
||||
type UseViralVideoMediaOptions,
|
||||
type MediaActions,
|
||||
} from "./useViralVideoMedia"
|
||||
import { resolveModelSwitch } from "../utils/resolveModelSwitch"
|
||||
|
||||
export interface ViralVideoActionsApi extends MediaActions {
|
||||
resetTask: () => void
|
||||
handleAnalyzeImages: () => void
|
||||
handleGenerateCopy: () => void
|
||||
handleGenerateVideo: () => void
|
||||
handleRetry: () => void
|
||||
onPollUpdate: (job: ViralVideoJob) => void
|
||||
onScriptDelta: (ev: { data: { full_text?: string }; progress?: number; message?: string }) => void
|
||||
handleModelChange: (
|
||||
v: string,
|
||||
videoModels: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
|
||||
) => void
|
||||
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
|
||||
setAssetPicker: React.Dispatch<
|
||||
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
|
||||
>
|
||||
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
refreshVoiceAssets: () => void
|
||||
reloadMyVoices: () => void
|
||||
}
|
||||
|
||||
export interface UseViralVideoHandlersOptions extends Omit<UseViralVideoMediaOptions, "setTask"> {
|
||||
activeId: string
|
||||
setTasks: React.Dispatch<React.SetStateAction<TabTask[]>>
|
||||
videoModels: {
|
||||
key: string
|
||||
supported_resolutions?: string[]
|
||||
max_duration?: number
|
||||
display_name?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
function emptyTask(id: string, title: string): TabTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
images: [],
|
||||
refVideo: null,
|
||||
voicePanel: null,
|
||||
refAudio: null,
|
||||
fusionLevel: "ai_full",
|
||||
styleStrength: "medium",
|
||||
userCopy: "",
|
||||
industry: "",
|
||||
targetCustomer: "",
|
||||
language: "中文(普通话)",
|
||||
viralStructure: "反差破局+亮明观点+还原现状",
|
||||
marketingPurpose: "",
|
||||
duration: 20,
|
||||
persona: "",
|
||||
videoRatio: "9:16",
|
||||
videoModel: "seedance-2.5",
|
||||
quality: "480p",
|
||||
upscale: "default",
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
uiStep: "step1_upload",
|
||||
imageAnalysis: null,
|
||||
storyboard: null,
|
||||
voiceTipsOpen: false,
|
||||
job: null,
|
||||
jobId: null,
|
||||
playingVoiceId: null,
|
||||
scriptStreamText: "",
|
||||
scriptStreamProgress: 0,
|
||||
scriptStreamMessage: "",
|
||||
}
|
||||
}
|
||||
|
||||
const CLEAR_STREAM = {
|
||||
scriptStreamText: "",
|
||||
scriptStreamProgress: 0,
|
||||
scriptStreamMessage: "",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 业务 action hook:聚合素材操作 + 三步骤 submit/retry/polling/model switch 等业务动作。
|
||||
*/
|
||||
export function useViralVideoHandlers(opts: UseViralVideoHandlersOptions): ViralVideoActionsApi {
|
||||
const {
|
||||
task,
|
||||
activeId,
|
||||
setTasks,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
refreshVoiceAssets,
|
||||
reloadMyVoices,
|
||||
setPresetVoicePickerOpen,
|
||||
} = opts
|
||||
|
||||
const setTask = useCallback(
|
||||
(patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => {
|
||||
setTasks((ts) =>
|
||||
ts.map((t) =>
|
||||
t.id === activeId ? (typeof patch === "function" ? patch(t) : { ...t, ...patch }) : t,
|
||||
),
|
||||
)
|
||||
},
|
||||
[activeId, setTasks],
|
||||
)
|
||||
|
||||
const media = useViralVideoMedia({
|
||||
task,
|
||||
setTask,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
setPresetVoicePickerOpen,
|
||||
refreshVoiceAssets,
|
||||
reloadMyVoices,
|
||||
})
|
||||
|
||||
// 卸载时停掉正在播放的音频
|
||||
useEffect(() => () => media.audioRef.current?.pause(), [media.audioRef])
|
||||
|
||||
const resetTask = () => {
|
||||
media.audioRef.current?.pause()
|
||||
setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t)))
|
||||
}
|
||||
|
||||
const handleAnalyzeImages = async () => {
|
||||
const readyImgs = task.images.filter((i) => i.ossUrl && !i.uploading)
|
||||
if (readyImgs.length === 0) {
|
||||
message.warning("请至少上传 1 张产品图")
|
||||
return
|
||||
}
|
||||
if (task.images.some((i) => i.uploading)) {
|
||||
message.warning("图片上传中,请稍候")
|
||||
return
|
||||
}
|
||||
if (task.refVideo?.uploading) {
|
||||
message.warning("参考视频上传中,请稍候")
|
||||
return
|
||||
}
|
||||
setTask({
|
||||
uiStep: "step1_analyzing",
|
||||
jobId: null,
|
||||
job: null,
|
||||
analysisError: undefined,
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
imageAnalysis: undefined,
|
||||
storyboard: undefined,
|
||||
...CLEAR_STREAM,
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
})
|
||||
try {
|
||||
const job = await analyzeViralImages({
|
||||
images: readyImgs.map((i) => i.ossUrl as string),
|
||||
reference_video_url: task.refVideo?.ossUrl || undefined,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
})
|
||||
setTask({ job, jobId: job.id })
|
||||
message.success("已提交图片分析…")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "分析失败")
|
||||
setTask({
|
||||
uiStep: "failed",
|
||||
analysisError: err instanceof Error ? err.message : "分析失败",
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateCopy = async () => {
|
||||
if (!task.jobId) {
|
||||
message.warning("请先完成图片分析")
|
||||
return
|
||||
}
|
||||
setTask({
|
||||
uiStep: "step2_generating",
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
analysisError: undefined,
|
||||
...CLEAR_STREAM,
|
||||
})
|
||||
try {
|
||||
const job = await generateViralCopy(task.jobId, {
|
||||
industry: task.industry || undefined,
|
||||
target_customer: task.targetCustomer || undefined,
|
||||
viral_structure: task.viralStructure || undefined,
|
||||
marketing_purpose: task.marketingPurpose || undefined,
|
||||
duration: task.duration,
|
||||
persona_id: task.persona || undefined,
|
||||
user_copy_text:
|
||||
(task.fusionLevel === "user_primary" || task.fusionLevel === "ai_polish") && task.userCopy
|
||||
? task.userCopy
|
||||
: undefined,
|
||||
fusion_level: task.fusionLevel,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
voice_id:
|
||||
task.refAudio?.source === "preset"
|
||||
? task.refAudio.id
|
||||
: task.refAudio?.source === "my_voice"
|
||||
? task.refAudio.ttsVoiceId || task.refAudio.id
|
||||
: undefined,
|
||||
voice_source: task.refAudio?.source || undefined,
|
||||
video_ratio: task.videoRatio,
|
||||
video_model: task.videoModel,
|
||||
})
|
||||
setTask({ job })
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "文案生成失败")
|
||||
setTask({
|
||||
uiStep: "failed",
|
||||
copyError: err instanceof Error ? err.message : "文案生成失败",
|
||||
videoError: undefined,
|
||||
analysisError: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateVideo = async () => {
|
||||
setTask({
|
||||
uiStep: "step3_generating",
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
...CLEAR_STREAM,
|
||||
})
|
||||
try {
|
||||
let job
|
||||
if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) {
|
||||
const edited = task.storyboard.voiceover_script.trim()
|
||||
const originalCopy =
|
||||
(task.job?.copy_result as CopyResult | null | undefined)?.voiceover_script ||
|
||||
task.job?.copy_result?.final_copy ||
|
||||
""
|
||||
job = await confirmViralCopy(task.jobId, {
|
||||
edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined,
|
||||
video_model: task.videoModel,
|
||||
video_resolution: task.quality,
|
||||
video_ratio: task.videoRatio,
|
||||
duration: task.duration,
|
||||
voice_id:
|
||||
task.refAudio?.source === "preset"
|
||||
? task.refAudio.id
|
||||
: task.refAudio?.source === "my_voice"
|
||||
? task.refAudio.ttsVoiceId || task.refAudio.id
|
||||
: undefined,
|
||||
voice_source: task.refAudio?.source || undefined,
|
||||
language: task.language || undefined,
|
||||
})
|
||||
} else {
|
||||
message.warning("请先生成文案后再生成视频")
|
||||
setTask({
|
||||
uiStep: task.storyboard
|
||||
? "step2_copy_ready"
|
||||
: task.imageAnalysis
|
||||
? "step1_done"
|
||||
: "step1_upload",
|
||||
})
|
||||
return
|
||||
}
|
||||
setTask({ job, jobId: job.id })
|
||||
message.success("视频已提交生成,预计 1-3 分钟…")
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 402) {
|
||||
const detail =
|
||||
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
|
||||
?.detail ||
|
||||
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
|
||||
?.message ||
|
||||
"积分不足,请充值"
|
||||
message.error(detail)
|
||||
setTask({
|
||||
uiStep: "step2_copy_ready",
|
||||
videoError: detail,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
...CLEAR_STREAM,
|
||||
})
|
||||
return
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "提交失败"
|
||||
message.error(msg)
|
||||
setTask({ uiStep: "failed", videoError: msg, copyError: undefined, analysisError: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (task.jobId) {
|
||||
try {
|
||||
const j = await retryViralVideo(task.jobId)
|
||||
const retryStage = getJobStage(j)
|
||||
let retryStep: UIStep = "step1_analyzing"
|
||||
if (isVideoStage(retryStage)) retryStep = "step3_generating"
|
||||
else if (isCopyStage(retryStage)) retryStep = "step2_generating"
|
||||
else if (task.storyboard) retryStep = "step2_copy_ready"
|
||||
else if (task.imageAnalysis) retryStep = "step1_done"
|
||||
setTask({
|
||||
job: j,
|
||||
jobId: j.id,
|
||||
uiStep: retryStep,
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
imageAnalysis: undefined,
|
||||
storyboard: undefined,
|
||||
...CLEAR_STREAM,
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
})
|
||||
message.success("已重试")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "重试失败")
|
||||
}
|
||||
} else {
|
||||
setTask({
|
||||
uiStep: task.storyboard ? "step2_copy_ready" : "step1_done",
|
||||
jobId: null,
|
||||
job: null,
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
imageAnalysis: undefined,
|
||||
storyboard: undefined,
|
||||
...CLEAR_STREAM,
|
||||
estimatedCredits: null,
|
||||
estimatedCreditsError: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onPollUpdate = useCallback(
|
||||
(job: ViralVideoJob) => {
|
||||
setTask((t) => applyPollUpdate(t, job))
|
||||
},
|
||||
[setTask],
|
||||
)
|
||||
|
||||
const onScriptDelta = useCallback(
|
||||
(ev: { data: { full_text?: string }; progress?: number; message?: string }) => {
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
scriptStreamText: ev.data.full_text || t.scriptStreamText,
|
||||
scriptStreamProgress:
|
||||
typeof ev.progress === "number" ? ev.progress : t.scriptStreamProgress,
|
||||
scriptStreamMessage: ev.message || t.scriptStreamMessage,
|
||||
}))
|
||||
},
|
||||
[setTask],
|
||||
)
|
||||
|
||||
const handleModelChange = (
|
||||
v: string,
|
||||
vms: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
|
||||
) => {
|
||||
const m = vms.find((x) => x && x.key === v)
|
||||
if (!m) {
|
||||
setTask({ videoModel: v })
|
||||
return
|
||||
}
|
||||
const { quality, duration } = resolveModelSwitch(
|
||||
{ quality: task.quality, duration: task.duration },
|
||||
m,
|
||||
)
|
||||
setTask({ videoModel: v, quality, duration })
|
||||
}
|
||||
|
||||
return {
|
||||
...media,
|
||||
resetTask,
|
||||
handleAnalyzeImages,
|
||||
handleGenerateCopy,
|
||||
handleGenerateVideo,
|
||||
handleRetry,
|
||||
onPollUpdate,
|
||||
onScriptDelta,
|
||||
handleModelChange,
|
||||
setTask,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
setPresetVoicePickerOpen,
|
||||
refreshVoiceAssets,
|
||||
reloadMyVoices,
|
||||
}
|
||||
}
|
||||
|
||||
export default useViralVideoHandlers
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useCallback, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import { getAssetLibraries, uploadAssetDirect, type AssetItem } from "@/api/assets"
|
||||
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, TabTask } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
export interface UseViralVideoMediaOptions {
|
||||
task: TabTask
|
||||
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
|
||||
setAssetPicker: React.Dispatch<
|
||||
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
|
||||
>
|
||||
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
refreshVoiceAssets: () => void
|
||||
reloadMyVoices: () => void
|
||||
}
|
||||
|
||||
export interface MediaActions {
|
||||
imgInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||
videoInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||
voiceInputRef: React.MutableRefObject<HTMLInputElement | null>
|
||||
audioRef: React.MutableRefObject<HTMLAudioElement | null>
|
||||
appendImagesFromFiles: (files: FileList | File[]) => void
|
||||
appendImagesFromAssets: (assets: AssetItem[]) => void
|
||||
clearImages: () => void
|
||||
removeImg: (localId: string) => void
|
||||
moveImg: (idx: number, dir: -1 | 1) => void
|
||||
handleLocalRefVideo: (file: File) => void
|
||||
removeRefVideo: () => void
|
||||
handleVoiceFile: (file: File) => void
|
||||
onCloneSuccess: (v: VoiceClone | { id?: string; name?: string; voice_id?: string | null }) => void
|
||||
pickVoiceAsset: (a: AssetItem) => void
|
||||
selectMyVoice: (v: VoiceClone) => void
|
||||
toggleVoicePanel: (p: VoicePanel) => void
|
||||
toggleVoice: (v: { id: string; url: string }) => void
|
||||
openAssetPicker: (kind: "image" | "video" | "voice", multiple: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材上传/音色选择/图片列表/参考视频等操作:独立 hook,保持每个文件 ≤400 行。
|
||||
*/
|
||||
export function useViralVideoMedia(opts: UseViralVideoMediaOptions): MediaActions {
|
||||
const {
|
||||
task,
|
||||
setTask,
|
||||
setAssetPicker,
|
||||
setCloneModalOpen,
|
||||
refreshVoiceAssets,
|
||||
setPresetVoicePickerOpen,
|
||||
} = opts
|
||||
const imgInputRef = useRef<HTMLInputElement>(null)
|
||||
const videoInputRef = useRef<HTMLInputElement>(null)
|
||||
const voiceInputRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => {
|
||||
const libs = await getAssetLibraries()
|
||||
const lib = (libs as { kind: string; id: string }[]).find((l) => l.kind === kind) || libs[0]
|
||||
if (!lib) throw new Error("未找到素材库")
|
||||
const res = await uploadAssetDirect({
|
||||
file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => onProgress?.(Math.round(p)),
|
||||
})
|
||||
return res.url
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const appendImagesFromFiles = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
const remaining = 9 - task.images.length
|
||||
if (remaining <= 0) {
|
||||
message.warning("最多上传 9 张产品图")
|
||||
return
|
||||
}
|
||||
const arr = Array.from(files)
|
||||
.filter((f) => f.type.startsWith("image/"))
|
||||
.slice(0, remaining)
|
||||
if (arr.length === 0) return
|
||||
const newImgs: UploadedImage[] = arr.map((f) => ({
|
||||
localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
url: URL.createObjectURL(f),
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
uploading: true,
|
||||
pct: 0,
|
||||
file: f,
|
||||
}))
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: [...t.images, ...newImgs.map(({ file: _f, ...rest }) => rest)],
|
||||
}))
|
||||
for (const item of newImgs) {
|
||||
try {
|
||||
const url = await uploadFile(item.file!, "image", (p) => {
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)),
|
||||
}))
|
||||
})
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: t.images.map((im) =>
|
||||
im.localId === item.localId
|
||||
? {
|
||||
...im,
|
||||
uploading: false,
|
||||
pct: 100,
|
||||
ossUrl: url,
|
||||
size: item.file!.size || im.size,
|
||||
}
|
||||
: im,
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
message.error(`${item.name} 上传失败`)
|
||||
setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) }))
|
||||
}
|
||||
}
|
||||
},
|
||||
[task.images.length, setTask, uploadFile],
|
||||
)
|
||||
|
||||
const appendImagesFromAssets = useCallback(
|
||||
(assets: AssetItem[]) => {
|
||||
const remaining = 9 - task.images.length
|
||||
const picked = assets.slice(0, remaining)
|
||||
if (picked.length === 0) return
|
||||
const newImgs: UploadedImage[] = picked.map((a) => ({
|
||||
localId: `lib_${a.id}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
url: a.thumbnail_url || a.file_url || "",
|
||||
name: a.name || "未命名",
|
||||
size: a.file_size || undefined,
|
||||
uploading: false,
|
||||
pct: 100,
|
||||
ossUrl: a.file_url || undefined,
|
||||
}))
|
||||
setTask((t) => ({ ...t, images: [...t.images, ...newImgs] }))
|
||||
message.success(`已添加 ${picked.length} 张图片`)
|
||||
},
|
||||
[task.images.length, setTask],
|
||||
)
|
||||
|
||||
const clearImages = () => {
|
||||
setTask((t) => {
|
||||
t.images.forEach((im) => {
|
||||
if (im.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
|
||||
})
|
||||
return {
|
||||
...t,
|
||||
images: [],
|
||||
imageAnalysis: null,
|
||||
uiStep: "step1_upload",
|
||||
storyboard: null,
|
||||
job: null,
|
||||
jobId: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeImg = (localId: string) => {
|
||||
setTask((t) => {
|
||||
const im = t.images.find((i) => i.localId === localId)
|
||||
if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
|
||||
const newImgs = t.images.filter((i) => i.localId !== localId)
|
||||
return {
|
||||
...t,
|
||||
images: newImgs,
|
||||
imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis,
|
||||
storyboard: newImgs.length === 0 ? null : t.storyboard,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const moveImg = (idx: number, dir: -1 | 1) => {
|
||||
setTask((t) => {
|
||||
const imgs = [...t.images]
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= imgs.length) return t
|
||||
;[imgs[idx], imgs[target]] = [imgs[target], imgs[idx]]
|
||||
return { ...t, images: imgs }
|
||||
})
|
||||
}
|
||||
|
||||
const handleLocalRefVideo = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > 200 * 1024 * 1024) {
|
||||
message.warning("视频不能超过 200MB")
|
||||
return
|
||||
}
|
||||
const localUrl = URL.createObjectURL(file)
|
||||
setTask({
|
||||
refVideo: {
|
||||
source: "local",
|
||||
url: localUrl,
|
||||
name: file.name,
|
||||
uploading: true,
|
||||
pct: 0,
|
||||
file,
|
||||
} as RefVideo,
|
||||
})
|
||||
try {
|
||||
const url = await uploadFile(file, "video", (p) => {
|
||||
setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t))
|
||||
})
|
||||
setTask((t) =>
|
||||
t.refVideo
|
||||
? { ...t, refVideo: { ...t.refVideo, uploading: false, pct: 100, ossUrl: url } }
|
||||
: t,
|
||||
)
|
||||
} catch {
|
||||
message.error("参考视频上传失败")
|
||||
if (localUrl.startsWith("blob:")) URL.revokeObjectURL(localUrl)
|
||||
setTask({ refVideo: null })
|
||||
}
|
||||
},
|
||||
[setTask, uploadFile],
|
||||
)
|
||||
|
||||
const removeRefVideo = () => {
|
||||
setTask((t) => {
|
||||
if (t.refVideo?.url.startsWith("blob:")) URL.revokeObjectURL(t.refVideo.url)
|
||||
return { ...t, refVideo: null }
|
||||
})
|
||||
}
|
||||
|
||||
const handleVoiceFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.warning("音频不能超过 10MB")
|
||||
return
|
||||
}
|
||||
const localUrl = URL.createObjectURL(file)
|
||||
try {
|
||||
const url = await uploadFile(file, "voice")
|
||||
setTask({
|
||||
refAudio: { source: "upload", name: file.name, url, id: url } as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
message.success("音频已上传")
|
||||
refreshVoiceAssets()
|
||||
} catch {
|
||||
message.error("音频上传失败")
|
||||
URL.revokeObjectURL(localUrl)
|
||||
}
|
||||
},
|
||||
[setTask, uploadFile, refreshVoiceAssets],
|
||||
)
|
||||
|
||||
const onCloneSuccess = (
|
||||
v: VoiceClone | { id?: string; name?: string; voice_id?: string | null },
|
||||
) => {
|
||||
setCloneModalOpen(false)
|
||||
opts.reloadMyVoices()
|
||||
const vid = "voice_id" in v ? v.voice_id : undefined
|
||||
if (v?.id) {
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "my_voice",
|
||||
id: v.id,
|
||||
name: v.name || "我录制的音色",
|
||||
ttsVoiceId: vid || undefined,
|
||||
} as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
refreshVoiceAssets()
|
||||
message.success("音色克隆完成,已自动选中")
|
||||
}
|
||||
}
|
||||
|
||||
const pickVoiceAsset = (asset: AssetItem) => {
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "library",
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
url: asset.file_url || "",
|
||||
} as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
}
|
||||
|
||||
const selectMyVoice = (v: VoiceClone) => {
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "my_voice",
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
ttsVoiceId: v.voice_id || undefined,
|
||||
} as RefAudio,
|
||||
voicePanel: null,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleVoicePanel = (p: VoicePanel) => {
|
||||
if (p === "preset") {
|
||||
setPresetVoicePickerOpen(true)
|
||||
return
|
||||
}
|
||||
setTask({ voicePanel: task.voicePanel === p ? null : p })
|
||||
}
|
||||
|
||||
const openAssetPicker = (kind: "image" | "video" | "voice", multiple: boolean) => {
|
||||
setAssetPicker({ open: true, kind, multiple })
|
||||
}
|
||||
|
||||
const toggleVoice = (v: { id: string; url: string }) => {
|
||||
if (!v.url) return
|
||||
if (task.playingVoiceId === v.id) {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
setTask({ playingVoiceId: null })
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause()
|
||||
const a = new Audio(v.url)
|
||||
a.dataset.playingId = v.id
|
||||
a.onended = () => setTask({ playingVoiceId: null })
|
||||
a.play().catch(() => {})
|
||||
audioRef.current = a
|
||||
setTask({ playingVoiceId: v.id })
|
||||
}
|
||||
|
||||
return {
|
||||
imgInputRef,
|
||||
videoInputRef,
|
||||
voiceInputRef,
|
||||
audioRef,
|
||||
appendImagesFromFiles,
|
||||
appendImagesFromAssets,
|
||||
clearImages,
|
||||
removeImg,
|
||||
moveImg,
|
||||
handleLocalRefVideo,
|
||||
removeRefVideo,
|
||||
handleVoiceFile,
|
||||
onCloneSuccess,
|
||||
pickVoiceAsset,
|
||||
selectMyVoice,
|
||||
toggleVoicePanel,
|
||||
toggleVoice,
|
||||
openAssetPicker,
|
||||
}
|
||||
}
|
||||
|
||||
export default useViralVideoMedia
|
||||
@@ -16,8 +16,9 @@ export interface UseViralVideoPollingOptions {
|
||||
|
||||
/**
|
||||
* 爆款视频任务 HTTP 轮询 hook。
|
||||
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
|
||||
* 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
* - onUpdate 通过 ref 持有,避免 useCallback 依赖变化导致重启轮询。
|
||||
* - 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
* - 连续失败 20 次(约 2 分钟)后熔断。
|
||||
*/
|
||||
export function useViralVideoPolling(
|
||||
jobId: string | null | undefined,
|
||||
@@ -28,6 +29,9 @@ export function useViralVideoPolling(
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const stoppedRef = useRef(false)
|
||||
const failCountRef = useRef(0)
|
||||
// ref 持有最新 onUpdate,避免依赖变化导致 pollOnce 重建、轮询被重启
|
||||
const onUpdateRef = useRef(onUpdate)
|
||||
onUpdateRef.current = onUpdate
|
||||
|
||||
const stop = useCallback(() => {
|
||||
stoppedRef.current = true
|
||||
@@ -42,25 +46,22 @@ export function useViralVideoPolling(
|
||||
try {
|
||||
const job = await getViralVideoJob(id)
|
||||
failCountRef.current = 0
|
||||
onUpdate(job)
|
||||
onUpdateRef.current(job)
|
||||
if (TERMINAL.includes(job.status)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (stoppedRef.current) return
|
||||
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
|
||||
const stage = getJobStage(job)
|
||||
const inRender = stage === "rendering"
|
||||
// 分析阶段走默认间隔即可
|
||||
const isAnalyzing = isAnalysisStage(stage)
|
||||
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
|
||||
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
|
||||
} catch (_err) {
|
||||
failCountRef.current += 1
|
||||
if (stoppedRef.current) return
|
||||
// P2-5: 连续失败 20 次(约 2 分钟)后熔断,避免后端挂掉时前端无限轮询
|
||||
if (failCountRef.current >= 20) {
|
||||
console.warn("[viral-video] 轮询连续失败 20 次,停止轮询,请刷新页面重试")
|
||||
// 轮询连续失败 20 次,停止轮询
|
||||
stop()
|
||||
return
|
||||
}
|
||||
@@ -68,7 +69,7 @@ export function useViralVideoPolling(
|
||||
timerRef.current = setTimeout(() => pollOnce(id), delay)
|
||||
}
|
||||
},
|
||||
[intervalMs, onUpdate, stop],
|
||||
[intervalMs, stop],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
FusionLevel,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ImageAnalysisResult,
|
||||
CopyResult,
|
||||
ShotScript,
|
||||
ViralVideoModel,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
/* ─────────── UI 阶段(三步骤独立交互) ─────────── */
|
||||
export type UIStep =
|
||||
| "step1_upload" // 初始:上传素材
|
||||
| "step1_analyzing" // 图片分析中
|
||||
| "step1_done" // 图片分析完成
|
||||
| "step2_generating" // 文案生成中
|
||||
| "step2_copy_ready" // 文案已生成,等待确认
|
||||
| "step3_generating" // 视频生成中
|
||||
| "step3_done" // 视频完成
|
||||
| "failed" // 失败
|
||||
|
||||
export const TERMINAL_UI_STEPS: UIStep[] = ["step3_done", "failed"]
|
||||
|
||||
export type UploadedImage = {
|
||||
localId: string
|
||||
url: string
|
||||
name: string
|
||||
size: number | undefined
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
export type RefVideo = {
|
||||
source: "local"
|
||||
url: string
|
||||
name: string
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
file?: File
|
||||
} | null
|
||||
|
||||
export type RefAudio = {
|
||||
source: "preset" | "library" | "upload" | "clone" | "my_voice"
|
||||
id?: string
|
||||
name: string
|
||||
url?: string
|
||||
/** 克隆音色的 CosyVoice voice_id(source=my_voice 时使用) */
|
||||
ttsVoiceId?: string
|
||||
} | null
|
||||
|
||||
export type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | null
|
||||
|
||||
// ── 分镜脚本数据模型(新后端 copy_result 结构) ──
|
||||
export interface StoryboardShot {
|
||||
time_range: string
|
||||
speaker?: string
|
||||
shot_type_angle_movement: string
|
||||
scene_and_dialogue: string
|
||||
action_details: string
|
||||
audio_bgm: string
|
||||
transition: string
|
||||
reference_image_index?: number
|
||||
}
|
||||
export interface Storyboard {
|
||||
overview: { theme: string; total_duration: string; aspect_ratio: string }
|
||||
scene_and_lighting: string
|
||||
shots: StoryboardShot[]
|
||||
hard_constraints: string[]
|
||||
negative_prompts: string[]
|
||||
voiceover_script: string
|
||||
/** v3: 用户端展示用 markdown 文案(由提示词控制排版) */
|
||||
copy_display_markdown: string
|
||||
}
|
||||
|
||||
export type TabTask = {
|
||||
id: string
|
||||
title: string
|
||||
// 素材
|
||||
images: UploadedImage[]
|
||||
refVideo: RefVideo
|
||||
voicePanel: VoicePanel
|
||||
refAudio: RefAudio
|
||||
// STEP2 参数
|
||||
fusionLevel: FusionLevel
|
||||
styleStrength: StyleStrength
|
||||
userCopy: string
|
||||
industry: string
|
||||
targetCustomer: string
|
||||
persona: string
|
||||
language: string
|
||||
viralStructure: string
|
||||
marketingPurpose: string
|
||||
duration: number
|
||||
// STEP3 设置
|
||||
videoRatio: string
|
||||
videoModel: string
|
||||
quality: string
|
||||
upscale: string
|
||||
// 积分预估
|
||||
estimatedCredits: number | null
|
||||
estimatedCreditsError: boolean
|
||||
// UI 状态
|
||||
uiStep: UIStep
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
storyboard: Storyboard | null
|
||||
voiceTipsOpen: boolean
|
||||
copyError?: string
|
||||
videoError?: string
|
||||
analysisError?: string
|
||||
// 运行态(后端 job)
|
||||
job: ViralVideoJob | null
|
||||
jobId: string | null
|
||||
playingVoiceId: string | null
|
||||
// 分镜脚本流式预览
|
||||
scriptStreamText: string
|
||||
scriptStreamProgress: number
|
||||
scriptStreamMessage: string
|
||||
}
|
||||
|
||||
// re-export api types for convenience
|
||||
export type {
|
||||
FusionLevel,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ImageAnalysisResult,
|
||||
CopyResult,
|
||||
ShotScript,
|
||||
ViralVideoModel,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { message } from "antd"
|
||||
import {
|
||||
isVideoStage,
|
||||
isCopyStage,
|
||||
isImageAnalysisStage,
|
||||
getJobStage,
|
||||
type ViralVideoJob,
|
||||
} from "@/api/viral-video/types"
|
||||
import type { UIStep, TabTask } from "../types"
|
||||
import { TERMINAL_UI_STEPS } from "../types"
|
||||
import { copyResultToStoryboard } from "./copyResultToStoryboard"
|
||||
import { classifyJobError, cleanErrorMessage } from "./errorClassification"
|
||||
|
||||
/**
|
||||
* 根据 job 状态计算下一个 TabTask 快照:单调性守卫 + 阶段推进 + 错误分类。
|
||||
* 返回 null 表示丢弃此次更新(终态守卫命中,使用旧状态即可)。
|
||||
*/
|
||||
export function applyPollUpdate(t: TabTask, job: ViralVideoJob): TabTask {
|
||||
if (
|
||||
TERMINAL_UI_STEPS.includes(t.uiStep) &&
|
||||
job.status !== "completed" &&
|
||||
job.status !== "failed"
|
||||
) {
|
||||
return t
|
||||
}
|
||||
let nextStep: UIStep = t.uiStep
|
||||
let copyError = t.copyError
|
||||
let videoError = t.videoError
|
||||
let analysisError = t.analysisError
|
||||
const err = job.error_message || job.error_msg
|
||||
const nextImageAnalysis = job.image_analysis || t.imageAnalysis
|
||||
const nextCopy = job.copy_result
|
||||
const nextStoryboard = nextCopy ? copyResultToStoryboard(nextCopy) || t.storyboard : t.storyboard
|
||||
const stage = getJobStage(job)
|
||||
|
||||
switch (job.status) {
|
||||
case "image_analyzed":
|
||||
nextStep = "step1_done"
|
||||
copyError = undefined
|
||||
break
|
||||
case "copy_generated":
|
||||
nextStep = "step2_copy_ready"
|
||||
copyError = undefined
|
||||
break
|
||||
case "running":
|
||||
if (isVideoStage(stage)) nextStep = "step3_generating"
|
||||
else if (isCopyStage(stage)) nextStep = "step2_generating"
|
||||
else if (isImageAnalysisStage(stage)) nextStep = "step1_analyzing"
|
||||
copyError = undefined
|
||||
videoError = undefined
|
||||
break
|
||||
case "completed":
|
||||
nextStep = "step3_done"
|
||||
copyError = undefined
|
||||
videoError = undefined
|
||||
break
|
||||
case "failed": {
|
||||
if (stage === "review" && err && (err.includes("合规") || err.includes("审核未通过"))) {
|
||||
nextStep = "step2_copy_ready"
|
||||
copyError = err
|
||||
videoError = undefined
|
||||
analysisError = undefined
|
||||
message.warning(err)
|
||||
break
|
||||
}
|
||||
nextStep = "failed"
|
||||
const cleaned = cleanErrorMessage(err || "")
|
||||
const kind = classifyJobError(cleaned, stage)
|
||||
if (kind === "video") {
|
||||
videoError = cleaned || "视频生成失败"
|
||||
copyError = undefined
|
||||
analysisError = undefined
|
||||
} else if (kind === "image") {
|
||||
analysisError = cleaned || "图片分析失败"
|
||||
copyError = undefined
|
||||
videoError = undefined
|
||||
} else {
|
||||
copyError = cleaned || "文案生成失败"
|
||||
videoError = undefined
|
||||
analysisError = undefined
|
||||
}
|
||||
break
|
||||
}
|
||||
case "pending":
|
||||
break
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
job,
|
||||
uiStep: nextStep,
|
||||
imageAnalysis: nextImageAnalysis,
|
||||
storyboard: nextStoryboard,
|
||||
copyError,
|
||||
videoError,
|
||||
analysisError,
|
||||
}
|
||||
}
|
||||
|
||||
export default applyPollUpdate
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Storyboard } from "../types"
|
||||
import type { CopyResult, ShotScript } from "@/api/viral-video/types"
|
||||
|
||||
export function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
|
||||
if (!cr) return null
|
||||
if (Array.isArray(cr.shots) && cr.overview) {
|
||||
return {
|
||||
overview: {
|
||||
theme: cr.overview.theme || cr.title || "",
|
||||
total_duration: (() => {
|
||||
const d = cr.overview.total_duration
|
||||
if (!d) return ""
|
||||
return String(d).endsWith("s") ? String(d) : `${d}s`
|
||||
})(),
|
||||
aspect_ratio: cr.overview.aspect_ratio || "9:16",
|
||||
},
|
||||
scene_and_lighting: cr.scene_and_lighting || "",
|
||||
shots: ((cr.shots ?? []) as ShotScript[]).map((s) => ({
|
||||
time_range: s.time_range || "",
|
||||
speaker: s.speaker || "",
|
||||
shot_type_angle_movement: s.shot_type_angle_movement || "",
|
||||
scene_and_dialogue: s.scene_and_dialogue || "",
|
||||
action_details: s.action_details || "",
|
||||
audio_bgm: s.audio_bgm || "",
|
||||
transition: s.transition || "",
|
||||
reference_image_index:
|
||||
typeof s.reference_image_index === "number" ? s.reference_image_index : undefined,
|
||||
})),
|
||||
hard_constraints: Array.isArray(cr.hard_constraints) ? cr.hard_constraints : [],
|
||||
negative_prompts: Array.isArray(cr.negative_prompts) ? cr.negative_prompts : [],
|
||||
voiceover_script: cr.voiceover_script || cr.final_copy || cr.suggested_copy || "",
|
||||
copy_display_markdown: cr.copy_display_markdown || "",
|
||||
}
|
||||
}
|
||||
const finalCopy = cr.final_copy || cr.suggested_copy || ""
|
||||
const title = cr.title || ""
|
||||
return {
|
||||
overview: { theme: title || "AI 分镜脚本", total_duration: "", aspect_ratio: "9:16" },
|
||||
scene_and_lighting: "",
|
||||
shots:
|
||||
cr.scenes && cr.scenes.length
|
||||
? cr.scenes.map((sc, i: number) => ({
|
||||
time_range: sc.duration
|
||||
? `${i * sc.duration}-${(i + 1) * sc.duration}s`
|
||||
: `镜头${i + 1}`,
|
||||
shot_type_angle_movement: sc.shot || "",
|
||||
scene_and_dialogue: sc.narration || "",
|
||||
action_details: "",
|
||||
audio_bgm: "",
|
||||
transition: "",
|
||||
}))
|
||||
: finalCopy
|
||||
? [
|
||||
{
|
||||
time_range: "0-15s",
|
||||
shot_type_angle_movement: "",
|
||||
scene_and_dialogue: finalCopy,
|
||||
action_details: "",
|
||||
audio_bgm: "",
|
||||
transition: "",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
hard_constraints: [],
|
||||
negative_prompts: [],
|
||||
voiceover_script: finalCopy,
|
||||
copy_display_markdown: cr.copy_display_markdown || "",
|
||||
}
|
||||
}
|
||||
|
||||
export default copyResultToStoryboard
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
isCopyStage,
|
||||
isImageAnalysisStage,
|
||||
isVideoStage,
|
||||
getJobStage,
|
||||
} from "@/api/viral-video/types"
|
||||
import type { ViralVideoJob, ViralVideoStage } from "@/api/viral-video/types"
|
||||
|
||||
/**
|
||||
* 统一的视频/文案/图片阶段错误分类。
|
||||
* 优先按 stage 字段判定;stage 丢失时基于错误消息关键词兜底。
|
||||
*/
|
||||
export type JobErrorKind = "image" | "copy" | "video"
|
||||
|
||||
const VIDEO_ERR_RE =
|
||||
/wan 3\.0|wan-?3|seedance|视频生成|视频下载|视频渲染|tts|配音|render|musetalk|lip.?sync/i
|
||||
const IMAGE_ERR_RE = /vlm|图片分析|vision|商品识别|ocr|image analysis|图片识别|图像分析/i
|
||||
|
||||
export function classifyJobError(err: string, stage?: string | null): JobErrorKind {
|
||||
const msg = err || ""
|
||||
if (stage) {
|
||||
const s = stage as ViralVideoStage
|
||||
if (isVideoStage(s)) return "video"
|
||||
if (isImageAnalysisStage(s)) return "image"
|
||||
if (isCopyStage(s)) return "copy"
|
||||
}
|
||||
if (VIDEO_ERR_RE.test(msg)) return "video"
|
||||
if (IMAGE_ERR_RE.test(msg)) return "image"
|
||||
return "copy"
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉后端错误消息中常见的"XX失败:"前缀,避免前端 UI 重复前缀。
|
||||
*/
|
||||
const ERROR_PREFIX_RE =
|
||||
/^(视频生成失败|文案生成失败|图片分析失败|Wan 3\.0 视频生成失败|Seedance[0-9. ]*视频生成失败)[::]\s*/g
|
||||
|
||||
export function cleanErrorMessage(err: string): string {
|
||||
if (!err) return ""
|
||||
return err.replace(ERROR_PREFIX_RE, "").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 job 统一提取错误分类和清理后的消息;无错误返回 null。
|
||||
*/
|
||||
export function extractJobError(
|
||||
job: ViralVideoJob | null | undefined,
|
||||
): { kind: JobErrorKind; msg: string } | null {
|
||||
if (!job) return null
|
||||
const raw = job.error_message || job.error_msg
|
||||
if (!raw) return null
|
||||
const stage = getJobStage(job)
|
||||
const kind = classifyJobError(raw, stage)
|
||||
return { kind, msg: cleanErrorMessage(raw) }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 极简 markdown 渲染器:仅支持粗体、斜体、标题、列表、换行。
|
||||
* 不依赖 marked 包(node_modules 中暂未安装),满足 viral-video 文案预览的
|
||||
* 简单排版需求。
|
||||
*/
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
function inline(md: string): string {
|
||||
let s = escapeHtml(md)
|
||||
// bold **xxx**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
// italic *xxx*
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
||||
// inline code `xxx`
|
||||
s = s.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
return s
|
||||
}
|
||||
|
||||
export function renderMarkdown(md: string): string {
|
||||
if (!md) return ""
|
||||
const lines = md.split(/\r?\n/)
|
||||
const out: string[] = []
|
||||
let inList = false
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) {
|
||||
if (inList) {
|
||||
out.push("</ul>")
|
||||
inList = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// headings
|
||||
const h = line.match(/^(#{1,4})\s+(.*)$/)
|
||||
if (h) {
|
||||
if (inList) {
|
||||
out.push("</ul>")
|
||||
inList = false
|
||||
}
|
||||
const lvl = h[1].length
|
||||
out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`)
|
||||
continue
|
||||
}
|
||||
// unordered list
|
||||
const li = line.match(/^\s*[-*]\s+(.*)$/)
|
||||
if (li) {
|
||||
if (!inList) {
|
||||
out.push("<ul>")
|
||||
inList = true
|
||||
}
|
||||
out.push(`<li>${inline(li[1])}</li>`)
|
||||
continue
|
||||
}
|
||||
if (inList) {
|
||||
out.push("</ul>")
|
||||
inList = false
|
||||
}
|
||||
out.push(`<p>${inline(line)}</p>`)
|
||||
}
|
||||
if (inList) out.push("</ul>")
|
||||
return out.join("\n")
|
||||
}
|
||||
|
||||
export default renderMarkdown
|
||||
@@ -0,0 +1,30 @@
|
||||
import { RESOLUTION_ORDER } from "../ViralVideoPage.constants"
|
||||
|
||||
/**
|
||||
* resolveModelSwitch 所需的最小模型信息(结构性类型,兼容 ViralVideoModel)。
|
||||
*/
|
||||
export interface ModelSwitchInfo {
|
||||
supported_resolutions?: string[]
|
||||
max_duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换视频模型时,联动修正分辨率/时长:新模型不支持当前分辨率则回退到最低支持,
|
||||
* 当前时长超过新模型 max_duration 则截断到 max_duration。
|
||||
*/
|
||||
export function resolveModelSwitch(
|
||||
cur: { quality: string; duration: number },
|
||||
m: ModelSwitchInfo,
|
||||
): { quality: string; duration: number } {
|
||||
const mRes = Array.isArray(m.supported_resolutions)
|
||||
? m.supported_resolutions
|
||||
: ["480p", "720p", "1080p"]
|
||||
const sortedRes = mRes.slice().sort((a, b) => {
|
||||
const ra = RESOLUTION_ORDER.indexOf(a)
|
||||
const rb = RESOLUTION_ORDER.indexOf(b)
|
||||
return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra)
|
||||
})
|
||||
const newRes = mRes.includes(cur.quality) ? cur.quality : sortedRes[0] || "480p"
|
||||
const newDur = Math.min(cur.duration, typeof m.max_duration === "number" ? m.max_duration : 15)
|
||||
return { quality: newRes, duration: newDur }
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
generateViralVideo,
|
||||
getViralVideoJob,
|
||||
confirmViralVideoIntent,
|
||||
retryViralVideo,
|
||||
getViralVideoHistory,
|
||||
getViralStyleTemplates,
|
||||
@@ -76,10 +74,10 @@ describe("viral-video constants & stage helpers", () => {
|
||||
})
|
||||
|
||||
it("isCopyStage", () => {
|
||||
expect(isCopyStage("intent_parsing")).toBe(true)
|
||||
expect(isCopyStage("script_generation")).toBe(true)
|
||||
expect(isCopyStage("review")).toBe(true)
|
||||
expect(isCopyStage("tts")).toBe(false)
|
||||
expect(isCopyStage("image_analysis")).toBe(false)
|
||||
})
|
||||
|
||||
it("isAnalysisStage is union", () => {
|
||||
@@ -101,14 +99,6 @@ describe("viral-video API wrappers", () => {
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it("generateViralVideo", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j1" } })
|
||||
const r = generateViralVideo({ images: ["img1"] } as never)
|
||||
vi.runAllTimersAsync()
|
||||
expect(await r).toEqual({ id: "j1" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] })
|
||||
})
|
||||
|
||||
it("getViralVideoJob", async () => {
|
||||
mockGet.mockResolvedValue({ data: { id: "j2" } })
|
||||
const r = getViralVideoJob("j2")
|
||||
@@ -117,16 +107,6 @@ describe("viral-video API wrappers", () => {
|
||||
expect(mockGet).toHaveBeenCalledWith("/viral-video/j2")
|
||||
})
|
||||
|
||||
it("confirmViralVideoIntent", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j3" } })
|
||||
const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" })
|
||||
vi.runAllTimersAsync()
|
||||
await r
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", {
|
||||
confirmed_copy: "hi",
|
||||
})
|
||||
})
|
||||
|
||||
it("retryViralVideo", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j4" } })
|
||||
await retryViralVideo("j4")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -252,6 +252,9 @@ DOUBAO_TIMEOUT=${DOUBAO_TIMEOUT}
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=${DOUBAO_MAX_RETRIES}
|
||||
|
||||
# Ditto 情绪分析 LLM 调用超时(秒)
|
||||
DITTO_EMOTION_TIMEOUT=${DITTO_EMOTION_TIMEOUT}
|
||||
|
||||
# 视觉模型(支持图片/视频理解的模型,model name 格式)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
current_stage=getattr(model, "current_stage", "") or "",
|
||||
phase_message=getattr(model, "phase_message", "") or "",
|
||||
heartbeat_at=getattr(model, "heartbeat_at", None),
|
||||
intent_result=dict(model.intent_result) if model.intent_result else None,
|
||||
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
|
||||
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
|
||||
generated_copy_text=getattr(model, "generated_copy_text", "") or "",
|
||||
@@ -110,7 +109,6 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
current_stage=job.current_stage or "",
|
||||
phase_message=job.phase_message or "",
|
||||
heartbeat_at=job.heartbeat_at,
|
||||
intent_result=job.intent_result,
|
||||
image_analysis=job.image_analysis,
|
||||
storyboard=job.storyboard,
|
||||
generated_copy_text=job.generated_copy_text,
|
||||
@@ -139,7 +137,6 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
model.current_stage = job.current_stage or ""
|
||||
model.phase_message = job.phase_message or ""
|
||||
model.heartbeat_at = job.heartbeat_at
|
||||
model.intent_result = job.intent_result
|
||||
model.image_analysis = job.image_analysis
|
||||
model.storyboard = job.storyboard
|
||||
model.generated_copy_text = job.generated_copy_text or ""
|
||||
@@ -199,9 +196,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
self.session.query(ViralVideoJobModel)
|
||||
.filter(
|
||||
ViralVideoJobModel.user_id == user_id,
|
||||
ViralVideoJobModel.status.in_(
|
||||
["pending", "running", "wait_user_confirm", "image_analyzed", "copy_generated"]
|
||||
),
|
||||
ViralVideoJobModel.status.in_(["pending", "running", "image_analyzed", "copy_generated"]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
@@ -37,9 +37,10 @@ _SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*")
|
||||
_CLAUSE_SPLIT_RE = re.compile(r"(?<=[,,、::])")
|
||||
# 整段无句末标点且超过该长度时才做逗号二次切分
|
||||
_SINGLE_SENTENCE_THRESHOLD = 30
|
||||
# 二次切分后过短片段(<5 字,如"家人们,")并入相邻句避免切太碎;
|
||||
# 4 字以上语义完整的短句(如"心中有爱,")保留独立成段
|
||||
_MIN_CLAUSE_LEN = 5
|
||||
# 二次切分后过短片段(<10 字)向后并入相邻句,确保每段至少 10 字、语义相对完整
|
||||
# (如"心中有爱,眼里有光,"合并为一段);避免长文案被切成十几个碎段导致
|
||||
# LLM 处理的句子过多而超时,且过碎片段语义不完整、情绪分析意义不大
|
||||
_MIN_CLAUSE_LEN = 10
|
||||
|
||||
# ── 默认 prompt 模板文件路径 ──────────────────────────────────────
|
||||
_DEFAULT_PROMPT_PATH = Path(__file__).parent / "prompts" / "ditto_emotion.txt"
|
||||
@@ -94,9 +95,10 @@ class EmotionTimelineEntry:
|
||||
|
||||
# ── 分句 ─────────────────────────────────────────────────────────
|
||||
def _split_by_clauses(sentence: str) -> list[str]:
|
||||
"""按逗号/分号等语义停顿二次切分;过短片段(<5 字)并入相邻句,避免切太碎.
|
||||
"""按逗号/分号等语义停顿二次切分;过短片段(<10 字)向后并入相邻句,避免切太碎.
|
||||
|
||||
每句尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持语义完整不硬切)。
|
||||
每段至少 10 字、尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持
|
||||
语义完整不硬切)。例:"心中有爱,眼里有光,"合并为一段而非各自成段。
|
||||
"""
|
||||
raw = [c for c in _CLAUSE_SPLIT_RE.split(sentence) if c and c.strip()]
|
||||
if len(raw) <= 1:
|
||||
|
||||
@@ -49,3 +49,38 @@ def get_expected_shot_count(dur: int) -> tuple[int, int]:
|
||||
"""返回 (min_shots, max_shots),供代码校验使用。"""
|
||||
spec = get_duration_spec(dur)
|
||||
return spec["min_shots"], spec["max_shots"]
|
||||
|
||||
|
||||
# ── Seedance 生成默认约束(worker/API 共用,避免硬编码漂移) ──────────────
|
||||
DEFAULT_HARD_CONSTRAINTS: list[str] = [
|
||||
"无字幕、无水印、无任何自动生成文字、无 logo",
|
||||
"严格还原参考图片中的真实场景、门店环境、商品陈列、人物外貌服装特征,不得凭空生成与参考图无关的人物、场景或物品",
|
||||
"同一人物全程保持一致的五官、发型、服装、身材,不得换脸或变形",
|
||||
"口播语音必须在指定时长内自然念完,语速自然,口型与语音同步",
|
||||
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
|
||||
"色彩自然、曝光正确、电影级质感、高清细节",
|
||||
]
|
||||
|
||||
DEFAULT_NEGATIVE_PROMPTS: list[str] = [
|
||||
"字幕",
|
||||
"自动字幕",
|
||||
"水印",
|
||||
"logo",
|
||||
"图标",
|
||||
"错误文字",
|
||||
"乱码文字",
|
||||
"男女声错配",
|
||||
"中途换声",
|
||||
"五官崩坏",
|
||||
"脸部变形",
|
||||
"多余手指",
|
||||
"肢体扭曲",
|
||||
"闪烁",
|
||||
"画面抖动",
|
||||
"模糊",
|
||||
"低分辨率",
|
||||
]
|
||||
|
||||
# API _build_copy_result 兼容老数据的兜底(比 worker 的短,仅用于老字段拼装)
|
||||
API_FALLBACK_HARD_CONSTRAINTS: list[str] = ["无字幕", "无水印", "人物一致性"]
|
||||
API_FALLBACK_NEGATIVE_PROMPTS: list[str] = ["字幕", "水印", "错误文字", "五官崩坏"]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""爆款视频参数指导(persona/viral_structure/marketing_purpose/target_customer/language)。
|
||||
|
||||
PR3 P2(h): 把原来 worker 里 130 行 if/elif 硬编码 mapping 迁到 DB 表
|
||||
`viral_video_param_guides`,提供 30 秒 TTL 缓存读取。DB miss 时返回 None,
|
||||
由调用方兜底到代码默认值(保证 worker 冷启动/DB 不可用时不阻断流程)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||||
|
||||
CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
VALID_GUIDE_TYPES = frozenset({"persona", "structure", "marketing_purpose", "target_customer", "language"})
|
||||
|
||||
_lock = threading.Lock()
|
||||
# key = f"{guide_type}:{guide_key}", value = (expire_monotonic_ts, text|None)
|
||||
_cache: dict[str, tuple[float, Optional[str]]] = {}
|
||||
|
||||
_lazy_session = None
|
||||
|
||||
|
||||
def _get_session():
|
||||
"""优先复用 worker 的 SessionLocal;否则懒建(API 场景)。"""
|
||||
global _lazy_session
|
||||
if _session_mod.SessionLocal is not None:
|
||||
return _session_mod.SessionLocal()
|
||||
if _lazy_session is not None:
|
||||
return _lazy_session()
|
||||
try:
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if not url:
|
||||
return None
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
_lazy_session = sessionmaker(bind=engine)
|
||||
return _lazy_session()
|
||||
|
||||
|
||||
def _cache_key(guide_type: str, guide_key: str) -> str:
|
||||
return f"{guide_type}:{guide_key}"
|
||||
|
||||
|
||||
def _load_from_db(guide_type: str, guide_key: str) -> Optional[str]:
|
||||
session = None
|
||||
try:
|
||||
session = _get_session()
|
||||
if session is None:
|
||||
return None
|
||||
sql = sa.text("""
|
||||
SELECT guide_text
|
||||
FROM viral_video_param_guides
|
||||
WHERE guide_type = :gt AND guide_key = :gk
|
||||
""")
|
||||
row = session.execute(sql, {"gt": guide_type, "gk": guide_key}).first()
|
||||
if row is None:
|
||||
return None
|
||||
return row[0] if row[0] is not None else None
|
||||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用静默返回 None
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def get_param_guide(guide_type: str, guide_key: str, *, force_refresh: bool = False) -> Optional[str]:
|
||||
"""读参数指导文本,30 秒 TTL;DB 无数据返回 None,调用方自行兜底。"""
|
||||
if guide_type not in VALID_GUIDE_TYPES:
|
||||
return None
|
||||
gk = (guide_key or "").strip()
|
||||
if not gk:
|
||||
return None
|
||||
key = _cache_key(guide_type, gk)
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if not force_refresh:
|
||||
cached = _cache.get(key)
|
||||
if cached and now < cached[0]:
|
||||
return cached[1]
|
||||
|
||||
text = _load_from_db(guide_type, gk)
|
||||
with _lock:
|
||||
_cache[key] = (now + CACHE_TTL_SECONDS, text)
|
||||
return text
|
||||
|
||||
|
||||
def invalidate() -> None:
|
||||
"""清空缓存(测试用)。"""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CACHE_TTL_SECONDS",
|
||||
"VALID_GUIDE_TYPES",
|
||||
"get_param_guide",
|
||||
"invalidate",
|
||||
]
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Prompt 模板加载器:从 viral_video_prompt_templates 读模板,30 秒 TTL 热加载。
|
||||
|
||||
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断。
|
||||
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断;
|
||||
首次回落会打 warning(PR3 P2(f)),避免静默掩盖配置问题,又不刷屏。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -15,10 +17,28 @@ import sqlalchemy as sa
|
||||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
_VALID_TYPES = {"image_analysis", "storyboard", "review"}
|
||||
|
||||
# PR3 P2(f): 回落 warning 去重 — key -> 上次打 warning 的 monotonic 时间戳,
|
||||
# 同一 key 在 _WARN_COOLDOWN_SECONDS 内不再重复告警。
|
||||
_WARN_COOLDOWN_SECONDS = 300.0 # 5 分钟
|
||||
_warned: dict[str, float] = {}
|
||||
_warn_lock = threading.Lock()
|
||||
|
||||
|
||||
def _warn_once(key: str, template_type: str, e: BaseException | str | None = None) -> None:
|
||||
now = time.monotonic()
|
||||
with _warn_lock:
|
||||
last = _warned.get(key, 0.0)
|
||||
if now - last < _WARN_COOLDOWN_SECONDS:
|
||||
return
|
||||
_warned[key] = now
|
||||
logger.warning("viral_video prompt template %s fallback to default: %s", template_type, e)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplate:
|
||||
@@ -64,12 +84,15 @@ def _get_session():
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("session_config", "db_config", e)
|
||||
return None
|
||||
if not url:
|
||||
_warn_once("session_url", "db_url", "database_url empty")
|
||||
return None
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://") if url.startswith("postgresql://") else url
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -104,14 +127,15 @@ def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
|
||||
example_output=row[5] or "",
|
||||
is_active=bool(row[6]),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
except Exception as e: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
_warn_once(f"load_db:{prompt_type}", prompt_type, e)
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once(f"session_close:{prompt_type}", prompt_type, e)
|
||||
|
||||
|
||||
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
|
||||
@@ -126,6 +150,8 @@ def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[P
|
||||
return cached[1]
|
||||
|
||||
template = _load_from_db(prompt_type) or _fallback(prompt_type)
|
||||
if template is None:
|
||||
_warn_once(f"no_template:{prompt_type}", prompt_type, "no template in db or code defaults")
|
||||
if template is not None:
|
||||
with _lock:
|
||||
_cache[prompt_type] = (now, template)
|
||||
@@ -136,6 +162,8 @@ def invalidate() -> None:
|
||||
"""清空缓存(测试用)。"""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
with _warn_lock:
|
||||
_warned.clear()
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
@@ -146,7 +174,8 @@ class _SafeDict(dict):
|
||||
def _safe_format(text: str, kwargs: dict) -> str:
|
||||
try:
|
||||
return text.format_map(_SafeDict(kwargs))
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("safe_format", "format", e)
|
||||
return text
|
||||
|
||||
|
||||
|
||||
@@ -37,26 +37,6 @@ class ImageAnalysis(BaseModel):
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class CoreMessage(BaseModel):
|
||||
text: str
|
||||
must_keep: bool = False
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class PersonalBrand(BaseModel):
|
||||
text: str
|
||||
category: str = "brand"
|
||||
|
||||
|
||||
class IntentResult(BaseModel):
|
||||
intent_summary: str = ""
|
||||
core_messages: list[CoreMessage] = Field(default_factory=list)
|
||||
personal_brands: list[PersonalBrand] = Field(default_factory=list)
|
||||
emotion_tone: str = ""
|
||||
missing_info: list[str] = Field(default_factory=list)
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class BodyPoint(BaseModel):
|
||||
text: str
|
||||
elaboration: str = ""
|
||||
|
||||
@@ -223,7 +223,7 @@ class SharedSettings(BaseSettings):
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_TEMPERATURE", "ditto_emotion_temperature"),
|
||||
)
|
||||
ditto_emotion_timeout: int = Field(
|
||||
default=10,
|
||||
default=30,
|
||||
validation_alias=AliasChoices("DITTO_EMOTION_TIMEOUT", "ditto_emotion_timeout"),
|
||||
)
|
||||
ditto_emotion_max_tokens: int = Field(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""爆款视频领域层包(PR3 P2:从单文件转为包,保持原有对外符号不变)。"""
|
||||
|
||||
from packages.domain.viral_video.entities import (
|
||||
STAGE_LABELS,
|
||||
FusionLevel,
|
||||
PromptType,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ViralVideoStage,
|
||||
ViralVideoStatus,
|
||||
)
|
||||
from packages.domain.viral_video.errors import (
|
||||
ViralVideoComplianceError,
|
||||
ViralVideoConfigError,
|
||||
ViralVideoCreditsError,
|
||||
ViralVideoError,
|
||||
ViralVideoLLMError,
|
||||
ViralVideoResourceError,
|
||||
ViralVideoTimeoutError,
|
||||
ViralVideoUpstreamError,
|
||||
)
|
||||
from packages.domain.viral_video.stages import (
|
||||
PROGRESS_COPY_DONE,
|
||||
PROGRESS_COPY_START,
|
||||
PROGRESS_COPY_STREAMING,
|
||||
PROGRESS_DONE,
|
||||
PROGRESS_IMAGE_DONE,
|
||||
PROGRESS_REVIEW,
|
||||
PROGRESS_START,
|
||||
PROGRESS_TRUST,
|
||||
PROGRESS_TTS,
|
||||
PROGRESS_UPLOAD_DONE,
|
||||
PROGRESS_VIDEO_DONE,
|
||||
PROGRESS_VIDEO_START,
|
||||
STAGE_COPY_GENERATION,
|
||||
STAGE_IMAGE_ANALYSIS,
|
||||
STAGE_PHASE_MESSAGES,
|
||||
STAGE_REVIEW,
|
||||
STAGE_TRUST_CHAIN,
|
||||
STAGE_TTS,
|
||||
STAGE_UPLOADING,
|
||||
STAGE_VIDEO_ANALYSIS,
|
||||
STAGE_VIDEO_GENERATION,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# entities
|
||||
"FusionLevel",
|
||||
"PromptType",
|
||||
"STAGE_LABELS",
|
||||
"StyleStrength",
|
||||
"ViralVideoJob",
|
||||
"ViralVideoStage",
|
||||
"ViralVideoStatus",
|
||||
# errors
|
||||
"ViralVideoError",
|
||||
"ViralVideoConfigError",
|
||||
"ViralVideoUpstreamError",
|
||||
"ViralVideoComplianceError",
|
||||
"ViralVideoResourceError",
|
||||
"ViralVideoCreditsError",
|
||||
"ViralVideoTimeoutError",
|
||||
"ViralVideoLLMError",
|
||||
# stages
|
||||
"STAGE_IMAGE_ANALYSIS",
|
||||
"STAGE_VIDEO_ANALYSIS",
|
||||
"STAGE_COPY_GENERATION",
|
||||
"STAGE_REVIEW",
|
||||
"STAGE_TRUST_CHAIN",
|
||||
"STAGE_TTS",
|
||||
"STAGE_VIDEO_GENERATION",
|
||||
"STAGE_UPLOADING",
|
||||
"STAGE_PHASE_MESSAGES",
|
||||
"PROGRESS_START",
|
||||
"PROGRESS_IMAGE_DONE",
|
||||
"PROGRESS_COPY_START",
|
||||
"PROGRESS_COPY_STREAMING",
|
||||
"PROGRESS_COPY_DONE",
|
||||
"PROGRESS_REVIEW",
|
||||
"PROGRESS_TRUST",
|
||||
"PROGRESS_TTS",
|
||||
"PROGRESS_VIDEO_START",
|
||||
"PROGRESS_VIDEO_DONE",
|
||||
"PROGRESS_UPLOAD_DONE",
|
||||
"PROGRESS_DONE",
|
||||
]
|
||||
@@ -3,9 +3,10 @@
|
||||
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
|
||||
状态机(三步分步):
|
||||
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
|
||||
wait_user_confirm -> running -> completed (旧路径兼容)
|
||||
任意阶段 fail; 任意非终态 cancel.
|
||||
failed -> pending (retry 重置后重跑)。
|
||||
|
||||
意图解析(intent_parsing) 已并入脚本生成一步完成,不再单独作为阶段/状态存在。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,7 +32,6 @@ class ViralVideoStatus(StrEnum):
|
||||
RUNNING = "running"
|
||||
IMAGE_ANALYZED = "image_analyzed"
|
||||
COPY_GENERATED = "copy_generated"
|
||||
WAIT_USER_CONFIRM = "wait_user_confirm"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
@@ -40,7 +40,6 @@ class ViralVideoStatus(StrEnum):
|
||||
class ViralVideoStage(StrEnum):
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
VIDEO_ANALYSIS = "video_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
|
||||
REVIEW = "review"
|
||||
TTS = "tts"
|
||||
@@ -62,7 +61,6 @@ class StyleStrength(StrEnum):
|
||||
|
||||
class PromptType(StrEnum):
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
SCRIPT_GENERATION = "script_generation"
|
||||
REVIEW = "review"
|
||||
VIDEO_STYLE_INTEGRATION = "video_style_integration"
|
||||
@@ -72,7 +70,6 @@ class PromptType(StrEnum):
|
||||
STAGE_LABELS = {
|
||||
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
|
||||
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
|
||||
ViralVideoStage.INTENT_PARSING: "意图解析",
|
||||
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
|
||||
ViralVideoStage.REVIEW: "合规审核",
|
||||
ViralVideoStage.TTS: "AI 配音",
|
||||
@@ -112,7 +109,6 @@ class ViralVideoJob:
|
||||
video_model: str = ""
|
||||
# v1.4+ 产物
|
||||
image_analysis: dict | None = None
|
||||
intent_result: dict | None = None
|
||||
generated_copy_text: str = "" # v1.6: 存 voiceover_script(纯口播对白),字段名兼容
|
||||
storyboard: list | None = None # v1.6: 存 copy_result.shots,字段名兼容
|
||||
copy_result: dict | None = None # v1.6: 完整编导脚本结构
|
||||
@@ -141,7 +137,6 @@ class ViralVideoJob:
|
||||
ViralVideoStatus.PENDING,
|
||||
ViralVideoStatus.IMAGE_ANALYZED,
|
||||
ViralVideoStatus.COPY_GENERATED,
|
||||
ViralVideoStatus.WAIT_USER_CONFIRM,
|
||||
ViralVideoStatus.RUNNING,
|
||||
):
|
||||
raise ValueError(f"Cannot transition from {self.status} to running")
|
||||
@@ -184,17 +179,10 @@ class ViralVideoJob:
|
||||
self.storyboard = list(shots) if isinstance(shots, list) else []
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_wait_user_confirm(self, intent_result: dict) -> None:
|
||||
if self.status != ViralVideoStatus.RUNNING:
|
||||
raise ValueError(f"Cannot transition from {self.status} to wait_user_confirm")
|
||||
self.status = ViralVideoStatus.WAIT_USER_CONFIRM
|
||||
self.intent_result = intent_result
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_image_analyzed(self, **kwargs) -> None:
|
||||
"""阶段2入口:允许从 IMAGE_ANALYZED/PENDING 首次进入,也允许从 COPY_GENERATED/COMPLETED/FAILED 重新生成文案。
|
||||
|
||||
重新生成时清空上一轮文案产物(copy_result/intent_result/storyboard/generated_copy_text),
|
||||
重新生成时清空上一轮文案产物(copy_result/storyboard/generated_copy_text),
|
||||
并重置 completed_at/result_video_url/error_msg,确保前端轮询能看到新的阶段2进度。
|
||||
"""
|
||||
_allowed = (
|
||||
@@ -216,7 +204,6 @@ class ViralVideoJob:
|
||||
setattr(self, k, v)
|
||||
if _is_regen:
|
||||
# 清空上一轮文案/视频产物,避免前端拿到旧数据
|
||||
self.intent_result = None
|
||||
self.copy_result = None
|
||||
self.storyboard = None
|
||||
self.generated_copy_text = ""
|
||||
@@ -252,15 +239,6 @@ class ViralVideoJob:
|
||||
self.completed_at = None
|
||||
self.credits_cost = 0.0
|
||||
|
||||
def resume_from_confirm(self) -> None:
|
||||
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
raise ValueError(f"Cannot resume from {self.status}")
|
||||
_now = datetime.now(timezone.utc)
|
||||
self.started_at = _now
|
||||
self.heartbeat_at = _now
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = _now
|
||||
|
||||
def mark_completed(self, video_url: str) -> None:
|
||||
self.status = ViralVideoStatus.COMPLETED
|
||||
self.result_video_url = video_url
|
||||
@@ -0,0 +1,167 @@
|
||||
"""爆款视频领域统一异常体系(PR3 P2)。
|
||||
|
||||
所有爆款视频相关业务异常均继承 ViralVideoError,保证:
|
||||
1. 上层可统一捕获,按 code/user_message 组装前端响应;
|
||||
2. 不暴露内部栈/traceback;
|
||||
3. http_status 供 API 层映射 HTTP 状态码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ViralVideoError(Exception):
|
||||
"""爆款视频业务异常基类。"""
|
||||
|
||||
code: str = "viral_video_error"
|
||||
user_message: str = "视频生成失败,请稍后重试"
|
||||
detail: str | None = None
|
||||
http_status: int = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str | None = None,
|
||||
detail: str | None = None,
|
||||
code: str | None = None,
|
||||
http_status: int | None = None,
|
||||
) -> None:
|
||||
if user_message is not None:
|
||||
self.user_message = user_message
|
||||
self.detail = detail
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if http_status is not None:
|
||||
self.http_status = http_status
|
||||
super().__init__(self.user_message)
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover
|
||||
return f"[{self.code}] {self.user_message}" + (f" :: {self.detail}" if self.detail else "")
|
||||
|
||||
|
||||
class ViralVideoConfigError(ViralVideoError):
|
||||
"""配置/初始化错误:缺模型、缺 API Key、依赖未就绪等。"""
|
||||
|
||||
code = "config_error"
|
||||
http_status = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str = "服务配置异常,请稍后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoUpstreamError(ViralVideoError):
|
||||
"""上游模型/云服务错误(火山 Seedance、阿里 Wan、OSS 等)。"""
|
||||
|
||||
code = "upstream_error"
|
||||
http_status = 502
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
upstream_code: str = "unknown",
|
||||
user_message: str = "视频生成服务暂时不可用,请稍后重试",
|
||||
detail: str | None = None,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.upstream_code = upstream_code
|
||||
self.status_code = status_code
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoComplianceError(ViralVideoError):
|
||||
"""合规审核不通过(用户文案命中敏感/违规内容)。"""
|
||||
|
||||
code = "compliance_rejected"
|
||||
http_status = 400
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str = "文案未通过合规审核,请修改后重试",
|
||||
issues: list | None = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.issues: list = list(issues) if issues else []
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoResourceError(ViralVideoError):
|
||||
"""资源不存在/失效:OSS URL 过期、图片找不到、返回空文件等。"""
|
||||
|
||||
code = "resource_error"
|
||||
http_status = 502
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str = "file",
|
||||
resource_id: str = "",
|
||||
user_message: str = "素材失效,请重新上传后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.resource_type = resource_type
|
||||
self.resource_id = resource_id
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoCreditsError(ViralVideoError):
|
||||
"""积分不足/结算失败。"""
|
||||
|
||||
code = "credits_insufficient"
|
||||
http_status = 402
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str = "积分不足,请充值后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoTimeoutError(ViralVideoError):
|
||||
"""某阶段执行超时(LLM/渲染/上传)。"""
|
||||
|
||||
code = "timeout"
|
||||
http_status = 504
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stage: str,
|
||||
timeout_s: float = 0.0,
|
||||
user_message: str = "处理超时,请稍后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.stage = stage
|
||||
self.timeout_s = timeout_s
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoLLMError(ViralVideoError):
|
||||
"""LLM 调用失败/重试耗尽/解析失败。"""
|
||||
|
||||
code = "llm_error"
|
||||
http_status = 502
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stage: str = "script_generation",
|
||||
model: str = "",
|
||||
user_message: str = "文案生成失败,请稍后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.stage = stage
|
||||
self.model = model
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ViralVideoError",
|
||||
"ViralVideoConfigError",
|
||||
"ViralVideoUpstreamError",
|
||||
"ViralVideoComplianceError",
|
||||
"ViralVideoResourceError",
|
||||
"ViralVideoCreditsError",
|
||||
"ViralVideoTimeoutError",
|
||||
"ViralVideoLLMError",
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""爆款视频阶段与进度点常量(PR3 P2)。
|
||||
|
||||
worker/API 中硬编码的阶段字符串与进度数值集中到此文件统一维护;
|
||||
注意:Celery task name / HTTP path / WS event name 属对外契约,不在此列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── 阶段标识(对齐 ViralVideoStage 但更贴合 v1.6 实际链路) ──────────────
|
||||
STAGE_IMAGE_ANALYSIS: str = "image_analysis"
|
||||
STAGE_VIDEO_ANALYSIS: str = "video_analysis"
|
||||
STAGE_COPY_GENERATION: str = "script_generation" # v1.6 文案=编导分镜
|
||||
STAGE_REVIEW: str = "review"
|
||||
STAGE_TRUST_CHAIN: str = "trust_chain" # #2172 信任链预热
|
||||
STAGE_TTS: str = "tts"
|
||||
STAGE_VIDEO_GENERATION: str = "rendering"
|
||||
STAGE_UPLOADING: str = "uploading"
|
||||
|
||||
# ── 阶段中文提示 ────────────────────────────────────────────────────────
|
||||
STAGE_PHASE_MESSAGES: dict[str, str] = {
|
||||
STAGE_IMAGE_ANALYSIS: "正在分析图片内容...",
|
||||
STAGE_VIDEO_ANALYSIS: "正在分析参考视频风格...",
|
||||
STAGE_COPY_GENERATION: "正在生成文案与分镜...",
|
||||
STAGE_REVIEW: "正在进行出片前合规审核...",
|
||||
STAGE_TRUST_CHAIN: "正在AI化人像参考...",
|
||||
STAGE_TTS: "正在合成AI配音...",
|
||||
STAGE_VIDEO_GENERATION: "正在生成视频(约1-3分钟)...",
|
||||
STAGE_UPLOADING: "正在上传成片...",
|
||||
}
|
||||
|
||||
# ── 关键进度点(0-100)────────────────────────────────────────────────
|
||||
PROGRESS_START: float = 0.0
|
||||
PROGRESS_IMAGE_DONE: float = 10.0 # 图片分析完成
|
||||
PROGRESS_COPY_START: float = 15.0 # 进入文案生成
|
||||
PROGRESS_COPY_STREAMING: float = 30.0 # 文案流式输出中
|
||||
PROGRESS_COPY_DONE: float = 45.0 # 文案生成完成
|
||||
PROGRESS_REVIEW: float = 50.0 # 合规审核
|
||||
PROGRESS_TRUST: float = 60.0 # 信任链 AI 化
|
||||
PROGRESS_TTS: float = 65.0 # TTS 合成
|
||||
PROGRESS_VIDEO_START: float = 70.0 # 开始视频生成
|
||||
PROGRESS_VIDEO_DONE: float = 95.0 # 视频生成完成
|
||||
PROGRESS_UPLOAD_DONE: float = 98.0 # 上传完成
|
||||
PROGRESS_DONE: float = 100.0 # 全流程完成
|
||||
|
||||
|
||||
__all__ = [
|
||||
"STAGE_IMAGE_ANALYSIS",
|
||||
"STAGE_VIDEO_ANALYSIS",
|
||||
"STAGE_COPY_GENERATION",
|
||||
"STAGE_REVIEW",
|
||||
"STAGE_TRUST_CHAIN",
|
||||
"STAGE_TTS",
|
||||
"STAGE_VIDEO_GENERATION",
|
||||
"STAGE_UPLOADING",
|
||||
"STAGE_PHASE_MESSAGES",
|
||||
"PROGRESS_START",
|
||||
"PROGRESS_IMAGE_DONE",
|
||||
"PROGRESS_COPY_START",
|
||||
"PROGRESS_COPY_STREAMING",
|
||||
"PROGRESS_COPY_DONE",
|
||||
"PROGRESS_REVIEW",
|
||||
"PROGRESS_TRUST",
|
||||
"PROGRESS_TTS",
|
||||
"PROGRESS_VIDEO_START",
|
||||
"PROGRESS_VIDEO_DONE",
|
||||
"PROGRESS_UPLOAD_DONE",
|
||||
"PROGRESS_DONE",
|
||||
]
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE DOUBAO_IMAGE_MODEL DOUBAO_IMAGE_SIZE DOUBAO_IMAGE_TIMEOUT DOUBAO_FAST_MODEL DOUBAO_TIMEOUT DOUBAO_MAX_RETRIES WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE DOUBAO_IMAGE_MODEL DOUBAO_IMAGE_SIZE DOUBAO_IMAGE_TIMEOUT DOUBAO_FAST_MODEL DOUBAO_TIMEOUT DOUBAO_MAX_RETRIES DITTO_EMOTION_TIMEOUT WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Seed viral_video_param_guides 表(PR3 P2-h)。
|
||||
|
||||
将 worker 中硬编码的人设/结构/语言默认值灌入 DB,保证部署后查询有默认返回;
|
||||
运营可后续在后台修改,30s TTL 缓存自动生效。
|
||||
|
||||
用法:
|
||||
cd /root/work/xiaoxia-saas
|
||||
PYTHONPATH=. python scripts/seed_viral_video_param_guides.py
|
||||
|
||||
幂等:用 ON CONFLICT (guide_type, guide_key) DO UPDATE 保证重复执行安全。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
|
||||
SEED_DATA: list[tuple[str, str, str]] = [
|
||||
# ── persona:人设风格 ──────────────────────────────────────────────
|
||||
("persona", "宝妈", "温柔贴心,多从带娃和家庭实用角度分享,语气亲切有共鸣"),
|
||||
("persona", "店主", "热情实在,像当面招呼客人,突出靠谱和实在优惠"),
|
||||
("persona", "专业顾问", "专业可信,讲清原理和效果,用事实打消顾虑"),
|
||||
("persona", "年轻达人", "活泼有网感,节奏轻快,金句和梗自然不尬"),
|
||||
("persona", "老板型IP", "以老板第一人称出镜,真诚接地气,像招呼街坊邻居一样分享,突出创业初心和靠谱"),
|
||||
("persona", "知识博主", "条理清晰、数据说话,干货密度高,语气专业但不枯燥"),
|
||||
("persona", "生活美学", "画面感强,注重氛围和质感描述,语速偏慢,文字有诗意"),
|
||||
("persona", "健身教练", "energetic、鼓励式口吻,强调动作要领和效果变化"),
|
||||
("persona", "美妆达人", "细腻讲质地和妆效,像闺蜜安利,语气亲切有感染力"),
|
||||
("persona", "美食博主", "色香味描述丰富,口语化带馋感,节奏轻快"),
|
||||
("persona", "穿搭博主", "讲搭配逻辑和场景适配,时尚但不高冷,像朋友建议"),
|
||||
("persona", "育儿师", "科学育儿角度,温柔坚定,给具体可操作的建议"),
|
||||
# ── structure:爆款结构 ────────────────────────────────────────────
|
||||
(
|
||||
"structure",
|
||||
"反差破局+亮明观点+还原现状",
|
||||
"开头3秒用反差/痛点钩子抓注意力,中段亮出核心卖点或观点,结尾还原真实到店/使用场景引导行动",
|
||||
),
|
||||
(
|
||||
"structure",
|
||||
"痛点切入+方案展示+效果对比",
|
||||
"开头直击用户痛点场景,中间展示产品/服务解决方案,结尾用前后对比强化效果",
|
||||
),
|
||||
(
|
||||
"structure",
|
||||
"故事引入+产品种草+行动引导",
|
||||
"用一个真实小故事/案例引入,自然过渡到产品种草,结尾明确引导用户下一步行动",
|
||||
),
|
||||
(
|
||||
"structure",
|
||||
"场景展示+价值输出+信任背书",
|
||||
"开头展示使用场景让用户代入,中间输出核心价值主张,结尾用客户评价/数据等信任背书收尾",
|
||||
),
|
||||
("structure", "悬念开场+层层递进+高潮转化", "开头制造悬念引发好奇,内容层层推进保持张力,高潮处给出转化钩子"),
|
||||
# ── language:语言提示 ─────────────────────────────────────────────
|
||||
("language", "zh-cn", "使用标准普通话,口语化表达"),
|
||||
("language", "zh-tw", "使用台湾腔中文,语气温柔亲切"),
|
||||
("language", "zh-hk", "使用粤语风格中文表达"),
|
||||
("language", "en-us", "使用美式英语,自然口语化"),
|
||||
("language", "en-gb", "使用英式英语"),
|
||||
("language", "ja-jp", "使用日语,自然口语化"),
|
||||
("language", "ko-kr", "使用韩语,亲切自然"),
|
||||
# ── marketing_purpose:营销目的(前端枚举 + 常见关键词,20项)─────────
|
||||
(
|
||||
"marketing_purpose",
|
||||
"获客引流",
|
||||
"【口播获客】第一人称对镜头发声,突出到店/加粉福利和行动引导,节奏紧凑有钩子,结尾明确CTA",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"账号涨粉",
|
||||
"【涨粉内容】开头有强反差/悬念钩子抓注意力,内容有信息密度和共鸣点,结尾引导关注不生硬",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"活动通知",
|
||||
"【活动通知】开头直接说活动主题和时间,中段讲福利/优惠力度,结尾强调截止时间和参与方式,信息清晰不绕弯",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"口播带货",
|
||||
"【口播带货】第一人称对镜头说话,像真人出镜推荐好物,有真实使用体验感,突出卖点和使用效果,结尾有购买引导",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"促销转化",
|
||||
"【促销转化】强调限时/限量/专属优惠,制造紧迫感,价格/优惠信息清晰突出,有明确购买路径",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"场景种草",
|
||||
"【场景种草】在真实使用场景中展示产品/服务,自然带出卖点,让用户产生代入感和'我也想试试'的欲望",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"痛点解决",
|
||||
"【痛点解决】开头直击目标用户的具体痛点场景,中段给出解决方案和使用演示,结尾强调效果对比",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"功能演示",
|
||||
"【功能演示】以产品功能展示为主,操作步骤清晰可见,讲清楚'怎么用'和'用了有什么效果',少废话多演示",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"门店发现",
|
||||
"【门店探店】店主或探店博主出镜,真实展示门店环境、招牌产品/服务、价格和体验感,结尾有到店引导",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"到店实录",
|
||||
"【到店实录】真实记录到店体验全过程,镜头跟着主走进店、看环境、体验服务、尝产品,节奏自然真实",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"招牌体验",
|
||||
"【招牌体验】聚焦门店/品牌的招牌产品/服务,细节展示特色卖点,突出'为什么这是招牌',有记忆点",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"同城团购",
|
||||
"【同城团购】突出本地/同城/到店属性,讲清楚团购优惠内容、使用规则、门店位置,结尾引导下单",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"品牌主张",
|
||||
"【品牌主张】传递品牌理念和价值观,有态度有情感共鸣,画面质感高级,少硬广多故事感",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"品牌故事",
|
||||
"【品牌故事】用叙事方式讲品牌的创立初心/工艺传承/用户故事,有情感起伏,建立品牌信任",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"生活方式",
|
||||
"【生活方式】营造向往的生活场景和氛围,产品/服务自然融入生活片段,调性好看有质感,引发'想过这样的生活'的共鸣",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"创意概念",
|
||||
"【创意概念】画面有创意和视觉冲击力,概念新颖不套路,适合做品牌传播内容,让人愿意转发分享",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"对话短剧",
|
||||
"【短剧模式】必须双/多角色对话格式,台词用'角色:xxx'格式,镜头在角色间切换,有剧情冲突或反转,禁止第三人称旁白解说",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"反转短剧",
|
||||
"【反转短剧】前半段建立常规预期,后半段出人意料反转,对话推动剧情,节奏紧凑有爽感,结尾反转干脆利落",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"悬念短剧",
|
||||
"【悬念短剧】开头制造悬念或谜题,剧情层层推进吊足胃口,结尾揭晓答案,角色对话推进,不要旁白解说",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"情绪短片",
|
||||
"【情绪短片】用画面、音乐和简短台词传递情绪(治愈/感动/激励/共鸣),节奏偏慢有氛围感,文字克制留白",
|
||||
),
|
||||
# ── target_customer:目标客群(前端枚举 8项)─────────────────────
|
||||
(
|
||||
"target_customer",
|
||||
"小镇青年",
|
||||
"【目标小镇青年】台词接地气有网感,贴近下沉市场生活场景,讲实在优惠和真实体验,节奏轻快不端着",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"小镇中老年",
|
||||
"【目标小镇中老年】台词慢节奏、吐字清晰、信息直白,强调实惠耐用和健康养生,少用网络梗",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"Z世代",
|
||||
"【目标Z世代】台词活泼有梗、节奏快,可用流行语和弹幕感表达,突出个性、有趣、社交话题性",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"精致妈妈",
|
||||
"【目标精致妈妈】台词温柔贴心,从育儿/家庭/品质生活角度切入,强调安全、省心、性价比,有真实带娃/家居场景共鸣",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"新锐白领",
|
||||
"【目标新锐白领】台词简洁干练,突出效率、品质、格调,讲职场/都市生活场景,少废话直接说价值",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"资深中产",
|
||||
"【目标资深中产】台词专业有深度,强调品质、品位、身份感,调性高级克制,不浮夸不喊麦",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"都市蓝领",
|
||||
"【目标都市蓝领】台词实在接地气,强调性价比、耐用、实惠,讲打工/租房/日常刚需场景,贴近生活",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"都市银发",
|
||||
"【目标都市银发】台词清晰慢节奏,强调健康、实惠、方便操作,避免复杂术语和网络用语,信息直白",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _get_engine():
|
||||
"""获取 DB engine。优先 DATABASE_URL 环境变量;否则走应用配置(与 seed_viral_video_prompts.py 一致)。"""
|
||||
import os
|
||||
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
if database_url:
|
||||
url = database_url
|
||||
else:
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
# asyncpg → psycopg2(脚本里不能用 async driver)
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||
return create_engine(url, pool_pre_ping=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
engine = _get_engine()
|
||||
insert_sql = text("""
|
||||
INSERT INTO viral_video_param_guides (guide_type, guide_key, guide_text)
|
||||
VALUES (:gt, :gk, :gtxt)
|
||||
ON CONFLICT (guide_type, guide_key)
|
||||
DO UPDATE SET guide_text = EXCLUDED.guide_text, updated_at = NOW()
|
||||
""")
|
||||
count = 0
|
||||
with engine.begin() as conn:
|
||||
for gt, gk, gtxt in SEED_DATA:
|
||||
conn.execute(insert_sql, {"gt": gt, "gk": gk, "gtxt": gtxt})
|
||||
count += 1
|
||||
print(f"[seed_viral_video_param_guides] upserted {count} rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -39,12 +39,24 @@ class TestSplitSentences:
|
||||
|
||||
# ── Bug1 修复:无句末标点长文案按逗号二次切分 ──
|
||||
def test_no_ending_punct_long_text_split_by_comma(self):
|
||||
"""公益文案:全是逗号、结尾无标点,整段超过30字 → 逗号二次切分."""
|
||||
text = "做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光"
|
||||
"""公益文案:几乎全是逗号、整段超过30字 → 逗号二次切分;短句合并后每段≥10字."""
|
||||
text = "做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光。"
|
||||
sents = split_sentences(text)
|
||||
assert len(sents) >= 3
|
||||
assert any("心中有爱" in s for s in sents)
|
||||
assert any("眼里有光" in s for s in sents)
|
||||
assert len(sents) == 3
|
||||
assert all(len(s) >= 10 for s in sents)
|
||||
# "心中有爱,"(5字)与"眼里有光。"(5字)合并为一段(10字,语义完整)
|
||||
assert sents[-1] == "心中有爱,眼里有光。"
|
||||
|
||||
def test_long_text_clause_count_bounded(self):
|
||||
"""约100字多逗号长文案:短句合并后段数有界(4-8段而非12段),避免LLM超时."""
|
||||
text = (
|
||||
"做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,"
|
||||
"心中有爱,眼里有光,手中有暖,脚下有路,每一份微小的善意都值得被看见,"
|
||||
"每一个普通的坚持都能汇聚成改变世界的力量"
|
||||
)
|
||||
sents = split_sentences(text)
|
||||
assert 3 <= len(sents) <= 8
|
||||
assert all(len(s) >= 10 for s in sents)
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
"""不超过30字的无句末标点文案不二次切分."""
|
||||
|
||||
@@ -535,7 +535,6 @@ class TestViralVideoResumeForRegenerate:
|
||||
# 模拟已经生成过文案和视频
|
||||
job.status = ViralVideoStatus.COPY_GENERATED
|
||||
job.copy_result = {"shots": [{"x": 1}], "voiceover_script": "旧文案"}
|
||||
job.intent_result = {"intent": "旧意图"}
|
||||
job.storyboard = [{"x": 1}]
|
||||
job.generated_copy_text = "旧文案"
|
||||
job.result_video_url = "http://old.mp4"
|
||||
@@ -549,7 +548,6 @@ class TestViralVideoResumeForRegenerate:
|
||||
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
assert job.copy_result is None
|
||||
assert job.intent_result is None
|
||||
assert job.storyboard is None
|
||||
assert job.generated_copy_text == ""
|
||||
assert job.result_video_url == ""
|
||||
@@ -564,14 +562,12 @@ class TestViralVideoResumeForRegenerate:
|
||||
job = ViralVideoJob(user_id="u1", images=["img1"])
|
||||
job.status = ViralVideoStatus.COMPLETED
|
||||
job.copy_result = {"shots": [], "voiceover_script": "xx"}
|
||||
job.intent_result = {"intent": "x"}
|
||||
job.result_video_url = "http://v.mp4"
|
||||
|
||||
job.resume_from_image_analyzed()
|
||||
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
assert job.copy_result is None
|
||||
assert job.intent_result is None
|
||||
assert job.result_video_url == ""
|
||||
|
||||
def test_first_call_from_image_analyzed_keeps_fields(self):
|
||||
@@ -588,14 +584,3 @@ class TestViralVideoResumeForRegenerate:
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
assert job.image_analysis == {"products": []}
|
||||
assert job.industry == "美妆"
|
||||
|
||||
def test_wait_user_confirm_rejected(self):
|
||||
"""wait_user_confirm 中间状态应被拒绝(前端正在编辑/确认文案)。"""
|
||||
import pytest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
|
||||
|
||||
job = ViralVideoJob(user_id="u1", images=["img1"])
|
||||
job.status = ViralVideoStatus.WAIT_USER_CONFIRM
|
||||
with pytest.raises(ValueError, match="Cannot resume"):
|
||||
job.resume_from_image_analyzed()
|
||||
|
||||
@@ -34,7 +34,6 @@ class TestViralVideoStatus:
|
||||
def test_status_values(self):
|
||||
assert ViralVideoStatus.PENDING == "pending"
|
||||
assert ViralVideoStatus.RUNNING == "running"
|
||||
assert ViralVideoStatus.WAIT_USER_CONFIRM == "wait_user_confirm"
|
||||
assert ViralVideoStatus.COMPLETED == "completed"
|
||||
assert ViralVideoStatus.FAILED == "failed"
|
||||
assert ViralVideoStatus.CANCELLED == "cancelled"
|
||||
@@ -66,28 +65,6 @@ class TestViralVideoJobStateTransitions:
|
||||
with pytest.raises(ValueError, match="Cannot transition"):
|
||||
job.mark_running()
|
||||
|
||||
def test_mark_wait_user_confirm(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
intent = {"intent": "推广", "key_messages": ["卖点1"]}
|
||||
job.mark_wait_user_confirm(intent)
|
||||
assert job.status == ViralVideoStatus.WAIT_USER_CONFIRM
|
||||
assert job.intent_result == intent
|
||||
|
||||
def test_mark_wait_user_confirm_from_non_running_raises(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.PENDING)
|
||||
with pytest.raises(ValueError, match="Cannot transition"):
|
||||
job.mark_wait_user_confirm({})
|
||||
|
||||
def test_resume_from_confirm(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
|
||||
job.resume_from_confirm()
|
||||
assert job.status == ViralVideoStatus.RUNNING
|
||||
|
||||
def test_resume_from_non_confirm_raises(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
with pytest.raises(ValueError, match="Cannot resume"):
|
||||
job.resume_from_confirm()
|
||||
|
||||
def test_mark_completed(self):
|
||||
job = ViralVideoJob(user_id="u1", status=ViralVideoStatus.RUNNING)
|
||||
job.mark_completed("https://oss.example.com/video.mp4")
|
||||
@@ -140,7 +117,6 @@ class TestViralVideoStage:
|
||||
expected_order = [
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"script_generation",
|
||||
"review",
|
||||
"tts",
|
||||
@@ -157,46 +133,6 @@ class TestViralVideoStage:
|
||||
class TestViralVideoSchemas:
|
||||
"""Pydantic Schema 校验测试。"""
|
||||
|
||||
def test_create_request_valid(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
req = CreateViralVideoRequest(images=["https://example.com/img.jpg"])
|
||||
assert req.images == ["https://example.com/img.jpg"]
|
||||
assert req.fusion_level == "ai_polish"
|
||||
assert req.style_strength == "medium"
|
||||
assert req.duration == 15
|
||||
|
||||
def test_create_request_empty_images_raises(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreateViralVideoRequest(images=[])
|
||||
|
||||
def test_create_request_invalid_fusion_level(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreateViralVideoRequest(
|
||||
images=["https://example.com/img.jpg"],
|
||||
fusion_level="invalid_level",
|
||||
)
|
||||
|
||||
def test_create_request_invalid_style_strength(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreateViralVideoRequest(
|
||||
images=["https://example.com/img.jpg"],
|
||||
style_strength="ultra",
|
||||
)
|
||||
|
||||
def test_confirm_intent_request_defaults(self):
|
||||
from app.schemas.viral_video import ConfirmIntentRequest
|
||||
|
||||
req = ConfirmIntentRequest()
|
||||
assert req.confirmed_copy == ""
|
||||
assert req.adjustments == ""
|
||||
|
||||
def test_analyze_style_request(self):
|
||||
from app.schemas.viral_video import AnalyzeStyleRequest
|
||||
|
||||
@@ -549,7 +485,7 @@ class TestPipelineIntegration:
|
||||
):
|
||||
"""v1.6: TTS整段合成 → 上传TTS到OSS → 单次 Seedance → 上传成片。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import (
|
||||
resume_viral_video_pipeline,
|
||||
run_viral_video_render,
|
||||
)
|
||||
|
||||
job = ViralVideoJob(
|
||||
@@ -557,7 +493,6 @@ class TestPipelineIntegration:
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
status=ViralVideoStatus.RUNNING,
|
||||
intent_result={"intent": "推广"},
|
||||
duration=15,
|
||||
video_ratio="9:16",
|
||||
)
|
||||
@@ -583,7 +518,7 @@ class TestPipelineIntegration:
|
||||
mock_render.return_value = ("/tmp/video.mp4", {"completion_tokens": 1000000})
|
||||
mock_upload.return_value = "https://oss.example.com/final.mp4"
|
||||
|
||||
result = resume_viral_video_pipeline.run("job-001")
|
||||
result = run_viral_video_render.run("job-001")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["video_url"] == "https://oss.example.com/final.mp4"
|
||||
|
||||
@@ -98,16 +98,6 @@ class TestScriptGenerationV16:
|
||||
assert _safe_json_loads(None) is None
|
||||
assert _safe_json_loads("not json") is None
|
||||
|
||||
def test_validate_normalize_fills_defaults(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _validate_and_normalize_script
|
||||
|
||||
raw = {"voiceover_script": "你好", "shots": [{"scene_and_dialogue": "测试"}]}
|
||||
out = _validate_and_normalize_script(raw, mock_job)
|
||||
assert out["voiceover_script"] == "你好"
|
||||
assert len(out["shots"]) == 1
|
||||
assert out["shots"][0]["shot_type_angle_movement"]
|
||||
assert out["overview"]["total_duration"] == mock_job.duration
|
||||
|
||||
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
|
||||
|
||||
@@ -299,19 +289,19 @@ class TestDoubaoClientVideoGen:
|
||||
# ── P0-3: resume 从 job 读 image_analysis ────────────────────────
|
||||
|
||||
|
||||
class TestResumeReadsImageAnalysis:
|
||||
def test_resume_uses_persisted_image_analysis(self):
|
||||
"""resume/render pipeline 应从 job.image_analysis 读(v1.5 _run_render_pipeline 共享渲染逻辑)。"""
|
||||
class TestRenderReadsImageAnalysis:
|
||||
def test_render_uses_persisted_image_analysis(self):
|
||||
"""render pipeline 应从 job.copy_result 读(_run_render_pipeline 共享渲染逻辑)。"""
|
||||
import inspect
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
# v1.5 改造后 resume 委托给 _run_render_pipeline,那里读取 job.image_analysis
|
||||
# render task 委托给 _run_render_pipeline,那里读取 job.copy_result
|
||||
src = inspect.getsource(vv._run_render_pipeline)
|
||||
assert "job.copy_result" in src
|
||||
# resume 本身应该调用 _run_render_pipeline
|
||||
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
|
||||
assert "_run_render_pipeline" in resume_src
|
||||
# run_viral_video_render 本身应该调用 _run_render_pipeline
|
||||
render_src = inspect.getsource(vv.run_viral_video_render)
|
||||
assert "_run_render_pipeline" in render_src
|
||||
|
||||
|
||||
# ============ PR1: _replace_henjin_everywhere 已删除(错别字改为 prompt NEGATIVE_RULES 禁止) ============
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""PR3 P2 爆款视频后端:errors/stages/param_guides 基础单测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_errors_hierarchy():
|
||||
from packages.domain.viral_video import (
|
||||
ViralVideoComplianceError,
|
||||
ViralVideoConfigError,
|
||||
ViralVideoCreditsError,
|
||||
ViralVideoError,
|
||||
ViralVideoLLMError,
|
||||
ViralVideoResourceError,
|
||||
ViralVideoTimeoutError,
|
||||
ViralVideoUpstreamError,
|
||||
)
|
||||
|
||||
# 基类
|
||||
e = ViralVideoError()
|
||||
assert e.code == "viral_video_error"
|
||||
assert e.http_status == 500
|
||||
assert str(e)
|
||||
|
||||
# 上游错误
|
||||
ue = ViralVideoUpstreamError("doubao", upstream_code="50411", user_message="模型不可用", status_code=400)
|
||||
assert isinstance(ue, ViralVideoError)
|
||||
assert ue.provider == "doubao"
|
||||
assert ue.upstream_code == "50411"
|
||||
assert ue.http_status == 502
|
||||
assert ue.status_code == 400
|
||||
|
||||
# 合规错误带 issues
|
||||
ce = ViralVideoComplianceError(user_message="审核未通过", issues=[{"text": "违规", "severity": "error"}])
|
||||
assert ce.code == "compliance_rejected"
|
||||
assert ce.http_status == 400
|
||||
assert len(ce.issues) == 1
|
||||
|
||||
# 资源/积分/超时/LLM/配置
|
||||
assert ViralVideoResourceError("video_file", "x").http_status == 502
|
||||
assert ViralVideoCreditsError().http_status == 402
|
||||
te = ViralVideoTimeoutError("rendering", timeout_s=180)
|
||||
assert te.stage == "rendering"
|
||||
assert te.http_status == 504
|
||||
le = ViralVideoLLMError(stage="script", model="seed-2.1-lite", detail="max retries")
|
||||
assert le.http_status == 502
|
||||
assert le.model == "seed-2.1-lite"
|
||||
assert ViralVideoConfigError().http_status == 500
|
||||
|
||||
|
||||
def test_stages_constants():
|
||||
from packages.domain.viral_video import (
|
||||
PROGRESS_COPY_DONE,
|
||||
PROGRESS_COPY_START,
|
||||
PROGRESS_COPY_STREAMING,
|
||||
PROGRESS_DONE,
|
||||
PROGRESS_IMAGE_DONE,
|
||||
PROGRESS_REVIEW,
|
||||
PROGRESS_TRUST,
|
||||
PROGRESS_TTS,
|
||||
PROGRESS_UPLOAD_DONE,
|
||||
PROGRESS_VIDEO_DONE,
|
||||
PROGRESS_VIDEO_START,
|
||||
STAGE_COPY_GENERATION,
|
||||
STAGE_IMAGE_ANALYSIS,
|
||||
STAGE_PHASE_MESSAGES,
|
||||
STAGE_REVIEW,
|
||||
STAGE_TRUST_CHAIN,
|
||||
STAGE_TTS,
|
||||
STAGE_UPLOADING,
|
||||
STAGE_VIDEO_GENERATION,
|
||||
)
|
||||
|
||||
assert STAGE_IMAGE_ANALYSIS == "image_analysis"
|
||||
assert STAGE_COPY_GENERATION == "script_generation"
|
||||
assert STAGE_REVIEW == "review"
|
||||
assert STAGE_TRUST_CHAIN == "trust_chain"
|
||||
assert STAGE_VIDEO_GENERATION == "rendering"
|
||||
assert STAGE_UPLOADING == "uploading"
|
||||
# 进度单调递增
|
||||
assert PROGRESS_IMAGE_DONE < PROGRESS_COPY_START < PROGRESS_COPY_STREAMING < PROGRESS_COPY_DONE
|
||||
assert PROGRESS_COPY_DONE < PROGRESS_REVIEW < PROGRESS_TRUST < PROGRESS_TTS < PROGRESS_VIDEO_START
|
||||
assert PROGRESS_VIDEO_START < PROGRESS_VIDEO_DONE < PROGRESS_UPLOAD_DONE <= PROGRESS_DONE
|
||||
# 阶段中文提示
|
||||
assert "分析" in STAGE_PHASE_MESSAGES[STAGE_IMAGE_ANALYSIS]
|
||||
assert "审核" in STAGE_PHASE_MESSAGES[STAGE_REVIEW]
|
||||
|
||||
|
||||
def test_param_guides_invalid_type_returns_none():
|
||||
from packages.application.viral_video.param_guides import get_param_guide
|
||||
|
||||
assert get_param_guide("bogus", "x") is None
|
||||
assert get_param_guide("persona", "") is None
|
||||
assert get_param_guide("persona", " ") is None
|
||||
|
||||
|
||||
def test_fallback_script_honest_short(monkeypatch):
|
||||
"""兜底文案必须短、诚实、基于真实 VLM 输入。"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# mock celery/db 依赖
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
# 有 name+brand
|
||||
j = ViralVideoJob(user_id="u", duration=15, video_ratio="9:16")
|
||||
j.image_analysis = {"images": [{"name": "口红", "brand": "XX牌", "type": "product"}]}
|
||||
fb = wv._fallback_script(j)
|
||||
assert "XX牌" in fb["voiceover_script"]
|
||||
assert "口红" in fb["voiceover_script"]
|
||||
assert len(fb["voiceover_script"]) < 20 # 非常短,不再是营销套话
|
||||
assert fb["shots"] and len(fb["shots"]) == 1 # 只有一个镜头
|
||||
assert fb["shots"][0]["time_range"] == "0-15秒"
|
||||
|
||||
# 只有 name
|
||||
j2 = ViralVideoJob(user_id="u", duration=10)
|
||||
j2.image_analysis = {"images": [{"name": "面馆", "type": "store"}]}
|
||||
fb2 = wv._fallback_script(j2)
|
||||
assert "来看看面馆" in fb2["voiceover_script"]
|
||||
assert "都很赞" not in fb2["voiceover_script"] # 不再有营销套话
|
||||
|
||||
# 都没有
|
||||
j3 = ViralVideoJob(user_id="u", duration=15)
|
||||
j3.image_analysis = {"images": []}
|
||||
fb3 = wv._fallback_script(j3)
|
||||
assert "好东西" in fb3["voiceover_script"]
|
||||
|
||||
# user_copy_text 优先
|
||||
j4 = ViralVideoJob(user_id="u", duration=15, user_copy_text="自定义文案")
|
||||
j4.image_analysis = {"images": [{"name": "X", "brand": "Y"}]}
|
||||
fb4 = wv._fallback_script(j4)
|
||||
assert fb4["voiceover_script"] == "自定义文案"
|
||||
|
||||
|
||||
def test_video_analysis_returns_none_on_import_error(monkeypatch):
|
||||
"""ImportError 时返回 None,下游 isinstance(dict) 会跳过。"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
|
||||
# 让 import viral_video.video_analyzer 抛 ImportError
|
||||
import builtins
|
||||
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *a, **kw):
|
||||
if "video_analyzer" in name:
|
||||
raise ImportError("no module")
|
||||
return real_import(name, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
j = ViralVideoJob(user_id="u", reference_video_url="https://x/y.mp4")
|
||||
res = wv._step_video_analysis(j)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_seedance_prompt_uses_helpers(monkeypatch):
|
||||
"""_assemble_seedance_prompt 拆分后输出包含必要段落,且长度合理。"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
j = ViralVideoJob(user_id="u", duration=15, video_ratio="9:16", images=["https://x/1.jpg"])
|
||||
copy_result = {
|
||||
"overview": {"theme": "口红试色", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "暖光,室内美妆场景",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-15秒",
|
||||
"shot_type_angle_movement": "中景",
|
||||
"voiceover": "这支口红颜色超正",
|
||||
"reference_image_index": 0,
|
||||
"transition": "结束",
|
||||
}
|
||||
],
|
||||
"hard_constraints": ["无水印"],
|
||||
"negative_prompts": ["字幕"],
|
||||
}
|
||||
with patch("packages.domain.points_rules.get_viral_video_model_config", return_value={"provider": "doubao"}):
|
||||
with patch.object(wv, "_shot_visual", return_value="女生涂口红特写"):
|
||||
with patch.object(wv, "_parse_shot_seconds", return_value=(0, 15)):
|
||||
p = wv._assemble_seedance_prompt(copy_result, j)
|
||||
assert "【视频总览】" in p
|
||||
assert "【分镜脚本】" in p
|
||||
assert "【硬性约束】" in p
|
||||
assert "【负面提示词】" in p
|
||||
assert "@图片1" in p
|
||||
assert "口红试色" in p
|
||||
@@ -1,11 +1,12 @@
|
||||
"""viral_video.py HTTP 端点单元测试(celery send_task 分支覆盖)。
|
||||
|
||||
直接调用路由函数(不启动 TestClient),通过 patch 注入 repo/session/user,
|
||||
覆盖 4 个 celery_app.send_task(...) 调用点:
|
||||
覆盖三步流程 celery_app.send_task(...) 调用点:
|
||||
|
||||
- create_viral_video (generate) -> worker.run_viral_video_pipeline
|
||||
- retry_viral_video_job (retry) -> worker.run_viral_video_pipeline
|
||||
- confirm_intent -> worker.resume_viral_video_pipeline
|
||||
- analyze_images (analyze-images) -> worker.run_viral_video_analyze
|
||||
- generate_copy (generate-copy) -> worker.run_viral_video_generate_copy
|
||||
- confirm_copy (confirm-copy) -> worker.run_viral_video_render
|
||||
- retry_viral_video_job (retry) -> worker.run_viral_video_analyze
|
||||
- analyze_style -> worker.run_video_style_analysis
|
||||
"""
|
||||
|
||||
@@ -61,7 +62,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"completed_at": None,
|
||||
"stage": "",
|
||||
"progress": 0.0,
|
||||
"intent_result": None,
|
||||
"image_analysis": None,
|
||||
"storyboard": None,
|
||||
"generated_copy_text": "",
|
||||
@@ -86,42 +86,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
return job
|
||||
|
||||
|
||||
# ── generate ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateViralVideo:
|
||||
def _req(self, **kw):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
d = {"images": ["https://x.com/a.jpg"], "industry": "电商", "target_customer": "年轻人"}
|
||||
d.update(kw)
|
||||
return CreateViralVideoRequest(**d)
|
||||
|
||||
def test_generate_dispatches_celery_task(self):
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
|
||||
req = self._req()
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
saved_job = _make_job(job_id="job-new", user_id="u1", status="pending")
|
||||
repo = MagicMock()
|
||||
|
||||
def fake_save(job):
|
||||
job.id = saved_job.id
|
||||
|
||||
repo.save.side_effect = fake_save
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
resp = vv_mod.create_viral_video(req, authenticated_user=user, session=session)
|
||||
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=[saved_job.id])
|
||||
assert resp.id == saved_job.id
|
||||
|
||||
|
||||
# ── retry ───────────────────────────────────────────────────────────────
|
||||
# ── retry ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRetryViralVideo:
|
||||
@@ -173,7 +138,7 @@ class TestRetryViralVideo:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch("app.api.routes.viral_video.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -219,9 +184,9 @@ class TestRetryViralVideo:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)),
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits_with_breakdown", return_value=(15.0, {})),
|
||||
patch("app.api.routes.viral_video.PointsService", return_value=fake_svc),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(1920, 1080)),
|
||||
patch("app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown", return_value=(15.0, {})),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -268,10 +233,10 @@ class TestRetryViralVideo:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)),
|
||||
patch("app.api.routes.viral_video.PointsService", return_value=fake_svc),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(1920, 1080)),
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
|
||||
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
|
||||
),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
@@ -320,10 +285,10 @@ class TestRetryViralVideo:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(270, 480)),
|
||||
patch("app.api.routes.viral_video.PointsService", return_value=fake_svc),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(270, 480)),
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
|
||||
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown", return_value=(new_est, {})
|
||||
),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
@@ -339,37 +304,6 @@ class TestRetryViralVideo:
|
||||
assert call_args.kwargs["amount"] == 7.0
|
||||
|
||||
|
||||
# ── confirm-intent ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConfirmIntent:
|
||||
def test_confirm_intent_dispatches_resume_task(self):
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmIntentRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-cfm", user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmIntentRequest(confirmed_copy="确认后的文案")
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
resp = vv_mod.confirm_intent("job-cfm", req, authenticated_user=user, session=session)
|
||||
|
||||
assert job.user_copy_text == "确认后的文案"
|
||||
job.resume_from_confirm.assert_called_once()
|
||||
mock_send.assert_called_once_with("worker.resume_viral_video_pipeline", args=["job-cfm"])
|
||||
assert resp.id == "job-cfm"
|
||||
|
||||
|
||||
# ── analyze-style ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAnalyzeStyle:
|
||||
def test_analyze_style_dispatches_analysis_task(self):
|
||||
@@ -474,7 +408,7 @@ class TestGenerateCopy:
|
||||
assert resp.id == "job-gc"
|
||||
|
||||
def test_generate_copy_rejects_wrong_status(self):
|
||||
"""wait_user_confirm 等中间状态不允许调用 generate-copy(状态保护)。"""
|
||||
"""cancelled 等非预期状态不允许调用 generate-copy(状态保护)。"""
|
||||
import pytest
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import GenerateCopyRequest
|
||||
@@ -484,8 +418,8 @@ class TestGenerateCopy:
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
# wait_user_confirm 属于前端在编辑/确认文案的中间状态,应拒绝重新触发生成
|
||||
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.WAIT_USER_CONFIRM)
|
||||
# cancelled 状态下不应触发生成文案
|
||||
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.CANCELLED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
@@ -663,9 +597,9 @@ class TestConfirmCopyPointsDeduction:
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod, "_settings", None, create=True), # ensure not cached
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=5.2),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
|
||||
patch("app.api.routes.viral_video.calculate_viral_video_credits", return_value=5.2),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("app.api.routes.viral_video.PointsService", return_value=mock_svc),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -706,9 +640,9 @@ class TestConfirmCopyPointsDeduction:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=10.0),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
|
||||
patch("app.api.routes.viral_video.calculate_viral_video_credits", return_value=10.0),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("app.api.routes.viral_video.PointsService", return_value=mock_svc),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -746,7 +680,7 @@ class TestConfirmCopyPointsDeduction:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch("app.api.routes.viral_video.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -783,7 +717,7 @@ class TestConfirmCopyPointsDeduction:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch("app.api.routes.viral_video.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -808,7 +742,7 @@ class TestConfirmCopyPointsDeduction:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch("app.api.routes.viral_video.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = False
|
||||
@@ -863,9 +797,9 @@ class TestEstimateCredits:
|
||||
}
|
||||
|
||||
with (
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)) as mock_res,
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(1920, 1080)) as mock_res,
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(8.88, fake_bd),
|
||||
) as mock_calc,
|
||||
):
|
||||
@@ -902,9 +836,9 @@ class TestEstimateCredits:
|
||||
}
|
||||
|
||||
with (
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(3.5, fake_bd),
|
||||
) as mock_calc,
|
||||
):
|
||||
@@ -936,9 +870,9 @@ class TestEstimateCredits:
|
||||
}
|
||||
|
||||
with (
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(480, 480)) as mock_res,
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(480, 480)) as mock_res,
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
"app.api.routes.viral_video.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(1.0, fake_bd),
|
||||
) as mock_calc,
|
||||
):
|
||||
|
||||
@@ -51,7 +51,6 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"completed_at": None,
|
||||
"stage": "",
|
||||
"progress": 0.0,
|
||||
"intent_result": None,
|
||||
"image_analysis": None,
|
||||
"storyboard": None,
|
||||
"copy_result": {"voiceover_script": "测试口播", "shots": [{"clip_id": 1}]},
|
||||
@@ -228,9 +227,9 @@ class TestBug1ConfirmCopyParams:
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=15.0),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
|
||||
patch("app.api.routes.viral_video.calculate_viral_video_credits", return_value=15.0),
|
||||
patch("app.api.routes.viral_video.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("app.api.routes.viral_video.PointsService", return_value=mock_svc),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
@@ -966,7 +965,7 @@ class TestIssue2249UserParamsFix:
|
||||
|
||||
def test_bug2_persona_style_guide_covers_boss_ip(self):
|
||||
"""Bug2: _PERSONA_STYLE_GUIDE 必须覆盖'老板型IP'"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _PERSONA_STYLE_GUIDE
|
||||
from apps.worker.worker_app.tasks.viral_video import _DEFAULT_PERSONA_HINTS as _PERSONA_STYLE_GUIDE
|
||||
|
||||
assert "老板型IP" in _PERSONA_STYLE_GUIDE
|
||||
assert "老板" in _PERSONA_STYLE_GUIDE["老板型IP"] or "第一人称" in _PERSONA_STYLE_GUIDE["老板型IP"]
|
||||
@@ -1050,13 +1049,6 @@ class TestIssue2249UserParamsFix:
|
||||
assert hasattr(job, "language")
|
||||
assert job.language == "zh-CN"
|
||||
|
||||
def test_bug5_language_in_create_request(self):
|
||||
"""Bug5: CreateViralVideoRequest 接受 language"""
|
||||
from apps.api.app.schemas.viral_video import CreateViralVideoRequest
|
||||
|
||||
req = CreateViralVideoRequest(images=["http://a.jpg"], language="en-US")
|
||||
assert req.language == "en-US"
|
||||
|
||||
def test_bug5_language_in_generate_copy_request(self):
|
||||
"""Bug5: GenerateCopyRequest 接受 language"""
|
||||
from apps.api.app.schemas.viral_video import GenerateCopyRequest
|
||||
|
||||
@@ -209,7 +209,6 @@ class TestReviewWiring:
|
||||
"voiceover_script": "这款口红显白又持久",
|
||||
"shots": [{"scene_and_dialogue": "展示口红"}],
|
||||
}
|
||||
job.intent_result = {"key_messages": ["显白", "持久"], "intent": "推广"}
|
||||
|
||||
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
with patch.object(Reviewer, "review", return_value=pass_result):
|
||||
@@ -228,7 +227,6 @@ class TestReviewWiring:
|
||||
"voiceover_script": "这款口红100%有效",
|
||||
"shots": [{"scene_and_dialogue": "展示"}],
|
||||
}
|
||||
job.intent_result = {"key_messages": ["显白"], "intent": "推广"}
|
||||
|
||||
fail_result = ReviewResult(
|
||||
passed=False,
|
||||
|
||||
@@ -106,31 +106,11 @@ class TestWorkerEmitProgress:
|
||||
assert parsed["type"] == "viral_video:failed"
|
||||
assert parsed["data"]["error"] == "oom"
|
||||
|
||||
def test_emit_progress_wait_user_event(self):
|
||||
fake_r = MagicMock()
|
||||
with patch("redis.from_url", return_value=fake_r):
|
||||
worker_vv._emit_progress(
|
||||
"job-4",
|
||||
"intent_parsing",
|
||||
35,
|
||||
message="waiting for you",
|
||||
event_type="viral_video:wait_user",
|
||||
)
|
||||
_, payload = fake_r.publish.call_args.args
|
||||
parsed = json.loads(payload)
|
||||
assert parsed["type"] == "viral_video:wait_user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers on the route module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWSHelpers:
|
||||
def test_estimate_progress_maps_status(self):
|
||||
assert vv_module._estimate_progress(_make_job(status="pending")) == 0.0
|
||||
assert vv_module._estimate_progress(_make_job(status="running")) == 5.0
|
||||
assert vv_module._estimate_progress(_make_job(status="wait_user_confirm")) == 35.0
|
||||
assert vv_module._estimate_progress(_make_job(status="completed")) == 100.0
|
||||
assert vv_module._estimate_progress(_make_job(status="failed")) == 0.0
|
||||
|
||||
@@ -140,8 +120,6 @@ class TestWSHelpers:
|
||||
assert isinstance(msg, str) and msg
|
||||
job_failed = _make_job(status="failed", error_msg="boom")
|
||||
assert "boom" in vv_module._initial_message(job_failed)
|
||||
job_wait = _make_job(status="wait_user_confirm")
|
||||
assert "等待" in vv_module._initial_message(job_wait)
|
||||
|
||||
def test_stage_from_status_falls_back(self):
|
||||
assert isinstance(vv_module._stage_from_status(_make_job(status="pending")), str)
|
||||
@@ -325,16 +303,16 @@ class TestWSInitialSnapshot:
|
||||
# Session was used for both ownership check and initial snapshot.
|
||||
assert sess.close.call_count >= 2
|
||||
|
||||
def test_running_job_with_enum_status(self):
|
||||
def test_image_analyzed_job_sends_snapshot(self):
|
||||
job = _make_job(
|
||||
status=SimpleNamespace(value="wait_user_confirm"),
|
||||
status=SimpleNamespace(value="image_analyzed"),
|
||||
user_id="user-a",
|
||||
is_terminal=False,
|
||||
)
|
||||
received, _, _ = _run_ws_handshake(job=job)
|
||||
assert received[0]["data"]["status"] == "wait_user_confirm"
|
||||
assert received[0]["progress"] == 35.0
|
||||
assert "等待" in received[0]["message"]
|
||||
assert received[0]["data"]["status"] == "image_analyzed"
|
||||
assert received[0]["progress"] == 15.0
|
||||
assert "图片分析" in received[0]["message"]
|
||||
|
||||
def test_already_completed_job_sends_completion_event_and_closes(self):
|
||||
job = _make_job(
|
||||
|
||||
Reference in New Issue
Block a user