Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d707b64876 | |||
| fe4464df2e | |||
| 0c0fe4619d | |||
| ad686dcd8b | |||
| 347cc82ffa | |||
| d3ee11d27a | |||
| 16ef616907 | |||
| ea51b372af | |||
| 9c807444da | |||
| b31205e965 | |||
| b4b53c9e5d | |||
| cf0a08f503 | |||
| 3ede4dca1f | |||
| 133a6c5914 | |||
| 1e8ba91bba | |||
| 2a739dee17 | |||
| 6b75eb5f67 | |||
| dbb4e57810 | |||
| ecb049a57e | |||
| 58d6852a71 | |||
| 66033c520f | |||
| 07069144c7 | |||
| 5bbc34d4c3 |
@@ -212,9 +212,14 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
|
||||
DOUBAO_API_KEY=your-doubao-api-key
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_FAST_MODEL=doubao-1-5-pro-32k-250115
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
|
||||
DOUBAO_VISION_MODEL=doubao-1-5-vision-pro-250328
|
||||
DOUBAO_VISION_LITE_MODEL=doubao-1-5-vision-lite-250315
|
||||
DOUBAO_VISION_USE_LITE=true
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""viral video add phase_message column (#2134)
|
||||
|
||||
Revision ID: 090_viral_video_phase_msg
|
||||
Revises: 089_viral_video_cols
|
||||
Create Date: 2026-10-02
|
||||
|
||||
#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。
|
||||
current_stage 列已在之前版本存在,本迁移只补 phase_message。
|
||||
幂等 ADD COLUMN IF NOT EXISTS。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "090_viral_video_phase_msg"
|
||||
down_revision = "089_viral_video_cols"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite/PostgreSQL 兼容的幂等添加列
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "phase_message" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("viral_video_jobs", "phase_message")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""viral video add current_stage column (#2137 follow-up)
|
||||
|
||||
Revision ID: 091_viral_video_stage
|
||||
Revises: 090_viral_video_phase_msg
|
||||
Create Date: 2026-10-02
|
||||
|
||||
#2137 follow-up fix: 090 migration missed current_stage column on viral_video_jobs,
|
||||
causing UndefinedColumn errors and 500s on all authenticated viral-video endpoints.
|
||||
Idempotently add current_stage and double-check phase_message.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "091_viral_video_stage"
|
||||
down_revision = "090_viral_video_phase_msg"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "current_stage" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column(
|
||||
"current_stage",
|
||||
sa.String(length=200),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
if "phase_message" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column(
|
||||
"phase_message",
|
||||
sa.String(length=500),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("viral_video_jobs", "current_stage")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""viral_video_jobs 增加 heartbeat_at 列(worker 心跳,用于僵尸任务超时回收)
|
||||
|
||||
Revision ID: 092_viral_video_heartbeat
|
||||
Revises: 091_viral_video_stage
|
||||
Create Date: 2026-10-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "092_viral_video_heartbeat"
|
||||
down_revision = "091_viral_video_stage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "heartbeat_at" not in cols:
|
||||
op.add_column("viral_video_jobs", sa.Column("heartbeat_at", sa.DateTime(), nullable=True))
|
||||
op.execute(
|
||||
"UPDATE viral_video_jobs SET heartbeat_at = updated_at " "WHERE status = 'running' AND heartbeat_at IS NULL"
|
||||
)
|
||||
try:
|
||||
op.create_index("ix_viral_video_jobs_heartbeat_at", "viral_video_jobs", ["heartbeat_at"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "heartbeat_at" in cols:
|
||||
try:
|
||||
op.drop_index("ix_viral_video_jobs_heartbeat_at", table_name="viral_video_jobs")
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_column("viral_video_jobs", "heartbeat_at")
|
||||
@@ -128,6 +128,8 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
style_guide=job.style_guide,
|
||||
style_template_id=job.style_template_id,
|
||||
status=job.status,
|
||||
current_stage=getattr(job, "current_stage", "") or "",
|
||||
phase_message=getattr(job, "phase_message", "") or "",
|
||||
image_analysis=getattr(job, "image_analysis", None),
|
||||
storyboard=getattr(job, "storyboard", None),
|
||||
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
|
||||
@@ -398,28 +400,42 @@ def retry_viral_video_job(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""重试失败的爆款视频任务。"""
|
||||
"""重试失败的爆款视频任务(也支持对僵尸/超时 running 任务强制重置后重试)。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
if job.status != ViralVideoStatus.FAILED:
|
||||
raise HTTPException(status_code=409, detail="只有失败的任务可以重试")
|
||||
|
||||
# 判定是否为僵尸 running 任务:running 超过 10 分钟且心跳停止超过 2 分钟
|
||||
now = datetime.now(timezone.utc)
|
||||
is_stale_running = False
|
||||
if job.status == ViralVideoStatus.RUNNING and job.started_at is not None:
|
||||
hb = getattr(job, "heartbeat_at", None) or job.updated_at
|
||||
if (now - job.started_at).total_seconds() > 10 * 60 and hb is not None and (now - hb).total_seconds() > 2 * 60:
|
||||
is_stale_running = True
|
||||
|
||||
if job.status != ViralVideoStatus.FAILED and not is_stale_running:
|
||||
raise HTTPException(status_code=409, detail="只有失败或超时的任务可以重试")
|
||||
|
||||
# 重置状态
|
||||
job.retry_count += 1
|
||||
job.status = ViralVideoStatus.PENDING
|
||||
job.error_msg = ""
|
||||
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.current_stage = ""
|
||||
job.phase_message = ""
|
||||
job.heartbeat_at = None
|
||||
repo.update(job)
|
||||
|
||||
# 重新入队
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
|
||||
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count)
|
||||
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s", job.id, job.retry_count, is_stale_running)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"重试入队失败: {e}")
|
||||
|
||||
@@ -201,6 +201,10 @@ class ViralVideoJobResponse(BaseModel):
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
status: str
|
||||
current_stage: str = (
|
||||
"" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading)
|
||||
)
|
||||
phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示)
|
||||
image_analysis: dict | None = None
|
||||
# v1.6 编导脚本(推荐前端使用)
|
||||
copy_result: dict | None = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,6 @@ import {
|
||||
CaretRightOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
BulbOutlined,
|
||||
ThunderboltOutlined,
|
||||
ReloadOutlined,
|
||||
WarningFilled,
|
||||
SearchOutlined,
|
||||
@@ -23,6 +21,7 @@ import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
EditOutlined,
|
||||
HistoryOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Select, Input, message } from "antd"
|
||||
import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
@@ -72,7 +71,7 @@ type UploadedImage = {
|
||||
localId: string
|
||||
url: string
|
||||
name: string
|
||||
size: number
|
||||
size: number | undefined
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
@@ -122,6 +121,8 @@ type TabTask = {
|
||||
// STEP3 设置
|
||||
videoRatio: string
|
||||
videoModel: string
|
||||
quality: string
|
||||
upscale: string
|
||||
// UI 状态
|
||||
uiStep: UIStep
|
||||
imageAnalysis: ImageAnalysisResult | null
|
||||
@@ -150,14 +151,61 @@ const TARGET_CUSTOMERS = [
|
||||
"都市蓝领",
|
||||
"都市银发",
|
||||
]
|
||||
const PERSONAS = ["亲切邻家姐姐", "专业顾问", "搞笑段子手", "真实测评博主", "闺蜜种草", "行业专家"]
|
||||
const PERSONAS = [
|
||||
"通用个人IP",
|
||||
"老板型IP",
|
||||
"专家型IP",
|
||||
"顾问型IP",
|
||||
"创始人IP",
|
||||
"创业者IP",
|
||||
"从业者经验派",
|
||||
"避坑顾问型",
|
||||
"知识科普型",
|
||||
"测评种草型",
|
||||
]
|
||||
const STRUCTURES = [
|
||||
"痛点开场→卖点→促单",
|
||||
"反差对比→证据→促单",
|
||||
"故事讲述→共鸣→促单",
|
||||
"专家背书→数据→促单",
|
||||
"反差破局+亮明观点+还原现状",
|
||||
"痛点场景植入+给出通关秘籍",
|
||||
"反常识开场+扎心现状+制造焦虑",
|
||||
"核心难题+化解焦虑+描绘愿景",
|
||||
"痛点前置+前后对比+给出期许",
|
||||
"悬念拉满+揭露危害+明确目的",
|
||||
"情绪共鸣+勾起好奇+拔高价值",
|
||||
"颠覆认知+现状拆解+赋予价值",
|
||||
"结果先行+消除顾虑+保留悬念",
|
||||
"真实困境+认知反转+传授秘籍",
|
||||
"反差事实+案例佐证+戳破真相",
|
||||
"预埋悬念+标杆案例+引出核心",
|
||||
"危机渲染+放大痛点+输出干货",
|
||||
"逆袭开场+成功诀窍+低价引流",
|
||||
"反常识观点+分析危害+描述现状",
|
||||
"正反对比+解析原因+简易方法",
|
||||
"真实场景+内心刻画+共情引导+展望未来",
|
||||
"连续否定+代入感受+打破认知+精准戳痛",
|
||||
"揭秘行业+类比施压+放大焦虑+精准锁客",
|
||||
]
|
||||
const PURPOSES = [
|
||||
"获客引流",
|
||||
"账号涨粉",
|
||||
"活动通知",
|
||||
"口播带货",
|
||||
"促销转化",
|
||||
"场景种草",
|
||||
"痛点解决",
|
||||
"功能演示",
|
||||
"门店发现",
|
||||
"到店实录",
|
||||
"招牌体验",
|
||||
"同城团购",
|
||||
"品牌主张",
|
||||
"品牌故事",
|
||||
"生活方式",
|
||||
"创意概念",
|
||||
"对话短剧",
|
||||
"反转短剧",
|
||||
"悬念短剧",
|
||||
"情绪短片",
|
||||
]
|
||||
const PURPOSES = ["品牌种草", "新品上市", "促销转化", "活动引流"]
|
||||
const DURATIONS = [15, 20, 30, 45, 60]
|
||||
const RATIOS = [
|
||||
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
|
||||
@@ -168,6 +216,18 @@ const MODELS = [
|
||||
{ v: "seedance-2.5", label: "Seedance 2.5(推荐)" },
|
||||
{ v: "seedance-2.0", label: "Seedance 2.0" },
|
||||
]
|
||||
const QUALITY_OPTIONS = [
|
||||
{ v: "480p", label: "480p(快速)" },
|
||||
{ v: "720p", label: "720p(清晰)" },
|
||||
{ v: "1080p", label: "1080p(高清)" },
|
||||
]
|
||||
const UPSCALE_OPTIONS = [
|
||||
{ v: "default", label: "默认" },
|
||||
{ v: "sd", label: "普清" },
|
||||
{ v: "hd", label: "高清" },
|
||||
{ v: "uhd", label: "超清" },
|
||||
{ v: "omnipotent", label: "全能" },
|
||||
]
|
||||
const VOICE_TIPS =
|
||||
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
|
||||
|
||||
@@ -306,7 +366,8 @@ const MOCK_STORYBOARD: Storyboard = {
|
||||
"还在为餐桌选不到好桌子发愁?这张北美黑胡桃木餐桌,一家人坐下来吃饭刚刚好。全实木、无贴皮,纹理好看又耐刮。点小黄车,给家里添一张好桌子。",
|
||||
}
|
||||
|
||||
const fmtSize = (bytes: number) => {
|
||||
const fmtSize = (bytes: number | undefined) => {
|
||||
if (!bytes || bytes <= 0) return "--"
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
@@ -327,11 +388,13 @@ const emptyTask = (id: string, title: string): TabTask => ({
|
||||
targetCustomer: "",
|
||||
language: "中文(普通话)",
|
||||
viralStructure: STRUCTURES[0],
|
||||
marketingPurpose: PURPOSES[0],
|
||||
marketingPurpose: "",
|
||||
duration: 15,
|
||||
persona: "",
|
||||
videoRatio: "9:16",
|
||||
videoModel: "seedance-2.5",
|
||||
quality: "480p",
|
||||
upscale: "default",
|
||||
uiStep: "step1_upload",
|
||||
imageAnalysis: null,
|
||||
storyboard: null,
|
||||
@@ -343,6 +406,32 @@ const emptyTask = (id: string, title: string): TabTask => ({
|
||||
})
|
||||
|
||||
/* ─────────── 主页面 ─────────── */
|
||||
|
||||
function getCopyStageText(stage: string | undefined): string {
|
||||
switch (stage) {
|
||||
case "image_analysis":
|
||||
case "video_analysis":
|
||||
case "analyzing_images":
|
||||
return "🔍 正在分析商品特征..."
|
||||
case "intent_parsing":
|
||||
case "generating_outline":
|
||||
case "planning":
|
||||
return "📋 正在构思脚本框架..."
|
||||
case "script_generation":
|
||||
case "generating_storyboard":
|
||||
case "writing_shots":
|
||||
return "🎬 正在生成分镜脚本..."
|
||||
case "review":
|
||||
case "generating_copy":
|
||||
case "writing_copy":
|
||||
return "✍️ 正在撰写口播文案..."
|
||||
case "finalizing":
|
||||
return "✨ 正在优化细节..."
|
||||
default:
|
||||
return "🎬 AI 正在创作视频分镜脚本..."
|
||||
}
|
||||
}
|
||||
|
||||
const ViralVideoPage: React.FC = () => {
|
||||
const [tasks, setTasks] = useState<TabTask[]>(() => [emptyTask("t1", "生成 1")])
|
||||
const [activeId, setActiveId] = useState<string>("t1")
|
||||
@@ -493,8 +582,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
)
|
||||
|
||||
/* ── 图片 ── */
|
||||
const canEditAssets = task.uiStep === "step1_upload" || task.uiStep === "failed"
|
||||
|
||||
const appendImagesFromFiles = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
const remaining = 9 - task.images.length
|
||||
@@ -530,7 +617,15 @@ const ViralVideoPage: React.FC = () => {
|
||||
setTask((t) => ({
|
||||
...t,
|
||||
images: t.images.map((im) =>
|
||||
im.localId === item.localId ? { ...im, uploading: false, pct: 100, ossUrl: url } : im,
|
||||
im.localId === item.localId
|
||||
? {
|
||||
...im,
|
||||
uploading: false,
|
||||
pct: 100,
|
||||
ossUrl: url,
|
||||
size: item.file!.size || im.size,
|
||||
}
|
||||
: im,
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
@@ -551,7 +646,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
localId: `lib_${a.id}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
url: a.thumbnail_url || a.file_url || "",
|
||||
name: a.name || "未命名",
|
||||
size: a.file_size || 0,
|
||||
size: a.file_size || undefined,
|
||||
uploading: false,
|
||||
pct: 100,
|
||||
ossUrl: a.file_url || undefined,
|
||||
@@ -788,7 +883,9 @@ const ViralVideoPage: React.FC = () => {
|
||||
duration: task.duration,
|
||||
persona_id: task.persona || undefined,
|
||||
user_copy_text:
|
||||
task.fusionLevel === "user_primary" && task.userCopy ? task.userCopy : undefined,
|
||||
(task.fusionLevel === "user_primary" || task.fusionLevel === "ai_polish") && task.userCopy
|
||||
? task.userCopy
|
||||
: undefined,
|
||||
fusion_level: task.fusionLevel,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
voice_id: task.refAudio?.source === "preset" ? task.refAudio.id : undefined,
|
||||
@@ -895,10 +992,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
const outputUrl = task.job?.output_url || task.job?.result_video_url
|
||||
const errMsg = task.videoError || task.job?.error_message || task.job?.error_msg
|
||||
|
||||
const canEditStep2 =
|
||||
task.uiStep !== "step2_generating" &&
|
||||
task.uiStep !== "step3_generating" &&
|
||||
task.uiStep !== "step3_done"
|
||||
const step2Enabled =
|
||||
task.uiStep === "step1_done" ||
|
||||
task.uiStep === "step2_generating" ||
|
||||
@@ -943,24 +1036,61 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
{products.map((p, i) => (
|
||||
<div key={i} className="vv-recog-item">
|
||||
{p.name && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">图片{i + 1}:</span>
|
||||
<span>
|
||||
{p.name || "未识别"}
|
||||
{p.spec && <span className="vv-recog-meta">({p.spec})</span>}
|
||||
{p.brand && <span className="vv-recog-meta"> · {p.brand}</span>}
|
||||
{p.category && <span className="vv-recog-meta"> · {p.category}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{featureText(p.key_features ?? p.features) && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">商品:</span>
|
||||
<span>{p.name}</span>
|
||||
{p.spec && <span style={{ color: "#6b7280" }}>({p.spec})</span>}
|
||||
{p.brand && <span style={{ color: "#6b7280" }}> · {p.brand}</span>}
|
||||
<span className="vv-recog-k">核心特征:</span>
|
||||
<span className="vv-recog-v">{featureText(p.key_features ?? p.features)}</span>
|
||||
</div>
|
||||
)}
|
||||
{featureText(p.features) && (
|
||||
{p.colors && p.colors.length > 0 && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">视觉特征:</span>
|
||||
<span>{featureText(p.features)}</span>
|
||||
<span className="vv-recog-k">主色调:</span>
|
||||
<span className="vv-recog-v">{p.colors.join(" / ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.label_text && (
|
||||
{p.material_or_texture && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">品牌/标签文字:</span>
|
||||
<span>{p.label_text}</span>
|
||||
<span className="vv-recog-k">材质/纹理:</span>
|
||||
<span className="vv-recog-v">{p.material_or_texture}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.visual_style && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">视觉风格:</span>
|
||||
<span className="vv-recog-v">{p.visual_style}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.scene && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">场景:</span>
|
||||
<span className="vv-recog-v">{p.scene}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.target_audience_hint && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">目标人群:</span>
|
||||
<span className="vv-recog-v">{p.target_audience_hint}</span>
|
||||
</div>
|
||||
)}
|
||||
{(p.text_on_image || p.label_text) && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">包装文字:</span>
|
||||
<span className="vv-recog-v">{p.text_on_image || p.label_text}</span>
|
||||
</div>
|
||||
)}
|
||||
{p.selling_points && (
|
||||
<div className="vv-recog-line">
|
||||
<span className="vv-recog-k">卖点:</span>
|
||||
<span className="vv-recog-v">{p.selling_points}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1009,8 +1139,11 @@ const ViralVideoPage: React.FC = () => {
|
||||
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 className="vv-spinner" />
|
||||
<span className="vv-copy-loading-text">
|
||||
{task.job?.progress_message || getCopyStageText(task.job?.progress_stage)}
|
||||
</span>
|
||||
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1049,160 +1182,160 @@ const ViralVideoPage: React.FC = () => {
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const locked = task.uiStep === "step3_generating" || task.uiStep === "step3_done"
|
||||
const locked = false
|
||||
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>
|
||||
|
||||
{/* 1. 总览卡片 */}
|
||||
<div className="vv-sb-overview">
|
||||
<div className="vv-sb-ov-field">
|
||||
<label>主题</label>
|
||||
<div className="vv-sb-doc">
|
||||
{/* 视频总览 */}
|
||||
<h4 className="vv-sb-h">视频总览</h4>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">整体主题:</span>
|
||||
<input
|
||||
className="vv-input vv-sb-input"
|
||||
className="vv-sb-inline vv-sb-inline-input"
|
||||
value={sb.overview.theme}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateOverview({ theme: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-ov-field">
|
||||
<label>总时长</label>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">总时长:</span>
|
||||
<input
|
||||
className="vv-input vv-sb-input"
|
||||
className="vv-sb-inline vv-sb-inline-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>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">画幅:</span>
|
||||
<Select
|
||||
className="vv-select vv-select-purple vv-sb-input"
|
||||
style={{ width: "100%" }}
|
||||
className="vv-select vv-sb-inline-select"
|
||||
style={{ width: 200 }}
|
||||
value={sb.overview.aspect_ratio}
|
||||
disabled={locked}
|
||||
onChange={(v) => updateOverview({ aspect_ratio: v })}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.label }))}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
|
||||
variant="borderless"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 场景与光线 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<BulbOutlined style={{ color: "#7c3aed" }} /> 场景与光线
|
||||
</div>
|
||||
{/* 场景与光线 */}
|
||||
<h4 className="vv-sb-h">场景与光线</h4>
|
||||
<textarea
|
||||
className="vv-textarea"
|
||||
className="vv-textarea vv-sb-doc-ta"
|
||||
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">
|
||||
{/* 逐镜头 */}
|
||||
<h4 className="vv-sb-h">逐秒镜头拆解</h4>
|
||||
<div className="vv-sb-doc-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>
|
||||
<div key={idx} className="vv-sb-doc-shot">
|
||||
<div className="vv-sb-doc-shot-head">
|
||||
<input
|
||||
className="vv-sb-time"
|
||||
className="vv-sb-time-doc"
|
||||
value={sh.time_range}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { time_range: e.target.value })}
|
||||
placeholder="0-3秒"
|
||||
/>
|
||||
</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 className="vv-sb-kv">
|
||||
<span className="vv-sb-k">景别/角度与运镜:</span>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm"
|
||||
value={sh.shot_type_angle_movement}
|
||||
disabled={locked}
|
||||
onChange={(e) =>
|
||||
updateShot(idx, { shot_type_angle_movement: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">场景与对白:</span>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-doc-ta"
|
||||
value={sh.scene_and_dialogue}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { scene_and_dialogue: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">动作与真人细节:</span>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm"
|
||||
value={sh.action_details}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { action_details: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">音效/BGM:</span>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm"
|
||||
value={sh.audio_bgm}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { audio_bgm: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-kv">
|
||||
<span className="vv-sb-k">转场:</span>
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-doc-ta vv-sb-doc-ta-sm"
|
||||
value={sh.transition}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateShot(idx, { transition: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-sb-kv vv-sb-kv-ref">
|
||||
<span className="vv-sb-k">参考图片:</span>
|
||||
{refImg?.url ? (
|
||||
<span className="vv-sb-ref-badge">
|
||||
<img src={refImg.url} alt={`ref-${idx}`} className="vv-sb-ref-thumb" />
|
||||
{!locked && (
|
||||
<button
|
||||
className="vv-sb-ref-x"
|
||||
onClick={() => updateShot(idx, { reference_image_index: undefined })}
|
||||
aria-label="移除参考图"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
) : !locked && task.images.length > 0 ? (
|
||||
<span className="vv-sb-ref-pick">
|
||||
{task.images.map((im, i) => (
|
||||
<button
|
||||
key={im.localId}
|
||||
className="vv-sb-ref-opt"
|
||||
onClick={() => updateShot(idx, { reference_image_index: i })}
|
||||
title={`@图片${i + 1}`}
|
||||
>
|
||||
<img src={im.url} alt={`img${i}`} />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="vv-sb-muted">未指定</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 硬性约束 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<ThunderboltOutlined style={{ color: "#7c3aed" }} /> 硬性约束
|
||||
</div>
|
||||
{/* 硬性约束 */}
|
||||
<h4 className="vv-sb-h">硬性约束</h4>
|
||||
<div className="vv-sb-taglist">
|
||||
{sb.hard_constraints.map((c, i) => (
|
||||
<span key={i} className="vv-sb-tag vv-sb-tag-hard">
|
||||
@@ -1229,13 +1362,9 @@ const ViralVideoPage: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. 负面提示词 */}
|
||||
<div className="vv-sb-section">
|
||||
<div className="vv-sb-section-title">
|
||||
<CloseOutlined style={{ color: "#ef4444" }} /> 负面提示词
|
||||
</div>
|
||||
{/* 负面提示词 */}
|
||||
<h4 className="vv-sb-h">负面提示词</h4>
|
||||
<div className="vv-sb-taglist">
|
||||
{sb.negative_prompts.map((c, i) => (
|
||||
<span key={i} className="vv-sb-tag vv-sb-tag-neg">
|
||||
@@ -1262,20 +1391,18 @@ const ViralVideoPage: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. 完整口播稿(折叠) */}
|
||||
<div className="vv-sb-section">
|
||||
<button className="vv-sb-collapse" onClick={() => setVoOpen(!voOpen)}>
|
||||
<span>
|
||||
{/* 完整口播稿(可折叠) */}
|
||||
<h4 className="vv-sb-h">
|
||||
<button className="vv-sb-doc-collapse" onClick={() => setVoOpen(!voOpen)} type="button">
|
||||
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
|
||||
完整口播稿(TTS 合成使用)
|
||||
</span>
|
||||
<span className={`vv-sb-caret ${voOpen ? "open" : ""}`}>▾</span>
|
||||
</button>
|
||||
<span className={`vv-sb-caret ${voOpen ? "open" : ""}`}>▾</span>
|
||||
</button>
|
||||
</h4>
|
||||
{voOpen && (
|
||||
<textarea
|
||||
className="vv-textarea vv-sb-vo"
|
||||
className="vv-textarea vv-sb-doc-ta vv-sb-vo"
|
||||
value={sb.voiceover_script}
|
||||
disabled={locked}
|
||||
onChange={(e) => updateStoryboard({ voiceover_script: e.target.value })}
|
||||
@@ -1284,16 +1411,18 @@ const ViralVideoPage: React.FC = () => {
|
||||
)}
|
||||
</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 className="vv-sb-actions">
|
||||
{isMockPreview && (
|
||||
<span className="vv-copy-tag vv-copy-tag-preview">
|
||||
示例预览 · 后端新结构就绪后自动替换
|
||||
</span>
|
||||
)}
|
||||
{task.uiStep === "step2_copy_ready" && (
|
||||
<button className="vv-btn vv-btn-ghost" onClick={handleGenerateCopy}>
|
||||
<ReloadOutlined /> 重新生成文案
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1341,17 +1470,12 @@ const ViralVideoPage: React.FC = () => {
|
||||
<div className="vv-section-body" style={{ paddingTop: 4 }}>
|
||||
{/* 图片按钮 */}
|
||||
<div className="vv-action-row">
|
||||
<button
|
||||
className="vv-action-btn"
|
||||
onClick={() => imgInputRef.current?.click()}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<button className="vv-action-btn" onClick={() => imgInputRef.current?.click()}>
|
||||
<UploadOutlined /> 本地上传
|
||||
</button>
|
||||
<button
|
||||
className="vv-action-btn"
|
||||
onClick={() => setAssetPicker({ open: true, kind: "image", multiple: true })}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<FolderOpenOutlined /> 从素材库选择图片
|
||||
</button>
|
||||
@@ -1373,7 +1497,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
已选 <strong style={{ color: "#111827" }}>{task.images.length}</strong>/9
|
||||
</span>
|
||||
{task.images.length > 0 && (
|
||||
<button className="vv-link-btn" onClick={clearImages} disabled={!canEditAssets}>
|
||||
<button className="vv-link-btn" onClick={clearImages}>
|
||||
清空图片
|
||||
</button>
|
||||
)}
|
||||
@@ -1406,7 +1530,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className="vv-icon-btn"
|
||||
onClick={() => moveImg(i, -1)}
|
||||
disabled={i === 0 || im.uploading || !canEditAssets}
|
||||
disabled={i === 0 || im.uploading}
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
@@ -1414,7 +1538,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className="vv-icon-btn"
|
||||
onClick={() => moveImg(i, 1)}
|
||||
disabled={i === task.images.length - 1 || im.uploading || !canEditAssets}
|
||||
disabled={i === task.images.length - 1 || im.uploading}
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
@@ -1422,7 +1546,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className="vv-icon-btn vv-icon-btn-danger"
|
||||
onClick={() => removeImg(im.localId)}
|
||||
disabled={im.uploading || !canEditAssets}
|
||||
disabled={!!im.uploading}
|
||||
title="删除"
|
||||
>
|
||||
<CloseOutlined />
|
||||
@@ -1441,11 +1565,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<span className="vv-subblock-hint">最大 200MB · 最长 120 秒</span>
|
||||
</div>
|
||||
<div className="vv-action-row">
|
||||
<button
|
||||
className="vv-action-btn"
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<button className="vv-action-btn" onClick={() => videoInputRef.current?.click()}>
|
||||
<UploadOutlined /> 本地上传视频
|
||||
</button>
|
||||
<button
|
||||
@@ -1453,7 +1573,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
onClick={() =>
|
||||
setTask({ voicePanel: task.voicePanel === "douyin" ? null : "douyin" })
|
||||
}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<LinkOutlined /> 抖音链接识别
|
||||
</button>
|
||||
@@ -1504,11 +1623,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="vv-link-btn"
|
||||
onClick={removeRefVideo}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<button className="vv-link-btn" onClick={removeRefVideo}>
|
||||
删除重传
|
||||
</button>
|
||||
{task.refVideo.ossUrl && (
|
||||
@@ -1522,7 +1637,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
key={s.value}
|
||||
className={`vv-pill ${task.styleStrength === s.value ? "active" : ""}`}
|
||||
onClick={() => setTask({ styleStrength: s.value })}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
@@ -1545,7 +1659,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className={`vv-audio-cell ${task.refAudio?.source === "preset" ? "selected" : ""}`}
|
||||
onClick={() => setVoicePickerOpen(true)}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<FileTextOutlined className="vv-audio-ic" />
|
||||
<span>选择内置音色</span>
|
||||
@@ -1553,7 +1666,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className={`vv-audio-cell ${task.voicePanel === "upload" ? "active" : ""} ${task.refAudio?.source === "upload" ? "selected" : ""}`}
|
||||
onClick={() => voiceInputRef.current?.click()}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<UploadOutlined className="vv-audio-ic" />
|
||||
<span>本地上传</span>
|
||||
@@ -1561,7 +1673,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className={`vv-audio-cell ${task.voicePanel === "library" ? "active" : ""} ${task.refAudio?.source === "library" ? "selected" : ""}`}
|
||||
onClick={() => setAssetPicker({ open: true, kind: "voice", multiple: false })}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<FolderOpenOutlined className="vv-audio-ic" />
|
||||
<span>从素材库选择</span>
|
||||
@@ -1569,7 +1680,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
<button
|
||||
className={`vv-audio-cell ${task.voicePanel === "record" ? "active" : ""} ${task.refAudio?.source === "clone" ? "selected" : ""}`}
|
||||
onClick={() => setCloneModalOpen(true)}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<AudioMutedOutlined className="vv-audio-ic" />
|
||||
<span>直接录音</span>
|
||||
@@ -1589,11 +1699,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
<div className="vv-selected-audio">
|
||||
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
|
||||
<span style={{ flex: 1 }}>已选:{task.refAudio.name}</span>
|
||||
<button
|
||||
className="vv-link-btn"
|
||||
onClick={() => setTask({ refAudio: null })}
|
||||
disabled={!canEditAssets}
|
||||
>
|
||||
<button className="vv-link-btn" onClick={() => setTask({ refAudio: null })}>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
@@ -1689,7 +1795,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ═══════════ 中列 STEP 2 ═══════════ */}
|
||||
<div className={`vv-col ${step2Enabled ? "" : "vv-col-disabled"}`}>
|
||||
<div className="vv-col">
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead">
|
||||
<span className={`vv-step-badge ${step2Enabled ? "" : "vv-step-badge-dim"}`}>2</span>
|
||||
@@ -1705,22 +1811,27 @@ const ViralVideoPage: React.FC = () => {
|
||||
key={f.value}
|
||||
className={`vv-fusion-btn ${task.fusionLevel === f.value ? "active" : ""}`}
|
||||
onClick={() => setTask({ fusionLevel: f.value })}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
>
|
||||
<strong>{f.label}</strong>
|
||||
{f.desc}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{task.fusionLevel === "user_primary" && (
|
||||
{(task.fusionLevel === "ai_polish" || task.fusionLevel === "user_primary") && (
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">写下你的文案/草稿</label>
|
||||
<label className="vv-label">
|
||||
{task.fusionLevel === "ai_polish" ? "写下你的文案草稿" : "写下你的文案/草稿"}
|
||||
</label>
|
||||
<textarea
|
||||
key={task.fusionLevel}
|
||||
className="vv-textarea"
|
||||
placeholder="请输入你想要的原始文案,AI 将严格保留你的表达"
|
||||
placeholder={
|
||||
task.fusionLevel === "ai_polish"
|
||||
? "请输入你的文案草稿,AI将在此基础上润色优化..."
|
||||
: "请输入你想要的原始文案,AI将严格保留你的表达..."
|
||||
}
|
||||
value={task.userCopy}
|
||||
onChange={(e) => setTask({ userCopy: e.target.value })}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1736,77 +1847,71 @@ const ViralVideoPage: React.FC = () => {
|
||||
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 vv-select-purple"
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
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>
|
||||
<Select
|
||||
className="vv-select vv-select-purple"
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={task.language}
|
||||
onChange={(v) => setTask({ language: v })}
|
||||
options={LANGUAGES.map((i) => ({ value: i, label: i }))}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">我的人设</label>
|
||||
<Select
|
||||
className="vv-select vv-select-purple"
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="请选择人设"
|
||||
placeholder="请选择人设类型"
|
||||
optionFilterProp="children"
|
||||
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>
|
||||
<Select
|
||||
className="vv-select vv-select-purple"
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={task.viralStructure}
|
||||
onChange={(v) => setTask({ viralStructure: v })}
|
||||
options={STRUCTURES.map((i) => ({ value: i, label: i }))}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">营销目的</label>
|
||||
<Select
|
||||
className="vv-select vv-select-purple"
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={task.marketingPurpose}
|
||||
placeholder="请选择营销目的"
|
||||
value={task.marketingPurpose || undefined}
|
||||
onChange={(v) => setTask({ marketingPurpose: v })}
|
||||
options={PURPOSES.map((i) => ({ value: i, label: i }))}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
|
||||
<label className="vv-label">文案视频时长</label>
|
||||
<Select
|
||||
className="vv-select vv-select-purple"
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={task.duration}
|
||||
onChange={(v) => setTask({ duration: v })}
|
||||
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
|
||||
disabled={!canEditStep2 || !step2Enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1860,43 +1965,68 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* ═══════════ 右列 STEP 3 ═══════════ */}
|
||||
<div className={`vv-col ${step3Enabled ? "" : "vv-col-disabled"}`}>
|
||||
<div className="vv-col">
|
||||
<div className="vv-section">
|
||||
<div className="vv-step-subhead">
|
||||
<span className={`vv-step-badge ${step3Enabled ? "" : "vv-step-badge-dim"}`}>3</span>
|
||||
<span className="vv-step-subtitle">STEP 3 视频设置并生成视频</span>
|
||||
<div className="vv-step-subhead vv-step3-head">
|
||||
<div className="vv-step3-head-left">
|
||||
<span className={`vv-step-badge ${step3Enabled ? "" : "vv-step-badge-dim"}`}>
|
||||
3
|
||||
</span>
|
||||
<span className="vv-step-subtitle">STEP 3 生成视频</span>
|
||||
</div>
|
||||
{step3Enabled && (
|
||||
<button
|
||||
className="vv-btn vv-btn-ghost vv-btn-xs vv-history-btn"
|
||||
onClick={() => message.info("历史作品功能开发中")}
|
||||
>
|
||||
<HistoryOutlined /> 历史作品
|
||||
</button>
|
||||
)}
|
||||
{!step3Enabled && <span className="vv-step-lock">请先完成 STEP 2</span>}
|
||||
</div>
|
||||
<div className="vv-step3-divider" />
|
||||
<div className="vv-section-body">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频比例</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={task.videoRatio}
|
||||
onChange={(v) => setTask({ videoRatio: v })}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.label }))}
|
||||
disabled={
|
||||
!step3Enabled ||
|
||||
task.uiStep === "step3_generating" ||
|
||||
task.uiStep === "step3_done"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">生成模型</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
style={{ width: "100%" }}
|
||||
value={task.videoModel}
|
||||
onChange={(v) => setTask({ videoModel: v })}
|
||||
options={MODELS.map((m) => ({ value: m.v, label: m.label }))}
|
||||
disabled={
|
||||
!step3Enabled ||
|
||||
task.uiStep === "step3_generating" ||
|
||||
task.uiStep === "step3_done"
|
||||
}
|
||||
/>
|
||||
<div className="vv-step3-grid">
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频比例</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={task.videoRatio}
|
||||
onChange={(v) => setTask({ videoRatio: v })}
|
||||
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">视频模型</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={task.videoModel}
|
||||
onChange={(v) => setTask({ videoModel: v })}
|
||||
options={MODELS.map((m) => ({ value: m.v, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">清晰度</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={task.quality}
|
||||
onChange={(v) => setTask({ quality: v })}
|
||||
options={QUALITY_OPTIONS.map((m) => ({ value: m.v, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">成片超分</label>
|
||||
<Select
|
||||
className="vv-select vv-select-step3"
|
||||
style={{ width: "100%" }}
|
||||
value={task.upscale}
|
||||
onChange={(v) => setTask({ upscale: v })}
|
||||
options={UPSCALE_OPTIONS.map((m) => ({ value: m.v, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,9 +32,10 @@ interface Props {
|
||||
onConfirm: (voice: PresetVoice) => void
|
||||
}
|
||||
|
||||
/** 兜底 mock 音色(仅当后端 /voices/presets 接口失败时使用,voice_id 必须与后端 CosyVoice 预置音色一致)
|
||||
* 参考: packages/domain/preset_voices.py — PRESET_VOICES(8 个 v3 系统音色) */
|
||||
/** 兜底 mock 音色(后端 /api/v1/tts/presets 返回字段不够时使用) */
|
||||
const MOCK_VOICES: PresetVoice[] = [
|
||||
// ⚠️ 兜底 mock,仅在 /voices/presets 接口不可达时使用;ID 必须与后端
|
||||
// packages/domain/preset_voices.py PRESET_VOICES 的 voice_id 对齐(v3后缀)
|
||||
{
|
||||
id: "longxiaochun_v3",
|
||||
name: "龙小淳",
|
||||
@@ -157,8 +158,9 @@ 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) => {
|
||||
// 按 voice_id 精确匹配 mock 获取补充信息
|
||||
// 按 id 精确匹配 mock 获取补充元信息(id 即 voice_id,唯一稳定键)
|
||||
const mockMatch = MOCK_VOICES.find((m) => m.id === v.id)
|
||||
return {
|
||||
...v,
|
||||
@@ -166,12 +168,12 @@ const PresetVoicePickerModal: React.FC<Props> = ({
|
||||
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])
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
from celery import Task, shared_task
|
||||
@@ -40,6 +43,7 @@ from packages.domain.viral_video import (
|
||||
ViralVideoStage,
|
||||
ViralVideoStatus,
|
||||
)
|
||||
from packages.shared import get_shared_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,6 +92,112 @@ def _save_job(repo, job, session):
|
||||
session.commit()
|
||||
|
||||
|
||||
def _set_stage(job, repo, session, stage: str, message: str, persist: bool = True) -> None:
|
||||
"""更新细粒度阶段并持久化到 DB,同时通过 Redis 推送进度事件。
|
||||
|
||||
stage 用 ViralVideoStage.value(snake_case,与前端 phase 对齐)。
|
||||
message 为中文提示文案,前端轮询/SSE 直接展示给用户。
|
||||
"""
|
||||
job.current_stage = stage or ""
|
||||
job.phase_message = message or ""
|
||||
_emit_progress(job.id, stage, 0.0, message)
|
||||
if persist and repo is not None and session is not None:
|
||||
try:
|
||||
_save_job(repo, job, session)
|
||||
except Exception as e: # 阶段持久化失败不阻塞主流程
|
||||
logger.warning("[爆款视频] 阶段持久化失败 stage=%s err=%s", stage, e)
|
||||
|
||||
|
||||
# ── worker 心跳(僵尸任务检测) ─────────────────────────────────────────
|
||||
|
||||
# 心跳间隔(秒);超过此时间未更新 heartbeat_at 视为 worker 异常
|
||||
_HEARTBEAT_INTERVAL_SEC = 25
|
||||
# 任务整体超时:running 超过此时长且心跳停止,则判定为僵尸并回收
|
||||
_STALE_RUNNING_TIMEOUT_SEC = 10 * 60 # 10 分钟
|
||||
# 心跳过期窗口:heartbeat_at 距 now 超过此时长视为失效
|
||||
_HEARTBEAT_EXPIRE_SEC = 2 * 60 # 2 分钟
|
||||
|
||||
|
||||
def _heartbeat_once(job_id: str) -> None:
|
||||
"""在独立 session 中更新一次 heartbeat_at(不捕获主流程事务状态)。"""
|
||||
ssn = None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ssn = SessionLocal()
|
||||
ssn.execute(
|
||||
__import__("sqlalchemy").text(
|
||||
"UPDATE viral_video_jobs SET heartbeat_at = :now, updated_at = :now "
|
||||
"WHERE id = :jid AND status = 'running'"
|
||||
),
|
||||
{"now": datetime.now(timezone.utc), "jid": job_id},
|
||||
)
|
||||
ssn.commit()
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 心跳更新失败 job=%s err=%s", job_id, e)
|
||||
finally:
|
||||
if ssn is not None:
|
||||
try:
|
||||
ssn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _start_heartbeat_thread(job_id: str) -> tuple[threading.Event, threading.Thread]:
|
||||
"""启动后台心跳线程,每 _HEARTBEAT_INTERVAL_SEC 秒更新一次 heartbeat_at。
|
||||
返回 (stop_event, thread);任务结束时调用 stop_event.set() 停止心跳。
|
||||
"""
|
||||
stop = threading.Event()
|
||||
|
||||
def _loop():
|
||||
# 立即打一次心跳
|
||||
_heartbeat_once(job_id)
|
||||
while not stop.wait(_HEARTBEAT_INTERVAL_SEC):
|
||||
_heartbeat_once(job_id)
|
||||
|
||||
t = threading.Thread(target=_loop, name=f"vv-heartbeat-{job_id[:8]}", daemon=True)
|
||||
t.start()
|
||||
return stop, t
|
||||
|
||||
|
||||
def _recover_stale_jobs() -> int:
|
||||
"""启动/定时扫描:把僵尸任务(running 超时且心跳停止)标记为 failed。
|
||||
返回本次回收的任务数。可由 celery beat 周期性调用,也可在任务启动前顺带扫一次。
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
ssn = None
|
||||
try:
|
||||
ssn = SessionLocal()
|
||||
now = datetime.now(timezone.utc)
|
||||
# 判定条件:status=running 且 (started_at 距今 > 10min) 且 (heartbeat_at < now-2min 或 heartbeat_at IS NULL 且 updated_at < now-2min)
|
||||
cutoff_beat = now - timedelta(seconds=_HEARTBEAT_EXPIRE_SEC)
|
||||
cutoff_start = now - timedelta(seconds=_STALE_RUNNING_TIMEOUT_SEC)
|
||||
sql = __import__("sqlalchemy").text(
|
||||
"UPDATE viral_video_jobs "
|
||||
"SET status='failed', error_msg='任务执行超时,请重试', updated_at=:now "
|
||||
"WHERE status='running' "
|
||||
" AND started_at IS NOT NULL AND started_at < :cutoff_start "
|
||||
" AND (heartbeat_at IS NULL OR heartbeat_at < :cutoff_beat) "
|
||||
" AND (heartbeat_at IS NOT NULL OR updated_at < :cutoff_beat)"
|
||||
)
|
||||
result = ssn.execute(sql, {"now": now, "cutoff_start": cutoff_start, "cutoff_beat": cutoff_beat})
|
||||
ssn.commit()
|
||||
cnt = result.rowcount or 0
|
||||
if cnt > 0:
|
||||
logger.warning("[爆款视频] 回收 %d 个僵尸 running 任务", cnt)
|
||||
return cnt
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 僵尸任务扫描失败: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
if ssn is not None:
|
||||
try:
|
||||
ssn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 默认结构 ─────────────────────────────────────────────────────────────
|
||||
|
||||
_DEFAULT_HARD_CONSTRAINTS = [
|
||||
@@ -136,37 +246,42 @@ def _empty_copy_result(duration: int = 15, ratio: str = "9:16") -> dict:
|
||||
# ── 流水线各步骤 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_IMAGE_ANALYSIS_PROMPT = """请仔细观察这张图片,只基于图片中真实可见的内容进行分析,不要凭空想象。
|
||||
|
||||
必须输出严格的 JSON(不要 Markdown 代码块,不要额外解释),字段如下:
|
||||
_IMAGE_ANALYSIS_SYSTEM_PROMPT = """你是电商商品视觉分析师,从商品图片中提取关键商品信息。严格规则:
|
||||
1. 只说图片里真实可见的内容,看不清/没有的填「无法判断」,不要瞎猜。
|
||||
2. 输出必须是严格 JSON(不要 Markdown 代码块,不要额外解释文字)。
|
||||
3. 字段说明:
|
||||
{
|
||||
"category": "产品大类,如护肤品/彩妆/食品/数码/服饰/家居等,若无法识别填『无法判断』",
|
||||
"name": "产品名称(从包装/品牌/logo/文字推断;没有品牌时描述外观如『粉色包装面霜』)",
|
||||
"brand": "品牌名(看 logo/包装文字;看不清填『未知』)",
|
||||
"colors": ["主体颜色"],
|
||||
"material_or_texture": "材质/质地描述(如玻璃瓶装/塑料软管/哑光质感/金属外壳等;无法判断填『无法判断』)",
|
||||
"name": "商品全名(品牌+产品名+规格,如『大公鸡头管家 多功能油污净 625ml』,从包装 OCR 读出)",
|
||||
"brand": "品牌名(从 Logo/包装文字读出,看不清填『无法判断』)",
|
||||
"category": "商品品类(如『家用清洁/油污清洁剂』『日化/洗衣液』;非产品图填『非产品图』)",
|
||||
"appearance": "外观特征(50-100字:瓶身形状、颜色、瓶盖、标签颜色、尺寸感)",
|
||||
"packaging": "包装细节(50-100字:标签分区、图案元素、瓶盖/泵头样式、塑封状态)",
|
||||
"text_on_package": ["包装上清晰可见的文字列表(品牌、产品名、卖点、规格等,看不清的不列)"],
|
||||
"key_features": [
|
||||
"3-5 条**图片中确实能看到**的外观特征/卖点描述(如『按压式泵头』『瓶身有金色装饰线』等),不要编图片里没有的功效"
|
||||
"3-5 条图片中能看到的外观/视觉特征(如『红色瓶盖白色瓶身』『鸡头图案 Logo』等)"
|
||||
],
|
||||
"visual_style": "视觉风格(如简约高端/粉嫩少女/国潮/科技感/生活方式实拍等)",
|
||||
"scene": "图片中的使用/展示场景(如白底棚拍/浴室场景/户外街拍/桌面静物等;纯白底填『白底产品图』)",
|
||||
"target_audience_hint": "从视觉推断的目标人群(如年轻女性/男性商务/亲子家庭等;不确定填『通用』)",
|
||||
"text_on_image": "图片上出现的可读文字(品牌名/Slogan/产品名等,没有则填『无』)"
|
||||
}
|
||||
"scene": "图片场景(如白底棚拍/浴室实拍/桌面静物/手持实拍等)",
|
||||
"summary": "100-180字中文导购描述,连贯自然段落,像电商详情页介绍,前端直接展示,必须提到品牌/品名/核心外观特征,不能写『无法判断』"
|
||||
}"""
|
||||
|
||||
严格要求:
|
||||
1. 任何字段无法确认时填『无法判断』或『未知』,不要猜。
|
||||
2. key_features 只能描述图片里肉眼可见的物理外观,不要写『补水保湿』『抗衰老』这类功效词(除非包装上明确印了)。
|
||||
3. 如果图片完全不是产品图(比如风景/人像/截图),category 填『非产品图』,name 填实际看到的内容。
|
||||
"""
|
||||
_IMAGE_ANALYSIS_USER_PROMPT = """请分析这张商品图片,输出严格 JSON。重点:
|
||||
1. name/brand/text_on_package 从图片包装 OCR 读取,不编造;
|
||||
2. appearance/packaging 各写 50-100 字,要具体;
|
||||
3. summary 必须是 100-180 字连贯中文段落,说清商品是什么、长什么样、适合谁用,不要写「无法判断」;
|
||||
4. 非产品图时 category 填「非产品图」,name 填实际看到的内容;
|
||||
5. 看不清的字段填「无法判断」。"""
|
||||
|
||||
|
||||
def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||||
d = {
|
||||
"name": "未识别",
|
||||
"category": "无法判断",
|
||||
"appearance": "无法判断",
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": [],
|
||||
"key_features": [],
|
||||
"scene": "通用",
|
||||
"summary": "",
|
||||
"_source": reason,
|
||||
}
|
||||
if extra:
|
||||
@@ -174,10 +289,118 @@ def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def _is_vision_result_usable(result: dict) -> bool:
|
||||
"""判断 VLM 返回是否有效:name/summary 不能为未识别/无法判断/空,summary 要够长。"""
|
||||
if not isinstance(result, dict):
|
||||
return False
|
||||
name = (result.get("name") or "").strip()
|
||||
if not name or name in ("未识别", "无法判断", "未知"):
|
||||
return False
|
||||
summary = (result.get("summary") or "").strip()
|
||||
if len(summary) < 30 or summary in ("无法判断", "未识别"):
|
||||
return False
|
||||
category = (result.get("category") or "").strip()
|
||||
if category == "非产品图":
|
||||
return True
|
||||
feats = result.get("key_features") or []
|
||||
if not isinstance(feats, list) or len(feats) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _analyze_single_image(
|
||||
idx: int,
|
||||
img_url: str,
|
||||
vision_model: str,
|
||||
timeout: int,
|
||||
*,
|
||||
pro_fallback_model: str | None = None,
|
||||
) -> dict:
|
||||
"""单张图片 VLM 分析(线程池并行调用)。
|
||||
- lite 失败/结果不可用 时自动用 pro 模型降级重试 1 次。
|
||||
- 失败/None/不可用最终返回含默认字段的 dict(不会让用户看到「未识别·无法判断」裸结果)。
|
||||
"""
|
||||
from packages.shared.ai_service import call_vision
|
||||
|
||||
if not img_url or not isinstance(img_url, str):
|
||||
return _vision_fallback(idx, "invalid_url")
|
||||
|
||||
def _call(model: str, tmo: int):
|
||||
try:
|
||||
return call_vision(
|
||||
image_url=img_url,
|
||||
prompt=_IMAGE_ANALYSIS_USER_PROMPT,
|
||||
model=model,
|
||||
max_tokens=800,
|
||||
temperature=0.1,
|
||||
timeout=tmo,
|
||||
system_prompt=_IMAGE_ANALYSIS_SYSTEM_PROMPT,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 图片 #%d call_vision(%s) 异常 err=%s", idx, model, e)
|
||||
return None
|
||||
|
||||
def _normalize(raw, source: str) -> dict:
|
||||
if raw is None:
|
||||
return _vision_fallback(idx, f"{source}_none")
|
||||
if isinstance(raw, str):
|
||||
logger.warning("[爆款视频] 图片 #%d VLM(%s) 返回非 JSON: %s", idx, source, raw[:200])
|
||||
return _vision_fallback(idx, f"{source}_text", {"_raw": raw[:500]})
|
||||
if not isinstance(raw, dict):
|
||||
return _vision_fallback(idx, f"{source}_badtype")
|
||||
raw.setdefault("_source", source)
|
||||
raw.setdefault("name", "未识别")
|
||||
raw.setdefault("brand", "无法判断")
|
||||
raw.setdefault("category", "无法判断")
|
||||
raw.setdefault("appearance", "无法判断")
|
||||
raw.setdefault("packaging", "无法判断")
|
||||
raw.setdefault("text_on_package", [])
|
||||
raw.setdefault("key_features", [])
|
||||
raw.setdefault("scene", "通用")
|
||||
raw.setdefault("summary", "")
|
||||
if not isinstance(raw.get("text_on_package"), list):
|
||||
raw["text_on_package"] = []
|
||||
if not isinstance(raw.get("key_features"), list):
|
||||
raw["key_features"] = []
|
||||
return raw
|
||||
|
||||
# 第一次:传入模型(通常是 lite)
|
||||
first_raw = _call(vision_model, timeout)
|
||||
tag1 = vision_model.split("/")[-1] if "/" in vision_model else vision_model
|
||||
first_result = _normalize(first_raw, tag1)
|
||||
if _is_vision_result_usable(first_result):
|
||||
return first_result
|
||||
|
||||
logger.warning(
|
||||
"[爆款视频] 图片 #%d VLM(%s) 结果不可用 name=%r summary_len=%d,尝试 pro 降级",
|
||||
idx,
|
||||
vision_model,
|
||||
first_result.get("name"),
|
||||
len(first_result.get("summary") or ""),
|
||||
)
|
||||
|
||||
# 第二次:pro 降级重试
|
||||
if pro_fallback_model and pro_fallback_model != vision_model:
|
||||
pro_raw = _call(pro_fallback_model, max(60, timeout))
|
||||
pro_result = _normalize(pro_raw, "pro_fallback")
|
||||
if _is_vision_result_usable(pro_result):
|
||||
pro_result["_fallback_used"] = True
|
||||
return pro_result
|
||||
logger.warning(
|
||||
"[爆款视频] 图片 #%d pro 降级仍不可用 name=%r summary_len=%d",
|
||||
idx,
|
||||
pro_result.get("name"),
|
||||
len(pro_result.get("summary") or ""),
|
||||
)
|
||||
return pro_result
|
||||
|
||||
return first_result
|
||||
|
||||
|
||||
def _step_image_analysis(job: ViralVideoJob) -> dict:
|
||||
"""步骤 1: 图片 VLM 分析 — 识别产品特征、场景、卖点(加 None 防护)。"""
|
||||
"""步骤 1: 图片 VLM 分析 — 识别产品特征(v1.6 优化:并行 + lite 模型提速)。"""
|
||||
try:
|
||||
from packages.shared.ai_service import call_vision
|
||||
from packages.shared.ai_service import call_vision # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning("[爆款视频] ai_service.call_vision 不可用,使用占位结果")
|
||||
return {"products": [_vision_fallback(0, "fallback_import_error")]}
|
||||
@@ -186,36 +409,46 @@ def _step_image_analysis(job: ViralVideoJob) -> dict:
|
||||
logger.warning("[爆款视频] 任务无 images,跳过图片分析")
|
||||
return {"products": []}
|
||||
|
||||
results = []
|
||||
for idx, img_url in enumerate(job.images):
|
||||
if not img_url or not isinstance(img_url, str):
|
||||
logger.warning("[爆款视频] 图片 #%d URL 非法", idx)
|
||||
results.append(_vision_fallback(idx, "invalid_url"))
|
||||
continue
|
||||
logger.info("[爆款视频] 图片分析 #%d img=%s", idx, img_url[:160])
|
||||
try:
|
||||
result = call_vision(image_url=img_url, prompt=_IMAGE_ANALYSIS_PROMPT)
|
||||
if result is None:
|
||||
logger.warning("[爆款视频] 图片 #%d call_vision 返回 None", idx)
|
||||
results.append(_vision_fallback(idx, "vision_none"))
|
||||
elif isinstance(result, str):
|
||||
# VLM 返回了非 JSON 文本(JSON 解析失败),记录原始文本但不要让 None 传播
|
||||
logger.warning("[爆款视频] 图片 #%d VLM 返回非 JSON 文本: %s", idx, result[:200])
|
||||
results.append(_vision_fallback(idx, "vision_text", {"_raw": result[:500]}))
|
||||
elif isinstance(result, dict):
|
||||
result.setdefault("_source", "vision")
|
||||
# 防御:关键字段缺失则补默认
|
||||
result.setdefault("name", "未识别")
|
||||
result.setdefault("category", "无法判断")
|
||||
result.setdefault("key_features", [])
|
||||
result.setdefault("scene", "通用")
|
||||
results.append(result)
|
||||
else:
|
||||
logger.warning("[爆款视频] 图片 #%d VLM 返回意外类型 %s", idx, type(result))
|
||||
results.append(_vision_fallback(idx, "vision_unexpected_type"))
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 图片分析失败 img=%s err=%s", img_url[:120], e, exc_info=True)
|
||||
results.append(_vision_fallback(idx, "vision_exception", {"_error": str(e)[:200]}))
|
||||
# 选择视觉模型:lite 速度优先(默认),pro 作为降级备用
|
||||
try:
|
||||
_s = get_shared_settings()
|
||||
if _s.doubao_vision_use_lite:
|
||||
vision_model = _s.doubao_vision_lite_model
|
||||
pro_model = _s.doubao_vision_model
|
||||
vision_timeout = 45 # lite 给到 45s 避免首轮就 timeout 降级 pro
|
||||
else:
|
||||
vision_model = _s.doubao_vision_model
|
||||
pro_model = None # 已经是 pro,不再降级
|
||||
vision_timeout = 60
|
||||
except Exception:
|
||||
vision_model = "doubao-1-5-vision-lite-250315"
|
||||
pro_model = "doubao-1-5-vision-pro-250328"
|
||||
vision_timeout = 45
|
||||
|
||||
results: list[dict] = [None] * len(job.images) # type: ignore
|
||||
max_workers = min(4, max(1, len(job.images)))
|
||||
logger.info(
|
||||
"[爆款视频] 开始并行图片分析 n=%d model=%s pro_fallback=%s timeout=%d workers=%d",
|
||||
len(job.images),
|
||||
vision_model,
|
||||
pro_model,
|
||||
vision_timeout,
|
||||
max_workers,
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
future_to_idx = {
|
||||
pool.submit(
|
||||
_analyze_single_image, idx, url, vision_model, vision_timeout, pro_fallback_model=pro_model
|
||||
): idx
|
||||
for idx, url in enumerate(job.images)
|
||||
}
|
||||
for fut in as_completed(future_to_idx):
|
||||
idx = future_to_idx[fut]
|
||||
try:
|
||||
results[idx] = fut.result()
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 图片 #%d future 异常 err=%s", idx, e, exc_info=True)
|
||||
results[idx] = _vision_fallback(idx, "future_exception", {"_error": str(e)[:200]})
|
||||
|
||||
return {"products": results}
|
||||
|
||||
@@ -289,8 +522,11 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
|
||||
"suggested_title": "视频主题标题(5-15字)"
|
||||
}}"""
|
||||
|
||||
_s = get_shared_settings()
|
||||
_fast = _s.doubao_fast_model
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
# 用快模型提速(结构化输出任务,不需要推理模型)
|
||||
result = call_llm(prompt, temperature=0.4, max_tokens=800, model=_fast)
|
||||
return (
|
||||
result
|
||||
if isinstance(result, dict)
|
||||
@@ -304,26 +540,47 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
|
||||
# ── 编导分镜脚本生成(核心,v1.6 新 prompt) ──────────────────────────────
|
||||
|
||||
|
||||
_SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI 视频生成模型(Seedance 2.5)撰写专业编导分镜脚本。
|
||||
# 人设 IP 类型 → 文案/出镜风格指导(前端下拉 10 个 IP 类型)
|
||||
_PERSONA_STYLE_GUIDE = {
|
||||
"通用个人IP": "亲切自然、像朋友分享好物,第一人称口语化,不端着",
|
||||
"老板型IP": "沉稳大气、有行业格局感,适度使用『我做了XX年』『我一直坚持』等老板视角,语气自信不夸张",
|
||||
"专家型IP": "专业权威、讲原理和数据支撑,用词严谨,少用网梗,像行业专家做科普",
|
||||
"顾问型IP": "贴心周到、给建议给方案,多用『建议你』『可以试试』『我帮你梳理』",
|
||||
"创始人IP": "真诚有温度、讲品牌故事和创业初心,带点情怀和个人观点,不端老板架子",
|
||||
"创业者IP": "真实接地气、讲踩坑经验和创业心路,带点自嘲和韧劲,像身边的创业者朋友",
|
||||
"从业者经验派": "内行视角、讲行业内幕/实操经验/踩坑教训,多用『干了X年我发现』『内行都知道』",
|
||||
"避坑顾问型": "直接点出痛点和雷区,先讲『别买XX』『很多人踩过的坑』再给正确选择,节奏感强",
|
||||
"知识科普型": "清晰讲原理、讲知识点,条理分明、信息密度高,像做一期小科普",
|
||||
"测评种草型": "真实测评感、讲使用体验和优缺点对比,带『亲测』『我用了XX天』『实测下来』真实感词汇",
|
||||
}
|
||||
|
||||
## 产品信息
|
||||
|
||||
def _persona_style_hint(persona_id: str) -> str:
|
||||
"""根据 persona_id 查文案风格指导;未命中/空值返回通用提示。"""
|
||||
pid = (persona_id or "").strip()
|
||||
if pid in _PERSONA_STYLE_GUIDE:
|
||||
return f"【人设风格:{pid}】{_PERSONA_STYLE_GUIDE[pid]}"
|
||||
if pid:
|
||||
# 前端传了自由值,照直提示,不阻塞
|
||||
return f"【人设风格:{pid}】按该人设的口吻、话术习惯组织口播和出镜动作"
|
||||
return "【人设风格:未指定】亲切自然、像朋友分享好物"
|
||||
|
||||
|
||||
_SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5(单次生成最多{duration}秒)写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
|
||||
|
||||
## 产品
|
||||
{products_summary}
|
||||
|
||||
## 营销参数
|
||||
- 视频主题/意图:{intent}
|
||||
- 主题/意图:{intent}
|
||||
- 关键信息:{key_messages}
|
||||
- 调性:{tone}
|
||||
- 目标客户:{target_customer}
|
||||
- 用户原始文案/卖点(必须融入口播):{user_copy}
|
||||
- 视频时长:{duration} 秒(单次生成)
|
||||
- 画幅比例:{ratio}
|
||||
- 产品图片数量:{n_images} 张(将作为 reference_images 传给视频模型,第1张通常作为首帧/主产品图)
|
||||
- 参考风格(可选):{style_hint}
|
||||
|
||||
## 任务
|
||||
请撰写**一段完整的编导分镜脚本**,包含视频总览、场景光线、逐镜头时间轴、硬性约束、负面提示词,以及自然口语化的口播对白。
|
||||
|
||||
这段脚本会**整个拼成一个长 prompt**一次性传给 Seedance 2.5(单次生成最多30秒视频),所以你的描述必须让模型在一个长镜头/连贯镜头流里理解每个时间段该拍什么、画面如何、人物说什么做什么。
|
||||
- 用户原始卖点(必须融入口播):{user_copy}
|
||||
- 时长:{duration}秒 / 画幅:{ratio} / 产品图:{n_images}张(第1张通常是主图/首帧)
|
||||
- 风格参考:{style_hint}
|
||||
- 爆款结构(必须严格遵循节奏/段落顺序):{viral_structure_block}
|
||||
- 人设/出镜口吻(必须贯穿全部对白和动作描写):{persona_hint}
|
||||
|
||||
## 输出格式(必须输出严格 JSON,不要 Markdown,不要解释,字段一个都不能少)
|
||||
|
||||
@@ -364,17 +621,21 @@ _SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI
|
||||
```
|
||||
|
||||
## 关键要求
|
||||
1. **镜头感**:每镜必须写清景别(特写/近景/中景/全景)、角度(平视/俯拍/仰拍/45度侧拍)、运镜(推/拉/摇/移/跟/固定),不能笼统说"展示产品"。
|
||||
2. **画面具体**:描述主体是谁(性别/年龄/穿着风格)、在什么场景、做什么动作、光线从哪来、镜头怎么动,让 AI 能画出来。
|
||||
3. **对白自然**:像真人说话,不要"家人们谁懂啊""宝子们"这种浮夸腔,也不要"今天给大家推荐一款XX真的太好用了"这种硬广推销腔。要像朋友自然分享好物。
|
||||
4. **参考图片分配**:reference_image_index 填 0-based 索引,产品特写镜头用产品图(索引0通常是主图),人像/场景镜头可留 null。
|
||||
5. **时长控制**:所有 shots 的 time_range 加起来必须等于 {duration} 秒,单镜 2-8 秒。
|
||||
6. **硬性约束和负面词必须包含**:不要删减,可根据产品类型追加。
|
||||
7. **voiceover_script 必须是纯口播文本**:不含任何标记、括号、说明,字数按中文每秒 3-4 字估算({duration}秒约{approx_chars}字)。
|
||||
"""
|
||||
1. 每镜写清景别/角度/运镜(特写/近景/中景+平视/俯拍+推/拉/固定)。
|
||||
2. 画面具体:主体(性别/年龄/穿着)、场景、动作、光线、镜头运动要可落地。
|
||||
3. 对白自然口语化,像朋友分享好物;拒绝"家人们""宝子们""太好用了"等浮夸/硬广腔。
|
||||
4. reference_image_index 填 0-based 索引(产品特写用索引0主图),人像/场景可 null。
|
||||
5. shots time_range 累计={duration}秒,单镜2-8秒。
|
||||
6. hard_constraints/negative_prompts 保留默认项可追加,不要删减。
|
||||
7. voiceover_script 为纯口播文本(无标记/括号/前缀),{duration}秒约{approx_chars}字。
|
||||
8. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。
|
||||
9. 输出前自检:口播对白禁止错别字和语病(特别注意"很/最"等常见误用),同音字错误一律修正。
|
||||
10. 必须使用产品信息中真实的品牌、品名和外观特征,不要编造与产品无关的内容。"""
|
||||
|
||||
|
||||
def _build_products_summary(image_analysis: dict) -> str:
|
||||
"""把 VLM 返回的商品分析结果拼给文案/分镜生成 prompt 用。
|
||||
优先用 summary(自然段落);没有时用结构化字段兜底拼一段。"""
|
||||
products = (image_analysis or {}).get("products", []) or []
|
||||
if not products:
|
||||
return "- (无图片信息,请自由创作自然生活化场景)"
|
||||
@@ -383,34 +644,60 @@ def _build_products_summary(image_analysis: dict) -> str:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
name = p.get("name") or "产品"
|
||||
# 优先 VLM 生成的 summary 段(自然语言,给编导模型看效果最好)
|
||||
summary = (p.get("summary") or "").strip()
|
||||
if summary and len(summary) >= 30:
|
||||
lines.append(f"- 图{i+1} {name}:{summary}")
|
||||
continue
|
||||
# 结构化字段兜底
|
||||
brand = p.get("brand") or ""
|
||||
cat = p.get("category") or ""
|
||||
spec = p.get("spec") or ""
|
||||
appearance = p.get("appearance") or ""
|
||||
packaging = p.get("packaging") or ""
|
||||
colors = p.get("colors") or []
|
||||
mat = p.get("material_or_texture") or ""
|
||||
style = p.get("visual_style") or ""
|
||||
scene = p.get("scene") or ""
|
||||
audience = p.get("target_audience_hint") or ""
|
||||
text_on_img = p.get("text_on_image") or ""
|
||||
audience = p.get("target_audience") or p.get("target_audience_hint") or ""
|
||||
# text_on_package 可能是数组(新格式)或字符串(旧格式)
|
||||
text_list = p.get("text_on_package") or []
|
||||
if isinstance(text_list, str):
|
||||
text_on_img = text_list
|
||||
else:
|
||||
text_on_img = ";".join([str(x) for x in text_list[:8]]) if text_list else (p.get("text_on_image") or "")
|
||||
feats = p.get("key_features") or p.get("features") or []
|
||||
sellings = p.get("selling_points") or []
|
||||
scenes = p.get("suitable_scenes") or []
|
||||
parts = [f"图{i+1} {name}"]
|
||||
if brand and brand not in ("未知", "无法判断"):
|
||||
parts.append(f"品牌={brand}")
|
||||
if cat and cat not in ("无法判断", "非产品图"):
|
||||
parts.append(f"品类={cat}")
|
||||
if spec and spec != "无法判断":
|
||||
parts.append(f"规格={spec}")
|
||||
if appearance and appearance != "无法判断":
|
||||
parts.append(f"外观={appearance}")
|
||||
if packaging and packaging != "无法判断":
|
||||
parts.append(f"包装={packaging}")
|
||||
if colors:
|
||||
parts.append(f"颜色={','.join(colors)}")
|
||||
if mat and mat not in ("无法判断",):
|
||||
if mat and mat != "无法判断":
|
||||
parts.append(f"材质={mat}")
|
||||
if style:
|
||||
parts.append(f"风格={style}")
|
||||
if scene and scene not in ("通用",):
|
||||
parts.append(f"场景={scene}")
|
||||
if audience and audience != "通用":
|
||||
parts.append(f"展示场景={scene}")
|
||||
if scenes:
|
||||
parts.append(f"适用场景={','.join([str(x) for x in scenes[:4]])}")
|
||||
if audience and audience not in ("通用", "无法判断"):
|
||||
parts.append(f"目标人群={audience}")
|
||||
if text_on_img and text_on_img not in ("无",):
|
||||
parts.append(f"图片文字={text_on_img}")
|
||||
parts.append(f"包装文字={text_on_img[:300]}")
|
||||
if feats:
|
||||
parts.append("外观特征=" + ";".join([str(x) for x in feats[:6]]))
|
||||
if sellings:
|
||||
parts.append("营销卖点=" + ";".join([str(x) for x in sellings[:5]]))
|
||||
lines.append("- " + ",".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -594,6 +881,14 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
|
||||
intent_str = "推广产品"
|
||||
tone = "亲切自然"
|
||||
|
||||
# 爆款结构:用户在 STEP1/STEP2 选的中文结构名,必须严格注入 prompt 指导 AI 编排
|
||||
_vs = (job.viral_structure or "").strip()
|
||||
if _vs:
|
||||
viral_structure_block = f"【{_vs}】—— 请严格按照这个爆款结构的节奏/段落顺序编排镜头、台词和情绪节点(开场钩子、痛点、反转、案例、行动号召等按结构走),不要打乱顺序"
|
||||
else:
|
||||
viral_structure_block = "未指定(自由编排,但仍需有钩子开头+产品展示+行动号召的基本节奏)"
|
||||
|
||||
persona_hint = _persona_style_hint(getattr(job, "persona_id", ""))
|
||||
prompt = _SCRIPT_GENERATION_PROMPT.format(
|
||||
products_summary=products_summary,
|
||||
intent=intent_str,
|
||||
@@ -606,14 +901,58 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
|
||||
n_images=len(job.images or []),
|
||||
style_hint=style_hint,
|
||||
approx_chars=approx_chars,
|
||||
viral_structure_block=viral_structure_block,
|
||||
persona_hint=persona_hint,
|
||||
)
|
||||
|
||||
_s = get_shared_settings()
|
||||
_fast = _s.doubao_fast_model
|
||||
_pro = getattr(_s, "doubao_model", None) or _fast
|
||||
|
||||
def _try_gen(model: str, temp: float, max_tok: int, label: str):
|
||||
logger.info("[爆款视频] 编导脚本生成 model=%s label=%s", model, label)
|
||||
r = call_llm(prompt, temperature=temp, max_tokens=max_tok, model=model)
|
||||
if r is None:
|
||||
logger.warning("[爆款视频] 编导脚本返回None label=%s", label)
|
||||
return None
|
||||
parsed = _safe_json_loads(r)
|
||||
normalized = _validate_and_normalize_script(parsed, job)
|
||||
voiceover = (normalized or {}).get("voiceover_script") or ""
|
||||
voiceover_len = len(voiceover)
|
||||
shots_cnt = len((normalized or {}).get("shots") or [])
|
||||
# 判定是否"退化到兜底质量":口播过短(<20字)或镜头数<1;正常的短口播(如15s视频~40字)不视为兜底
|
||||
fallback_marker = "我最近在用的好物" in voiceover # _fallback_script 的特征串
|
||||
is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20
|
||||
logger.info(
|
||||
"[爆款视频] 编导脚本结果 label=%s voiceover_len=%d shots=%d fallback=%s raw_type=%s",
|
||||
label,
|
||||
voiceover_len,
|
||||
shots_cnt,
|
||||
is_fallback,
|
||||
type(r).__name__,
|
||||
)
|
||||
if is_fallback:
|
||||
return None # 触发重试
|
||||
return normalized
|
||||
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
parsed = _safe_json_loads(result)
|
||||
return _validate_and_normalize_script(parsed, job)
|
||||
# 第一次:快模型
|
||||
normalized = _try_gen(_fast, 0.8, 2500, "fast-first")
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
# 第二次:快模型降温度+加大 max_tokens
|
||||
normalized = _try_gen(_fast, 0.6, 3200, "fast-retry")
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
# 第三次:用主力模型兜底
|
||||
if _pro and _pro != _fast:
|
||||
normalized = _try_gen(_pro, 0.7, 3500, "pro-fallback")
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
logger.warning("[爆款视频] 编导脚本三次都未生成合格结果,使用兜底脚本")
|
||||
return _fallback_script(job)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 编导脚本生成失败: %s,使用兜底脚本", e, exc_info=True)
|
||||
logger.warning("[爆款视频] 编导脚本生成异常: %s,使用兜底脚本", e, exc_info=True)
|
||||
return _fallback_script(job)
|
||||
|
||||
|
||||
@@ -638,8 +977,11 @@ def _step_review(job: ViralVideoJob, copy_result: dict) -> dict:
|
||||
- score: int(0-100分)
|
||||
- details: 各维度评分和说明
|
||||
- issues: 需要修改的问题列表(如有)"""
|
||||
_s = get_shared_settings()
|
||||
_fast = _s.doubao_fast_model
|
||||
try:
|
||||
result = call_llm(prompt)
|
||||
# 合规审核用快模型 + 短输出(结构化判断)
|
||||
result = call_llm(prompt, temperature=0.1, max_tokens=500, model=_fast)
|
||||
return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}}
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 合规审核失败: %s", e)
|
||||
@@ -654,7 +996,7 @@ def _step_tts(job: ViralVideoJob, voiceover_script: str):
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
tts_service = get_tts_service()
|
||||
voice_id = (getattr(job, "voice_id", "") or job.persona_id or "").strip()
|
||||
voice_id = (getattr(job, "voice_id", "") or "").strip()
|
||||
text = (voiceover_script or "").strip()
|
||||
if not text:
|
||||
logger.warning("[爆款视频] voiceover_script 为空,跳过 TTS")
|
||||
@@ -819,6 +1161,27 @@ def _step_upload(job: ViralVideoJob, video_path: str) -> str:
|
||||
return video_url
|
||||
|
||||
|
||||
def _wait_oss_ready(url: str, timeout_sec: int = 10) -> bool:
|
||||
"""轮询 OSS 公网 URL,直到 HEAD 返回 200 或超时。
|
||||
用于缓解 OSS 上传后 1-5s 公网 eventual consistency 导致的 NoSuchKey。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
last_status = 0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = httpx.head(url, follow_redirects=True, timeout=3.0)
|
||||
last_status = r.status_code
|
||||
if r.status_code == 200 and int(r.headers.get("content-length", "0") or 0) > 0:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("[爆款视频] OSS head 轮询失败: %s", e)
|
||||
time.sleep(1.0)
|
||||
logger.warning("[爆款视频] OSS 成片在 %ds 内未就绪 last_status=%s url=%s", timeout_sec, last_status, url[:120])
|
||||
return False
|
||||
|
||||
|
||||
# ── 主编排器 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -827,15 +1190,17 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
"""旧一键流水线(保留兼容):图片分析→风格分析→意图解析→WAIT_USER_CONFIRM。"""
|
||||
session = None
|
||||
try:
|
||||
_recover_stale_jobs() # 顺带回收僵尸任务
|
||||
_hb_stop = None
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": "job not found"}
|
||||
|
||||
job.mark_running()
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...")
|
||||
image_analysis = _step_image_analysis(job)
|
||||
job.image_analysis = image_analysis
|
||||
_save_job(repo, job, session)
|
||||
@@ -843,16 +1208,18 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
|
||||
style_guide = None
|
||||
if job.reference_video_url or job.style_template_id:
|
||||
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 20.0, "正在分析参考视频风格...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...")
|
||||
style_guide = _step_video_analysis(job)
|
||||
job.style_guide = style_guide
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 25.0, "风格分析完成", {"style_guide": style_guide})
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 30.0, "正在解析文案意图...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
|
||||
intent_result = _step_intent_parsing(job, image_analysis)
|
||||
|
||||
job.mark_wait_user_confirm(intent_result)
|
||||
job.current_stage = ViralVideoStage.INTENT_PARSING
|
||||
job.phase_message = "意图解析完成,等待用户确认"
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(
|
||||
job_id,
|
||||
@@ -878,6 +1245,8 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if _hb_stop is not None:
|
||||
_hb_stop.set()
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
@@ -960,15 +1329,17 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
|
||||
"""v1.5+ 阶段1:图片 VLM 分析 + 可选视频风格分析。"""
|
||||
session = None
|
||||
try:
|
||||
_recover_stale_jobs() # 顺带回收僵尸任务
|
||||
_hb_stop = None
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": "job not found"}
|
||||
|
||||
job.mark_running()
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...")
|
||||
image_analysis = _step_image_analysis(job)
|
||||
job.image_analysis = image_analysis
|
||||
_save_job(repo, job, session)
|
||||
@@ -976,7 +1347,7 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
|
||||
|
||||
style_guide = None
|
||||
if job.reference_video_url or job.style_template_id:
|
||||
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 70.0, "正在分析参考视频风格...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...")
|
||||
style_guide = _step_video_analysis(job)
|
||||
job.style_guide = style_guide
|
||||
_save_job(repo, job, session)
|
||||
@@ -989,6 +1360,8 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
|
||||
)
|
||||
|
||||
job.mark_image_analyzed()
|
||||
job.current_stage = ViralVideoStage.IMAGE_ANALYSIS
|
||||
job.phase_message = "图片分析完成,请填写营销参数以生成编导脚本"
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(
|
||||
job_id,
|
||||
@@ -1007,55 +1380,72 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.IMAGE_ANALYSIS)
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if _hb_stop is not None:
|
||||
_hb_stop.set()
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=1, name="worker.run_viral_video_generate_copy")
|
||||
def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
|
||||
"""v1.6 阶段2:意图解析 → 编导分镜脚本生成 → 合规审核,完成后状态=copy_generated。"""
|
||||
"""v1.6 阶段2(v1.6.1 提速版):意图解析 → 编导分镜脚本生成 → 直接返回,合规审核后置到出片前。
|
||||
|
||||
优化点(#2134 问题7):
|
||||
- 意图解析/编导脚本均使用快模型(doubao_fast_model,非推理模型),max_tokens 收紧
|
||||
- _SCRIPT_GENERATION_PROMPT 精简冗余描述
|
||||
- 合规审核改为异步后置:不阻塞前端,在 confirm-copy(阶段3 TTS前)再做最终审核
|
||||
- 每个阶段通过 _set_stage 持久化 current_stage/phase_message 到 DB(问题8)
|
||||
"""
|
||||
session = None
|
||||
try:
|
||||
_recover_stale_jobs() # 顺带回收僵尸任务
|
||||
_hb_stop = None
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": "job not found"}
|
||||
if job.status != ViralVideoStatus.RUNNING:
|
||||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||||
job.touch_heartbeat()
|
||||
_save_job(repo, job, session)
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
|
||||
# 阶段:意图解析
|
||||
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
|
||||
image_analysis = job.image_analysis or {"products": []}
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 20.0, "正在解析文案意图...")
|
||||
intent_result = _step_intent_parsing(job, image_analysis)
|
||||
job.intent_result = intent_result
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 35.0, "意图解析完成")
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.0, "正在生成编导分镜脚本...")
|
||||
# 阶段:编导脚本生成(核心耗时环节,已用快模型)
|
||||
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在编排分镜脚本...")
|
||||
copy_result = _step_script_generation(job, intent_result, image_analysis)
|
||||
_emit_progress(
|
||||
job_id,
|
||||
ViralVideoStage.SCRIPT_GENERATION,
|
||||
60.0,
|
||||
"编导脚本生成完成",
|
||||
85.0,
|
||||
"分镜脚本生成完成",
|
||||
{"shots": len(copy_result.get("shots", []))},
|
||||
)
|
||||
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 65.0, "正在进行合规审核...")
|
||||
review_result = _step_review(job, copy_result)
|
||||
if not review_result.get("passed", True):
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
|
||||
copy_result = _step_script_generation(job, intent_result, image_analysis)
|
||||
_step_review(job, copy_result)
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成")
|
||||
# 合规审核后置:不再阻塞前端返回;在阶段3(confirm-copy 出片前)_run_render_pipeline 里再做最终审核。
|
||||
# 这里只做一个快速轻量检查(关键字黑名单),发现明显违规再触发重写;LLM 深度审核放到出片前。
|
||||
_set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在快速检查脚本合规性...")
|
||||
voiceover = (copy_result or {}).get("voiceover_script", "") or ""
|
||||
_quick_compliance_blacklist_check(copy_result)
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 95.0, "脚本合规初检完成")
|
||||
|
||||
# 标记 copy_generated 并持久化
|
||||
job.mark_copy_generated(copy_result)
|
||||
job.current_stage = ViralVideoStage.REVIEW
|
||||
job.phase_message = "分镜脚本已生成,请确认或编辑口播文案"
|
||||
_save_job(repo, job, session)
|
||||
voiceover = copy_result.get("voiceover_script", "")
|
||||
|
||||
_emit_progress(
|
||||
job_id,
|
||||
ViralVideoStage.REVIEW,
|
||||
100.0,
|
||||
"编导分镜脚本已生成,请确认或编辑口播文案",
|
||||
"分镜脚本已生成,请确认或编辑口播文案",
|
||||
{
|
||||
"copy_result": copy_result,
|
||||
"generated_copy_text": voiceover,
|
||||
@@ -1086,43 +1476,113 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.SCRIPT_GENERATION)
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if _hb_stop is not None:
|
||||
_hb_stop.set()
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
|
||||
def _quick_compliance_blacklist_check(copy_result: dict) -> None:
|
||||
"""阶段2快速黑名单检查:不调用 LLM,只扫描高风险关键词;命中则在 voiceover 中就地替换。
|
||||
|
||||
LLM 深度合规审核(_step_review)在阶段3 confirm-copy 出片前执行。
|
||||
"""
|
||||
if not isinstance(copy_result, dict):
|
||||
return
|
||||
voiceover = copy_result.get("voiceover_script", "") or ""
|
||||
# 广告法绝对化用语黑名单(常见速查,远非完整,仅挡住最明显违规)
|
||||
BLACKLIST = {
|
||||
"最": "很",
|
||||
"第一": "领先",
|
||||
"国家级": "高品质",
|
||||
"世界级": "高品质",
|
||||
"顶级": "优质",
|
||||
"极品": "优质",
|
||||
"独家": "特色",
|
||||
"绝无仅有": "少见",
|
||||
"100%": "大幅",
|
||||
"百分百": "大幅",
|
||||
"永久": "长久",
|
||||
"万能": "多用途",
|
||||
"特效": "效果好",
|
||||
"速效": "快速见效",
|
||||
"根治": "改善",
|
||||
"包治": "改善",
|
||||
"药到病除": "缓解不适",
|
||||
}
|
||||
changed = False
|
||||
for k, v in BLACKLIST.items():
|
||||
if k in voiceover:
|
||||
voiceover = voiceover.replace(k, v)
|
||||
changed = True
|
||||
if changed:
|
||||
copy_result["voiceover_script"] = voiceover
|
||||
# 同步 final_copy/suggested_copy(如果存在)
|
||||
for k in ("final_copy", "suggested_copy"):
|
||||
if isinstance(copy_result.get(k), str) and copy_result[k]:
|
||||
for bk, bv in BLACKLIST.items():
|
||||
copy_result[k] = copy_result[k].replace(bk, bv)
|
||||
|
||||
|
||||
def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
|
||||
"""v1.6 阶段3 / 旧 resume 共用:TTS → 单次 Seedance → Upload → Completed。"""
|
||||
"""v1.6.1 阶段3:出片前合规审核(LLM 深度)→ TTS → Seedance → Upload → Completed。
|
||||
|
||||
阶段2 generate-copy 已把 LLM 深度审核后置,这里在 TTS 前做最终审核(不通过则自动重写1次)。
|
||||
所有阶段通过 _set_stage 持久化 current_stage/phase_message。
|
||||
"""
|
||||
image_analysis = job.image_analysis or {"products": []}
|
||||
|
||||
# 如果没有 copy_result(旧数据/失败重试),现场补生成
|
||||
# 如果没有 copy_result(旧数据/失败重试),现场补生成(意图+脚本,不走 LLM 审核,出片前会统一做)
|
||||
copy_result = job.copy_result
|
||||
if not isinstance(copy_result, dict) or not copy_result:
|
||||
_emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.0, "正在补生成编导脚本...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在补生成编导脚本...")
|
||||
intent = job.intent_result or _step_intent_parsing(job, image_analysis)
|
||||
copy_result = _step_script_generation(job, intent, image_analysis)
|
||||
_step_review(job, copy_result)
|
||||
job.mark_copy_generated(copy_result)
|
||||
_save_job(repo, job, session)
|
||||
|
||||
# 出片前 LLM 深度合规审核(#2134 问题7:审核从阶段2后置到这里,不阻塞前端预览脚本)
|
||||
_set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在进行出片前合规审核...")
|
||||
try:
|
||||
review_result = _step_review(job, copy_result)
|
||||
if not review_result.get("passed", True):
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
|
||||
intent = job.intent_result or _step_intent_parsing(job, image_analysis)
|
||||
copy_result = _step_script_generation(job, intent, image_analysis)
|
||||
_step_review(job, copy_result) # 二次审核,不通过也继续出片(避免反复循环)
|
||||
job.copy_result = copy_result
|
||||
job.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||||
_save_job(repo, job, session)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频][阶段3] 合规审核异常,继续出片: %s", e)
|
||||
_emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成")
|
||||
|
||||
voiceover = copy_result.get("voiceover_script", "") or job.effective_copy_text
|
||||
|
||||
# Step 5: TTS 整段合成
|
||||
_emit_progress(job_id, ViralVideoStage.TTS, 72.0, "正在生成AI配音...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.TTS, "正在合成AI配音...")
|
||||
tts_path = _step_tts(job, voiceover)
|
||||
tts_url = _upload_tts_to_oss(job, tts_path)
|
||||
_emit_progress(job_id, ViralVideoStage.TTS, 78.0, "配音完成", {"has_tts": tts_url is not None})
|
||||
|
||||
# Step 6: 单次 Seedance
|
||||
_emit_progress(job_id, ViralVideoStage.RENDERING, 80.0, "正在调用AI生成视频(约1-3分钟)...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.RENDERING, "正在生成视频(约1-3分钟)...")
|
||||
video_path = _step_render(job, copy_result, tts_url)
|
||||
_emit_progress(job_id, ViralVideoStage.RENDERING, 92.0, "视频生成完成")
|
||||
|
||||
# Step 7: Upload
|
||||
_emit_progress(job_id, ViralVideoStage.UPLOADING, 95.0, "正在上传视频...")
|
||||
_set_stage(job, repo, session, ViralVideoStage.UPLOADING, "正在上传视频...")
|
||||
video_url = _step_upload(job, video_path)
|
||||
|
||||
# P2-2 OSS 一致性:上传后循环 head 确认公网可访问(最多等 10s),
|
||||
# 避免前端拿到 completed 立即下载时命中 NoSuchKey。
|
||||
if video_url:
|
||||
_wait_oss_ready(video_url, timeout_sec=10)
|
||||
|
||||
job.credits_cost = CREDITS_VIRAL_VIDEO_COST
|
||||
job.mark_completed(video_url)
|
||||
job.current_stage = ViralVideoStage.UPLOADING
|
||||
job.phase_message = "视频生成完成"
|
||||
_save_job(repo, job, session)
|
||||
_emit_progress(job_id, ViralVideoStage.UPLOADING, 100.0, "视频生成完成!", {"video_url": video_url})
|
||||
_emit_progress(
|
||||
@@ -1142,11 +1602,16 @@ def run_viral_video_render(self: Task, job_id: str) -> dict:
|
||||
"""v1.6 阶段3:TTS + 单次 Seedance 生成 + 上传。"""
|
||||
session = None
|
||||
try:
|
||||
_recover_stale_jobs() # 顺带回收僵尸任务
|
||||
_hb_stop = None
|
||||
session, repo, job = _get_repo_and_job(job_id)
|
||||
if job is None:
|
||||
return {"ok": False, "error": "job not found"}
|
||||
if job.status != ViralVideoStatus.RUNNING:
|
||||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||||
job.touch_heartbeat()
|
||||
_save_job(repo, job, session)
|
||||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||||
return _run_render_pipeline(job_id, session, repo, job)
|
||||
except Retry:
|
||||
raise
|
||||
@@ -1155,5 +1620,7 @@ def run_viral_video_render(self: Task, job_id: str) -> dict:
|
||||
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.RENDERING)
|
||||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||||
finally:
|
||||
if _hb_stop is not None:
|
||||
_hb_stop.set()
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
@@ -950,6 +950,9 @@ class ViralVideoJobModel(Base):
|
||||
video_model = Column(String(100), nullable=False, default="")
|
||||
# 结果与状态
|
||||
status = Column(String(30), nullable=False, default="pending", index=True)
|
||||
current_stage = Column(String(200), nullable=False, default="") # 细粒度阶段 snake_case
|
||||
phase_message = Column(String(500), nullable=False, default="") # 阶段中文提示文案
|
||||
heartbeat_at = Column(DateTime, nullable=True, index=True) # worker 心跳,用于僵尸任务超时回收
|
||||
intent_result = Column(JSON, nullable=True)
|
||||
image_analysis = Column(JSON, nullable=True)
|
||||
storyboard = Column(JSON, nullable=True)
|
||||
|
||||
@@ -37,6 +37,9 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
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,
|
||||
current_stage=getattr(model, "current_stage", "") or "",
|
||||
phase_message=getattr(model, "phase_message", "") or "",
|
||||
heartbeat_at=getattr(model, "heartbeat_at", None),
|
||||
intent_result=dict(model.intent_result) if model.intent_result else None,
|
||||
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
|
||||
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
|
||||
@@ -83,6 +86,9 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
video_ratio=job.video_ratio,
|
||||
video_model=job.video_model,
|
||||
status=job.status,
|
||||
current_stage=job.current_stage or "",
|
||||
phase_message=job.phase_message or "",
|
||||
heartbeat_at=job.heartbeat_at,
|
||||
intent_result=job.intent_result,
|
||||
image_analysis=job.image_analysis,
|
||||
storyboard=job.storyboard,
|
||||
@@ -106,6 +112,9 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
if model is None:
|
||||
raise ValueError(f"ViralVideoJob {job.id} not found")
|
||||
model.status = job.status
|
||||
model.current_stage = job.current_stage or ""
|
||||
model.phase_message = job.phase_message or ""
|
||||
model.heartbeat_at = job.heartbeat_at
|
||||
model.intent_result = job.intent_result
|
||||
model.image_analysis = job.image_analysis
|
||||
model.storyboard = job.storyboard
|
||||
|
||||
@@ -90,11 +90,14 @@ class SharedSettings(BaseSettings):
|
||||
|
||||
# ── 豆包大模型(火山引擎方舟) ────────────────────────────────────────
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-1-6-250615"
|
||||
doubao_model: str = "doubao-seed-1-6-250615" # 推理模型(通用兜底)
|
||||
doubao_fast_model: str = "doubao-1-5-pro-32k-250115" # 快速结构化输出模型(编导脚本/意图解析/审核)
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250328" # 高精度视觉(备用)
|
||||
doubao_vision_lite_model: str = "doubao-1-5-vision-lite-250315" # 快速视觉(商品识别默认,速度优先)
|
||||
doubao_vision_use_lite: bool = True # viral-video 图片分析默认用 lite 提速
|
||||
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
|
||||
doubao_video_model: str = "doubao-seedance-2-5-260628"
|
||||
doubao_video_timeout: int = 600 # 视频生成轮询总超时(秒)
|
||||
|
||||
@@ -117,6 +117,9 @@ class ViralVideoJob:
|
||||
# 状态
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
status: ViralVideoStatus = ViralVideoStatus.PENDING
|
||||
current_stage: str = "" # 细粒度阶段(ViralVideoStage.value,snake_case)
|
||||
phase_message: str = "" # 阶段中文提示文案,前端轮询直接展示
|
||||
heartbeat_at: datetime | None = None # worker 心跳时间,用于超时僵尸任务检测
|
||||
result_video_url: str = ""
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
@@ -138,9 +141,19 @@ class ViralVideoJob:
|
||||
):
|
||||
raise ValueError(f"Cannot transition from {self.status} to running")
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
now = datetime.now(timezone.utc)
|
||||
if self.started_at is None:
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
self.started_at = now
|
||||
self.heartbeat_at = now
|
||||
self.updated_at = now
|
||||
|
||||
def touch_heartbeat(self) -> None:
|
||||
"""更新心跳时间(worker 在长任务中周期性调用,用于超时检测)。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if self.started_at is None:
|
||||
self.started_at = now
|
||||
self.heartbeat_at = now
|
||||
self.updated_at = now
|
||||
|
||||
def mark_image_analyzed(self) -> None:
|
||||
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
|
||||
|
||||
@@ -40,6 +40,8 @@ class DoubaoClient:
|
||||
self.timeout: int = settings.doubao_timeout
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
self.vision_lite_model: str = settings.doubao_vision_lite_model
|
||||
self.fast_model: str = settings.doubao_fast_model
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
@@ -92,6 +94,7 @@ class DoubaoClient:
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
model: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""调用 Chat Completion 接口.
|
||||
|
||||
@@ -112,7 +115,7 @@ class DoubaoClient:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"model": model or self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
@@ -154,11 +157,12 @@ class DoubaoClient:
|
||||
max_tokens: int = 2048,
|
||||
temperature: float = 0.3,
|
||||
timeout: int | None = None,
|
||||
model: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""调用豆包视觉理解 API(OpenAI 兼容多模态格式).
|
||||
|
||||
将 images 附加到最后一条 user message 的 content 中,
|
||||
使用 vision_model(默认 doubao-1-5-vision-pro-250915)。
|
||||
使用 vision_model(默认 doubao-1-5-vision-pro-250328)。
|
||||
|
||||
Args:
|
||||
messages: 对话消息列表。最后一条 user message 会被注入图片内容。
|
||||
@@ -204,7 +208,7 @@ class DoubaoClient:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.vision_model,
|
||||
"model": model or self.vision_model,
|
||||
"messages": vision_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
@@ -264,9 +268,9 @@ class DoubaoClient:
|
||||
|
||||
Args:
|
||||
prompt: 文本提示词(含完整编导脚本:总览+场景光线+逐镜头时间轴+硬约束+负面词)
|
||||
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 跟随首帧)
|
||||
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 仍按参数传,模型会自动居中裁剪到目标比例)
|
||||
duration: 视频时长 4~30 秒
|
||||
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;image_url 存在时自动忽略
|
||||
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;图生视频时模型会自动居中裁剪首帧到目标比例
|
||||
resolution: 480p/720p/1080p
|
||||
generate_audio: 是否让模型原生合成音效/BGM(v1.6 默认 True,配合 reference_audios 做口型驱动)
|
||||
watermark: 是否加水印
|
||||
@@ -315,8 +319,9 @@ class DoubaoClient:
|
||||
# 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:
|
||||
# 图生视频也必须传 ratio,否则模型默认 adaptive 可能输出 1:1(首帧方形商品图会导致 960x960)
|
||||
# 官方文档:图生视频会自动居中裁剪首帧到目标比例,支持 16:9/9:16/1:1/4:3/3:4/21:9/adaptive
|
||||
if ratio:
|
||||
create_payload["ratio"] = ratio
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -496,16 +496,32 @@ def run_generate_cover(
|
||||
# ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)──
|
||||
|
||||
|
||||
def call_llm(prompt: str, temperature: float = 0.7) -> object:
|
||||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。"""
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
model: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> object:
|
||||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。
|
||||
|
||||
Args:
|
||||
prompt: 用户侧提示。
|
||||
temperature: 采样温度。
|
||||
max_tokens: 输出上限(结构化任务默认 2048,长文案可按需加大)。
|
||||
model: 覆盖默认模型(如 fast_model 提速用),None 走配置默认推理模型。
|
||||
system_prompt: 覆盖默认 system prompt。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
return None
|
||||
if system_prompt is None:
|
||||
system_prompt = "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"
|
||||
messages = [
|
||||
{"role": "system", "content": "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"},
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=4096)
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
@@ -514,14 +530,26 @@ def call_llm(prompt: str, temperature: float = 0.7) -> object:
|
||||
return raw
|
||||
|
||||
|
||||
def call_vision(image_url: str, prompt: str) -> object:
|
||||
def call_vision(
|
||||
image_url: str,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 1024,
|
||||
temperature: float = 0.2,
|
||||
timeout: int = 45,
|
||||
system_prompt: str | None = None,
|
||||
) -> object:
|
||||
"""调用豆包视觉大模型分析图片,返回解析后的 JSON 或原文字符串;失败返回 None。
|
||||
|
||||
Bug #2114 (VLM 牛头不对马嘴根因修复):
|
||||
之前误走 client.chat_completion(用文本模型 doubao-seed-1.6),多模态 content list 被当成
|
||||
纯文本发给文本模型 → 模型要么看不到图、要么抛 400,静默被 except 吞掉 → 返回 None →
|
||||
_step_image_analysis fallback 到 {"name":"未识别"} → 后续文案/分镜完全没图的信息。
|
||||
现改走 vision_completion,走视觉模型 doubao-1-5-vision-pro-250915。
|
||||
Args:
|
||||
image_url: 可公网访问的图片 URL(直接传给豆包视觉模型,无需本地下载)。
|
||||
prompt: 用户侧文本提示。
|
||||
model: 覆盖默认视觉模型(如 vision_lite_model 提速用),None 走配置默认。
|
||||
max_tokens: 输出上限,商品识别用 800~1200 足够,避免长输出拖慢首 token。
|
||||
temperature: 温度。
|
||||
timeout: 单次请求超时(秒)。
|
||||
system_prompt: 覆盖默认 system prompt(viral-video 商品分析会传专门的详细 prompt)。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
@@ -531,28 +559,33 @@ def call_vision(image_url: str, prompt: str) -> object:
|
||||
logger.warning("[call_vision] 空 image_url,跳过视觉分析")
|
||||
return None
|
||||
|
||||
system_prompt = (
|
||||
"你是资深电商视觉分析师。请严格基于用户提供的图片观察回答,"
|
||||
"图片里没有的信息不要凭空想象或编造;看不清或无法判断时明确说"
|
||||
"「图片中无法判断」,不要猜测。输出必须是严格 JSON,不要附加 Markdown 或解释文字。"
|
||||
)
|
||||
if system_prompt is None:
|
||||
system_prompt = (
|
||||
"你是资深电商视觉分析师。请严格基于用户提供的图片观察回答,"
|
||||
"图片里没有的信息不要凭空想象或编造;看不清或无法判断时明确说"
|
||||
"「无法判断」,不要猜测。输出必须是严格 JSON,不要附加 Markdown 或解释文字。"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
used_model = model or getattr(client, "vision_model", "?")
|
||||
logger.info(
|
||||
"[call_vision] 调用豆包视觉模型 vision_model=%s image_url=%s prompt_len=%d",
|
||||
getattr(client, "vision_model", "?"),
|
||||
"[call_vision] 调用豆包视觉模型 model=%s image_url=%s prompt_len=%d max_tokens=%d timeout=%d",
|
||||
used_model,
|
||||
image_url[:120],
|
||||
len(prompt),
|
||||
max_tokens,
|
||||
timeout,
|
||||
)
|
||||
raw = client.vision_completion(
|
||||
messages=messages,
|
||||
images=[image_url],
|
||||
temperature=0.2,
|
||||
max_tokens=2048,
|
||||
timeout=60,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
model=model,
|
||||
)
|
||||
if raw is None:
|
||||
logger.warning("[call_vision] 视觉模型返回 None (image_url=%s)", image_url[:80])
|
||||
@@ -592,13 +625,14 @@ def call_video_generation(
|
||||
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
|
||||
- reference_images 传产品素材 URL 数组做视觉参考;
|
||||
- 单次最长 30 秒,不分段不拼接;
|
||||
- image_url 存在时为「首帧图生视频」模式,自动不传 ratio(Bug #2110)。
|
||||
- 图生视频(image_url 存在)也强制传 ratio,避免商品方图导致默认输出 1:1。
|
||||
官方文档:图生视频时 ratio 由参数决定,模型会居中裁剪首帧到目标比例。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
|
||||
return None
|
||||
effective_ratio = None if image_url else ratio
|
||||
effective_ratio = ratio or "9:16"
|
||||
try:
|
||||
kwargs: dict = dict(
|
||||
prompt=prompt,
|
||||
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -14,10 +14,13 @@ from packages.shared.ai_client import DoubaoClient
|
||||
class _FakeSettings:
|
||||
doubao_api_key = "test-key"
|
||||
doubao_model = "test-model"
|
||||
doubao_fast_model = "test-fast-model"
|
||||
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout = 10
|
||||
doubao_max_retries = 0
|
||||
doubao_vision_model = "test-vision"
|
||||
doubao_vision_lite_model = "test-vision-lite"
|
||||
doubao_vision_use_lite = False
|
||||
doubao_embedding_model = "test-embedding"
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestCheckDatabase:
|
||||
assert result["type"] == "postgresql"
|
||||
assert result["message"] == "Database connection successful"
|
||||
mock_psycopg.connect.assert_called_once_with(
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
"postgresql://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_cur.execute.assert_called_once_with("SELECT 1")
|
||||
mock_conn.close.assert_called_once()
|
||||
@@ -96,7 +96,7 @@ class TestCheckMigrations:
|
||||
assert result["status"] == "healthy"
|
||||
assert result["message"] == "Database migrations applied"
|
||||
mock_psycopg.connect.assert_called_once_with(
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
"postgresql://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -253,8 +253,8 @@ class TestCallVideoGenerationV16:
|
||||
)
|
||||
assert result == str(out)
|
||||
kwargs = mock_client.video_generation.call_args.kwargs
|
||||
# 首帧模式不传 ratio(Bug #2110)
|
||||
assert "ratio" not in kwargs
|
||||
# 图生视频也必须传 ratio(避免首帧方图导致默认输出 1:1)
|
||||
assert kwargs.get("ratio") == "9:16", f"ratio 应透传,got {kwargs.get('ratio')!r}"
|
||||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||||
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
|
||||
assert kwargs["reference_images"] == ["https://img/r1.jpg"]
|
||||
|
||||
@@ -61,6 +61,8 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"video_ratio": "9:16",
|
||||
"video_model": "",
|
||||
"credits_cost": 0,
|
||||
"current_stage": "",
|
||||
"phase_message": "",
|
||||
"updated_at": None,
|
||||
"is_terminal": False,
|
||||
"effective_copy_text": "",
|
||||
|
||||
Reference in New Issue
Block a user