auto-save 2026-05-14 07:01 (+1, ~4)

This commit is contained in:
2026-05-14 07:01:12 +08:00
parent aff05b89dd
commit edb54e141a
5 changed files with 286 additions and 23 deletions

View File

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

View File

@@ -556,7 +556,7 @@
<div class="step"><div class="num">3</div><h3>清洗水印</h3><p>对关键帧做全图或区域清洗,必要时应用为当前参考图。</p></div>
<div class="step"><div class="num">4</div><h3>主体识别</h3><p>识别场景和主体候选,只是候选,不应锁死。</p></div>
<div class="step"><div class="num">5</div><h3>素材准备</h3><p>清洗关键帧,把多张关键帧作为同一主体的参考,先重绘六张标准站立主体资产图,再按关键帧生成多个去主体、相似或换风格场景图。</p></div>
<div class="step"><div class="num">6</div><h3>分镜改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构。</p></div>
<div class="step"><div class="num">6</div><h3>分镜改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构SKG 产品可从内置白底图库直接加入产品参考组</p></div>
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>用分镜 4 图槽、改造目标和时长调用 Seedance / Kling / Veo 3 生视频 API结果回写到画面工作台节点。</p></div>
<div class="step"><div class="num">8</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
</div>
@@ -571,7 +571,8 @@
<tbody>
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr>
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义Input、VisualLab、Audio、Compose以及画布工作面板 KeyframePanel / VideoFramePanel旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图和审核。</td></tr>
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、产品融合和审核。</td></tr>
<tr><td><code>web/components/product-library-picker.tsx</code></td><td>SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 <code>asset</code></td></tr>
<tr><td><code>web/components/storyboard-bar.tsx</code></td><td>顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。</td></tr>
<tr><td><code>web/components/storyboard-workbench.tsx</code></td><td>顶部分镜编排条下方的明细区4 图槽、改造目标、时长、自动保存。</td></tr>
<tr><td><code>web/lib/api.ts</code></td><td>前端类型和 API client是前后端数据契约镜像。</td></tr>
@@ -583,6 +584,7 @@
<table>
<tbody>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、文件返回。</td></tr>
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图,<code>images/</code> 存 41 张压缩后的参考图。</td></tr>
<tr><td><code>jobs/&lt;jobId&gt;/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr>
<tr><td><code>jobs/&lt;jobId&gt;/frames</code></td><td>关键帧 jpg。注意 frame.index 是稳定 ID不等于数组下标。</td></tr>
<tr><td><code>jobs/&lt;jobId&gt;/cleaned</code></td><td>清洗后待应用图片。</td></tr>
@@ -623,13 +625,13 @@ api/main.py
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>关键帧素材审核面板</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、场景图、审核”个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,场景图页左侧显示全部关键帧并可勾选场景参考;右侧承载当前页操作、状态和结果。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立主体图;场景图依赖主体资产,右侧通过地点、生成方式、风格和参考要素拼出可编辑 prompt再按当前关键帧生成去主体原场景、相似新场景或同构换风格。相关接口包括 <code>cleanupFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code></span></div>
<div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图和质量风险应该如何审核”。</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、场景图、产品融合、审核”个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,场景图页左侧显示全部关键帧并可勾选场景参考,产品融合页左侧接入内置 SKG 白底图库;右侧承载当前页操作、状态和结果。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立主体图;场景图依赖主体资产,右侧通过地点、生成方式、风格和参考要素拼出可编辑 prompt再按当前关键帧生成去主体原场景、相似新场景或同构换风格。相关接口包括 <code>cleanupFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>listProductLibrary</code><code>copyProductLibraryAsset</code></span></div>
<div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、SKG 产品融合参考和质量风险应该如何审核”。</span></div>
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>顶部分镜头编排下拉面板</span></div>
<div><strong>主要源码</strong><span><code>StoryboardWorkbench</code>;保存到 <code>frame.storyboard</code>;接口 <code>PUT /storyboard</code></span></div>
<div><strong>适合怎么描述</strong><span>“每个分镜需要哪些图片槽、哪些改造说明,如何为视频生成做准备”。</span></div>
<div><strong>主要源码</strong><span><code>StoryboardWorkbench</code>;保存到 <code>frame.storyboard</code>;接口 <code>PUT /storyboard</code>SKG 产品参考区同时支持上传、剪贴板和内置白底产品库。</span></div>
<div><strong>适合怎么描述</strong><span>“每个分镜需要哪些图片槽、哪些改造说明,哪些 SKG 产品图要作为视频生成参考”。</span></div>
</div>
</div>
</section>
@@ -692,6 +694,18 @@ SubjectAsset {
background: white | black,
width, height, size,
source_frame_indices[]
}</pre>
</div>
<div class="card">
<h3>ProductLibraryItem</h3>
<p>内置 SKG 白底图库条目。实际图片保存在 <code>api/product_library/skg-products/images</code>,被选中时会复制到 <code>jobs/&lt;jobId&gt;/assets</code>,再以普通 <code>ImageRef(kind="asset")</code> 进入产品参考组。</p>
<pre>ProductLibraryItem {
id, handle, title, product_type,
image_index, filename, url,
width, height,
white_score,
source_path,
tags[]
}</pre>
</div>
<div class="card">
@@ -732,6 +746,8 @@ SubjectAsset {
<tr><td>元素提取</td><td><code>POST /elements/{element_id}/cutout</code></td><td><code>cutoutElement</code></td><td>调用图像模型生成独立白底素材图,每次累积一张 cutout。</td></tr>
<tr><td>主体资产包</td><td><code>POST /elements/{element_id}/subject-assets</code></td><td><code>generateSubjectAssets</code></td><td>根据参考帧重新绘制一个统一主体资产包;前端默认把全部关键帧作为 <code>source_frame_indices</code>,如果用户手动选择了关键帧则只传已选帧,后端拼参考板。人物默认输出六张身份标准图,另有表情补充和动作补充分组可选;纯白/黑背景,不含其他元素,并裁去空白让主体占满画面。</td></tr>
<tr><td>场景资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>在统一主体资产之后,按当前关键帧生成去主体背景板;请求包含 <code>scene_mode</code><code>scene_style</code><code>prompt</code><code>source_frame_indices</code>,可用左侧选择的参考帧 + 右侧关键词生成原场景补背景、相似新场景或同构换风格,保留历史版本用于人工审核。</td></tr>
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
<tr><td>产品图入库到 job</td><td><code>POST /jobs/{id}/assets/product-library</code></td><td><code>copyProductLibraryAsset</code></td><td>把一个内置产品图库条目复制为当前 job 的普通 asset返回 <code>ImageRef(kind="asset")</code>,用于画面工作台产品融合和分镜产品参考组。</td></tr>
<tr><td>分镜保存</td><td><code>PUT /frames/{idx}/storyboard</code></td><td><code>updateStoryboard</code></td><td>保存 4 图槽、时长和改造说明。</td></tr>
<tr><td>生图</td><td><code>POST /frames/{idx}/generate</code></td><td><code>generateImage</code></td><td>基于关键帧或已选生成图做 image-to-image目前可用。</td></tr>
</tbody>
@@ -841,6 +857,19 @@ SubjectAsset {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 增加产品融合和 SKG 内置白底图库</h3>
<span class="tag violet">FrameLightbox</span>
<span class="tag orange">产品融合</span>
</header>
<div class="body">
<p><strong>问题:</strong>生成视频需要稳定的 SKG 产品真源,不能每次都依赖临时上传或从参考视频里找产品图;桌面已有整理过的 SKG 产品图,应作为内置数据库使用。</p>
<p><strong>改动:</strong>从桌面 <code>skg_product_downloads/all_products</code> 的 gallery 中筛出 41 张白底产品图,生成 <code>api/product_library/skg-products/manifest.json</code> 和压缩预览图。<code>FrameLightbox</code> 新增“产品融合”页签,<code>StoryboardWorkbench</code> 的 SKG 产品参考区也接入同一个 <code>ProductLibraryPicker</code>,支持搜索、品类筛选、尺寸预览和一键加入。</p>
<p><strong>后端:</strong>新增 <code>GET /product-library/skg</code><code>GET /product-library/skg/images/{filename}</code><code>POST /jobs/{job_id}/assets/product-library</code>。选中库内产品图时,后端会复制成当前 job 的 <code>asset</code>,后续仍通过既有 <code>product_images</code> 进入生视频接口。</p>
<p><strong>影响:</strong><code>api/main.py</code><code>api/product_library/skg-products</code><code>web/lib/api.ts</code><code>web/components/product-library-picker.tsx</code><code>web/components/lightbox.tsx</code><code>web/components/storyboard-workbench.tsx</code><code>docs/source-analysis.html</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-14 · 场景图改为全图参考和关键词 Prompt</h3>

View File

@@ -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
</div>
</section>
) : isProductTab ? (
<ProductLibraryPicker
jobId={jobId}
buttonLabel="复制"
title="产品融合 · SKG 白底图库"
disabled={!onCopyImage}
onPick={(ref) => onCopyImage?.(ref)}
/>
) : (
<div
ref={imgWrapRef}
@@ -759,7 +772,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{/* 右侧主体识别 + 主体资产 */}
<div
className="flex flex-col gap-2.5 overflow-y-auto min-h-0"
style={isSubjectTab || isSceneTab
style={isSubjectTab || isSceneTab || isProductTab
? { flex: "0 0 360px", width: 360, minWidth: 320 }
: { flex: "0 0 320px", width: 320, minWidth: 280, maxWidth: 340 }}
>
@@ -1063,6 +1076,32 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
)}
</section>
)}
{activeTab === "product" && (
<section className="rounded-lg border border-amber-300/18 bg-amber-500/[0.08] p-2.5 text-[10.5px] leading-relaxed text-white/58">
<div className="mb-2 text-[12px] font-semibold text-white"></div>
<div className="mb-2 overflow-hidden rounded-md border border-white/10 bg-black">
<img src={mainSrc} alt={`frame ${f.index}`} className="max-h-48 w-full object-contain" draggable={false} />
<div className="flex items-center justify-between border-t border-white/10 px-2 py-1 text-[9.5px] text-white/42">
<span> {f.index + 1}</span>
<span>{f.timestamp.toFixed(2)}s</span>
</div>
</div>
<div className="space-y-1.5">
<div className="rounded border border-white/10 bg-black/25 px-2 py-1.5">
<div className="text-[9px] text-white/35"></div>
<div className="text-white/70"> SKG · gallery </div>
</div>
<div className="rounded border border-white/10 bg-black/25 px-2 py-1.5">
<div className="text-[9px] text-white/35">使</div>
<div className="text-white/70"> SKG </div>
</div>
<div className="rounded border border-white/10 bg-black/25 px-2 py-1.5">
<div className="text-[9px] text-white/35"></div>
<div className="text-white/70"></div>
</div>
</div>
</section>
)}
{activeTab === "review" && (
<section className="rounded-lg border border-white/10 bg-white/[0.035] p-2.5 text-[10.5px] leading-relaxed text-white/58">
<div className="mb-2 text-[12px] font-semibold text-white"></div>

View File

@@ -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<ProductLibraryItem[]>([])
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState("")
const [productType, setProductType] = useState("all")
const [addingId, setAddingId] = useState<string | null>(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 (
<section className={`rounded-lg border border-amber-300/18 bg-amber-500/[0.07] p-2.5 ${className}`}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white">
{title}
<span className="ml-1.5 font-mono text-[10px] text-white/35">{filteredItems.length}/{items.length}</span>
</div>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin text-amber-200/80" />}
</div>
<div className={`mb-2 grid gap-1.5 ${compact ? "grid-cols-1" : "grid-cols-[1fr_150px]"}`}>
<label className="relative block">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-white/35" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索型号 / 品类"
className="h-8 w-full rounded-md border border-white/10 bg-black/35 pl-7 pr-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-amber-300/45"
/>
</label>
<select
value={productType}
onChange={(e) => setProductType(e.target.value)}
className="h-8 rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white/75 outline-none focus:border-amber-300/45"
>
<option value="all"></option>
{productTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
{loading ? (
<div className="flex h-28 items-center justify-center rounded-md border border-white/10 bg-black/25 text-[11px] text-white/40">
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
</div>
) : filteredItems.length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 bg-black/25 px-3 py-6 text-center text-[11px] text-white/35">
</div>
) : (
<div className={`grid gap-2 ${compact ? "grid-cols-3" : "grid-cols-4"}`}>
{filteredItems.map((item) => {
const busy = addingId === item.id
return (
<div key={item.id} className="overflow-hidden rounded-md border border-white/10 bg-black/30">
<div className="relative bg-white" style={{ aspectRatio: "1/1" }}>
<img
src={apiAssetUrl(item.url)}
alt={item.title}
className="absolute inset-0 h-full w-full object-contain"
loading="lazy"
/>
<div className="absolute left-1 top-1 rounded bg-black/65 px-1 py-0.5 text-[8.5px] font-mono text-white">
{item.width}×{item.height}
</div>
</div>
<div className="space-y-1 border-t border-white/10 p-1.5">
<div className="truncate text-[10px] font-medium text-white/80" title={item.title}>
{item.title}
</div>
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[9px] text-white/35" title={item.handle}>
#{item.image_index} · {item.product_type || "SKG"}
</span>
<button
type="button"
onClick={() => handlePick(item)}
disabled={disabled || !!addingId}
className="inline-flex h-7 min-w-12 items-center justify-center gap-1 rounded bg-amber-500/75 px-2 text-[10px] font-medium text-white transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
title={disabled ? "产品参考已满" : buttonLabel}
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : buttonLabel.includes("复制") ? <Copy className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
{buttonLabel}
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
)
}

View File

@@ -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
</div>
</section>
<ProductLibraryPicker
jobId={job.id}
compact
buttonLabel="加入"
title="产品融合 · SKG 白底图库"
disabled={productRefs.length >= 6}
onPick={(ref) => addProductRef(ref)}
/>
{/* 改造 brief明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
<section className="rounded-lg bg-white/[0.035] border border-white/10 p-3">
<div className="text-[12.5px] font-semibold text-white mb-2">