diff --git a/.memory/worklog.json b/.memory/worklog.json
index a3280c9..b801ecc 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,19 +1,5 @@
{
"entries": [
- {
- "files_changed": 3,
- "hash": "67bbdae",
- "message": "auto-save 2026-05-12 19:08 (~3)",
- "ts": "2026-05-12T19:09:08+08:00",
- "type": "commit"
- },
- {
- "files_changed": 3,
- "hash": "30a4c46",
- "message": "auto-save 2026-05-12 19:14 (~3)",
- "ts": "2026-05-12T19:14:42+08:00",
- "type": "commit"
- },
{
"files_changed": 3,
"hash": "5a86328",
@@ -3344,6 +3330,19 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 06:49 (~1)",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-14T06:55:41+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-14 06:55 (+1, ~3)",
+ "hash": "aff05b8",
+ "files_changed": 45
+ },
+ {
+ "ts": "2026-05-13T22:58:51Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 06:55 (+1, ~3)",
+ "files_changed": 3
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 386c928..3052190 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -556,7 +556,7 @@
3
清洗水印
对关键帧做全图或区域清洗,必要时应用为当前参考图。
4
主体识别
识别场景和主体候选,只是候选,不应锁死。
5
素材准备
清洗关键帧,把多张关键帧作为同一主体的参考,先重绘六张标准站立主体资产图,再按关键帧生成多个去主体、相似或换风格场景图。
- 6
分镜改造
把参考主体、场景、动作和 SKG 产品放入分镜结构。
+ 6
分镜改造
把参考主体、场景、动作和 SKG 产品放入分镜结构;SKG 产品可从内置白底图库直接加入产品参考组。
7
生成视频
用分镜 4 图槽、改造目标和时长调用 Seedance / Kling / Veo 3 生视频 API,结果回写到画面工作台节点。
8
合成成品
片段、字幕、配音、转场合成最终 mp4。当前未实现。
@@ -571,7 +571,8 @@
web/app/page.tsx | 产品工作台主状态:jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。 |
web/components/nodes/index.tsx | DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。 |
- web/components/lightbox.tsx | 关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图和审核。 |
+ web/components/lightbox.tsx | 关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、产品融合和审核。 |
+ web/components/product-library-picker.tsx | SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 asset。 |
web/components/storyboard-bar.tsx | 顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。 |
web/components/storyboard-workbench.tsx | 顶部分镜编排条下方的明细区:4 图槽、改造目标、时长、自动保存。 |
web/lib/api.ts | 前端类型和 API client,是前后端数据契约镜像。 |
@@ -583,6 +584,7 @@
api/main.py | FastAPI 单文件后端:状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、文件返回。 |
+ api/product_library/skg-products | 内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图,images/ 存 41 张压缩后的参考图。 |
jobs/<jobId>/state.json | 运行时状态文件,不在源码列表里,但刷新恢复依赖它。 |
jobs/<jobId>/frames | 关键帧 jpg。注意 frame.index 是稳定 ID,不等于数组下标。 |
jobs/<jobId>/cleaned | 清洗后待应用图片。 |
@@ -623,13 +625,13 @@ api/main.py
你看到的区域关键帧素材审核面板
-
主要源码FrameLightbox;按“原图/清洗、主体资产、场景图、审核”四个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,场景图页左侧显示全部关键帧并可勾选场景参考;右侧承载当前页操作、状态和结果。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立主体图;场景图依赖主体资产,右侧通过地点、生成方式、风格和参考要素拼出可编辑 prompt,再按当前关键帧生成去主体原场景、相似新场景或同构换风格。相关接口包括 cleanupFrame、addElement、generateSubjectAssets、generateSceneAsset。
-
适合怎么描述“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图和质量风险应该如何审核”。
+
主要源码FrameLightbox;按“原图/清洗、主体资产、场景图、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,场景图页左侧显示全部关键帧并可勾选场景参考,产品融合页左侧接入内置 SKG 白底图库;右侧承载当前页操作、状态和结果。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立主体图;场景图依赖主体资产,右侧通过地点、生成方式、风格和参考要素拼出可编辑 prompt,再按当前关键帧生成去主体原场景、相似新场景或同构换风格。相关接口包括 cleanupFrame、addElement、generateSubjectAssets、generateSceneAsset、listProductLibrary 和 copyProductLibraryAsset。
+
适合怎么描述“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、SKG 产品融合参考和质量风险应该如何审核”。
你看到的区域顶部分镜头编排下拉面板
-
主要源码StoryboardWorkbench;保存到 frame.storyboard;接口 PUT /storyboard。
-
适合怎么描述“每个分镜需要哪些图片槽、哪些改造说明,如何为视频生成做准备”。
+
主要源码StoryboardWorkbench;保存到 frame.storyboard;接口 PUT /storyboard。SKG 产品参考区同时支持上传、剪贴板和内置白底产品库。
+
适合怎么描述“每个分镜需要哪些图片槽、哪些改造说明,哪些 SKG 产品图要作为视频生成参考”。
@@ -692,6 +694,18 @@ SubjectAsset {
background: white | black,
width, height, size,
source_frame_indices[]
+}
+
+
+
ProductLibraryItem
+
内置 SKG 白底图库条目。实际图片保存在 api/product_library/skg-products/images,被选中时会复制到 jobs/<jobId>/assets,再以普通 ImageRef(kind="asset") 进入产品参考组。
+
ProductLibraryItem {
+ id, handle, title, product_type,
+ image_index, filename, url,
+ width, height,
+ white_score,
+ source_path,
+ tags[]
}
@@ -732,6 +746,8 @@ SubjectAsset {
| 元素提取 | POST /elements/{element_id}/cutout | cutoutElement | 调用图像模型生成独立白底素材图,每次累积一张 cutout。 |
| 主体资产包 | POST /elements/{element_id}/subject-assets | generateSubjectAssets | 根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。人物默认输出六张身份标准图,另有表情补充和动作补充分组可选;纯白/黑背景,不含其他元素,并裁去空白让主体占满画面。 |
| 场景资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 在统一主体资产之后,按当前关键帧生成去主体背景板;请求包含 scene_mode、scene_style、prompt 和 source_frame_indices,可用左侧选择的参考帧 + 右侧关键词生成原场景补背景、相似新场景或同构换风格,保留历史版本用于人工审核。 |
+ | 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
+ | 产品图入库到 job | POST /jobs/{id}/assets/product-library | copyProductLibraryAsset | 把一个内置产品图库条目复制为当前 job 的普通 asset,返回 ImageRef(kind="asset"),用于画面工作台产品融合和分镜产品参考组。 |
| 分镜保存 | PUT /frames/{idx}/storyboard | updateStoryboard | 保存 4 图槽、时长和改造说明。 |
| 生图 | POST /frames/{idx}/generate | generateImage | 基于关键帧或已选生成图做 image-to-image,目前可用。 |
@@ -841,6 +857,19 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-14 · 增加产品融合和 SKG 内置白底图库
+ FrameLightbox
+ 产品融合
+
+
+
问题:生成视频需要稳定的 SKG 产品真源,不能每次都依赖临时上传或从参考视频里找产品图;桌面已有整理过的 SKG 产品图,应作为内置数据库使用。
+
改动:从桌面 skg_product_downloads/all_products 的 gallery 中筛出 41 张白底产品图,生成 api/product_library/skg-products/manifest.json 和压缩预览图。FrameLightbox 新增“产品融合”页签,StoryboardWorkbench 的 SKG 产品参考区也接入同一个 ProductLibraryPicker,支持搜索、品类筛选、尺寸预览和一键加入。
+
后端:新增 GET /product-library/skg、GET /product-library/skg/images/{filename} 和 POST /jobs/{job_id}/assets/product-library。选中库内产品图时,后端会复制成当前 job 的 asset,后续仍通过既有 product_images 进入生视频接口。
+
影响:api/main.py、api/product_library/skg-products、web/lib/api.ts、web/components/product-library-picker.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx、docs/source-analysis.html。
+
+
2026-05-14 · 场景图改为全图参考和关键词 Prompt
diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx
index 306a73b..17833e7 100644
--- a/web/components/lightbox.tsx
+++ b/web/components/lightbox.tsx
@@ -8,6 +8,7 @@ import {
generateSceneAsset, generateSubjectAssets,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SceneMode, type SceneStyle, type SubjectKind,
} from "@/lib/api"
+import { ProductLibraryPicker } from "@/components/product-library-picker"
import { toast } from "sonner"
interface Props {
@@ -63,12 +64,13 @@ const LIVING_VIEW_GROUPS = [
{ title: "动作补充", hint: "需要动作镜头时再勾,仍保持同一人物身份", options: LIVING_ACTION_OPTIONS },
]
-type LightboxTab = "clean" | "scene" | "subject" | "review"
+type LightboxTab = "clean" | "scene" | "subject" | "product" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "subject", label: "主体资产" },
{ key: "scene", label: "场景图" },
+ { key: "product", label: "产品融合" },
{ key: "review", label: "审核" },
]
@@ -219,6 +221,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
]
const isSubjectTab = activeTab === "subject"
const isSceneTab = activeTab === "scene"
+ const isProductTab = activeTab === "product"
const isCleanTab = activeTab === "clean"
const sceneReferenceFrameIndices = (selectedFrameIndices.length > 0 ? selectedFrameIndices : [f.index])
.filter((idx, pos, arr) => arr.indexOf(idx) === pos)
@@ -573,6 +576,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
? { flex: "1 1 360px", minWidth: 220, maxWidth: 460, minHeight: 0 }
: isSceneTab
? { flex: "1 1 430px", minWidth: 280, maxWidth: 560, minHeight: 0 }
+ : isProductTab
+ ? { flex: "1 1 600px", minWidth: 360, maxWidth: 760, minHeight: 0 }
: isCleanTab
? { flex: "1 1 500px", minWidth: 300, maxWidth: 600, minHeight: 0 }
: { flex: "1 1 560px", minWidth: 300, maxWidth: 680, minHeight: 0 }}
@@ -699,6 +704,14 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
左侧显示全部关键帧;点图片设为生成目标,点“选”加入场景参考。未选择时默认只参考当前目标帧。
+ ) : isProductTab ? (
+ onCopyImage?.(ref)}
+ />
) : (
@@ -1063,6 +1076,32 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
)}
)}
+ {activeTab === "product" && (
+
+ 产品融合目标
+
+

+
+ 分镜 {f.index + 1}
+ {f.timestamp.toFixed(2)}s
+
+
+
+
+
图库来源
+
桌面 SKG 产品图 · gallery 白底筛选
+
+
+
使用方式
+
复制产品图后,在画面工作台加入 SKG 产品参考组。
+
+
+
生成约束
+
白底图保留外观、颜色、结构;有人物的产品示范图也可作为佩戴参考。
+
+
+
+ )}
{activeTab === "review" && (
素材审核
diff --git a/web/components/product-library-picker.tsx b/web/components/product-library-picker.tsx
new file mode 100644
index 0000000..6286899
--- /dev/null
+++ b/web/components/product-library-picker.tsx
@@ -0,0 +1,179 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import { Copy, Loader2, Plus, Search } from "lucide-react"
+import {
+ apiAssetUrl,
+ copyProductLibraryAsset,
+ listProductLibrary,
+ type ImageRef,
+ type ProductLibraryItem,
+} from "@/lib/api"
+import { toast } from "sonner"
+
+interface ProductLibraryPickerProps {
+ jobId: string
+ onPick: (ref: ImageRef, item: ProductLibraryItem) => void
+ disabled?: boolean
+ buttonLabel?: string
+ title?: string
+ compact?: boolean
+ maxItems?: number
+ className?: string
+}
+
+export function ProductLibraryPicker({
+ jobId,
+ onPick,
+ disabled = false,
+ buttonLabel = "加入",
+ title = "内置白底产品库",
+ compact = false,
+ maxItems,
+ className = "",
+}: ProductLibraryPickerProps) {
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [query, setQuery] = useState("")
+ const [productType, setProductType] = useState("all")
+ const [addingId, setAddingId] = useState(null)
+
+ useEffect(() => {
+ let alive = true
+ setLoading(true)
+ listProductLibrary()
+ .then((next) => {
+ if (alive) setItems(next)
+ })
+ .catch((e) => {
+ if (alive) toast.error("产品库读取失败:" + (e instanceof Error ? e.message : String(e)))
+ })
+ .finally(() => {
+ if (alive) setLoading(false)
+ })
+ return () => {
+ alive = false
+ }
+ }, [])
+
+ const productTypes = useMemo(() => {
+ return Array.from(new Set(items.map((item) => item.product_type).filter(Boolean))).sort()
+ }, [items])
+
+ const filteredItems = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ const next = items.filter((item) => {
+ if (productType !== "all" && item.product_type !== productType) return false
+ if (!q) return true
+ const haystack = [
+ item.title,
+ item.handle,
+ item.product_type,
+ item.source_path,
+ String(item.image_index),
+ ].join(" ").toLowerCase()
+ return haystack.includes(q)
+ })
+ return typeof maxItems === "number" ? next.slice(0, maxItems) : next
+ }, [items, maxItems, productType, query])
+
+ const handlePick = async (item: ProductLibraryItem) => {
+ if (disabled || addingId) return
+ setAddingId(item.id)
+ try {
+ const ref = await copyProductLibraryAsset(jobId, item.id)
+ onPick(ref, item)
+ toast.success(`已${buttonLabel}:${item.title}`)
+ } catch (e) {
+ toast.error("产品图加入失败:" + (e instanceof Error ? e.message : String(e)))
+ } finally {
+ setAddingId(null)
+ }
+ }
+
+ return (
+
+
+
+ {title}
+ {filteredItems.length}/{items.length}
+
+ {loading &&
}
+
+
+
+
+
+
+
+ {loading ? (
+
+
+ 读取产品库
+
+ ) : filteredItems.length === 0 ? (
+
+ 没有匹配的白底产品图
+
+ ) : (
+
+ {filteredItems.map((item) => {
+ const busy = addingId === item.id
+ return (
+
+
+
})
+
+ {item.width}×{item.height}
+
+
+
+
+ {item.title}
+
+
+
+ #{item.image_index} · {item.product_type || "SKG"}
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/web/components/storyboard-workbench.tsx b/web/components/storyboard-workbench.tsx
index d22f53f..8c78a41 100644
--- a/web/components/storyboard-workbench.tsx
+++ b/web/components/storyboard-workbench.tsx
@@ -5,6 +5,7 @@ import {
type Job, type StoryboardScene, type ImageRef,
updateStoryboard, resolveImageRefUrl, uploadStoryboardAsset,
} from "@/lib/api"
+import { ProductLibraryPicker } from "@/components/product-library-picker"
import { toast } from "sonner"
interface Props {
@@ -145,6 +146,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
const next = refs.slice(0, 6)
queueSave({ ...form, product_image: next[0] ?? null, product_images: next })
}
+ const addProductRef = (ref: ImageRef) => {
+ if (productRefs.length >= 6) {
+ toast.error("最多添加 6 张产品参考")
+ return
+ }
+ setProductRefs([...productRefs, ref])
+ }
const addProductFiles = async (files: FileList | File[]) => {
if (!job) return
const room = 6 - productRefs.length
@@ -299,7 +307,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
toast.error("最多添加 6 张产品参考")
return
}
- setProductRefs([...productRefs, clipboard])
+ addProductRef(clipboard)
toast.success("已添加产品参考")
}}
disabled={!clipboard || productRefs.length >= 6}
@@ -374,6 +382,15 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
+ = 6}
+ onPick={(ref) => addProductRef(ref)}
+ />
+
{/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}