fix: clarify storyboard video candidate generation

This commit is contained in:
2026-05-19 13:50:46 +08:00
parent ce4ff74b7d
commit e6d957fcab
4 changed files with 49 additions and 37 deletions

View File

@@ -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`

View File

@@ -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

View File

@@ -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"