fix: clarify storyboard video candidate generation
This commit is contained in:
4
RULES.md
4
RULES.md
@@ -11,11 +11,11 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段抽卡”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认抽 4 张视频候选,顶部支持整片一键抽卡;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- 发布状态:已部署并验证(2026-05-19,三字段抽卡工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 发布状态:已部署并验证(2026-05-19,三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 主站 / 前端:`https://marketing.skg.com`
|
||||
- API / 后端:`https://marketing.skg.com/api`
|
||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||
|
||||
12
api/main.py
12
api/main.py
@@ -6214,7 +6214,7 @@ def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboar
|
||||
bg.add_task(render_storyboard_video, *task_args)
|
||||
else:
|
||||
threading.Thread(target=render_storyboard_video, args=task_args, daemon=True).start()
|
||||
update(job, generated_videos=items + job.generated_videos, message=f"视频抽卡已提交 · 分镜 {frame.index + 1} · {count} 张")
|
||||
update(job, generated_videos=items + job.generated_videos, message=f"视频候选已提交 · 分镜 {frame.index + 1} · {count} 条")
|
||||
return ids
|
||||
|
||||
|
||||
@@ -6239,7 +6239,7 @@ def _batch_generate_worker(job_id: str, req: BatchGenerateStoryboardReq) -> None
|
||||
count = max(1, min(12, int(req.count_per_row or 4)))
|
||||
concurrency = max(1, min(8, int(req.concurrency or 4)))
|
||||
frames = list(job.frames)
|
||||
update(job, message=f"整片一键抽卡已启动 · 0/{len(frames)} 条", error="")
|
||||
update(job, message=f"整片视频候选生成已启动 · 0/{len(frames)} 条", error="")
|
||||
done = 0
|
||||
|
||||
def submit_one(frame: KeyFrame) -> None:
|
||||
@@ -6274,15 +6274,15 @@ def _batch_generate_worker(job_id: str, req: BatchGenerateStoryboardReq) -> None
|
||||
)
|
||||
_enqueue_storyboard_videos(job, frame, video_req, None)
|
||||
except Exception as e:
|
||||
update(job, error=f"分镜 {frame.index + 1} 抽卡失败:{str(e)[:220]}")
|
||||
update(job, error=f"分镜 {frame.index + 1} 候选生成失败:{str(e)[:220]}")
|
||||
finally:
|
||||
done += 1
|
||||
update(job, message=f"整片一键抽卡进行中 · {done}/{len(frames)} 条")
|
||||
update(job, message=f"整片视频候选生成中 · {done}/{len(frames)} 条")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||||
futures = [executor.submit(submit_one, frame) for frame in frames]
|
||||
wait(futures)
|
||||
update(job, message=f"整片一键抽卡已提交 · {len(frames)}/{len(frames)} 条 · 每条 {count} 张")
|
||||
update(job, message=f"整片视频候选已提交 · {len(frames)}/{len(frames)} 条分镜 · 每条 {count} 个候选")
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/storyboard/batch-generate-all", response_model=Job)
|
||||
@@ -6294,7 +6294,7 @@ def batch_generate_all_storyboard(job_id: str, req: BatchGenerateStoryboardReq)
|
||||
if not job.frames:
|
||||
raise HTTPException(400, "no frames to generate")
|
||||
threading.Thread(target=_batch_generate_worker, args=(job_id, req), daemon=True).start()
|
||||
update(job, message=f"整片一键抽卡已启动 · {len(job.frames)} 条 · 每条 {max(1, min(12, int(req.count_per_row or 4)))} 张")
|
||||
update(job, message=f"整片视频候选生成已启动 · {len(job.frames)} 条分镜 · 每条 {max(1, min(12, int(req.count_per_row or 4)))} 个候选")
|
||||
return job
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -712,8 +712,8 @@ function buildWorkflowSteps({
|
||||
id: "video",
|
||||
no: "09",
|
||||
title: "视频候选",
|
||||
detail: generatedVideoCount ? `${generatedVideoCount} 条候选` : "可抽 4 张",
|
||||
judge: "单条默认抽 4 张候选;整片一键抽卡后台提交,失败行可单独重试。",
|
||||
detail: generatedVideoCount ? `${generatedVideoCount} 条候选` : "可生成 4 条",
|
||||
judge: "单条默认生成 4 条视频候选;整片一键批量生成后台提交,失败行可单独重试。",
|
||||
status: generatedVideoCount > 0 ? "ready" : stepStatus({ ready: false, blocked: !storyboardReady }),
|
||||
},
|
||||
]
|
||||
@@ -1782,7 +1782,7 @@ export function AdRecreationBoard({
|
||||
})
|
||||
const workflow = workflowStepMap(workflowSteps)
|
||||
const statusMessage = job?.message?.startsWith("视频生成已提交")
|
||||
? "视频候选已提交;当前默认按紧凑三字段抽卡,首尾帧细节自动处理。"
|
||||
? "视频候选已提交;当前默认按紧凑三字段生成候选,首尾帧细节自动处理。"
|
||||
: job?.message
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3625,9 +3625,9 @@ function AudioStoryboardPlanPanel({
|
||||
size: "720x1280",
|
||||
})
|
||||
onJobUpdate?.(updated)
|
||||
toast.success(`分镜 ${row.index + 1} 已提交 ${count} 张视频候选`)
|
||||
toast.success(`分镜 ${row.index + 1} 已提交 ${count} 条视频候选`)
|
||||
} catch (e) {
|
||||
toast.error("视频抽卡失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error("视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setQuickVideoBusyRow(null)
|
||||
}
|
||||
@@ -4003,9 +4003,9 @@ function AudioStoryboardPlanPanel({
|
||||
size: "720x1280",
|
||||
})
|
||||
onJobUpdate?.(updated)
|
||||
toast.success(`整片一键抽卡已启动:${rows.length} 条 × 4 张`)
|
||||
toast.success(`整片视频候选生成已启动:${rows.length} 条分镜 × 每条 4 个候选`)
|
||||
} catch (e) {
|
||||
toast.error("整片一键抽卡失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error("整片视频候选生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setBatchCardBusy(false)
|
||||
}
|
||||
@@ -4242,7 +4242,7 @@ function AudioStoryboardPlanPanel({
|
||||
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{batchCardBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
整片一键抽卡({rows.length}×4)
|
||||
整片生成候选({rows.length}×4条)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -4335,7 +4335,7 @@ function AudioStoryboardPlanPanel({
|
||||
className="skg-primary-action inline-flex h-8 items-center justify-center gap-1 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{quickVideoBusyRow === row.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
抽 4 张视频
|
||||
生成 4 条视频
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -4753,7 +4753,7 @@ function AudioStoryboardPlanPanel({
|
||||
})() : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成三字段分镜,并支持单条或整片一键抽 4 张视频候选。" />
|
||||
<EmptyState text="音频解析完成后,这里会按逐句时间轴生成三字段分镜,并支持单条或整片批量生成 4 条视频候选。" />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
@@ -5019,9 +5019,9 @@ function StoryboardVideoSlots({
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Film className="h-3.5 w-3.5 text-cyan-100/65" />
|
||||
<span className="text-[11px] font-semibold text-white/66">视频候选(4 张抽卡)</span>
|
||||
<span className="text-[11px] font-semibold text-white/66">视频候选(每次 4 条)</span>
|
||||
<span className="shrink-0 text-[10px] text-white/34">
|
||||
{videos.length ? `${videos.length} 张${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待抽卡" : "待抽帧"}
|
||||
{videos.length ? `${videos.length} 条${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
|
||||
</span>
|
||||
{selectedVideo ? <span className="rounded border border-emerald-300/20 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] text-emerald-100/72">已选 {shortId(selectedVideo.id)}</span> : null}
|
||||
<ChevronDown className={`ml-auto h-3.5 w-3.5 shrink-0 text-white/42 transition ${expanded ? "rotate-180" : ""}`} />
|
||||
@@ -5034,7 +5034,7 @@ function StoryboardVideoSlots({
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.07] px-2 text-[10px] font-semibold text-cyan-100/70 transition hover:border-cyan-300/45 hover:text-cyan-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
{videos.length ? "再抽 4 张" : "抽 4 张"}
|
||||
{videos.length ? "再生成 4 条" : "生成 4 条"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user