auto-save 2026-05-14 07:01 (+1, ~4)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/<jobId>/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr>
|
||||
<tr><td><code>jobs/<jobId>/frames</code></td><td>关键帧 jpg。注意 frame.index 是稳定 ID,不等于数组下标。</td></tr>
|
||||
<tr><td><code>jobs/<jobId>/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/<jobId>/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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
179
web/components/product-library-picker.tsx
Normal file
179
web/components/product-library-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user