Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58d6852a71 | |||
| 66033c520f | |||
| 07069144c7 | |||
| 5bbc34d4c3 | |||
| 83be8a7d35 | |||
| 6ee9ca6a33 | |||
| 9af967c4f0 | |||
| 75ca55b5e5 | |||
| a0d20bd55f | |||
| d2bd6cbc01 | |||
| 383367718c | |||
| 0ad5647d36 | |||
| e922b0b472 | |||
| 61142c0936 | |||
| 522668006f | |||
| f417611829 | |||
| e1ecea7a6e | |||
| 0200d499ef | |||
| eda3a3a540 | |||
| 649420bd35 | |||
| 069544da38 | |||
| 10007507a5 | |||
| 19024da223 | |||
| 033c4a2eab | |||
| 4ed906e5fa |
@@ -0,0 +1,51 @@
|
||||
"""viral video add copy_result + voice/video columns
|
||||
|
||||
Revision ID: 088_viral_video_copy_result
|
||||
Revises: 087_viral_video_image_analysis
|
||||
Create Date: 2026-10-01
|
||||
|
||||
v1.6 爆款视频字段补齐:
|
||||
- copy_result JSON: 编导分镜脚本完整结构(overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
|
||||
- voice_id/voice_source: TTS 音色参数
|
||||
- video_ratio/video_model: Seedance 视频比例/模型
|
||||
注意:线上启动也有幂等 ADD COLUMN 补列逻辑 (_ensure_viral_video_columns),本 migration 提供标准 Alembic 路径,
|
||||
两套机制互不冲突(IF NOT EXISTS 等价行为)。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "088_viral_video_copy_result"
|
||||
down_revision = "087_viral_video_image_analysis"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 幂等添加列(通过单独执行 + 异常忽略兼容已由 backfill 补上的环境)
|
||||
cols = [
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
conn = op.get_bind()
|
||||
for name, ddl in cols:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
|
||||
except Exception:
|
||||
# 不支持 IF NOT EXISTS 的库(如老版本 SQLite)直接尝试 ADD COLUMN,失败则忽略
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in ("copy_result", "video_model", "video_ratio", "voice_source", "voice_id"):
|
||||
try:
|
||||
op.drop_column("viral_video_jobs", name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,62 @@
|
||||
"""viral video add storyboard + generated_copy_text (complement 088)
|
||||
|
||||
Revision ID: 089_viral_video_cols
|
||||
Revises: 088_viral_video_copy_result
|
||||
Create Date: 2026-10-01
|
||||
|
||||
#2129 兜底迁移:补齐 _VIRAL_VIDEO_BACKFILL_COLS 中所有列,覆盖
|
||||
# watchtower 自动部署未跑历史 migration、且 AUTO_CREATE_SCHEMA=false 时
|
||||
# _ensure_viral_video_columns 未执行的场景。
|
||||
# 幂等 ADD COLUMN IF NOT EXISTS,已存在则跳过。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "089_viral_video_cols"
|
||||
down_revision = "088_viral_video_copy_result"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 扩展 alembic_version.version_num 字段长度(原来 VARCHAR(32) 装不下长 revision id)
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
conn.execute(sa.text("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(256)"))
|
||||
except Exception:
|
||||
pass
|
||||
cols = [
|
||||
("storyboard", "JSON"),
|
||||
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
for name, ddl in cols:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in (
|
||||
"copy_result",
|
||||
"video_model",
|
||||
"video_ratio",
|
||||
"voice_source",
|
||||
"voice_id",
|
||||
"generated_copy_text",
|
||||
"storyboard",
|
||||
):
|
||||
try:
|
||||
op.drop_column("viral_video_jobs", name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -9,6 +9,11 @@ from fastapi.responses import JSONResponse
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
|
||||
def _pg_url(url: str) -> str:
|
||||
"""Convert SQLAlchemy URL (postgresql+psycopg://...) to libpq connection string."""
|
||||
return url.replace("postgresql+psycopg://", "postgresql://", 1).replace("postgresql+psycopg2://", "postgresql://", 1)
|
||||
|
||||
@router.get("/health", status_code=status.HTTP_200_OK)
|
||||
async def health_check():
|
||||
return {
|
||||
@@ -49,7 +54,7 @@ async def _check_database() -> dict:
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
@@ -124,7 +129,7 @@ async def _check_migrations() -> dict:
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
@@ -137,3 +142,5 @@ async def _check_migrations() -> dict:
|
||||
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""爆款视频 API 路由。
|
||||
|
||||
v1.5 三步分步流水线端点(前端新交互):
|
||||
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:用户确认/编辑文案后跑渲染,直到完成
|
||||
@@ -10,7 +10,7 @@ v1.5 三步分步流水线端点(前端新交互):
|
||||
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
|
||||
|
||||
通用:
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/storyboard/generated_copy_text)
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
|
||||
GET /api/v1/viral-video/history 历史记录
|
||||
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
|
||||
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
|
||||
@@ -56,27 +56,55 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _build_copy_result(job) -> dict | None:
|
||||
"""将后端原始字段拼装为前端期望的 CopyResult 结构(final_copy/suggested_copy/title/scenes)。"""
|
||||
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
|
||||
|
||||
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
|
||||
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
|
||||
"""
|
||||
cr = getattr(job, "copy_result", None)
|
||||
if isinstance(cr, dict) and cr:
|
||||
out = dict(cr)
|
||||
# 向后兼容字段
|
||||
voiceover = out.get("voiceover_script", "") or ""
|
||||
out.setdefault("final_copy", voiceover)
|
||||
out.setdefault("suggested_copy", voiceover)
|
||||
out.setdefault("title", "")
|
||||
return out
|
||||
# 兼容 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
|
||||
scenes = []
|
||||
title = ""
|
||||
if isinstance(intent, dict):
|
||||
title = intent.get("suggested_title") or intent.get("intent", "") or ""
|
||||
shots = []
|
||||
for seg in sb:
|
||||
if isinstance(seg, dict):
|
||||
scenes.append(
|
||||
shots.append(
|
||||
{
|
||||
"shot": seg.get("description", ""),
|
||||
"narration": seg.get("text", ""),
|
||||
"duration": seg.get("duration"),
|
||||
"time_range": "",
|
||||
"shot_type_angle_movement": seg.get("ken_burns", ""),
|
||||
"scene_and_dialogue": (seg.get("text") or "")
|
||||
+ (" " + seg.get("description", "") if seg.get("description") else ""),
|
||||
"action_details": "",
|
||||
"audio_bgm": "",
|
||||
"transition": seg.get("transition", "硬切"),
|
||||
"reference_image_index": None,
|
||||
}
|
||||
)
|
||||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||||
return {
|
||||
"title": (intent.get("suggested_title") if isinstance(intent, dict) else None) or "",
|
||||
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
|
||||
"scene_and_lighting": "",
|
||||
"shots": shots,
|
||||
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
|
||||
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
|
||||
"voiceover_script": copy_text,
|
||||
"final_copy": copy_text,
|
||||
"suggested_copy": copy_text,
|
||||
"scenes": scenes,
|
||||
"title": title,
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +119,7 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration,
|
||||
duration=job.duration or 15,
|
||||
user_copy_text=job.user_copy_text,
|
||||
fusion_level=job.fusion_level,
|
||||
reference_audio_path=job.reference_audio_path,
|
||||
@@ -104,6 +132,10 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
storyboard=getattr(job, "storyboard", None),
|
||||
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
|
||||
copy_result=_build_copy_result(job),
|
||||
voice_id=getattr(job, "voice_id", "") or "",
|
||||
voice_source=getattr(job, "voice_source", "") or "",
|
||||
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,
|
||||
credits_cost=job.credits_cost,
|
||||
@@ -148,13 +180,18 @@ def create_viral_video(
|
||||
viral_structure=request.viral_structure,
|
||||
marketing_purpose=request.marketing_purpose,
|
||||
bgm_preference=request.bgm_preference,
|
||||
duration=request.duration,
|
||||
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 "",
|
||||
copy_result=None,
|
||||
)
|
||||
|
||||
# 持久化
|
||||
@@ -192,6 +229,11 @@ def analyze_images(
|
||||
reference_video_url=request.reference_video_url or "",
|
||||
style_template_id=request.style_template_id or "",
|
||||
style_strength=request.style_strength or "medium",
|
||||
voice_id=request.voice_id or "",
|
||||
voice_source=request.voice_source or "",
|
||||
video_ratio=request.video_ratio or "9:16",
|
||||
video_model=request.video_model or "",
|
||||
duration=request.duration or 15,
|
||||
)
|
||||
repo.save(job)
|
||||
|
||||
@@ -213,10 +255,11 @@ def generate_copy(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""v1.5 阶段2:用户填完营销参数后,跑 意图解析 → 文案融合 → 分镜 → 合规审核。
|
||||
"""v1.6 阶段2:用户填完营销参数后,跑 意图解析 → 编导分镜脚本生成 → 合规审核。
|
||||
|
||||
跑完后状态=copy_generated,响应包含 generated_copy_text + storyboard,
|
||||
前端展示文案供用户编辑;确认/编辑后调 /{id}/confirm-copy 进入阶段3。
|
||||
跑完后状态=copy_generated,响应 copy_result(含 overview/scene_and_lighting/shots/
|
||||
hard_constraints/negative_prompts/voiceover_script),前端展示脚本与口播供用户编辑;
|
||||
确认/编辑后调 /{id}/confirm-copy 进入阶段3(TTS + 单次 Seedance 出片)。
|
||||
"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
@@ -240,13 +283,19 @@ def generate_copy(
|
||||
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
|
||||
job.bgm_preference = request.bgm_preference or job.bgm_preference
|
||||
if request.duration:
|
||||
job.duration = request.duration
|
||||
job.duration = max(5, min(30, int(request.duration)))
|
||||
job.user_copy_text = request.user_copy_text if request.user_copy_text else job.user_copy_text
|
||||
job.fusion_level = request.fusion_level or job.fusion_level
|
||||
job.reference_audio_path = request.reference_audio_path or job.reference_audio_path
|
||||
job.reference_video_url = request.reference_video_url or job.reference_video_url
|
||||
job.style_strength = request.style_strength or job.style_strength
|
||||
job.style_template_id = request.style_template_id or job.style_template_id
|
||||
if request.style_guide is not None:
|
||||
job.style_guide = request.style_guide
|
||||
job.voice_id = request.voice_id or job.voice_id
|
||||
job.voice_source = request.voice_source or job.voice_source
|
||||
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
|
||||
job.video_model = request.video_model or job.video_model or ""
|
||||
|
||||
job.resume_from_image_analyzed()
|
||||
repo.update(job)
|
||||
@@ -269,7 +318,7 @@ def confirm_copy(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""v1.5 阶段3:用户确认/编辑文案后开始 TTS+BGM(skip)+渲染+上传。"""
|
||||
"""v1.6 阶段3:用户确认/编辑口播后开始 TTS + 单次 Seedance 生成 + 上传。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""爆款视频 API schemas。"""
|
||||
"""爆款视频 API schemas (v1.6 单次 Seedance 出片版)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,41 +14,84 @@ VALID_STAGES = (
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"script_generation",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
)
|
||||
VALID_VIDEO_RATIOS = ("9:16", "16:9", "1:1", "4:3", "3:4", "21:9")
|
||||
VALID_DURATIONS = (5, 10, 15, 20, 25, 30)
|
||||
|
||||
|
||||
# -- 编导脚本结构(v1.6) --
|
||||
|
||||
|
||||
class ShotScript(BaseModel):
|
||||
"""逐镜头分镜。"""
|
||||
|
||||
time_range: str = Field(default="", description="时间区间,如 0-3秒")
|
||||
shot_type_angle_movement: str = Field(default="", description="景别/角度/运镜,如『近景俯拍45度,缓慢推镜』")
|
||||
scene_and_dialogue: str = Field(default="", description="场景描述+口播台词")
|
||||
action_details: str = Field(default="", description="人物动作、表情、物品操作细节")
|
||||
audio_bgm: str = Field(default="", description="环境音+BGM提示")
|
||||
transition: str = Field(default="硬切", description="转场方式:硬切/淡入淡出/叠化")
|
||||
reference_image_index: int | None = Field(
|
||||
default=None, description="参考图片索引(0-based,对应上传的第几张产品图)"
|
||||
)
|
||||
|
||||
|
||||
class CopyResultOverview(BaseModel):
|
||||
theme: str = ""
|
||||
total_duration: int = 15
|
||||
aspect_ratio: str = "9:16"
|
||||
|
||||
|
||||
class CopyResult(BaseModel):
|
||||
"""v1.6 编导分镜脚本结构(给前端 + Seedance 用)。"""
|
||||
|
||||
overview: CopyResultOverview = Field(default_factory=CopyResultOverview)
|
||||
scene_and_lighting: str = ""
|
||||
shots: list[ShotScript] = Field(default_factory=list)
|
||||
hard_constraints: list[str] = Field(default_factory=list)
|
||||
negative_prompts: list[str] = Field(default_factory=list)
|
||||
voiceover_script: str = Field(
|
||||
default="", description="纯口播对白,从各镜 scene_and_dialogue 的对白部分拼接,供 TTS 使用"
|
||||
)
|
||||
# 向后兼容:final_copy = voiceover_script
|
||||
final_copy: str = ""
|
||||
suggested_copy: str = ""
|
||||
title: str = ""
|
||||
|
||||
|
||||
# -- Request Schemas --
|
||||
|
||||
|
||||
class CreateViralVideoRequest(BaseModel):
|
||||
"""创建爆款视频任务请求(旧接口:一键跑完前半段到 WAIT_USER_CONFIRM,保留兼容)。"""
|
||||
"""旧接口:一键创建(保留兼容)。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表")
|
||||
industry: str = Field(default="", description="行业")
|
||||
target_customer: str = Field(default="", description="目标客户描述")
|
||||
persona_id: str = Field(default="", description="人设 ID")
|
||||
viral_structure: str = Field(default="", description="爆款结构类型")
|
||||
marketing_purpose: str = Field(default="", description="营销目的")
|
||||
bgm_preference: str = Field(default="", description="BGM 偏好")
|
||||
duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)")
|
||||
user_copy_text: str = Field(default="", description="用户原始文案(我说你写)")
|
||||
fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary")
|
||||
reference_audio_path: str = Field(default="", description="参考音频路径")
|
||||
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
|
||||
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID")
|
||||
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 = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
def _validate_fusion_level(cls, v: str) -> str:
|
||||
def _v_fl(cls, v: str) -> str:
|
||||
if v == "full_ai":
|
||||
return "ai_full"
|
||||
if v not in VALID_FUSION_LEVELS:
|
||||
@@ -57,37 +100,47 @@ class CreateViralVideoRequest(BaseModel):
|
||||
|
||||
@field_validator("style_strength")
|
||||
@classmethod
|
||||
def _validate_style_strength(cls, v: str) -> str:
|
||||
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:创建任务并仅做图片/视频分析。images 必填,其他参数可选(阶段2再传)。"""
|
||||
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=20)
|
||||
reference_video_url: str = Field(default="", description="参考爆款视频 URL(可选,有则同步做风格分析)")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID(可选)")
|
||||
style_strength: str = Field(default="medium")
|
||||
images: list[str] = Field(..., min_length=1, max_length=30)
|
||||
reference_video_url: str = ""
|
||||
style_template_id: str = ""
|
||||
style_strength: str = "medium"
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
|
||||
|
||||
class GenerateCopyRequest(BaseModel):
|
||||
"""v1.5 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 COPY_GENERATED。"""
|
||||
"""v1.5+ 阶段2:填完营销参数,生成编导脚本。"""
|
||||
|
||||
industry: str = Field(default="")
|
||||
target_customer: str = Field(default="")
|
||||
persona_id: str = Field(default="")
|
||||
viral_structure: str = Field(default="")
|
||||
marketing_purpose: str = Field(default="")
|
||||
bgm_preference: str = Field(default="")
|
||||
duration: int = Field(default=30, ge=5, le=180)
|
||||
user_copy_text: str = Field(default="")
|
||||
fusion_level: str = Field(default="ai_polish")
|
||||
reference_audio_path: str = Field(default="")
|
||||
reference_video_url: str = Field(default="")
|
||||
style_strength: str = Field(default="medium")
|
||||
style_template_id: str = Field(default="")
|
||||
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)
|
||||
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 = ""
|
||||
style_guide: dict | None = None
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
@@ -107,30 +160,28 @@ class GenerateCopyRequest(BaseModel):
|
||||
|
||||
|
||||
class ConfirmCopyRequest(BaseModel):
|
||||
"""v1.5 阶段3:用户确认/编辑文案后开始渲染。"""
|
||||
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
|
||||
|
||||
edited_copy: str = Field(default="", description="用户编辑后的最终文案;为空则使用 AI 生成文案")
|
||||
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
|
||||
|
||||
|
||||
class ConfirmIntentRequest(BaseModel):
|
||||
"""确认意图请求(旧 confirm-intent,兼容)。"""
|
||||
"""旧 confirm-intent(兼容)。"""
|
||||
|
||||
confirmed_copy: str = Field(default="", description="用户确认/修改后的文案")
|
||||
adjustments: str = Field(default="", description="用户对 AI 文案的调整意见")
|
||||
confirmed_copy: str = ""
|
||||
adjustments: str = ""
|
||||
|
||||
|
||||
class AnalyzeStyleRequest(BaseModel):
|
||||
"""触发参考视频风格分析请求。"""
|
||||
|
||||
reference_video_url: str = Field(..., description="参考视频 URL")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)")
|
||||
style_template_id: str = ""
|
||||
|
||||
|
||||
# -- Response Schemas --
|
||||
|
||||
|
||||
class ViralVideoJobResponse(BaseModel):
|
||||
"""爆款视频任务响应。v1.5 新增 image_analysis/storyboard/generated_copy_text 字段。"""
|
||||
"""爆款视频任务响应(v1.6 包含 copy_result 编导脚本结构)。"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
@@ -141,7 +192,7 @@ class ViralVideoJobResponse(BaseModel):
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 30
|
||||
duration: int = 15
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
@@ -150,13 +201,17 @@ class ViralVideoJobResponse(BaseModel):
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
status: str
|
||||
# v1.4 VLM 结果
|
||||
image_analysis: dict | None = None
|
||||
# v1.5 三步分步产物(原始字段,保留给后端/老调用方)
|
||||
# v1.6 编导脚本(推荐前端使用)
|
||||
copy_result: dict | None = None
|
||||
# v1.5 兼容字段
|
||||
storyboard: list | None = None
|
||||
generated_copy_text: str = ""
|
||||
# v1.5 前端 CopyResult 结构(final_copy/suggested_copy/title/scenes)
|
||||
copy_result: dict | None = None
|
||||
# 音色/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
credits_cost: int = 0
|
||||
|
||||
@@ -12,6 +12,14 @@ export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
|
||||
{ value: "strict", label: "像素级复刻" },
|
||||
]
|
||||
|
||||
/** v1.6 前端时长下拉选项(5/10/15/20/25/30秒) */
|
||||
export const VALID_DURATIONS = [5, 10, 15, 20, 25, 30] as const
|
||||
export type VideoDuration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
/** v1.6 支持的画幅比例 */
|
||||
export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
|
||||
export type VideoRatio = (typeof VALID_RATIOS)[number]
|
||||
|
||||
export type ViralVideoStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
@@ -23,39 +31,25 @@ export type ViralVideoStatus =
|
||||
| "cancelled"
|
||||
|
||||
/**
|
||||
* 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型
|
||||
* 用于轮询时判断当前在哪个大阶段(分析中 vs 文案生成 vs 视频生成)。
|
||||
* v1.6 后端流水线阶段。单次 Seedance 出片版:
|
||||
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
|
||||
*/
|
||||
export type ViralVideoStage =
|
||||
| "image_analysis"
|
||||
| "video_analysis"
|
||||
| "intent_parsing"
|
||||
| "copy_fusion"
|
||||
| "storyboard"
|
||||
| "script_generation"
|
||||
| "review"
|
||||
| "tts"
|
||||
| "bgm_select"
|
||||
| "rendering"
|
||||
| "musetalk"
|
||||
| "uploading"
|
||||
|
||||
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
|
||||
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
|
||||
/** 文案相关阶段:属于「生成文案」按钮的范围 */
|
||||
const COPY_STAGES = new Set<ViralVideoStage>([
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
])
|
||||
/** 视频相关阶段:属于「开始生成视频」按钮的范围 */
|
||||
const VIDEO_STAGES = new Set<ViralVideoStage>([
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
])
|
||||
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
|
||||
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
|
||||
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
|
||||
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
|
||||
|
||||
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
|
||||
@@ -66,7 +60,7 @@ export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
|
||||
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && VIDEO_STAGES.has(stage)
|
||||
}
|
||||
/** 兼容旧调用:旧的 isAnalysisStage 视为「图片分析+文案」的所有前置阶段 */
|
||||
/** 兼容旧调用:分析图片+生成文案 的所有前置阶段 */
|
||||
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return isImageAnalysisStage(stage) || isCopyStage(stage)
|
||||
}
|
||||
@@ -74,12 +68,20 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
/** 单张图片 VLM 识别出的商品信息 */
|
||||
export interface ImageProductAnalysis {
|
||||
name?: string
|
||||
spec?: string
|
||||
category?: string
|
||||
brand?: string
|
||||
colors?: string[]
|
||||
material_or_texture?: string
|
||||
key_features?: string[]
|
||||
visual_style?: string
|
||||
scene?: string
|
||||
target_audience_hint?: string
|
||||
text_on_image?: string
|
||||
/** 旧字段兼容 */
|
||||
spec?: string
|
||||
features?: string[] | string
|
||||
label_text?: string
|
||||
selling_points?: string[]
|
||||
scene?: string
|
||||
selling_points?: string
|
||||
image_index?: number
|
||||
}
|
||||
|
||||
@@ -87,10 +89,50 @@ export interface ImageAnalysisResult {
|
||||
products?: ImageProductAnalysis[]
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本 - 单镜头 */
|
||||
export interface ShotScript {
|
||||
/** 时间区间,如 "0-3秒" */
|
||||
time_range?: string
|
||||
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
|
||||
shot_type_angle_movement?: string
|
||||
/** 场景描述+对白 */
|
||||
scene_and_dialogue?: string
|
||||
/** 人物动作/表情/物品操作细节 */
|
||||
action_details?: string
|
||||
/** 环境音+BGM提示 */
|
||||
audio_bgm?: string
|
||||
/** 转场方式(硬切/淡入淡出/叠化/结束) */
|
||||
transition?: string
|
||||
/** 参考图片索引(0-based,对应上传产品图数组) */
|
||||
reference_image_index?: number | null
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本 - 总览 */
|
||||
export interface CopyResultOverview {
|
||||
theme?: string
|
||||
total_duration?: number
|
||||
aspect_ratio?: string
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本(核心输出结构,给 Seedance 做 prompt,给 TTS 取 voiceover_script) */
|
||||
export interface CopyResult {
|
||||
overview?: CopyResultOverview
|
||||
/** 整体场景+光线描述 */
|
||||
scene_and_lighting?: string
|
||||
/** 逐镜头时间轴 */
|
||||
shots?: ShotScript[]
|
||||
/** 硬性约束(禁止字幕/水印/变形等) */
|
||||
hard_constraints?: string[]
|
||||
/** 负面提示词 */
|
||||
negative_prompts?: string[]
|
||||
/** 完整口播稿(纯文本,用于 TTS 合成) */
|
||||
voiceover_script?: string
|
||||
/** 向后兼容:= voiceover_script */
|
||||
final_copy?: string
|
||||
/** 向后兼容:= voiceover_script */
|
||||
suggested_copy?: string
|
||||
title?: string
|
||||
/** v1.5 旧字段兼容(老数据降级时可能出现) */
|
||||
scenes?: Array<{ shot: string; narration: string; duration?: number }>
|
||||
}
|
||||
|
||||
@@ -104,13 +146,18 @@ export interface StyleTemplate {
|
||||
}
|
||||
|
||||
export interface IntentResult {
|
||||
product: string
|
||||
selling_points: string[]
|
||||
target_audience: string
|
||||
tone: string
|
||||
structure: string
|
||||
duration: number
|
||||
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
|
||||
}
|
||||
|
||||
@@ -123,7 +170,9 @@ export interface ViralVideoJob {
|
||||
style_template_id?: string
|
||||
style_guide?: string | Record<string, unknown>
|
||||
user_copy_text?: string
|
||||
/** v1.6: = copy_result.voiceover_script(从 copy_result 派生,向后兼容) */
|
||||
final_copy_text?: string
|
||||
generated_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
voice_mode?: "global" | "per_video"
|
||||
@@ -131,8 +180,17 @@ export interface ViralVideoJob {
|
||||
bgm_preference?: string
|
||||
intent_result?: IntentResult
|
||||
intent_text?: string
|
||||
/** v1.6 编导分镜脚本(核心产物) */
|
||||
copy_result?: CopyResult
|
||||
/** 向后兼容:= copy_result.shots */
|
||||
storyboard?: ShotScript[]
|
||||
image_analysis?: ImageAnalysisResult
|
||||
/** 视频比例:9:16 / 16:9 / 1:1,默认 9:16 */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空=后端默认) */
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
progress_stage?: ViralVideoStage
|
||||
progress_percent?: number
|
||||
progress_message?: string
|
||||
@@ -162,6 +220,7 @@ export interface GenerateViralVideoRequest {
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
/** 视频时长(5-30秒,默认15) */
|
||||
duration?: number
|
||||
video_model?: string
|
||||
video_ratio?: string
|
||||
@@ -176,15 +235,25 @@ export interface HistoryResponse {
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** v1.5 阶段1请求:仅做图片/视频分析(POST /viral-video/analyze-images) */
|
||||
/** v1.6 阶段1请求:图片/视频分析(POST /viral-video/analyze-images) */
|
||||
export interface AnalyzeImagesRequest {
|
||||
images: string[]
|
||||
reference_video_url?: string
|
||||
style_template_id?: string
|
||||
style_strength?: StyleStrength
|
||||
/** TTS 音色 ID(STEP1 已选音色时传) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset | library | clone | upload */
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
/** Seedance 视频比例:9:16 | 16:9 | 1:1 */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空则使用服务端默认) */
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** v1.5 阶段2请求:填完营销参数后生成文案+分镜(POST /viral-video/{id}/generate-copy) */
|
||||
/** v1.6 阶段2请求:填完营销参数后生成编导分镜脚本(POST /viral-video/{id}/generate-copy) */
|
||||
export interface GenerateCopyRequest {
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
@@ -192,6 +261,7 @@ export interface GenerateCopyRequest {
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
bgm_preference?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
@@ -200,15 +270,23 @@ export interface GenerateCopyRequest {
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
style_guide?: string | Record<string, unknown>
|
||||
/** TTS 音色 ID(优先级高于 persona_id) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset | library | clone | upload */
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
/** Seedance 视频比例(9:16/16:9/1:1 等) */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空则使用服务端默认) */
|
||||
video_model?: string
|
||||
}
|
||||
|
||||
/** v1.5 阶段3请求:用户确认/编辑文案后开始渲染(POST /viral-video/{id}/confirm-copy) */
|
||||
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
|
||||
export interface ConfirmCopyRequest {
|
||||
/** 用户编辑后的最终文案;为空则使用 AI 生成文案 */
|
||||
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
|
||||
edited_copy?: string
|
||||
}
|
||||
|
||||
/** 分镜片段结构(后端 storyboard 字段的元素形态,保留供调试/进阶使用;主流程请使用 copy_result.scenes) */
|
||||
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
|
||||
export interface StoryboardSegment {
|
||||
order: number
|
||||
type: string
|
||||
|
||||
@@ -1596,3 +1596,370 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── STEP2 2列表单网格 ── */
|
||||
.vv-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.vv-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.vv-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.vv-label-optional {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
.vv-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.vv-input:focus {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
.vv-input:disabled {
|
||||
background: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.vv-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 自定义 Select 样式(浅色紫调) */
|
||||
.vv-select-purple .ant-select-selector {
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
background: #fff !important;
|
||||
min-height: 36px !important;
|
||||
padding: 0 28px 0 12px !important;
|
||||
box-shadow: none !important;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s !important;
|
||||
}
|
||||
.vv-select-purple.ant-select-focused .ant-select-selector,
|
||||
.vv-select-purple .ant-select-focused .ant-select-selector {
|
||||
border-color: #7c3aed !important;
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.15) !important;
|
||||
}
|
||||
.vv-select-purple .ant-select-selection-item,
|
||||
.vv-select-purple .ant-select-selection-placeholder {
|
||||
line-height: 34px !important;
|
||||
font-size: 13px !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
.vv-select-purple .ant-select-selection-placeholder {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
.vv-select-purple .ant-select-arrow {
|
||||
color: #6b7280;
|
||||
right: 10px !important;
|
||||
}
|
||||
.vv-select-purple.ant-select-disabled .ant-select-selector {
|
||||
background: #f9fafb !important;
|
||||
color: #9ca3af !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ghost/outline 按钮 */
|
||||
.vv-btn-ghost {
|
||||
background: #fff;
|
||||
color: #7c3aed;
|
||||
border: 1px solid #d6c6fb;
|
||||
}
|
||||
.vv-btn-ghost:hover {
|
||||
background: #f8f4ff;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vv-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 分镜脚本(Storyboard)样式 ── */
|
||||
.vv-storyboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vv-copy-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.vv-copy-tag-preview {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* 总览卡片 */
|
||||
.vv-sb-overview {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e9e4ff;
|
||||
}
|
||||
.vv-sb-ov-field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.vv-sb-input {
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 分块标题 */
|
||||
.vv-sb-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.vv-sb-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 镜头卡片 */
|
||||
.vv-sb-shots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.vv-sb-shot {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.vv-sb-shot-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vv-sb-shot-num {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.vv-sb-time {
|
||||
height: 28px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
width: 100px;
|
||||
outline: none;
|
||||
}
|
||||
.vv-sb-time:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.vv-sb-shot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
.vv-sb-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.vv-sb-cell-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.vv-sb-cell label {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
.vv-sb-cell-ta {
|
||||
min-height: 54px !important;
|
||||
font-size: 12.5px !important;
|
||||
padding: 7px 10px !important;
|
||||
line-height: 1.55 !important;
|
||||
resize: vertical;
|
||||
}
|
||||
.vv-sb-ref-box {
|
||||
width: 80px;
|
||||
height: 54px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px dashed #d1d5db;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.vv-sb-ref-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.vv-sb-ref-empty {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 标签(硬性约束/负面提示词) */
|
||||
.vv-sb-taglist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.vv-sb-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px 2px 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.vv-sb-tag-hard {
|
||||
background: #f3f0ff;
|
||||
color: #6d28d9;
|
||||
border: 1px solid #ddd6fe;
|
||||
}
|
||||
.vv-sb-tag-neg {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.vv-sb-tag-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
min-width: 80px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.vv-sb-tag-hard .vv-sb-tag-input {
|
||||
color: #6d28d9;
|
||||
}
|
||||
.vv-sb-tag-neg .vv-sb-tag-input {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.vv-sb-tag-del {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.vv-sb-tag-del:hover {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.vv-sb-tag-add {
|
||||
border: 1px dashed #d1d5db;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vv-sb-tag-add:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* 口播稿折叠 */
|
||||
.vv-sb-collapse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.vv-sb-collapse:hover {
|
||||
border-color: #7c3aed;
|
||||
background: #faf8ff;
|
||||
}
|
||||
.vv-sb-caret {
|
||||
transition: transform 0.2s;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
.vv-sb-caret.open {
|
||||
transform: rotate(180deg);
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-sb-vo {
|
||||
min-height: 100px !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.7 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vv-sb-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vv-sb-shot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vv-sb-cell-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
CaretRightOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
BulbOutlined,
|
||||
ThunderboltOutlined,
|
||||
ReloadOutlined,
|
||||
WarningFilled,
|
||||
SearchOutlined,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import { Select, Input, message } from "antd"
|
||||
import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import {
|
||||
FUSION_LEVELS,
|
||||
STYLE_STRENGTHS,
|
||||
@@ -35,6 +38,8 @@ import {
|
||||
type ViralVideoJob,
|
||||
type ImageProductAnalysis,
|
||||
type ImageAnalysisResult,
|
||||
type CopyResult,
|
||||
type ShotScript,
|
||||
} from "@/api/viral-video/types"
|
||||
import {
|
||||
generateViralVideo,
|
||||
@@ -109,19 +114,19 @@ type TabTask = {
|
||||
userCopy: string
|
||||
industry: string
|
||||
targetCustomer: string
|
||||
persona: string
|
||||
language: string
|
||||
viralStructure: string
|
||||
marketingPurpose: string
|
||||
duration: number
|
||||
bgm: string
|
||||
// STEP3 设置
|
||||
videoRatio: string
|
||||
videoModel: string
|
||||
// UI 状态
|
||||
uiStep: UIStep
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
generatedCopy: string // 文案结果(可编辑)
|
||||
generatedTitle: string
|
||||
// ── 分镜脚本(新结构) ──
|
||||
storyboard: Storyboard | null
|
||||
voiceTipsOpen: boolean
|
||||
copyError?: string
|
||||
videoError?: string
|
||||
@@ -134,17 +139,18 @@ type TabTask = {
|
||||
|
||||
/* ─────────── 常量 ─────────── */
|
||||
|
||||
const INDUSTRIES = [
|
||||
"美妆个护",
|
||||
"服饰鞋包",
|
||||
"数码3C",
|
||||
"食品饮料",
|
||||
"家居日用",
|
||||
"母婴亲子",
|
||||
"教育培训",
|
||||
"其他",
|
||||
]
|
||||
const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
|
||||
const TARGET_CUSTOMERS = [
|
||||
"小镇青年",
|
||||
"小镇中老年",
|
||||
"Z世代",
|
||||
"精致妈妈",
|
||||
"新锐白领",
|
||||
"资深中产",
|
||||
"都市蓝领",
|
||||
"都市银发",
|
||||
]
|
||||
const PERSONAS = ["亲切邻家姐姐", "专业顾问", "搞笑段子手", "真实测评博主", "闺蜜种草", "行业专家"]
|
||||
const STRUCTURES = [
|
||||
"痛点开场→卖点→促单",
|
||||
"反差对比→证据→促单",
|
||||
@@ -152,7 +158,7 @@ const STRUCTURES = [
|
||||
"专家背书→数据→促单",
|
||||
]
|
||||
const PURPOSES = ["品牌种草", "新品上市", "促销转化", "活动引流"]
|
||||
const BGMS = ["无BGM", "欢快活泼", "温柔治愈", "励志燃向", "搞笑魔性", "科技感"]
|
||||
const DURATIONS = [15, 20, 30, 45, 60]
|
||||
const RATIOS = [
|
||||
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
|
||||
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
|
||||
@@ -165,6 +171,141 @@ const MODELS = [
|
||||
const VOICE_TIPS =
|
||||
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
|
||||
|
||||
// ── 分镜脚本数据模型(新后端 copy_result 结构,前端先 mock 展示) ──
|
||||
interface StoryboardShot {
|
||||
time_range: string
|
||||
shot_type_angle_movement: string
|
||||
scene_and_dialogue: string
|
||||
action_details: string
|
||||
audio_bgm: string
|
||||
transition: string
|
||||
reference_image_index?: number
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */
|
||||
function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
|
||||
if (!cr) return null
|
||||
// 新结构:含 shots/overview 字段直接用
|
||||
if (Array.isArray(cr.shots) && cr.overview) {
|
||||
return {
|
||||
overview: {
|
||||
theme: cr.overview.theme || cr.title || "",
|
||||
total_duration: cr.overview.total_duration ? `${cr.overview.total_duration}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 || "",
|
||||
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 || "",
|
||||
}
|
||||
}
|
||||
// 兜底:旧结构转简单分镜
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock 分镜脚本(后端 API 未就绪时的占位展示) */
|
||||
const MOCK_STORYBOARD: Storyboard = {
|
||||
overview: { theme: "实木餐桌场景化种草", total_duration: "15s", aspect_ratio: "9:16" },
|
||||
scene_and_lighting:
|
||||
"暖黄调家居场景,午后阳光从纱帘斜射入餐厅,桌上摆满家常菜,整体营造温馨治愈、高质感的生活氛围。",
|
||||
shots: [
|
||||
{
|
||||
time_range: "0-3s",
|
||||
shot_type_angle_movement: "特写/俯拍/慢推",
|
||||
scene_and_dialogue: "(盘子掀开冒热气)还在为餐桌选不到好桌子发愁?",
|
||||
action_details: "双手掀开白色瓷盘盖,蒸汽缓缓升腾,桌上红烧排骨特写",
|
||||
audio_bgm: "轻快钢琴+盘子掀开的清脆音效",
|
||||
transition: "蒸汽模糊转场",
|
||||
reference_image_index: 0,
|
||||
},
|
||||
{
|
||||
time_range: "3-8s",
|
||||
shot_type_angle_movement: "中景/平视/稳定",
|
||||
scene_and_dialogue: "这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。",
|
||||
action_details: "镜头切到一家人围坐用餐,妈妈夹菜给孩子,爸爸微笑",
|
||||
audio_bgm: "温馨BGM持续,轻人声环境音",
|
||||
transition: "切",
|
||||
reference_image_index: 1,
|
||||
},
|
||||
{
|
||||
time_range: "8-12s",
|
||||
shot_type_angle_movement: "特写/45度/滑轨横移",
|
||||
scene_and_dialogue: "全实木、无贴皮,纹理好看又耐刮。",
|
||||
action_details: "手指轻抚桌面木纹,杯子放在桌上不晃动,展示承重细节",
|
||||
audio_bgm: "手指划木面的摩擦声",
|
||||
transition: "切",
|
||||
reference_image_index: 2,
|
||||
},
|
||||
{
|
||||
time_range: "12-15s",
|
||||
shot_type_angle_movement: "全景/微仰/拉远",
|
||||
scene_and_dialogue: "点小黄车,给家里添一张好桌子。",
|
||||
action_details: "产品全景+CTA字幕弹出,右下角购物车图标放大",
|
||||
audio_bgm: "叮咚提示音+BGM收尾",
|
||||
transition: "淡出",
|
||||
reference_image_index: 0,
|
||||
},
|
||||
],
|
||||
hard_constraints: [
|
||||
"画面暖调温馨,不可出现冷光",
|
||||
"人物必须是一家人(父母+孩子),不能单独人物",
|
||||
"餐桌材质必须显示为实木",
|
||||
],
|
||||
negative_prompts: ["冷色调", "模糊", "变形", "水印文字", "卡通风格", "空无一人"],
|
||||
voiceover_script:
|
||||
"还在为餐桌选不到好桌子发愁?这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。全实木、无贴皮,纹理好看又耐刮。点小黄车,给家里添一张好桌子。",
|
||||
}
|
||||
|
||||
const fmtSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
@@ -187,14 +328,13 @@ const emptyTask = (id: string, title: string): TabTask => ({
|
||||
language: "中文(普通话)",
|
||||
viralStructure: STRUCTURES[0],
|
||||
marketingPurpose: PURPOSES[0],
|
||||
duration: 30,
|
||||
bgm: BGMS[0],
|
||||
duration: 15,
|
||||
persona: "",
|
||||
videoRatio: "9:16",
|
||||
videoModel: "seedance-2.5",
|
||||
uiStep: "step1_upload",
|
||||
imageAnalysis: null,
|
||||
generatedCopy: "",
|
||||
generatedTitle: "",
|
||||
storyboard: null,
|
||||
voiceTipsOpen: false,
|
||||
job: null,
|
||||
jobId: null,
|
||||
@@ -207,6 +347,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
const [tasks, setTasks] = useState<TabTask[]>(() => [emptyTask("t1", "生成 1")])
|
||||
const [activeId, setActiveId] = useState<string>("t1")
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
||||
const [voOpen, setVoOpen] = useState(false) // 口播稿折叠
|
||||
const [voiceAssets, setVoiceAssets] = useState<
|
||||
{ id: string; name: string; url: string; desc?: string }[]
|
||||
>([])
|
||||
@@ -253,25 +394,21 @@ const ViralVideoPage: React.FC = () => {
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
fetch("/api/v1/tts/presets", { credentials: "include" })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list: { voice_id?: string; id?: string; name?: string; display_name?: string }[]) => {
|
||||
const arr = Array.isArray(list) ? list : []
|
||||
fetchPresetVoices()
|
||||
.then((res) => {
|
||||
const arr = Array.isArray(res?.items) ? res.items : []
|
||||
setPresetVoices(
|
||||
arr.map((v) => ({
|
||||
id: v.voice_id || v.id || "",
|
||||
name: v.display_name || v.name || v.voice_id || "预置音色",
|
||||
desc: "系统音色",
|
||||
id: v.voice_id,
|
||||
name: v.name,
|
||||
desc: v.description || v.tags?.join("/") || "系统音色",
|
||||
sample_audio_url: v.preview_url || undefined,
|
||||
})),
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
setPresetVoices([
|
||||
{ id: "zh-CN-XiaoxiaoNeural", name: "晓晓", desc: "温柔女声" },
|
||||
{ id: "zh-CN-YunxiNeural", name: "云希", desc: "活力男声" },
|
||||
{ id: "zh-CN-YunjianNeural", name: "云健", desc: "专业男声" },
|
||||
{ id: "zh-CN-XiaoyiNeural", name: "晓伊", desc: "甜美女声" },
|
||||
])
|
||||
// 接口失败兜底:弹窗内 MOCK_VOICES 会生效(voice_id 与后端 CosyVoice 一致)
|
||||
setPresetVoices([])
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -286,9 +423,10 @@ const ViralVideoPage: React.FC = () => {
|
||||
// 同步服务端最新的分析/文案结果(若已返回)
|
||||
const nextImageAnalysis = job.image_analysis || t.imageAnalysis
|
||||
const nextCopy = job.copy_result
|
||||
const nextGeneratedCopy =
|
||||
nextCopy?.final_copy || nextCopy?.suggested_copy || t.generatedCopy
|
||||
const nextGeneratedTitle = nextCopy?.title || t.generatedTitle
|
||||
// 新结构分镜 → storyboard;老结构通过 helper 兜底转换
|
||||
const nextStoryboard = nextCopy
|
||||
? copyResultToStoryboard(nextCopy) || t.storyboard
|
||||
: t.storyboard
|
||||
|
||||
switch (job.status) {
|
||||
case "image_analyzed":
|
||||
@@ -326,8 +464,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
job,
|
||||
uiStep: nextStep,
|
||||
imageAnalysis: nextImageAnalysis,
|
||||
generatedCopy: nextGeneratedCopy,
|
||||
generatedTitle: nextGeneratedTitle,
|
||||
storyboard: nextStoryboard,
|
||||
copyError:
|
||||
nextStep === "failed" && isCopyStage(job.progress_stage) ? copyError : copyError,
|
||||
videoError:
|
||||
@@ -435,8 +572,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
images: [],
|
||||
imageAnalysis: null,
|
||||
uiStep: "step1_upload",
|
||||
generatedCopy: "",
|
||||
generatedTitle: "",
|
||||
storyboard: null,
|
||||
job: null,
|
||||
jobId: null,
|
||||
}
|
||||
@@ -453,14 +589,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
...t,
|
||||
images: newImgs,
|
||||
imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis,
|
||||
uiStep:
|
||||
newImgs.length === 0
|
||||
? "step1_upload"
|
||||
: t.uiStep === "step1_analyzing" || t.uiStep === "step1_done"
|
||||
? "step1_upload"
|
||||
: t.uiStep,
|
||||
generatedCopy: newImgs.length === 0 ? "" : t.generatedCopy,
|
||||
generatedTitle: newImgs.length === 0 ? "" : t.generatedTitle,
|
||||
storyboard: newImgs.length === 0 ? null : t.storyboard,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -631,7 +760,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
const job = await analyzeViralImages({
|
||||
images: readyImgs.map((i) => i.ossUrl as string),
|
||||
reference_video_url: task.refVideo?.ossUrl || undefined,
|
||||
style_template_id: task.refVideo?.ossUrl ? undefined : undefined,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
})
|
||||
setTask({ job, jobId: job.id })
|
||||
@@ -655,15 +783,18 @@ const ViralVideoPage: React.FC = () => {
|
||||
const job = await generateViralCopy(task.jobId, {
|
||||
industry: task.industry || undefined,
|
||||
target_customer: task.targetCustomer || undefined,
|
||||
persona_id: undefined,
|
||||
viral_structure: task.viralStructure || undefined,
|
||||
marketing_purpose: task.marketingPurpose || undefined,
|
||||
bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm,
|
||||
duration: task.duration,
|
||||
persona_id: task.persona || undefined,
|
||||
user_copy_text:
|
||||
task.fusionLevel === "user_primary" && 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 : undefined,
|
||||
voice_source: task.refAudio?.source || undefined,
|
||||
video_ratio: task.videoRatio,
|
||||
video_model: task.videoModel,
|
||||
})
|
||||
setTask({ job })
|
||||
// 轮询会在 status=copy_generated 时推进 uiStep
|
||||
@@ -673,33 +804,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── STEP 2:确认文案 → 直接触发 STEP 3 视频生成(confirm-copy 接口) ── */
|
||||
const handleConfirmCopy = async () => {
|
||||
if (!task.generatedCopy.trim()) {
|
||||
message.warning("文案不能为空")
|
||||
return
|
||||
}
|
||||
if (!task.jobId) {
|
||||
message.warning("请先完成文案生成")
|
||||
return
|
||||
}
|
||||
setTask({ uiStep: "step3_generating", videoError: undefined })
|
||||
try {
|
||||
const edited = task.generatedCopy.trim()
|
||||
const originalCopy = task.job?.copy_result?.final_copy || task.generatedCopy
|
||||
const job = await confirmViralCopy(task.jobId, {
|
||||
// 若用户未修改则不传 edited_copy,后端用 AI 文案;若修改过则传编辑后文案
|
||||
edited_copy: edited !== originalCopy.trim() ? edited : undefined,
|
||||
})
|
||||
setTask({ job })
|
||||
// 轮询继续,status=completed 时推进 step3_done
|
||||
message.success("已提交视频生成,预计 1-3 分钟…")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "提交失败")
|
||||
setTask({ uiStep: "failed", videoError: err instanceof Error ? err.message : "提交失败" })
|
||||
}
|
||||
}
|
||||
|
||||
/* ── STEP 3:开始生成视频(重试/兜底入口,正常流程由 handleConfirmCopy 触发) ──
|
||||
* 三步拆分后端下,点击「确认文案并生成视频」会直接走 confirmViralCopy 触发 TTS+渲染。
|
||||
* 此处保留「开始生成视频」按钮作为网络中断/失败后重试、或旧状态兼容入口:
|
||||
@@ -709,10 +813,15 @@ const ViralVideoPage: React.FC = () => {
|
||||
setTask({ uiStep: "step3_generating", videoError: undefined })
|
||||
try {
|
||||
let job
|
||||
if (task.jobId && task.generatedCopy.trim()) {
|
||||
if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) {
|
||||
// 新三步链路:基于已有 job 触发 confirm-copy
|
||||
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: task.generatedCopy.trim() || undefined,
|
||||
edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined,
|
||||
})
|
||||
} else {
|
||||
// 兜底:走旧 /generate 接口(一次性跑完)
|
||||
@@ -723,11 +832,10 @@ const ViralVideoPage: React.FC = () => {
|
||||
images: imgUrls,
|
||||
reference_video_url: task.refVideo?.ossUrl || undefined,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
user_copy_text: task.generatedCopy || task.userCopy || undefined,
|
||||
user_copy_text: task.storyboard?.voiceover_script || task.userCopy || undefined,
|
||||
fusion_level: task.fusionLevel,
|
||||
voice_id: task.refAudio?.id || undefined,
|
||||
voice_source: task.refAudio?.source,
|
||||
bgm_preference: task.bgm === "无BGM" ? undefined : task.bgm,
|
||||
industry: task.industry || undefined,
|
||||
target_customer: task.targetCustomer || undefined,
|
||||
language: task.language || undefined,
|
||||
@@ -759,7 +867,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
} else {
|
||||
// 无 jobId 则重置到可重试的上一步
|
||||
setTask({
|
||||
uiStep: task.generatedCopy ? "step2_copy_ready" : "step1_done",
|
||||
uiStep: task.storyboard ? "step2_copy_ready" : "step1_done",
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
})
|
||||
@@ -800,6 +908,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
task.uiStep === "step3_done" ||
|
||||
task.uiStep === "failed"
|
||||
const step3Enabled =
|
||||
task.uiStep === "step2_copy_ready" ||
|
||||
task.uiStep === "step3_ready" ||
|
||||
task.uiStep === "step3_generating" ||
|
||||
task.uiStep === "step3_done" ||
|
||||
@@ -860,47 +969,333 @@ const ViralVideoPage: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 文案结果区 ── */
|
||||
/* ── 分镜脚本结果区(编导分镜卡片 UI) ── */
|
||||
const updateShot = (idx: number, patch: Partial<StoryboardShot>) => {
|
||||
if (!task.storyboard) return
|
||||
const shots = task.storyboard.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s))
|
||||
setTask({ storyboard: { ...task.storyboard, shots } })
|
||||
}
|
||||
const updateStoryboard = (patch: Partial<Storyboard>) => {
|
||||
if (!task.storyboard) return
|
||||
setTask({ storyboard: { ...task.storyboard, ...patch } })
|
||||
}
|
||||
const updateOverview = (patch: Partial<Storyboard["overview"]>) => {
|
||||
if (!task.storyboard) return
|
||||
setTask({
|
||||
storyboard: { ...task.storyboard, overview: { ...task.storyboard.overview, ...patch } },
|
||||
})
|
||||
}
|
||||
const updateConstraints = (
|
||||
list: "hard_constraints" | "negative_prompts",
|
||||
idx: number,
|
||||
v: string,
|
||||
) => {
|
||||
if (!task.storyboard) return
|
||||
const arr = [...task.storyboard[list]]
|
||||
arr[idx] = v
|
||||
setTask({ storyboard: { ...task.storyboard, [list]: arr } })
|
||||
}
|
||||
const addConstraint = (list: "hard_constraints" | "negative_prompts") => {
|
||||
if (!task.storyboard) return
|
||||
setTask({ storyboard: { ...task.storyboard, [list]: [...task.storyboard[list], ""] } })
|
||||
}
|
||||
const removeConstraint = (list: "hard_constraints" | "negative_prompts", idx: number) => {
|
||||
if (!task.storyboard) return
|
||||
const arr = task.storyboard[list].filter((_, i) => i !== idx)
|
||||
setTask({ storyboard: { ...task.storyboard, [list]: arr } })
|
||||
}
|
||||
|
||||
const renderCopyResult = () => {
|
||||
if (task.uiStep === "step2_generating") {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<LoadingOutlined style={{ color: "#7c3aed", fontSize: 18, marginRight: 8 }} />
|
||||
<span>AI 正在创作营销文案…</span>
|
||||
<span>AI 正在创作视频分镜脚本…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 后端新结构尚未就绪时,用 MOCK_STORYBOARD 做预览展示;新结构到了之后优先用真实数据
|
||||
const sb: Storyboard | null = (() => {
|
||||
if (!task.storyboard) return null
|
||||
const isNewStructure =
|
||||
task.storyboard.shots.length >= 2 &&
|
||||
!!task.storyboard.scene_and_lighting &&
|
||||
task.storyboard.overview.total_duration.length > 0
|
||||
if (isNewStructure) return task.storyboard
|
||||
// 后端还在旧结构:兜底用 mock 展示分镜卡片效果
|
||||
return MOCK_STORYBOARD
|
||||
})()
|
||||
if (
|
||||
!sb &&
|
||||
(task.uiStep === "step2_copy_ready" ||
|
||||
task.uiStep === "step3_ready" ||
|
||||
task.uiStep === "step3_generating" ||
|
||||
task.uiStep === "step3_done")
|
||||
) {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<span style={{ color: "#9ca3af" }}>暂无分镜数据</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (
|
||||
task.uiStep === "step2_copy_ready" ||
|
||||
task.uiStep === "step3_ready" ||
|
||||
task.uiStep === "step3_generating" ||
|
||||
task.uiStep === "step3_done"
|
||||
!sb ||
|
||||
!(
|
||||
task.uiStep === "step2_copy_ready" ||
|
||||
task.uiStep === "step3_ready" ||
|
||||
task.uiStep === "step3_generating" ||
|
||||
task.uiStep === "step3_done"
|
||||
)
|
||||
) {
|
||||
const locked = task.uiStep !== "step2_copy_ready"
|
||||
return (
|
||||
<div className="vv-copy-box">
|
||||
<div className="vv-copy-head">
|
||||
<EditOutlined style={{ color: "#7c3aed" }} />
|
||||
<span className="vv-copy-title">AI 生成文案</span>
|
||||
{task.generatedTitle && <span className="vv-copy-sub">{task.generatedTitle}</span>}
|
||||
{locked && <span className="vv-copy-locked">(已锁定)</span>}
|
||||
</div>
|
||||
<textarea
|
||||
className="vv-textarea vv-copy-textarea"
|
||||
value={task.generatedCopy}
|
||||
onChange={(e) => setTask({ generatedCopy: e.target.value })}
|
||||
disabled={locked}
|
||||
placeholder="AI 生成的营销文案将显示在这里…"
|
||||
/>
|
||||
{task.uiStep === "step2_copy_ready" && (
|
||||
<button className="vv-btn vv-btn-primary vv-btn-block" onClick={handleConfirmCopy}>
|
||||
<VideoCameraOutlined /> 确认文案并生成视频
|
||||
</button>
|
||||
return null
|
||||
}
|
||||
const locked = task.uiStep === "step3_generating" || task.uiStep === "step3_done"
|
||||
const isMockPreview = sb === MOCK_STORYBOARD
|
||||
|
||||
return (
|
||||
<div className="vv-copy-box vv-storyboard">
|
||||
<div className="vv-copy-head">
|
||||
<EditOutlined style={{ color: "#7c3aed" }} />
|
||||
<span className="vv-copy-title">AI 分镜脚本</span>
|
||||
{isMockPreview && (
|
||||
<span className="vv-copy-tag vv-copy-tag-preview">
|
||||
示例预览 · 等后端输出新结构后自动替换
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
||||
{/* 1. 总览卡片 */}
|
||||
<div className="vv-sb-overview">
|
||||
<div className="vv-sb-ov-field">
|
||||
<label>主题</label>
|
||||
<input
|
||||
className="vv-input vv-sb-input"
|
||||
value={sb.overview.theme}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateOverview({ theme: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-ov-field">
|
||||
<label>总时长</label>
|
||||
<input
|
||||
className="vv-input vv-sb-input"
|
||||
value={sb.overview.total_duration}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateOverview({ total_duration: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-ov-field">
|
||||
<label>画幅</label>
|
||||
<Select
|
||||
className="vv-select vv-select-purple vv-sb-input"
|
||||
style={{ width: "100%" }}
|
||||
value={sb.overview.aspect_ratio}
|
||||
disabled={locked}
|
||||
onChange={(v) => updateOverview({ aspect_ratio: v })}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 场景与光线 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<BulbOutlined style={{ color: "#7c3aed" }} /> 场景与光线
|
||||
</div>
|
||||
<textarea
|
||||
className="vv-textarea"
|
||||
value={sb.scene_and_lighting}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateStoryboard({ scene_and_lighting: e.target.value })}
|
||||
placeholder="描述整体场景氛围、光线方向与色温…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. 逐镜头卡片 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<VideoCameraOutlined style={{ color: "#7c3aed" }} /> 逐镜头拆解({sb.shots.length} 镜)
|
||||
</div>
|
||||
<div className="vv-sb-shots">
|
||||
{sb.shots.map((sh, idx) => {
|
||||
const refImg =
|
||||
typeof sh.reference_image_index === "number"
|
||||
? task.images[sh.reference_image_index]
|
||||
: undefined
|
||||
return (
|
||||
<div key={idx} className="vv-sb-shot">
|
||||
<div className="vv-sb-shot-head">
|
||||
<span className="vv-sb-shot-num">镜头 {idx + 1}</span>
|
||||
<input
|
||||
className="vv-sb-time"
|
||||
value={sh.time_range}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { time_range: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-shot-grid">
|
||||
<div className="vv-sb-cell">
|
||||
<label>景别 / 运镜</label>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-cell-ta"
|
||||
value={sh.shot_type_angle_movement}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
updateShot(idx, { shot_type_angle_movement: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-cell vv-sb-cell-wide">
|
||||
<label>场景与对白</label>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-cell-ta"
|
||||
value={sh.scene_and_dialogue}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { scene_and_dialogue: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-cell">
|
||||
<label>动作细节</label>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-cell-ta"
|
||||
value={sh.action_details}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { action_details: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-cell">
|
||||
<label>音效 / BGM</label>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-cell-ta"
|
||||
value={sh.audio_bgm}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { audio_bgm: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-cell">
|
||||
<label>转场</label>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-cell-ta"
|
||||
value={sh.transition}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { transition: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-cell vv-sb-ref">
|
||||
<label>参考图</label>
|
||||
<div className="vv-sb-ref-box">
|
||||
{refImg?.url ? (
|
||||
<img src={refImg.url} alt={`ref-${idx}`} className="vv-sb-ref-img" />
|
||||
) : (
|
||||
<span className="vv-sb-ref-empty">未指定</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 硬性约束 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<ThunderboltOutlined style={{ color: "#7c3aed" }} /> 硬性约束
|
||||
</div>
|
||||
<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) => updateConstraints("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>
|
||||
|
||||
{/* 5. 负面提示词 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<CloseOutlined style={{ color: "#ef4444" }} /> 负面提示词
|
||||
</div>
|
||||
<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) => updateConstraints("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>
|
||||
|
||||
{/* 6. 完整口播稿(折叠) */}
|
||||
<div className="vv-sb-section">
|
||||
<button className="vv-sb-collapse" onClick={() => setVoOpen(!voOpen)}>
|
||||
<span>
|
||||
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
|
||||
完整口播稿(TTS 合成使用)
|
||||
</span>
|
||||
<span className={`vv-sb-caret ${voOpen ? "open" : ""}`}>▾</span>
|
||||
</button>
|
||||
{voOpen && (
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-vo"
|
||||
value={sb.voiceover_script}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateStoryboard({ voiceover_script: e.target.value })}
|
||||
placeholder="AI 合成配音用的完整口播稿…"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 7. 重新生成按钮(ghost) */}
|
||||
{task.uiStep === "step2_copy_ready" && (
|
||||
<button
|
||||
className="vv-btn vv-btn-ghost vv-btn-block"
|
||||
onClick={handleGenerateCopy}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
<ReloadOutlined /> 重新生成文案
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1298,7 +1693,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead">
|
||||
<span className={`vv-step-badge ${step2Enabled ? "" : "vv-step-badge-dim"}`}>2</span>
|
||||
<span className="vv-step-subtitle">STEP 2 文案配置并生成文案</span>
|
||||
<span className="vv-step-subtitle">STEP 2 生成视频文案</span>
|
||||
{!step2Enabled && <span className="vv-step-lock">请先完成 STEP 1</span>}
|
||||
</div>
|
||||
<div className="vv-section-body">
|
||||
@@ -1330,38 +1725,37 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频参数 */}
|
||||
<div className="vv-section-subtitle" style={{ marginTop: 16 }}>
|
||||
视频参数
|
||||
</div>
|
||||
{/* 文案参数(2列网格) */}
|
||||
<div className="vv-form-grid">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">行业</label>
|
||||
<label className="vv-label">
|
||||
行业与产品 <span className="vv-label-optional">(选填)</span>
|
||||
</label>
|
||||
<input
|
||||
className="vv-input"
|
||||
placeholder="如:家具店、宠物店、装修公司"
|
||||
value={task.industry}
|
||||
onChange={(e) => setTask({ industry: e.target.value })}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">目标客户</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
className="vv-select vv-select-purple"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择"
|
||||
value={task.industry || undefined}
|
||||
onChange={(v) => setTask({ industry: v })}
|
||||
options={INDUSTRIES.map((i) => ({ value: i, label: i }))}
|
||||
placeholder="请选择目标客户"
|
||||
value={task.targetCustomer || undefined}
|
||||
onChange={(v) => setTask({ targetCustomer: v })}
|
||||
options={TARGET_CUSTOMERS.map((i) => ({ value: i, label: i }))}
|
||||
allowClear
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">目标人群</label>
|
||||
<input
|
||||
className="vv-input"
|
||||
placeholder="例:25-35岁职场女性"
|
||||
value={task.targetCustomer}
|
||||
onChange={(e) => setTask({ targetCustomer: e.target.value })}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">语言</label>
|
||||
<label className="vv-label">台词语言</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
className="vv-select vv-select-purple"
|
||||
style={{ width: "100%" }}
|
||||
value={task.language}
|
||||
onChange={(v) => setTask({ language: v })}
|
||||
@@ -1370,20 +1764,22 @@ const ViralVideoPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频时长</label>
|
||||
<label className="vv-label">我的人设</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
className="vv-select vv-select-purple"
|
||||
style={{ width: "100%" }}
|
||||
value={task.duration}
|
||||
onChange={(v) => setTask({ duration: v })}
|
||||
options={[15, 20, 30, 45, 60].map((n) => ({ value: n, label: `${n}秒` }))}
|
||||
placeholder="请选择人设"
|
||||
value={task.persona || undefined}
|
||||
onChange={(v) => setTask({ persona: v })}
|
||||
options={PERSONAS.map((i) => ({ value: i, label: i }))}
|
||||
allowClear
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">叙事结构</label>
|
||||
<label className="vv-label">爆款结构</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
className="vv-select vv-select-purple"
|
||||
style={{ width: "100%" }}
|
||||
value={task.viralStructure}
|
||||
onChange={(v) => setTask({ viralStructure: v })}
|
||||
@@ -1394,7 +1790,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">营销目的</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
className="vv-select vv-select-purple"
|
||||
style={{ width: "100%" }}
|
||||
value={task.marketingPurpose}
|
||||
onChange={(v) => setTask({ marketingPurpose: v })}
|
||||
@@ -1403,19 +1799,15 @@ const ViralVideoPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="vv-label">BGM 风格</label>
|
||||
<div className="vv-pill-row">
|
||||
{BGMS.map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
className={`vv-pill ${task.bgm === b ? "active" : ""}`}
|
||||
onClick={() => setTask({ bgm: b })}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="vv-label">文案视频时长</label>
|
||||
<Select
|
||||
className="vv-select vv-select-purple"
|
||||
style={{ width: "100%" }}
|
||||
value={task.duration}
|
||||
onChange={(v) => setTask({ duration: v })}
|
||||
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1575,13 +1967,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
className={`vv-btn vv-btn-primary ${!step3Enabled ? "vv-btn-disabled-gray" : ""}`}
|
||||
onClick={handleGenerateVideo}
|
||||
disabled={task.uiStep === "step3_generating" || !step3Enabled}
|
||||
title={
|
||||
!step3Enabled
|
||||
? !step2Enabled
|
||||
? "请先完成图片分析"
|
||||
: "请先在 STEP 2 生成并确认文案"
|
||||
: ""
|
||||
}
|
||||
title={!step3Enabled ? "请先在 STEP 2 生成文案" : ""}
|
||||
>
|
||||
{task.uiStep === "step3_generating" ? (
|
||||
<>
|
||||
@@ -1589,8 +1975,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
</>
|
||||
) : !step3Enabled ? (
|
||||
<>
|
||||
<VideoCameraOutlined /> 开始生成视频
|
||||
{!step2Enabled ? "(请先完成图片分析)" : "(请先确认文案)"}
|
||||
<VideoCameraOutlined /> 开始生成视频(请先生成文案)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -34,83 +34,63 @@ interface Props {
|
||||
|
||||
/** 兜底 mock 音色(后端 /api/v1/tts/presets 返回字段不够时使用) */
|
||||
const MOCK_VOICES: PresetVoice[] = [
|
||||
// ⚠️ 兜底 mock,仅在 /voices/presets 接口不可达时使用;ID 必须与后端
|
||||
// packages/domain/preset_voices.py PRESET_VOICES 的 voice_id 对齐(v3后缀)
|
||||
{
|
||||
id: "long-xiaochun",
|
||||
id: "longxiaochun_v3",
|
||||
name: "龙小淳",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "知性积极女声,适合语音助手",
|
||||
},
|
||||
{
|
||||
id: "long-xiaoxia",
|
||||
id: "longxiaoxia_v3",
|
||||
name: "龙小夏",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "沉稳权威女声,适合新闻播报",
|
||||
},
|
||||
{
|
||||
id: "long-xiaoyan",
|
||||
name: "龙小颜",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "温柔甜美女声,适合情感口播",
|
||||
},
|
||||
{
|
||||
id: "long-xiaotong",
|
||||
name: "龙小彤",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "活力少女音,适合短视频带货",
|
||||
},
|
||||
{
|
||||
id: "long-sanshu",
|
||||
id: "longsanshu_v3",
|
||||
name: "龙三叔",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "沉稳质感男声,适合有声书",
|
||||
},
|
||||
{
|
||||
id: "long-xiaogang",
|
||||
name: "龙小刚",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "阳光活力男声,适合解说",
|
||||
},
|
||||
{
|
||||
id: "long-xiaocheng",
|
||||
name: "龙小诚",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "磁性商务男声,适合品牌宣传",
|
||||
},
|
||||
{
|
||||
id: "long-xiaozhi",
|
||||
name: "龙小智",
|
||||
gender: "child",
|
||||
category: "童声",
|
||||
desc: "可爱童声,适合亲子内容",
|
||||
},
|
||||
{
|
||||
id: "long-yue",
|
||||
id: "longyue_v3",
|
||||
name: "龙悦",
|
||||
gender: "female",
|
||||
category: "情绪",
|
||||
desc: "温柔治愈女声,适合睡前/助眠",
|
||||
category: "女声",
|
||||
desc: "温暖磁性女声,适合广告配音",
|
||||
},
|
||||
{
|
||||
id: "long-xiaodong",
|
||||
name: "龙晓东",
|
||||
id: "longshu_v3",
|
||||
name: "龙书",
|
||||
gender: "male",
|
||||
category: "方言",
|
||||
desc: "东北方言男声,接地气",
|
||||
category: "男声",
|
||||
desc: "沉稳青年男声,适合教育讲解",
|
||||
},
|
||||
{ id: "long-xiaoling", name: "龙小玲", gender: "female", category: "方言", desc: "粤语女声" },
|
||||
{
|
||||
id: "long-xiaoxiao-neural",
|
||||
name: "晓晓",
|
||||
id: "longyingjing_v3",
|
||||
name: "龙应静",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "温柔女声",
|
||||
desc: "低调冷静女声,适合纪录片解说",
|
||||
},
|
||||
{
|
||||
id: "longshuo_v3",
|
||||
name: "龙硕",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "博才干练男声,适合科技类内容",
|
||||
},
|
||||
{
|
||||
id: "longtian_v3",
|
||||
name: "龙甜",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "活泼女声,适合短视频配音",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -178,23 +158,22 @@ const PresetVoicePickerModal: React.FC<Props> = ({
|
||||
|
||||
// 合并真实数据和 mock:如果真实数据 gender/category 缺失,用 mock 兜底
|
||||
const allVoices: PresetVoice[] = useMemo(() => {
|
||||
// 真实 API 返回的 voice_id 以 API 为准(如 longxiaochun_v3),前端不做硬编码覆盖
|
||||
const realList: PresetVoice[] = (voices || []).map((v) => {
|
||||
// 按 name 模糊匹配 mock 获取补充信息
|
||||
const mockMatch = MOCK_VOICES.find(
|
||||
(m) => v.name?.includes(m.name.slice(1)) || m.name.includes(v.name?.slice(0, 2) || "___"),
|
||||
)
|
||||
// 按 id 精确匹配 mock 获取补充元信息(id 即 voice_id,唯一稳定键)
|
||||
const mockMatch = MOCK_VOICES.find((m) => m.id === v.id)
|
||||
return {
|
||||
...v,
|
||||
gender: v.gender || mockMatch?.gender,
|
||||
category:
|
||||
v.category ||
|
||||
mockMatch?.category ||
|
||||
(v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : undefined),
|
||||
(v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : "其他"),
|
||||
desc: v.desc || mockMatch?.desc,
|
||||
sample_audio_url: v.sample_audio_url,
|
||||
}
|
||||
})
|
||||
// 如果没有真实数据,使用 mock
|
||||
// 如果没有真实数据,使用兜底 mock(接口失败时)
|
||||
return realList.length > 0 ? realList : MOCK_VOICES
|
||||
}, [voices])
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
|
||||
describe("api/errors", () => {
|
||||
it("returns string error directly", () => {
|
||||
expect(getErrorMessage("plain")).toBe("plain")
|
||||
})
|
||||
it("uses Error.message", () => {
|
||||
expect(getErrorMessage(new Error("boom"))).toBe("boom")
|
||||
})
|
||||
it("returns fallback for empty/unknown", () => {
|
||||
expect(getErrorMessage(null)).toBe("操作失败,请稍后重试")
|
||||
expect(getErrorMessage(undefined, "f")).toBe("f")
|
||||
})
|
||||
it("reads axios-like response.data.detail", () => {
|
||||
const err = { response: { data: { detail: "后端报错" } }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("后端报错")
|
||||
})
|
||||
it("reads axios-like response.data.message", () => {
|
||||
const err = { response: { data: { message: "消息字段" } }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("消息字段")
|
||||
})
|
||||
it("HTTP 404 fallback", () => {
|
||||
const err = { response: { status: 404, data: null }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("404")
|
||||
})
|
||||
it("HTTP 401 fallback", () => {
|
||||
const err = { response: { status: 401, data: null }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("登录")
|
||||
})
|
||||
it("network error", () => {
|
||||
const err = { request: {}, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("网络")
|
||||
})
|
||||
it("isErrorMsgShown returns false for auth/abort", () => {
|
||||
const authErr = { response: { status: 401 } }
|
||||
const abortErr = { code: "ECONNABORTED" }
|
||||
expect(isErrorMsgShown(authErr)).toBe(false)
|
||||
expect(isErrorMsgShown(abortErr)).toBe(false)
|
||||
const e: any = new Error("x")
|
||||
e.__msgShown = true
|
||||
expect(isErrorMsgShown(e)).toBe(true)
|
||||
expect(isErrorMsgShown(new Error("x"))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
generateViralVideo,
|
||||
getViralVideoJob,
|
||||
confirmViralVideoIntent,
|
||||
retryViralVideo,
|
||||
getViralVideoHistory,
|
||||
getViralStyleTemplates,
|
||||
analyzeViralStyle,
|
||||
mockImageAnalysis,
|
||||
mockGenerateCopy,
|
||||
analyzeViralImages,
|
||||
generateViralCopy,
|
||||
confirmViralCopy,
|
||||
} from "@/api/viral-video"
|
||||
import {
|
||||
VALID_DURATIONS,
|
||||
VALID_RATIOS,
|
||||
isVideoStage,
|
||||
isImageAnalysisStage,
|
||||
isCopyStage,
|
||||
isAnalysisStage,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
// 让 setTimeout 同步执行,避免测试等待 1.8s/2.2s
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("viral-video constants & stage helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("VALID_DURATIONS/VALID_RATIOS", () => {
|
||||
expect(VALID_DURATIONS).toEqual([5, 10, 15, 20, 25, 30])
|
||||
expect(VALID_RATIOS).toEqual(expect.arrayContaining(["9:16", "16:9", "1:1"]))
|
||||
})
|
||||
|
||||
it("isVideoStage", () => {
|
||||
expect(isVideoStage("tts")).toBe(true)
|
||||
expect(isVideoStage("rendering")).toBe(true)
|
||||
expect(isVideoStage("uploading")).toBe(true)
|
||||
expect(isVideoStage("script_generation")).toBe(false)
|
||||
expect(isVideoStage("completed")).toBe(false)
|
||||
expect(isVideoStage(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("isImageAnalysisStage", () => {
|
||||
expect(isImageAnalysisStage("image_analysis")).toBe(true)
|
||||
expect(isImageAnalysisStage("video_analysis")).toBe(true)
|
||||
expect(isImageAnalysisStage("script_generation")).toBe(false)
|
||||
expect(isImageAnalysisStage(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("isCopyStage", () => {
|
||||
expect(isCopyStage("intent_parsing")).toBe(true)
|
||||
expect(isCopyStage("script_generation")).toBe(true)
|
||||
expect(isCopyStage("review")).toBe(true)
|
||||
expect(isCopyStage("tts")).toBe(false)
|
||||
})
|
||||
|
||||
it("isAnalysisStage is union", () => {
|
||||
expect(isAnalysisStage("image_analysis")).toBe(true)
|
||||
expect(isAnalysisStage("script_generation")).toBe(true)
|
||||
expect(isAnalysisStage("tts")).toBe(false)
|
||||
expect(isAnalysisStage(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("viral-video API wrappers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
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")
|
||||
vi.runAllTimersAsync()
|
||||
expect(await r).toEqual({ id: "j2" })
|
||||
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")
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j4/retry")
|
||||
})
|
||||
|
||||
it("getViralVideoHistory", async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
await getViralVideoHistory({ page: 1, page_size: 20 })
|
||||
expect(mockGet).toHaveBeenCalledWith("/viral-video/history", {
|
||||
params: { page: 1, page_size: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it("getViralStyleTemplates", async () => {
|
||||
mockGet.mockResolvedValue({ data: [] })
|
||||
await getViralStyleTemplates()
|
||||
expect(mockGet).toHaveBeenCalledWith("/viral-video/style-templates")
|
||||
})
|
||||
|
||||
it("analyzeViralStyle", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j5" } })
|
||||
await analyzeViralStyle("j5")
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j5/analyze-style")
|
||||
})
|
||||
|
||||
it("analyzeViralImages", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j6" } })
|
||||
await analyzeViralImages({ images: ["a.png"] } as never)
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/analyze-images", { images: ["a.png"] })
|
||||
})
|
||||
|
||||
it("generateViralCopy", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j7" } })
|
||||
await generateViralCopy("j7", { duration: 15 } as never)
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j7/generate-copy", { duration: 15 })
|
||||
})
|
||||
|
||||
it("confirmViralCopy", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j8" } })
|
||||
await confirmViralCopy("j8", { edited_copy: "xxx" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", { edited_copy: "xxx" })
|
||||
mockPost.mockClear()
|
||||
await confirmViralCopy("j8")
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("viral-video client mocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("mockImageAnalysis returns product list", async () => {
|
||||
const p = mockImageAnalysis([
|
||||
{ name: "a.png" },
|
||||
{ name: "b.jpg" },
|
||||
{ name: "c.webp" },
|
||||
{ name: "d.png" },
|
||||
])
|
||||
vi.advanceTimersByTime(2000)
|
||||
const r = await p
|
||||
expect(r.products).toHaveLength(3)
|
||||
expect(r.products[0].image_index).toBe(0)
|
||||
expect(r.products[0].brand).toBe("示例品牌")
|
||||
expect(r.products[1].spec).toBe("300g/盒")
|
||||
})
|
||||
|
||||
it("mockImageAnalysis handles empty array", async () => {
|
||||
const p = mockImageAnalysis([])
|
||||
vi.advanceTimersByTime(2000)
|
||||
const r = await p
|
||||
expect(r.products).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("mockGenerateCopy returns copy_result shape", async () => {
|
||||
const p = mockGenerateCopy({ product: "矿泉水", industry: "饮料", marketingPurpose: "种草" })
|
||||
vi.advanceTimersByTime(3000)
|
||||
const r = await p
|
||||
expect(r.title).toContain("种草")
|
||||
expect(r.title).toContain("矿泉水")
|
||||
expect(r.final_copy.length).toBeGreaterThan(50)
|
||||
expect(r.suggested_copy).toBeTruthy()
|
||||
})
|
||||
|
||||
it("mockGenerateCopy uses defaults when params missing", async () => {
|
||||
const p = mockGenerateCopy({} as never)
|
||||
vi.advanceTimersByTime(3000)
|
||||
const r = await p
|
||||
expect(r.title).toContain("品牌种草")
|
||||
expect(r.final_copy).toContain("这款产品")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase"
|
||||
|
||||
describe("getGenerationPhase", () => {
|
||||
it("returns 分析素材与配置 for p<20", () => {
|
||||
expect(getGenerationPhase(0)).toEqual({ label: "分析素材与配置", icon: "🔍" })
|
||||
expect(getGenerationPhase(19).label).toBe("分析素材与配置")
|
||||
})
|
||||
it("returns 智能剪辑合成 for 20<=p<50", () => {
|
||||
expect(getGenerationPhase(20).label).toBe("智能剪辑合成")
|
||||
expect(getGenerationPhase(49).label).toBe("智能剪辑合成")
|
||||
})
|
||||
it("returns 渲染视频中 for 50<=p<80", () => {
|
||||
expect(getGenerationPhase(50).label).toBe("渲染视频中")
|
||||
expect(getGenerationPhase(79).label).toBe("渲染视频中")
|
||||
})
|
||||
it("returns 即将完成 for p>=80", () => {
|
||||
expect(getGenerationPhase(80)).toEqual({ label: "即将完成", icon: "✨" })
|
||||
expect(getGenerationPhase(100).label).toBe("即将完成")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest"
|
||||
import { formatDuration, formatFileSize, formatDate } from "@/pages/products/detailUtils"
|
||||
|
||||
describe("products/detailUtils", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it("formatDuration", () => {
|
||||
expect(formatDuration(0)).toBe("00:00")
|
||||
expect(formatDuration(-1)).toBe("00:00")
|
||||
expect(formatDuration(5)).toBe("00:05")
|
||||
expect(formatDuration(65)).toBe("01:05")
|
||||
expect(formatDuration(3600)).toBe("60:00")
|
||||
})
|
||||
it("formatFileSize MB/GB", () => {
|
||||
expect(formatFileSize(0)).toBe("-")
|
||||
expect(formatFileSize(-1)).toBe("-")
|
||||
expect(formatFileSize(5.3)).toBe("5.3 MB")
|
||||
expect(formatFileSize(2048)).toBe("2.00 GB")
|
||||
})
|
||||
it("formatDate returns zh-CN format", () => {
|
||||
vi.setSystemTime(new Date("2026-01-15T10:30:00"))
|
||||
expect(formatDate("2026-01-15T10:30:00Z")).toMatch(/2026/)
|
||||
expect(formatDate("")).toBe("-")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useViralVideoPolling } from "@/pages/viral-video/hooks/useViralVideoPolling"
|
||||
|
||||
const getViralVideoJobMock = vi.fn()
|
||||
vi.mock("@/api/viral-video", () => ({
|
||||
getViralVideoJob: (...args: unknown[]) => getViralVideoJobMock(...args),
|
||||
}))
|
||||
|
||||
describe("useViralVideoPolling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("不传入 jobId 时不发起请求", () => {
|
||||
renderHook(() => useViralVideoPolling(null, vi.fn()))
|
||||
expect(getViralVideoJobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("传入 jobId 后立即调用 getViralVideoJob", () => {
|
||||
getViralVideoJobMock.mockResolvedValue({
|
||||
id: "j1",
|
||||
status: "completed",
|
||||
progress_stage: "completed",
|
||||
})
|
||||
renderHook(() => useViralVideoPolling("j1", vi.fn()))
|
||||
expect(getViralVideoJobMock).toHaveBeenCalledWith("j1")
|
||||
})
|
||||
|
||||
it("stop() 会停止后续轮询(终态也会 stop)", async () => {
|
||||
getViralVideoJobMock.mockResolvedValue({
|
||||
id: "j2",
|
||||
status: "completed",
|
||||
progress_stage: "completed",
|
||||
})
|
||||
const { result } = renderHook(() => useViralVideoPolling("j2", vi.fn(), { intervalMs: 50 }))
|
||||
// 等第一次 promise 完成
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
// 终态后不会再调度新请求
|
||||
const calls = getViralVideoJobMock.mock.calls.length
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
expect(getViralVideoJobMock).toHaveBeenCalledTimes(calls)
|
||||
expect(result.current.stop).toBeTypeOf("function")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
genderLabel,
|
||||
languageLabel,
|
||||
genderClass,
|
||||
formatTime,
|
||||
formatFileSize,
|
||||
} from "@/pages/voices/utils/format"
|
||||
|
||||
describe("voices utils/format", () => {
|
||||
it("genderLabel returns label or falls back to value", () => {
|
||||
expect(genderLabel("female")).toContain("女")
|
||||
expect(genderLabel("male")).toContain("男")
|
||||
expect(genderLabel("unknown" as never)).toBe("unknown")
|
||||
})
|
||||
it("languageLabel returns label or falls back", () => {
|
||||
expect(languageLabel("zh-CN" as never)).toBeTruthy()
|
||||
expect(languageLabel("xx-XX" as never)).toBe("xx-XX")
|
||||
})
|
||||
it("genderClass returns css class", () => {
|
||||
expect(genderClass("female")).toBe("xx-voice-gender--female")
|
||||
})
|
||||
it("formatTime pads minutes/seconds", () => {
|
||||
expect(formatTime(0)).toBe("00:00")
|
||||
expect(formatTime(5)).toBe("00:05")
|
||||
expect(formatTime(65)).toBe("01:05")
|
||||
expect(formatTime(3600)).toBe("60:00")
|
||||
})
|
||||
it("formatFileSize human-readable", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B")
|
||||
expect(formatFileSize(512)).toBe("512 B")
|
||||
expect(formatFileSize(2048)).toBe("2.0 KB")
|
||||
expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0 MB")
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,12 @@ export default defineConfig({
|
||||
"src/pages/editing-planner/EditingPlanner.tsx",
|
||||
"src/pages/assets/AssetLibrary.tsx",
|
||||
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
|
||||
"src/pages/viral-video/ViralVideoPage.tsx",
|
||||
],
|
||||
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
|
||||
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
|
||||
thresholds: {
|
||||
lines: 50,
|
||||
lines: 49,
|
||||
branches: 50,
|
||||
functions: 20,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,43 @@ Staging 当前可以保持 no-op;Production 开启前必须先验证 SMTP/Redi
|
||||
|
||||
---
|
||||
|
||||
## Staging 服务器 Docker 凭证配置
|
||||
|
||||
Staging 服务器(116.62.226.203)需要配置 ACR 和 Gitea Registry 凭证,否则 docker pull 和 Watchtower 自动更新会失败。
|
||||
|
||||
### 凭证文件位置
|
||||
- Docker 配置文件:`/root/.docker/config.json`
|
||||
- 包含两个 registry 的认证信息:
|
||||
- `xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com`(阿里云 ACR)
|
||||
- `git.xiaoxiajianji.com`(Gitea 容器镜像仓库)
|
||||
|
||||
### 服务器迁移后恢复步骤
|
||||
```bash
|
||||
# 1. 登录 ACR
|
||||
docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u <ACR_USERNAME>
|
||||
|
||||
# 2. 登录 Gitea Registry
|
||||
docker login git.xiaoxiajianji.com -u xiaoxia -p <GITEA_REGISTRY_TOKEN>
|
||||
|
||||
# 3. 重启 Watchtower(确保挂载最新 config.json)
|
||||
docker restart watchtower
|
||||
```
|
||||
|
||||
### Watchtower 配置
|
||||
- 容器名:`watchtower`
|
||||
- 检查间隔:300 秒(5 分钟)
|
||||
- 监控容器:`xiaoxia-api-staging`、`xiaoxia-worker-staging`、`xiaoxia-web-staging`
|
||||
- 必须挂载 `-v /root/.docker/config.json:/config.json` 才能拉取私有镜像
|
||||
- 必须挂载 `-v /var/run/docker.sock:/var/run/docker.sock` 才能管理容器
|
||||
- 容器使用 `:dev` 稳定 tag,Watchtower 通过检测 `:dev` tag 的 digest 变化来发现更新
|
||||
|
||||
### 镜像 Tag 策略
|
||||
- CI 每次构建推送三种 tag:`${GITHUB_SHA}`(精确版本)、`${GITHUB_REF_NAME}`(分支名)、`:dev`(滚动 tag,仅 develop 分支)
|
||||
- Staging 容器统一使用 `:dev` tag 启动,确保 Watchtower 能自动发现新版本
|
||||
- Migration(alembic)使用 commit SHA tag 执行,不依赖 Watchtower
|
||||
|
||||
---
|
||||
|
||||
## Gitea Actions 约定
|
||||
|
||||
- `develop` 分支触发 staging 部署。
|
||||
|
||||
@@ -30,6 +30,10 @@ COPY deploy/configs/douyin_cookies.txt /app/configs/douyin_cookies.txt
|
||||
# 强制升级 yt-dlp 到最新(抖音反爬经常变更,旧版 cookies 支持失效;#1968/#1963)
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com --upgrade "yt-dlp>=2026.8.19"
|
||||
|
||||
# API 启动入口(幂等迁移 + uvicorn)—— #2129: watchtower 自动部署兜底
|
||||
COPY infra/docker/entrypoint-api.sh /usr/local/bin/entrypoint-api.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-api.sh
|
||||
|
||||
# 设置环境变量
|
||||
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
ENV PYTHONPATH=/app:/app/apps/api
|
||||
@@ -41,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
|
||||
# API 入口点
|
||||
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint-api.sh"]
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# API 启动入口:先幂等执行数据库迁移,再启动传入的 CMD(默认 uvicorn)
|
||||
# 解决 watchtower 自动拉取新镜像后容器重启、未跑 alembic upgrade head 导致新列缺失 500 的问题(#2129)
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
echo "[entrypoint-api] Running alembic upgrade head..."
|
||||
if alembic upgrade head; then
|
||||
echo "[entrypoint-api] Migrations ok."
|
||||
else
|
||||
echo "[entrypoint-api] WARNING: alembic upgrade failed, continuing (existing columns should be fine)..." >&2
|
||||
fi
|
||||
|
||||
# 若有显式 CMD(CI 部署时 docker compose run --rm api sh -c '...' 传入),直接 exec 它
|
||||
if [ "$#" -gt 0 ]; then
|
||||
echo "[entrypoint-api] Exec custom command: $*"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "[entrypoint-api] Starting uvicorn..."
|
||||
exec uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -18,6 +18,17 @@
|
||||
|
||||
set -e
|
||||
|
||||
# #2129: 幂等执行数据库迁移(watchtower 自动部署兜底)
|
||||
# worker 容器独立启动,不能依赖 API 容器先跑迁移
|
||||
cd /app
|
||||
echo "[entrypoint-worker] Running alembic upgrade head..."
|
||||
if alembic upgrade head; then
|
||||
echo "[entrypoint-worker] Migrations ok."
|
||||
else
|
||||
echo "[entrypoint-worker] WARNING: alembic upgrade failed, continuing to start workers..." >&2
|
||||
fi
|
||||
cd - >/dev/null
|
||||
|
||||
MAX_TASKS="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
|
||||
# ── 并发计算:显式 env 优先;否则从 WORKER_CONCURRENCY 按比例推导 ──
|
||||
|
||||
@@ -34,6 +34,7 @@ ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 复制文件(按变化频率从低到高排序,最大化层缓存命中)
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY alembic/ /app/alembic/
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
# PR #1844 起,worker 还需要加载 apps.api.app.tasks.lipsync_tts,
|
||||
|
||||
@@ -943,12 +943,20 @@ class ViralVideoJobModel(Base):
|
||||
style_strength = Column(String(20), nullable=False, default="medium")
|
||||
style_guide = Column(JSON, nullable=True)
|
||||
style_template_id = Column(String(36), nullable=False, default="", index=True)
|
||||
# v1.5 音频/视频参数
|
||||
voice_id = Column(String(200), nullable=False, default="")
|
||||
voice_source = Column(String(20), nullable=False, default="")
|
||||
video_ratio = Column(String(10), nullable=False, default="9:16")
|
||||
video_model = Column(String(100), nullable=False, default="")
|
||||
# 结果与状态
|
||||
status = Column(String(30), nullable=False, default="pending", index=True)
|
||||
intent_result = Column(JSON, nullable=True)
|
||||
image_analysis = Column(JSON, nullable=True)
|
||||
storyboard = Column(JSON, nullable=True)
|
||||
generated_copy_text = Column(Text, nullable=False, default="")
|
||||
copy_result = Column(
|
||||
JSON, nullable=True
|
||||
) # v1.6: 编导脚本结构{overview,scene_and_lighting,shots,hard_constraints,negative_prompts,voiceover_script}
|
||||
result_video_url = Column(String(1000), nullable=False, default="")
|
||||
credits_cost = Column(Integer, nullable=False, default=0)
|
||||
error_msg = Column(Text, nullable=False, default="")
|
||||
|
||||
@@ -84,6 +84,11 @@ def ensure_database_exists(database_url: str) -> None:
|
||||
_VIRAL_VIDEO_BACKFILL_COLS = [
|
||||
("storyboard", "JSON"),
|
||||
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
viral_structure=model.viral_structure or "",
|
||||
marketing_purpose=model.marketing_purpose or "",
|
||||
bgm_preference=model.bgm_preference or "",
|
||||
duration=model.duration or 30,
|
||||
duration=model.duration or 15,
|
||||
user_copy_text=model.user_copy_text or "",
|
||||
fusion_level=model.fusion_level or "ai_polish",
|
||||
reference_audio_path=model.reference_audio_path or "",
|
||||
@@ -32,11 +32,16 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
style_strength=getattr(model, "style_strength", "medium") or "medium",
|
||||
style_guide=dict(model.style_guide) if model.style_guide else None,
|
||||
style_template_id=getattr(model, "style_template_id", "") or "",
|
||||
voice_id=getattr(model, "voice_id", "") or "",
|
||||
voice_source=getattr(model, "voice_source", "") or "",
|
||||
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(model, "video_model", "") or "",
|
||||
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
|
||||
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 "",
|
||||
copy_result=dict(model.copy_result) if getattr(model, "copy_result", None) else None,
|
||||
result_video_url=model.result_video_url or "",
|
||||
credits_cost=model.credits_cost or 0,
|
||||
error_msg=model.error_msg or "",
|
||||
@@ -73,11 +78,16 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
style_strength=job.style_strength,
|
||||
style_guide=job.style_guide,
|
||||
style_template_id=job.style_template_id,
|
||||
voice_id=job.voice_id,
|
||||
voice_source=job.voice_source,
|
||||
video_ratio=job.video_ratio,
|
||||
video_model=job.video_model,
|
||||
status=job.status,
|
||||
intent_result=job.intent_result,
|
||||
image_analysis=job.image_analysis,
|
||||
storyboard=job.storyboard,
|
||||
generated_copy_text=job.generated_copy_text,
|
||||
copy_result=job.copy_result,
|
||||
result_video_url=job.result_video_url,
|
||||
credits_cost=job.credits_cost,
|
||||
error_msg=job.error_msg,
|
||||
@@ -100,6 +110,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
model.image_analysis = job.image_analysis
|
||||
model.storyboard = job.storyboard
|
||||
model.generated_copy_text = job.generated_copy_text or ""
|
||||
model.copy_result = job.copy_result
|
||||
model.result_video_url = job.result_video_url
|
||||
model.credits_cost = job.credits_cost
|
||||
model.error_msg = job.error_msg
|
||||
@@ -121,6 +132,10 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
model.reference_video_url = job.reference_video_url
|
||||
model.style_strength = job.style_strength
|
||||
model.style_template_id = job.style_template_id
|
||||
model.voice_id = job.voice_id or ""
|
||||
model.voice_source = job.voice_source or ""
|
||||
model.video_ratio = job.video_ratio or "9:16"
|
||||
model.video_model = job.video_model or ""
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""ViralVideoJob 领域模型 — 爆款视频任务.
|
||||
|
||||
状态机(v1.5 三步分步):
|
||||
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
|
||||
状态机(三步分步):
|
||||
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
|
||||
wait_user_confirm -> running -> completed (旧路径兼容)
|
||||
任意阶段 fail; 任意非终态 cancel.
|
||||
@@ -26,8 +27,6 @@ from uuid import uuid4
|
||||
|
||||
|
||||
class ViralVideoStatus(StrEnum):
|
||||
"""爆款视频任务状态枚举。"""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
IMAGE_ANALYZED = "image_analyzed"
|
||||
@@ -39,44 +38,32 @@ class ViralVideoStatus(StrEnum):
|
||||
|
||||
|
||||
class ViralVideoStage(StrEnum):
|
||||
"""编排流水线阶段枚举(用于 WS 进度推送)。"""
|
||||
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
VIDEO_ANALYSIS = "video_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
COPY_FUSION = "copy_fusion"
|
||||
STORYBOARD = "storyboard"
|
||||
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
|
||||
REVIEW = "review"
|
||||
TTS = "tts"
|
||||
BGM_SELECT = "bgm_select"
|
||||
RENDERING = "rendering"
|
||||
MUSETALK = "musetalk"
|
||||
RENDERING = "rendering" # v1.6: 单次 Seedance 生成(BGM/音效/画面一次出片)
|
||||
UPLOADING = "uploading"
|
||||
|
||||
|
||||
class FusionLevel(StrEnum):
|
||||
"""文案融合级别。"""
|
||||
|
||||
AI_FULL = "ai_full"
|
||||
AI_POLISH = "ai_polish"
|
||||
USER_PRIMARY = "user_primary"
|
||||
|
||||
|
||||
class StyleStrength(StrEnum):
|
||||
"""风格强度。"""
|
||||
|
||||
LIGHT = "light"
|
||||
MEDIUM = "medium"
|
||||
STRICT = "strict"
|
||||
|
||||
|
||||
class PromptType(StrEnum):
|
||||
"""Prompt 模板类型(与 #2040 seed 对齐)。"""
|
||||
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
COPY_FUSION = "copy_fusion"
|
||||
STORYBOARD = "storyboard"
|
||||
SCRIPT_GENERATION = "script_generation"
|
||||
REVIEW = "review"
|
||||
VIDEO_STYLE_INTEGRATION = "video_style_integration"
|
||||
STYLE_CONSTRAINT = "style_constraint"
|
||||
@@ -88,20 +75,17 @@ STAGE_LABELS = {
|
||||
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
|
||||
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
|
||||
ViralVideoStage.INTENT_PARSING: "意图解析",
|
||||
ViralVideoStage.COPY_FUSION: "文案融合",
|
||||
ViralVideoStage.STORYBOARD: "分镜脚本",
|
||||
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
|
||||
ViralVideoStage.REVIEW: "合规审核",
|
||||
ViralVideoStage.TTS: "AI 配音",
|
||||
ViralVideoStage.BGM_SELECT: "BGM 选择",
|
||||
ViralVideoStage.RENDERING: "视频渲染",
|
||||
ViralVideoStage.MUSETALK: "数字人口型",
|
||||
ViralVideoStage.RENDERING: "视频生成",
|
||||
ViralVideoStage.UPLOADING: "上传发布",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViralVideoJob:
|
||||
"""爆款视频任务领域实体。"""
|
||||
"""爆款视频任务领域实体(v1.6 单次 Seedance 出片版)。"""
|
||||
|
||||
user_id: str
|
||||
images: list[str] = field(default_factory=list)
|
||||
@@ -111,24 +95,28 @@ class ViralVideoJob:
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 30
|
||||
duration: int = 15 # v1.6: 默认15秒,上限30秒(Seedance 2.5 单次最大30s)
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = FusionLevel.AI_POLISH
|
||||
reference_audio_path: str = ""
|
||||
# v1.3
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = StyleStrength.MEDIUM
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
# v1.4 图片分析结果(run_pipeline 持久化,resume 时读取给文案/分镜)
|
||||
# v1.5.1 音频/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
# v1.4+ 产物
|
||||
image_analysis: dict | None = None
|
||||
# v1.5 三步分步流水线产物(持久化,供前端 GET 读取 + resume 消费)
|
||||
storyboard: list | None = None
|
||||
generated_copy_text: str = ""
|
||||
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: 完整编导脚本结构
|
||||
# 状态
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
status: ViralVideoStatus = ViralVideoStatus.PENDING
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
@@ -155,7 +143,6 @@ class ViralVideoJob:
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_image_analyzed(self) -> None:
|
||||
"""阶段1完成:图片/视频分析完成,等待用户填参数或直接触发阶段2。"""
|
||||
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
|
||||
raise ValueError(f"Cannot transition from {self.status} to image_analyzed")
|
||||
self.status = ViralVideoStatus.IMAGE_ANALYZED
|
||||
@@ -163,8 +150,8 @@ class ViralVideoJob:
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_copy_generated(self, copy_text: str, storyboard: list) -> None:
|
||||
"""阶段2完成:文案+分镜+审核完成,等待用户编辑后确认。"""
|
||||
def mark_copy_generated(self, copy_result: dict) -> None:
|
||||
"""v1.6 阶段2完成:编导脚本(含 voiceover_script/shots/硬约束/负面词)已生成。"""
|
||||
if self.status not in (
|
||||
ViralVideoStatus.IMAGE_ANALYZED,
|
||||
ViralVideoStatus.RUNNING,
|
||||
@@ -172,12 +159,14 @@ class ViralVideoJob:
|
||||
):
|
||||
raise ValueError(f"Cannot transition from {self.status} to copy_generated")
|
||||
self.status = ViralVideoStatus.COPY_GENERATED
|
||||
self.generated_copy_text = copy_text or ""
|
||||
self.storyboard = list(storyboard) if storyboard else []
|
||||
self.copy_result = copy_result or {}
|
||||
if isinstance(copy_result, dict):
|
||||
self.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||||
shots = copy_result.get("shots") or []
|
||||
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
|
||||
@@ -185,7 +174,6 @@ class ViralVideoJob:
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_image_analyzed(self, **kwargs) -> None:
|
||||
"""阶段1->阶段2:用户已填参数,开始跑文案/分镜。kwargs 覆盖参数字段。"""
|
||||
if self.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING):
|
||||
raise ValueError(f"Cannot resume from {self.status} to copy-gen")
|
||||
for k, v in kwargs.items():
|
||||
@@ -195,17 +183,16 @@ class ViralVideoJob:
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_copy_generated(self, edited_copy: str | None = None) -> None:
|
||||
"""阶段2->阶段3:用户确认/编辑文案,开始跑渲染。"""
|
||||
"""阶段2->阶段3:用户确认/编辑口播文案,开始跑 TTS+单次Seedance渲染。"""
|
||||
if self.status != ViralVideoStatus.COPY_GENERATED:
|
||||
raise ValueError(f"Cannot resume from {self.status} to render")
|
||||
if edited_copy:
|
||||
self.user_copy_text = edited_copy
|
||||
if edited_copy and isinstance(self.copy_result, dict):
|
||||
self.copy_result = {**self.copy_result, "voiceover_script": edited_copy}
|
||||
self.generated_copy_text = edited_copy
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_confirm(self) -> None:
|
||||
"""旧流水线兼容:从 WAIT_USER_CONFIRM 恢复。"""
|
||||
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
raise ValueError(f"Cannot resume from {self.status}")
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
@@ -240,5 +227,11 @@ class ViralVideoJob:
|
||||
|
||||
@property
|
||||
def effective_copy_text(self) -> str:
|
||||
"""渲染阶段使用的最终文案:用户编辑 > 生成文案 > 用户原始 > 占位。"""
|
||||
return self.user_copy_text or self.generated_copy_text or "精选好物推荐"
|
||||
"""TTS 用的最终口播文案:优先 copy_result.voiceover_script,兼容老字段。"""
|
||||
if isinstance(self.copy_result, dict) and self.copy_result.get("voiceover_script"):
|
||||
return self.copy_result["voiceover_script"]
|
||||
return self.generated_copy_text or self.user_copy_text or "你好,给大家推荐一款好物"
|
||||
|
||||
@property
|
||||
def voiceover_script(self) -> str:
|
||||
return self.effective_copy_text
|
||||
|
||||
@@ -250,21 +250,31 @@ class DoubaoClient:
|
||||
duration: int = 5,
|
||||
ratio: str | None = "9:16",
|
||||
resolution: str = "720p",
|
||||
generate_audio: bool = False,
|
||||
generate_audio: bool = True,
|
||||
watermark: bool = False,
|
||||
output_dir: str | None = None,
|
||||
model: str | None = None,
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""调用 Seedance 2.5 文生/图生视频(异步任务→轮询→下载),返回本地 MP4 路径;失败返回 None。
|
||||
|
||||
v1.6: 支持多参考图(产品素材)+ 参考音频(TTS口型驱动)+ 参考视频,单次生成最长 30 秒。
|
||||
|
||||
Args:
|
||||
prompt: 文本提示词
|
||||
image_url: 首帧参考图 URL(可选,提供则走图生视频)
|
||||
duration: 视频时长 2~30 秒,默认 5
|
||||
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive
|
||||
prompt: 文本提示词(含完整编导脚本:总览+场景光线+逐镜头时间轴+硬约束+负面词)
|
||||
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 跟随首帧)
|
||||
duration: 视频时长 4~30 秒
|
||||
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;image_url 存在时自动忽略
|
||||
resolution: 480p/720p/1080p
|
||||
generate_audio: 是否生成模型自带音效(默认 False,我们自己混 TTS)
|
||||
generate_audio: 是否让模型原生合成音效/BGM(v1.6 默认 True,配合 reference_audios 做口型驱动)
|
||||
watermark: 是否加水印
|
||||
output_dir: 下载目录,默认 /tmp
|
||||
model: 指定模型 ID;空则用 settings.doubao_video_model
|
||||
reference_images: 多参考图 URL 列表(产品素材,最多30张;注意 image_url 为首帧单独传)
|
||||
reference_audios: 参考音频 URL 列表(TTS口播,驱动口型,最多10段)
|
||||
reference_videos: 参考视频 URL 列表(风格参考)
|
||||
|
||||
Returns:
|
||||
本地 MP4 文件路径,失败返回 None。
|
||||
@@ -276,23 +286,37 @@ class DoubaoClient:
|
||||
|
||||
settings = get_shared_settings()
|
||||
poll_interval = getattr(settings, "doubao_video_poll_interval", 10) or 10
|
||||
total_timeout = getattr(settings, "doubao_video_timeout", 600) or 600
|
||||
video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
|
||||
total_timeout = getattr(settings, "doubao_video_timeout", 900) or 900
|
||||
default_video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
|
||||
video_model = model or default_video_model
|
||||
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt.strip()}]
|
||||
if image_url:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}})
|
||||
# v1.6: 多参考图(产品素材)
|
||||
if reference_images:
|
||||
for url in reference_images[:30]:
|
||||
if url and isinstance(url, str):
|
||||
content.append({"type": "image_url", "image_url": {"url": url}})
|
||||
|
||||
create_payload: dict[str, Any] = {
|
||||
"model": video_model,
|
||||
"content": content,
|
||||
"generate_audio": generate_audio,
|
||||
"generate_audio": bool(generate_audio),
|
||||
"duration": int(duration),
|
||||
"resolution": resolution,
|
||||
"watermark": watermark,
|
||||
"watermark": bool(watermark),
|
||||
}
|
||||
# Bug #2110: ratio=None 时不传(首帧图生视频跟随原图比例,传 ratio 会 400 InvalidParameter)
|
||||
if ratio:
|
||||
# v1.6: 参考音频(TTS 驱动口型)
|
||||
if reference_audios:
|
||||
create_payload["reference_audios"] = [
|
||||
{"url": u, "role": "audio_url"} for u in reference_audios[:10] if u and isinstance(u, str)
|
||||
]
|
||||
# v1.6: 参考视频(风格参考)
|
||||
if reference_videos:
|
||||
create_payload["reference_videos"] = [{"url": u} for u in reference_videos[:5] if u and isinstance(u, str)]
|
||||
# Bug #2110 / v1.6: ratio=None 时不传(首帧图生视频跟随原图比例)
|
||||
if ratio and not image_url:
|
||||
create_payload["ratio"] = ratio
|
||||
|
||||
headers = {
|
||||
@@ -301,13 +325,16 @@ class DoubaoClient:
|
||||
}
|
||||
create_url = f"{self.base_url}/contents/generations/tasks"
|
||||
logger.info(
|
||||
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s",
|
||||
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s ref_imgs=%d ref_audios=%d ref_videos=%d",
|
||||
create_url,
|
||||
video_model,
|
||||
duration,
|
||||
ratio or "(follow-image)",
|
||||
generate_audio,
|
||||
bool(image_url),
|
||||
len(reference_images or []),
|
||||
len(reference_audios or []),
|
||||
len(reference_videos or []),
|
||||
)
|
||||
|
||||
# 1) 创建任务(带重试)
|
||||
@@ -356,7 +383,13 @@ class DoubaoClient:
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Seedance 任务已创建: task_id=%s model=%s duration=%ds", task_id, video_model, duration)
|
||||
logger.info(
|
||||
"Seedance 任务已创建: task_id=%s model=%s duration=%ds gen_audio=%s",
|
||||
task_id,
|
||||
video_model,
|
||||
duration,
|
||||
generate_audio,
|
||||
)
|
||||
|
||||
# 2) 轮询状态
|
||||
poll_url = f"{create_url}/{task_id}"
|
||||
|
||||
@@ -575,32 +575,43 @@ def call_video_generation(
|
||||
prompt: str,
|
||||
*,
|
||||
image_url: str | None = None,
|
||||
duration: int = 5,
|
||||
duration: int = 15,
|
||||
ratio: str | None = "9:16",
|
||||
resolution: str = "720p",
|
||||
output_dir: str | None = None,
|
||||
model: str | None = None,
|
||||
generate_audio: bool = True,
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""调用 Seedance 2.5 生成视频段,返回本地 MP4 路径;失败返回 None。
|
||||
"""调用 Seedance 2.5 生成视频(v1.6 单次出片版),返回本地 MP4 路径;失败返回 None。
|
||||
|
||||
封装 ai_client.video_generation:提交异步任务→轮询→下载到本地。
|
||||
Bug #2110: 首帧参考图模式下不传 ratio(API 要求跟随首帧图比例,传 ratio=9:16
|
||||
会返回 400 InvalidParameter)。
|
||||
v1.6:
|
||||
- 默认 generate_audio=True,模型原生合成环境音效/BGM;
|
||||
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
|
||||
- reference_images 传产品素材 URL 数组做视觉参考;
|
||||
- 单次最长 30 秒,不分段不拼接;
|
||||
- image_url 存在时为「首帧图生视频」模式,自动不传 ratio(Bug #2110)。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
|
||||
return None
|
||||
# 首帧模式:不强制 ratio,让模型跟随首帧图比例
|
||||
effective_ratio = None if image_url else ratio
|
||||
try:
|
||||
kwargs: dict = dict(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=duration,
|
||||
duration=int(duration),
|
||||
resolution=resolution,
|
||||
generate_audio=False, # 我们自己混 TTS
|
||||
generate_audio=bool(generate_audio),
|
||||
watermark=False,
|
||||
output_dir=output_dir,
|
||||
model=model,
|
||||
reference_images=reference_images,
|
||||
reference_audios=reference_audios,
|
||||
reference_videos=reference_videos,
|
||||
)
|
||||
if effective_ratio:
|
||||
kwargs["ratio"] = effective_ratio
|
||||
|
||||
@@ -368,6 +368,21 @@ fi
|
||||
|
||||
echo "All images pulled."
|
||||
|
||||
# ====== 打稳定 tag(:dev),供 Watchtower 监控 ======
|
||||
# Watchtower 只能检测同一个 tag 的 digest 变化。
|
||||
# commit SHA tag 每次构建都不同,Watchtower 无法感知更新。
|
||||
# 因此每次部署都将最新镜像 tag 为 :dev,容器统一使用 :dev 启动。
|
||||
DEV_API="${REGISTRY}/xiaoxia-saas-api:dev"
|
||||
DEV_WORKER="${REGISTRY}/xiaoxia-saas-worker:dev"
|
||||
DEV_WEB="${REGISTRY}/xiaoxia-saas-web:dev"
|
||||
docker tag "$REGISTRY_API" "$DEV_API"
|
||||
docker tag "$REGISTRY_WORKER" "$DEV_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$DEV_WEB"
|
||||
echo "✅ Tagged images as :dev for Watchtower monitoring"
|
||||
echo " API: $DEV_API"
|
||||
echo " Worker: $DEV_WORKER"
|
||||
echo " Web: $DEV_WEB"
|
||||
|
||||
# ====== 镜像内容校验 ======
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
@@ -533,7 +548,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_API" &
|
||||
"$DEV_API" &
|
||||
PID_API_START=$!
|
||||
|
||||
# ── Worker: 通过 compose 启动(单一事实来源)──
|
||||
@@ -541,7 +556,7 @@ PID_API_START=$!
|
||||
# healthcheck 匹配 'celery.*worker'(不把 beat 算活)、资源限制 4C/8G。
|
||||
# WORKER_IMAGE 通过环境变量覆盖镜像 tag(compose.yml 默认 :dev)。
|
||||
echo "Starting worker via docker compose (from $INFRA_DOCKER_DIR)..."
|
||||
WORKER_IMAGE="$REGISTRY_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
|
||||
WORKER_IMAGE="$DEV_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
|
||||
PID_WORKER_START=$!
|
||||
|
||||
# ── Web: 暂保留 docker run(TODO: 后续收敛到 compose)──
|
||||
@@ -557,7 +572,7 @@ docker run -d \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$REGISTRY_WEB" &
|
||||
"$DEV_WEB" &
|
||||
PID_WEB_START=$!
|
||||
|
||||
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
|
||||
@@ -688,5 +703,5 @@ echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Worker: managed by docker compose (project=$COMPOSE_PROJECT)"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
echo "Version: $IMAGE_TAG (running as :dev for Watchtower)"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
|
||||
+104
-51
@@ -120,7 +120,7 @@ class TestViralVideoJobDefaults:
|
||||
job = ViralVideoJob(user_id="u1")
|
||||
assert job.images == []
|
||||
assert job.industry == ""
|
||||
assert job.duration == 30
|
||||
assert job.duration == 15
|
||||
assert job.fusion_level == FusionLevel.AI_POLISH
|
||||
assert job.style_strength == StyleStrength.MEDIUM
|
||||
assert job.status == ViralVideoStatus.PENDING
|
||||
@@ -145,13 +145,10 @@ class TestViralVideoStage:
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"script_generation",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
]
|
||||
actual_order = [s.value for s in ViralVideoStage]
|
||||
@@ -171,7 +168,7 @@ class TestViralVideoSchemas:
|
||||
assert req.images == ["https://example.com/img.jpg"]
|
||||
assert req.fusion_level == "ai_polish"
|
||||
assert req.style_strength == "medium"
|
||||
assert req.duration == 30
|
||||
assert req.duration == 15
|
||||
|
||||
def test_create_request_empty_images_raises(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
@@ -365,9 +362,10 @@ class TestViralVideoPipeline:
|
||||
industry="美妆",
|
||||
target_customer="年轻女性",
|
||||
marketing_purpose="品牌推广",
|
||||
duration=30,
|
||||
duration=15,
|
||||
user_copy_text="这款产品超好用",
|
||||
fusion_level="ai_polish",
|
||||
video_ratio="9:16",
|
||||
)
|
||||
|
||||
@patch("packages.shared.ai_service.call_vision")
|
||||
@@ -405,63 +403,110 @@ class TestViralVideoPipeline:
|
||||
assert "intent" in result
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_copy_fusion_ai_polish(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_copy_fusion
|
||||
def test_script_generation_returns_copy_result(self, mock_llm, mock_job):
|
||||
"""v1.6: _step_script_generation 返回 dict 形式的 CopyResult,含 voiceover_script + shots。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_script_generation
|
||||
|
||||
mock_llm.return_value = "融合后的文案内容"
|
||||
result = _step_copy_fusion(mock_job, {"intent": "推广"}, {"products": []})
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
mock_llm.return_value = {
|
||||
"overview": {"theme": "口红推荐", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台,柔和自然光",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-5秒",
|
||||
"shot_type_angle_movement": "近景平视,缓慢推镜",
|
||||
"scene_and_dialogue": "女主微笑展示口红:大家好,今天分享一款口红",
|
||||
"action_details": "手持口红特写",
|
||||
"audio_bgm": "轻快流行BGM",
|
||||
"transition": "硬切",
|
||||
"reference_image_index": 0,
|
||||
},
|
||||
{
|
||||
"time_range": "5-15秒",
|
||||
"shot_type_angle_movement": "特写,固定镜头",
|
||||
"scene_and_dialogue": "涂抹口红:颜色特别好看很显白",
|
||||
"action_details": "嘴唇涂抹特写",
|
||||
"audio_bgm": "轻快BGM继续",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 1,
|
||||
},
|
||||
],
|
||||
"hard_constraints": ["无字幕无水印"],
|
||||
"negative_prompts": ["字幕", "水印"],
|
||||
"voiceover_script": "大家好,今天分享一款口红,颜色特别好看很显白。",
|
||||
}
|
||||
result = _step_script_generation(
|
||||
mock_job, {"intent": "推广口红", "key_messages": [], "tone": "亲切"}, {"products": []}
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "voiceover_script" in result
|
||||
assert "shots" in result
|
||||
assert isinstance(result["shots"], list)
|
||||
assert len(result["shots"]) == 2
|
||||
assert result["overview"]["total_duration"] == 15
|
||||
# final_copy 必须 = voiceover_script(向后兼容)
|
||||
assert result.get("final_copy") == result["voiceover_script"]
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_storyboard_generation(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
|
||||
def test_script_generation_fallback(self, mock_llm, mock_job):
|
||||
"""LLM 返回异常时使用兜底脚本(不会抛错)。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _fallback_script
|
||||
|
||||
mock_llm.return_value = [
|
||||
{"order": 0, "type": "product_shot", "duration": 10},
|
||||
{"order": 1, "type": "closing", "duration": 5},
|
||||
]
|
||||
result = _step_storyboard(mock_job, "测试文案", {})
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
result = _fallback_script(mock_job)
|
||||
assert isinstance(result, dict)
|
||||
assert result["voiceover_script"]
|
||||
assert len(result["shots"]) >= 1
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_review_pass(self, mock_llm, mock_job):
|
||||
def test_review_pass_v16(self, mock_llm, mock_job):
|
||||
"""v1.6 _step_review 接收 copy_result dict。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_review
|
||||
|
||||
mock_llm.return_value = {"passed": True, "score": 90, "details": {}}
|
||||
result = _step_review(mock_job, "测试文案", [])
|
||||
cr = {"voiceover_script": "大家好", "shots": []}
|
||||
result = _step_review(mock_job, cr)
|
||||
assert result["passed"] is True
|
||||
|
||||
def test_bgm_select(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
def test_assemble_seedance_prompt(self, mock_job):
|
||||
"""编导脚本必须能拼出完整的 Seedance prompt,含总览/场景/逐镜头/约束。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
|
||||
|
||||
# P1: BGM 素材未就绪前 _step_bgm_select 统一返回 None(跳过 BGM 混音)
|
||||
mock_job.bgm_preference = "upbeat"
|
||||
bgm = _step_bgm_select(mock_job)
|
||||
assert bgm is None
|
||||
|
||||
def test_bgm_select_default(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
|
||||
mock_job.bgm_preference = ""
|
||||
bgm = _step_bgm_select(mock_job)
|
||||
assert bgm is None
|
||||
cr = {
|
||||
"overview": {"theme": "口红", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-15秒",
|
||||
"shot_type_angle_movement": "中景平视",
|
||||
"scene_and_dialogue": "你好分享",
|
||||
"action_details": "展示",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0,
|
||||
}
|
||||
],
|
||||
"hard_constraints": ["无字幕"],
|
||||
"negative_prompts": ["水印"],
|
||||
}
|
||||
prompt = _assemble_seedance_prompt(cr, mock_job)
|
||||
assert "【视频总览】" in prompt
|
||||
assert "【逐镜头时间轴】" in prompt
|
||||
assert "【硬性约束】" in prompt
|
||||
assert "【负面提示词】" in prompt
|
||||
assert "0-15秒" in prompt
|
||||
|
||||
|
||||
# ── 端到端流水线集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPipelineIntegration:
|
||||
"""流水线端到端集成测试(mock 外部依赖)。"""
|
||||
"""v1.6 流水线端到端集成测试(mock 外部依赖):TTS+单次 Seedance+上传。"""
|
||||
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_upload")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_render")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_bgm_select")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._upload_tts_to_oss")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_tts")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_review")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_storyboard")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_copy_fusion")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_script_generation")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_intent_parsing")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_video_analysis")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_image_analysis")
|
||||
@@ -474,38 +519,46 @@ class TestPipelineIntegration:
|
||||
mock_img_analysis,
|
||||
mock_video_analysis,
|
||||
mock_intent,
|
||||
mock_copy_fusion,
|
||||
mock_storyboard,
|
||||
mock_script,
|
||||
mock_review,
|
||||
mock_tts,
|
||||
mock_bgm,
|
||||
mock_tts_upload,
|
||||
mock_render,
|
||||
mock_upload,
|
||||
):
|
||||
"""测试 resume 流水线能从确认状态走到完成。"""
|
||||
"""v1.6: TTS整段合成 → 上传TTS到OSS → 单次 Seedance → 上传成片。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import (
|
||||
resume_viral_video_pipeline,
|
||||
)
|
||||
|
||||
# 构造 mock job
|
||||
job = ViralVideoJob(
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
status=ViralVideoStatus.RUNNING,
|
||||
intent_result={"intent": "推广"},
|
||||
duration=15,
|
||||
video_ratio="9:16",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_get_repo.return_value = (mock_session, mock_repo, job)
|
||||
|
||||
# 设置各步骤返回值
|
||||
mock_copy_fusion.return_value = "融合文案"
|
||||
mock_storyboard.return_value = [{"order": 0, "duration": 10}]
|
||||
# v1.6: 如果没有 copy_result 会现场补生成
|
||||
mock_intent.return_value = {"intent": "推广", "key_messages": [], "tone": "亲切"}
|
||||
mock_script.return_value = {
|
||||
"overview": {"theme": "口红", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台",
|
||||
"shots": [],
|
||||
"hard_constraints": [],
|
||||
"negative_prompts": [],
|
||||
"voiceover_script": "大家好,分享一款口红。",
|
||||
"final_copy": "大家好,分享一款口红。",
|
||||
}
|
||||
mock_review.return_value = {"passed": True, "score": 90}
|
||||
mock_tts.return_value = None # P1: TTS 返回 Path|None,mock 用 None 跳过混音
|
||||
mock_bgm.return_value = None # P1: BGM 未就绪前返回 None
|
||||
mock_tts.return_value = None # TTS 失败也能走下去(Seedance generate_audio=True 会自己合成音效)
|
||||
mock_tts_upload.return_value = None
|
||||
mock_render.return_value = "/tmp/video.mp4"
|
||||
mock_upload.return_value = "https://oss.example.com/final.mp4"
|
||||
|
||||
|
||||
@@ -67,49 +67,70 @@ class TestImageAnalysisField:
|
||||
# ── P0-1: storyboard 规范化 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStoryboardNormalize:
|
||||
def test_normalize_fills_defaults(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
|
||||
class TestScriptGenerationV16:
|
||||
"""v1.6 编导分镜脚本生成相关纯函数测试。"""
|
||||
|
||||
raw = [{"order": 0, "description": "镜头一"}]
|
||||
out = _normalize_storyboard(raw, total_duration=10, n_segments=1, copy_text="文案")
|
||||
assert len(out) == 1
|
||||
assert out[0]["duration"] >= 3
|
||||
assert out[0]["ken_burns"] in {"zoom_in", "zoom_out", "pan_left", "pan_right", "static"}
|
||||
assert out[0]["type"] == "product_shot"
|
||||
assert out[0]["text"] == ""
|
||||
def test_fallback_script_has_required_fields(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _fallback_script
|
||||
|
||||
def test_normalize_scales_to_total_duration(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
|
||||
out = _fallback_script(mock_job)
|
||||
assert isinstance(out, dict)
|
||||
assert "overview" in out
|
||||
assert "shots" in out
|
||||
assert "voiceover_script" in out
|
||||
assert "hard_constraints" in out
|
||||
assert "negative_prompts" in out
|
||||
assert out["overview"]["total_duration"] == mock_job.duration
|
||||
assert out["final_copy"] == out["voiceover_script"]
|
||||
assert len(out["shots"]) >= 1
|
||||
|
||||
raw = [
|
||||
{"order": 0, "duration": 10, "description": "a"},
|
||||
{"order": 1, "duration": 10, "description": "b"},
|
||||
]
|
||||
out = _normalize_storyboard(raw, total_duration=10, n_segments=2, copy_text="x")
|
||||
total = sum(s["duration"] for s in out)
|
||||
assert total == 10
|
||||
def test_safe_json_loads_parses_fenced_code(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||||
|
||||
def test_fallback_storyboard(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _fallback_storyboard
|
||||
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
|
||||
out = _safe_json_loads(fenced)
|
||||
assert out is not None
|
||||
assert out["voiceover_script"] == "你好"
|
||||
|
||||
out = _fallback_storyboard("文案", total_duration=15, n_segments=3)
|
||||
assert len(out) == 3
|
||||
assert sum(s["duration"] for s in out) == 15
|
||||
assert all(s["duration"] >= 3 for s in out)
|
||||
def test_safe_json_loads_handles_none(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||||
|
||||
def test_storyboard_llm_list(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
|
||||
assert _safe_json_loads(None) is None
|
||||
assert _safe_json_loads("not json") is None
|
||||
|
||||
with patch("packages.shared.ai_service.call_llm") as mock_llm:
|
||||
mock_llm.return_value = [
|
||||
{"order": 0, "description": "产品特写", "duration": 5, "text": "t1"},
|
||||
{"order": 1, "description": "使用场景", "duration": 5, "text": "t2"},
|
||||
{"order": 2, "description": "CTA", "duration": 5, "text": "t3"},
|
||||
]
|
||||
result = _step_storyboard(mock_job, "文案", {"products": []})
|
||||
assert len(result) == 3
|
||||
assert all("description" in s for s in result)
|
||||
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
|
||||
|
||||
cr = {
|
||||
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-15秒",
|
||||
"shot_type_angle_movement": "中景",
|
||||
"scene_and_dialogue": "你好",
|
||||
"action_details": "展示",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0,
|
||||
}
|
||||
],
|
||||
"hard_constraints": ["无字幕"],
|
||||
"negative_prompts": ["水印"],
|
||||
}
|
||||
p = _assemble_seedance_prompt(cr, mock_job)
|
||||
for key in ("【视频总览】", "【场景与光线】", "【逐镜头时间轴】", "【硬性约束】", "【负面提示词】"):
|
||||
assert key in p
|
||||
|
||||
|
||||
# ── P1: TTS 返回 Path|None ────────────────────────────────────────
|
||||
@@ -150,12 +171,24 @@ class TestTTSPath:
|
||||
# ── P1: BGM 跳过 / MuseTalk 无 persona 跳过 ───────────────────────
|
||||
|
||||
|
||||
class TestBGMSkip:
|
||||
def test_bgm_returns_none(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
class TestDurationClamp:
|
||||
"""v1.6 mark_copy_generated 派生字段 + duration clamp。"""
|
||||
|
||||
mock_job.bgm_preference = "upbeat"
|
||||
assert _step_bgm_select(mock_job) is None
|
||||
def test_mark_copy_generated_derives_fields(self):
|
||||
job = ViralVideoJob(user_id="u1", duration=15)
|
||||
cr = {
|
||||
"overview": {"theme": "x", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "亮",
|
||||
"shots": [{"time_range": "0-15秒", "scene_and_dialogue": "对白"}],
|
||||
"voiceover_script": "你好",
|
||||
"hard_constraints": [],
|
||||
"negative_prompts": [],
|
||||
}
|
||||
job.mark_copy_generated(cr)
|
||||
assert job.copy_result is cr
|
||||
assert job.generated_copy_text == "你好"
|
||||
assert job.storyboard == cr["shots"]
|
||||
assert job.effective_copy_text == "你好"
|
||||
|
||||
|
||||
# ── P0-1: call_video_generation 参数构造 ──────────────────────────
|
||||
@@ -188,23 +221,61 @@ class TestCallVideoGeneration:
|
||||
assert kwargs["prompt"] == "测试"
|
||||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||||
assert kwargs["duration"] == 5
|
||||
assert kwargs["generate_audio"] is True
|
||||
|
||||
|
||||
# ── P0-1: _step_render 占位片段生成 ──────────────────────────────
|
||||
|
||||
|
||||
class TestPlaceholderClip:
|
||||
def test_make_placeholder_clip(self, tmp_path):
|
||||
import shutil
|
||||
class TestCallVideoGenerationV16:
|
||||
"""v1.6 call_video_generation 透传 reference_audios/reference_images 等参数到 client。"""
|
||||
|
||||
from apps.worker.worker_app.tasks.viral_video import _make_placeholder_clip, _probe_ok
|
||||
def test_passes_reference_params_to_client(self, tmp_path):
|
||||
from packages.shared.ai_service import call_video_generation
|
||||
|
||||
if not shutil.which("ffmpeg"):
|
||||
pytest.skip("ffmpeg not available")
|
||||
out = tmp_path / "v.mp4"
|
||||
out.write_bytes(b"fake")
|
||||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.video_generation.return_value = str(out)
|
||||
mock_get.return_value = mock_client
|
||||
result = call_video_generation(
|
||||
prompt="测试",
|
||||
image_url="https://img/x.jpg",
|
||||
duration=15,
|
||||
ratio="9:16",
|
||||
reference_images=["https://img/r1.jpg"],
|
||||
reference_audios=["https://oss/tts.mp3"],
|
||||
reference_videos=["https://oss/ref.mp4"],
|
||||
generate_audio=True,
|
||||
model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
assert result == str(out)
|
||||
kwargs = mock_client.video_generation.call_args.kwargs
|
||||
# 首帧模式不传 ratio(Bug #2110)
|
||||
assert "ratio" not in kwargs
|
||||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||||
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
|
||||
assert kwargs["reference_images"] == ["https://img/r1.jpg"]
|
||||
assert kwargs["reference_videos"] == ["https://oss/ref.mp4"]
|
||||
assert kwargs["generate_audio"] is True
|
||||
assert kwargs["model"] == "doubao-seedance-2-5-260628"
|
||||
|
||||
out = _make_placeholder_clip(tmp_path, 0, 3)
|
||||
assert out.exists()
|
||||
assert _probe_ok(str(out))
|
||||
def test_ratio_passed_when_no_image(self, tmp_path):
|
||||
from packages.shared.ai_service import call_video_generation
|
||||
|
||||
out = tmp_path / "v.mp4"
|
||||
out.write_bytes(b"fake")
|
||||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.video_generation.return_value = str(out)
|
||||
mock_get.return_value = mock_client
|
||||
call_video_generation(prompt="测试", duration=10, ratio="16:9")
|
||||
kwargs = mock_client.video_generation.call_args.kwargs
|
||||
assert kwargs["ratio"] == "16:9"
|
||||
assert kwargs["image_url"] is None
|
||||
|
||||
|
||||
# ── P0-1: DoubaoClient.video_generation 在不可用时返回 None ───────
|
||||
|
||||
@@ -34,7 +34,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"viral_structure": "",
|
||||
"marketing_purpose": "",
|
||||
"bgm_preference": "",
|
||||
"duration": 30,
|
||||
"duration": 15,
|
||||
"user_copy_text": "",
|
||||
"fusion_level": "ai_polish",
|
||||
"reference_audio_path": "",
|
||||
@@ -53,9 +53,18 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"intent_result": None,
|
||||
"image_analysis": None,
|
||||
"storyboard": None,
|
||||
"copy_result": None,
|
||||
"generated_copy_text": "",
|
||||
"voice_id": "",
|
||||
"voice_source": "",
|
||||
"voice_mode": "global",
|
||||
"video_ratio": "9:16",
|
||||
"video_model": "",
|
||||
"credits_cost": 0,
|
||||
"updated_at": None,
|
||||
"is_terminal": False,
|
||||
"effective_copy_text": "",
|
||||
"voiceover_script": "",
|
||||
}.items():
|
||||
setattr(job, k, kwargs.pop(k, v))
|
||||
return job
|
||||
@@ -277,6 +286,71 @@ class TestGenerateCopy:
|
||||
vv_mod.generate_copy("job-gc2", GenerateCopyRequest(), authenticated_user=user, session=session)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
def test_generate_copy_persists_voice_and_ratio(self):
|
||||
"""generate-copy 应把 voice_id/voice_source/video_ratio 写入 job。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import GenerateCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-gc3", user_id="u1", status=ViralVideoStatus.IMAGE_ANALYZED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = GenerateCopyRequest(
|
||||
voice_id="cosy_voice_001",
|
||||
voice_source="library",
|
||||
video_ratio="16:9",
|
||||
)
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
vv_mod.generate_copy("job-gc3", req, authenticated_user=user, session=session)
|
||||
assert job.voice_id == "cosy_voice_001"
|
||||
assert job.voice_source == "library"
|
||||
assert job.video_ratio == "16:9"
|
||||
|
||||
|
||||
class TestAnalyzeImagesPersist:
|
||||
def test_analyze_images_persists_voice_and_ratio(self):
|
||||
"""analyze-images 创建任务时应带上 voice/video_ratio 字段。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import AnalyzeImagesRequest
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
saved = {}
|
||||
|
||||
class FakeRepo:
|
||||
def save(self, job):
|
||||
saved["job"] = job
|
||||
|
||||
def get(self, jid):
|
||||
return None
|
||||
|
||||
req = AnalyzeImagesRequest(
|
||||
images=["img-1"],
|
||||
voice_id="preset_v1",
|
||||
voice_source="preset",
|
||||
video_ratio="1:1",
|
||||
)
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=FakeRepo()),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
resp = vv_mod.analyze_images(req, authenticated_user=user, session=session)
|
||||
job = saved["job"]
|
||||
assert job.voice_id == "preset_v1"
|
||||
assert job.voice_source == "preset"
|
||||
assert job.video_ratio == "1:1"
|
||||
assert resp.images == ["img-1"]
|
||||
|
||||
|
||||
class TestConfirmCopy:
|
||||
def test_confirm_copy_dispatches_render(self):
|
||||
|
||||
Reference in New Issue
Block a user