Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce31a4438 | |||
| 9a4206e65c | |||
| 70aeb5e642 | |||
| d707b64876 | |||
| fe4464df2e | |||
| 0c0fe4619d | |||
| ad686dcd8b | |||
| 347cc82ffa | |||
| d3ee11d27a | |||
| 16ef616907 | |||
| ea51b372af | |||
| 9c807444da | |||
| b31205e965 | |||
| b4b53c9e5d | |||
| cf0a08f503 | |||
| 3ede4dca1f | |||
| 133a6c5914 | |||
| 1e8ba91bba | |||
| 2a739dee17 | |||
| 6b75eb5f67 | |||
| dbb4e57810 | |||
| ecb049a57e | |||
| 58d6852a71 |
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
+158
-108
@@ -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,
|
||||
@@ -258,26 +262,13 @@ class DoubaoClient:
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""调用 Seedance 2.5 文生/图生视频(异步任务→轮询→下载),返回本地 MP4 路径;失败返回 None。
|
||||
"""调用 Seedance 2.5 生视频(异步任务→轮询→下载),返回本地 MP4 路径;失败返回 None。
|
||||
|
||||
v1.6: 支持多参考图(产品素材)+ 参考音频(TTS口型驱动)+ 参考视频,单次生成最长 30 秒。
|
||||
|
||||
Args:
|
||||
prompt: 文本提示词(含完整编导脚本:总览+场景光线+逐镜头时间轴+硬约束+负面词)
|
||||
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 跟随首帧)
|
||||
duration: 视频时长 4~30 秒
|
||||
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;image_url 存在时自动忽略
|
||||
resolution: 480p/720p/1080p
|
||||
generate_audio: 是否让模型原生合成音效/BGM(v1.6 默认 True,配合 reference_audios 做口型驱动)
|
||||
watermark: 是否加水印
|
||||
output_dir: 下载目录,默认 /tmp
|
||||
model: 指定模型 ID;空则用 settings.doubao_video_model
|
||||
reference_images: 多参考图 URL 列表(产品素材,最多30张;注意 image_url 为首帧单独传)
|
||||
reference_audios: 参考音频 URL 列表(TTS口播,驱动口型,最多10段)
|
||||
reference_videos: 参考视频 URL 列表(风格参考)
|
||||
|
||||
Returns:
|
||||
本地 MP4 文件路径,失败返回 None。
|
||||
【v1.6.1 修复】严格按官方 content 数组协议构造请求:
|
||||
- 所有参考(图/音/视)必须放进 content 数组并带 role 字段,不能放顶层 reference_audios/reference_videos(非官方字段,会被忽略或导致异常)。
|
||||
- 首帧图(first_frame 模式)Seedance 2.5 强制 ratio=adaptive;走 omni_reference(参考生视频)模式时才能指定 9:16/1:1 等具体比例。
|
||||
判定:传了参考音频/视频或 ≥1 张多参考图时,走 omni_reference(首张图 role=reference_image);纯首帧无参考时走 first_frame(ratio 强制 adaptive)。
|
||||
- 创建任务若因 ratio 报错(HTTP 400),自动回退到 ratio=adaptive 重试一次。
|
||||
"""
|
||||
if not self.is_available:
|
||||
return None
|
||||
@@ -286,18 +277,48 @@ class DoubaoClient:
|
||||
|
||||
settings = get_shared_settings()
|
||||
poll_interval = getattr(settings, "doubao_video_poll_interval", 10) or 10
|
||||
total_timeout = getattr(settings, "doubao_video_timeout", 900) or 900
|
||||
# 收紧总超时:轮询 8min + 下载 2min = 最长 ~10min,防止出现 20min 卡死
|
||||
total_timeout = getattr(settings, "doubao_video_timeout", 480) or 480
|
||||
default_video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
|
||||
video_model = model or default_video_model
|
||||
|
||||
ref_audios = [u for u in (reference_audios or [])[:10] if u and isinstance(u, str)]
|
||||
ref_videos = [u for u in (reference_videos or [])[:3] if u and isinstance(u, str)]
|
||||
ref_imgs = [u for u in (reference_images or [])[:9] if u and isinstance(u, str)]
|
||||
|
||||
# 判断任务模式:有参考音/视/多图 → omni_reference(支持指定 ratio);纯首帧 → first_frame(ratio=adaptive)
|
||||
has_extra_refs = bool(ref_audios or ref_videos or ref_imgs)
|
||||
is_first_frame_mode = bool(image_url) and not has_extra_refs
|
||||
# 最终 ratio:first_frame 模式强制 adaptive,否则按用户传值(默认 9:16)
|
||||
final_ratio = "adaptive" if is_first_frame_mode else (ratio or "9:16")
|
||||
|
||||
# 构造 content 数组:text + 图 + 音 + 视
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt.strip()}]
|
||||
if image_url:
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}})
|
||||
# v1.6: 多参考图(产品素材)
|
||||
if reference_images:
|
||||
for url in reference_images[:30]:
|
||||
if url and isinstance(url, str):
|
||||
content.append({"type": "image_url", "image_url": {"url": url}})
|
||||
if has_extra_refs:
|
||||
# omni_reference:首张图作为 reference_image,允许指定 ratio
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"role": "reference_image",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 纯首帧:不带 role,服务端识别为 first_frame(或显式 role=first_frame)
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"role": "first_frame",
|
||||
}
|
||||
)
|
||||
for u in ref_imgs:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}, "role": "reference_image"})
|
||||
for u in ref_audios:
|
||||
content.append({"type": "audio_url", "audio_url": {"url": u}, "role": "reference_audio"})
|
||||
for u in ref_videos:
|
||||
content.append({"type": "video_url", "video_url": {"url": u}, "role": "reference_video"})
|
||||
|
||||
create_payload: dict[str, Any] = {
|
||||
"model": video_model,
|
||||
@@ -306,18 +327,8 @@ class DoubaoClient:
|
||||
"duration": int(duration),
|
||||
"resolution": resolution,
|
||||
"watermark": bool(watermark),
|
||||
"ratio": final_ratio,
|
||||
}
|
||||
# v1.6: 参考音频(TTS 驱动口型)
|
||||
if reference_audios:
|
||||
create_payload["reference_audios"] = [
|
||||
{"url": u, "role": "audio_url"} for u in reference_audios[:10] if u and isinstance(u, str)
|
||||
]
|
||||
# v1.6: 参考视频(风格参考)
|
||||
if reference_videos:
|
||||
create_payload["reference_videos"] = [{"url": u} for u in reference_videos[:5] if u and isinstance(u, str)]
|
||||
# Bug #2110 / v1.6: ratio=None 时不传(首帧图生视频跟随原图比例)
|
||||
if ratio and not image_url:
|
||||
create_payload["ratio"] = ratio
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -325,78 +336,94 @@ class DoubaoClient:
|
||||
}
|
||||
create_url = f"{self.base_url}/contents/generations/tasks"
|
||||
logger.info(
|
||||
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s ref_imgs=%d ref_audios=%d ref_videos=%d",
|
||||
create_url,
|
||||
"Seedance 创建任务: model=%s dur=%ds ratio=%s mode=%s gen_audio=%s img=%d aud=%d vid=%d",
|
||||
video_model,
|
||||
duration,
|
||||
ratio or "(follow-image)",
|
||||
final_ratio,
|
||||
"first_frame" if is_first_frame_mode else "omni_ref",
|
||||
generate_audio,
|
||||
bool(image_url),
|
||||
len(reference_images or []),
|
||||
len(reference_audios or []),
|
||||
len(reference_videos or []),
|
||||
(1 if image_url else 0) + len(ref_imgs),
|
||||
len(ref_audios),
|
||||
len(ref_videos),
|
||||
)
|
||||
|
||||
# 1) 创建任务(带重试)
|
||||
task_id: str | None = None
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(create_url, headers=headers, json=create_payload, timeout=self.timeout)
|
||||
# 测试环境下 MagicMock().status_code 是 MagicMock,与 int 比较会抛 TypeError;
|
||||
# 用显式 int() 转换+类型判断,避免误判。
|
||||
def _do_create(payload: dict) -> tuple[str | None, Exception | None, int, str]:
|
||||
"""返回 (task_id, last_err, status_code, body_text)。"""
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
_status = int(resp.status_code)
|
||||
except (TypeError, ValueError):
|
||||
_status = 200
|
||||
if _status >= 400:
|
||||
# 把响应体完整打出来(通常含 error.code/message,能直接定位:模型未开通/Key 无权限/模型 ID 错误)
|
||||
logger.error(
|
||||
"Seedance 创建任务 HTTP %d: body=%s",
|
||||
resp.status_code,
|
||||
(resp.text or "")[:1000],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
task_id = data.get("id")
|
||||
if task_id:
|
||||
break
|
||||
last_error = RuntimeError(f"create task returned no id: {str(data)[:200]}")
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"Seedance 创建任务失败,%.1fs 后重试 (%d/%d): %s", wait, attempt + 1, self.max_retries + 1, e
|
||||
)
|
||||
time.sleep(wait)
|
||||
resp = httpx.post(create_url, headers=headers, json=payload, timeout=self.timeout)
|
||||
sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
body = (getattr(resp, "text", "") or "")[:1500]
|
||||
if sc >= 400:
|
||||
logger.error("Seedance 创建任务 HTTP %d: body=%s", sc, body)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as ee:
|
||||
last_err = ee
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
return None, last_err, sc, body
|
||||
data = resp.json()
|
||||
tid = data.get("id")
|
||||
if tid:
|
||||
return tid, None, sc, body
|
||||
last_err = RuntimeError(f"create ok but no id: {str(data)[:300]}")
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"Seedance 创建任务失败,%.1fs 后重试 (%d/%d): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return None, last_err, 0, ""
|
||||
|
||||
# 第一次尝试
|
||||
task_id, last_err, sc, body = _do_create(create_payload)
|
||||
|
||||
# ratio 兜底:HTTP 400 且 body 提到 ratio / adaptive → 回退 adaptive 再试一次
|
||||
if (
|
||||
not task_id
|
||||
and sc == 400
|
||||
and final_ratio != "adaptive"
|
||||
and (
|
||||
"ratio" in (body or "").lower()
|
||||
or "aspect" in (body or "").lower()
|
||||
or "adaptive" in (body or "").lower()
|
||||
)
|
||||
):
|
||||
logger.warning("Seedance 创建因 ratio 失败,回退 ratio=adaptive 重试")
|
||||
create_payload["ratio"] = "adaptive"
|
||||
task_id, last_err, sc2, body2 = _do_create(create_payload)
|
||||
|
||||
if not task_id:
|
||||
logger.error(
|
||||
"Seedance 创建任务最终失败: model=%s base_url=%s err=%s 【排查建议】"
|
||||
"1) 确认方舟控制台已开通 Doubao-Seedance-2.5 模型;"
|
||||
"2) DOUBAO_API_KEY 对应的账号有该模型调用权限;"
|
||||
"3) DOUBAO_BASE_URL 必须为 https://ark.cn-beijing.volces.com/api/v3;"
|
||||
"4) 若控制台用「推理接入点」(endpoint),请把 DOUBAO_VIDEO_MODEL 改为 ep-xxx 接入点 ID。",
|
||||
"Seedance 创建任务最终失败: model=%s base_url=%s err=%s body=%s 【排查】"
|
||||
"1) 方舟控制台已开通 doubao-seedance-2-5-260628;2) API Key 有该模型权限;"
|
||||
"3) DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3;4) 参考素材 URL 公网可访问。",
|
||||
video_model,
|
||||
self.base_url,
|
||||
last_error,
|
||||
last_err,
|
||||
(body or "")[:500],
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Seedance 任务已创建: task_id=%s model=%s duration=%ds gen_audio=%s",
|
||||
task_id,
|
||||
video_model,
|
||||
duration,
|
||||
generate_audio,
|
||||
)
|
||||
logger.info("Seedance 任务已创建: task_id=%s ratio=%s", task_id, create_payload["ratio"])
|
||||
|
||||
# 2) 轮询状态
|
||||
poll_url = f"{create_url}/{task_id}"
|
||||
deadline = time.time() + total_timeout
|
||||
video_url: str | None = None
|
||||
last_status: str = "queued"
|
||||
poll_count = 0
|
||||
while time.time() < deadline:
|
||||
poll_count += 1
|
||||
try:
|
||||
resp = httpx.get(poll_url, headers=headers, timeout=self.timeout)
|
||||
try:
|
||||
@@ -411,48 +438,71 @@ class DoubaoClient:
|
||||
content_obj = data.get("content") or {}
|
||||
video_url = content_obj.get("video_url")
|
||||
if video_url:
|
||||
logger.info("Seedance 任务成功: task_id=%s polls=%d", task_id, poll_count)
|
||||
break
|
||||
last_error = RuntimeError(f"task succeeded but no video_url: {str(data)[:300]}")
|
||||
last_err = RuntimeError(f"task succeeded but no video_url: {str(data)[:300]}")
|
||||
logger.error("Seedance succeeded 但无 video_url: %s", last_err)
|
||||
break
|
||||
if status == "failed":
|
||||
err = data.get("error") or {}
|
||||
last_error = RuntimeError(f"task failed: {err.get('code','')} {err.get('message','')}")
|
||||
last_err = RuntimeError(f"task failed: code={err.get('code','')} msg={err.get('message','')}")
|
||||
logger.error("Seedance 任务失败 task_id=%s: %s", task_id, last_err)
|
||||
break
|
||||
if status in ("expired", "cancelled"):
|
||||
last_error = RuntimeError(f"task {status}")
|
||||
last_err = RuntimeError(f"task {status}")
|
||||
logger.error("Seedance 任务 %s: task_id=%s", status, task_id)
|
||||
break
|
||||
# queued / running: 继续轮询
|
||||
# 每 5 次轮询打一次 info 日志,便于观察进度
|
||||
if poll_count % 5 == 0:
|
||||
logger.info("Seedance 轮询中: task_id=%s status=%s polls=%d", task_id, status, poll_count)
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
"Seedance 轮询 HTTP %d: body=%s",
|
||||
e.response.status_code,
|
||||
(e.response.text or "")[:500],
|
||||
)
|
||||
last_err = e
|
||||
logger.warning("Seedance 轮询 HTTP %d: %s", e.response.status_code, (e.response.text or "")[:300])
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
last_err = e
|
||||
logger.debug("Seedance 轮询异常: %s", e)
|
||||
time.sleep(poll_interval)
|
||||
|
||||
if not video_url:
|
||||
logger.error("Seedance 任务未成功: task_id=%s status=%s err=%s", task_id, last_status, last_error)
|
||||
logger.error(
|
||||
"Seedance 任务未成功: task_id=%s last_status=%s polls=%d err=%s (总等待 %.0fs)",
|
||||
task_id,
|
||||
last_status,
|
||||
poll_count,
|
||||
last_err,
|
||||
total_timeout,
|
||||
)
|
||||
return None
|
||||
|
||||
# 3) 下载到本地
|
||||
# 3) 下载到本地(下载超时收紧到 120s)
|
||||
try:
|
||||
out_dir = output_dir or "/tmp"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
local_path = f"{out_dir}/seedance_{task_id}_{uuid.uuid4().hex[:8]}.mp4"
|
||||
with httpx.stream("GET", video_url, timeout=300) as r:
|
||||
download_timeout = 120.0
|
||||
logger.info(
|
||||
"Seedance 开始下载: task_id=%s url=%s timeout=%.0fs", task_id, video_url[:120], download_timeout
|
||||
)
|
||||
with httpx.stream("GET", video_url, timeout=download_timeout) as r:
|
||||
r.raise_for_status()
|
||||
downloaded = 0
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in r.iter_bytes(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
logger.info("Seedance 视频下载完成: %s (%d bytes)", local_path, os.path.getsize(local_path))
|
||||
downloaded += len(chunk)
|
||||
size = os.path.getsize(local_path)
|
||||
logger.info("Seedance 视频下载完成: %s size=%d bytes", local_path, size)
|
||||
if size == 0:
|
||||
logger.error("Seedance 下载文件大小为 0")
|
||||
try:
|
||||
os.remove(local_path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
return local_path
|
||||
except Exception as e:
|
||||
logger.error("Seedance 视频下载失败: %s", e)
|
||||
logger.error("Seedance 视频下载失败: %s", e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
@@ -585,20 +618,20 @@ def call_video_generation(
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""调用 Seedance 2.5 生成视频(v1.6 单次出片版),返回本地 MP4 路径;失败返回 None。
|
||||
"""调用 Seedance 2.5 生成视频(v1.6.1 单次出片版),返回本地 MP4 路径;失败返回 None。
|
||||
|
||||
v1.6:
|
||||
- 默认 generate_audio=True,模型原生合成环境音效/BGM;
|
||||
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
|
||||
- reference_images 传产品素材 URL 数组做视觉参考;
|
||||
- 单次最长 30 秒,不分段不拼接;
|
||||
- image_url 存在时为「首帧图生视频」模式,自动不传 ratio(Bug #2110)。
|
||||
v1.6.1 关键约束(避免 20min 卡死):
|
||||
- 参考音频/视频/多图全部放进 content 数组并带 role=reference_audio/reference_video/reference_image;
|
||||
- 纯首帧无参考时(first_frame 模式),Seedance 2.5 强制 ratio=adaptive;
|
||||
传了参考音/视/多图时走 omni_reference 模式,ratio 可指定为 9:16(客户端内部自动判断)。
|
||||
- ratio 默认 9:16(竖屏),客户端会根据是否有参考自动在 first_frame/adaptive 与 omni/9:16 间切换;
|
||||
若创建任务因 ratio 报错(HTTP 400),客户端会自动回退到 adaptive 再试一次。
|
||||
"""
|
||||
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