auto-save 2026-05-17 16:32 (~3)
This commit is contained in:
@@ -1,19 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "b610cd8",
|
||||
"message": "auto-save 2026-05-15 10:01 (~1)",
|
||||
"ts": "2026-05-15T10:02:07+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "5211fb5",
|
||||
"message": "auto-save 2026-05-15 10:09 (~1)",
|
||||
"ts": "2026-05-15T10:09:43+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 10:09 (~1)",
|
||||
@@ -3274,6 +3260,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add product refs and video candidate slots",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-17T16:27:18+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-17 16:27 (~4)",
|
||||
"hash": "3d851d8",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-17T08:28:26Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-17 16:27 (~4)",
|
||||
"files_changed": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@
|
||||
<tr><td><code>web/next.config.mjs</code></td><td>Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 <code>eslint</code> 顶层配置,避免本地 dev 出现配置 Issue 提示。</td></tr>
|
||||
<tr><td><code>web/app/globals.css</code></td><td>全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 <code>nextjs-portal</code> 遮挡隐藏规则。</td></tr>
|
||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始”编排状态只负责在下载完成后自动触发 <code>triggerTranscribe</code>,不再默认触发抽帧、Vision 扫描或分镜初稿保存;底部吸附音频条不再从主界面渲染。</td></tr>
|
||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部可上传产品白底图,建议 5 张、最多 6 张,用来锁定正面、左右 45 度、厚度、内侧触点/佩戴比例,避免非对称产品被生成成左右镜像;每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会先把该行规划和已上传产品图保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图、识别/标注视角、填写视角备注、鼠标悬停放大预览,并对缺失的正面/左右 45 度/厚度/内侧触点/背底视角提供 AI 补角度入口;每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会先把该行规划、已上传/补全的产品图和视角备注保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
||||
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。</td></tr>
|
||||
<tr><td><code>web/app/login/layout.tsx</code></td><td>登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 <code>/login</code> 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。</td></tr>
|
||||
<tr><td><code>web/components/login/oasis-canvas.tsx</code></td><td>登录页全屏动态视觉层:用 iframe 直接承载下载包 <code>web/public/oasis-source/index.html</code> 的原 WebGPU / Three.js 草场源码;父级登录页只覆盖自己的文案和表单,并在捕获阶段把全局鼠标坐标同时用原生事件和 <code>postMessage</code> 转发给 iframe,避免登录面板或输入框遮挡时草地失去鼠标响应。</td></tr>
|
||||
@@ -627,7 +627,7 @@ web/app/page.tsx
|
||||
-> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
|
||||
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
|
||||
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
|
||||
-> 信息流复刻分镜工作台:产品白底图上传(建议 5、最多 6)→ 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
|
||||
-> 信息流复刻分镜工作台:产品白底图上传 / 视角备注 / AI 补角度(建议 5、最多 6)→ 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
|
||||
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
|
||||
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
|
||||
-> API 契约:web/lib/api.ts
|
||||
@@ -654,8 +654,8 @@ api/main.py
|
||||
</div>
|
||||
<div class="flow-row">
|
||||
<div><strong>你看到的区域</strong><span>信息流复刻分镜工作台</span></div>
|
||||
<div><strong>主要源码</strong><span><code>AudioStoryboardPlanPanel</code>、<code>buildAudioStoryboardRows</code>、<code>buildStoryboardSceneFromAudioRow</code>、<code>StoryboardVideoSlots</code> in <code>web/components/ad-recreation-board.tsx</code>;逐行定向抽帧复用 <code>onAddManualFrameForJob</code>,产品白底图上传复用 <code>uploadStoryboardAsset</code>,单条生成复用 <code>onGenerateVideo</code> 和 <code>PUT /frames/{idx}/storyboard</code>。</span></div>
|
||||
<div><strong>适合怎么描述</strong><span>“按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、如何抽参考帧、生成的视频应该回显到哪一行”。</span></div>
|
||||
<div><strong>主要源码</strong><span><code>AudioStoryboardPlanPanel</code>、<code>ProductReferenceCard</code>、<code>MissingProductViewSlot</code>、<code>buildAudioStoryboardRows</code>、<code>buildStoryboardSceneFromAudioRow</code>、<code>StoryboardVideoSlots</code> in <code>web/components/ad-recreation-board.tsx</code>;逐行定向抽帧复用 <code>onAddManualFrameForJob</code>,产品白底图上传复用 <code>uploadStoryboardAsset</code>,AI 补角度复用 <code>generateProductAngleAsset</code>,单条生成复用 <code>onGenerateVideo</code> 和 <code>PUT /frames/{idx}/storyboard</code>。</span></div>
|
||||
<div><strong>适合怎么描述</strong><span>“按音频逐句生成产品分镜、每行怎样改写口播、上传几张产品白底图、每张产品图的视角备注是什么、缺哪个角度、生成的视频应该回显到哪一行”。</span></div>
|
||||
</div>
|
||||
<div class="flow-row">
|
||||
<div><strong>你看到的区域</strong><span>旧深度素材面板(当前不作为主路径)</span></div>
|
||||
@@ -839,6 +839,7 @@ SubjectAsset {
|
||||
<tr><td>首尾帧资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>同一接口兼容旧场景图和新首尾帧;新流程传 <code>asset_role=first_frame/last_frame</code>,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 <code>scene_assets</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>POST /jobs/{id}/assets/product-angle</code></td><td><code>generateProductAngleAsset</code></td><td>用当前产品白底图作为参考,通过图像模型补全缺失视角,输出新的 <code>ImageRef(kind="asset")</code>。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例。</td></tr>
|
||||
<tr><td>角色库</td><td><code>GET /character-library/skg</code></td><td><code>listCharacterLibrary</code></td><td>读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。</td></tr>
|
||||
<tr><td>角色图入库到 job</td><td><code>POST /jobs/{id}/assets/character-library</code></td><td><code>copyCharacterLibraryAssets</code></td><td>把所选角色的 7 张参考图复制为当前 job asset,返回 <code>subject_images</code>,产品融合生成视频时作为人物身份参考图提交。</td></tr>
|
||||
<tr><td>产品融合引导图</td><td><code>POST /jobs/{id}/product-fusion/guide</code></td><td><code>createProductFusionGuide</code></td><td>旧流程兼容接口:读取产品图和白底人物图,按 <code>product_region</code> 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。</td></tr>
|
||||
@@ -948,6 +949,19 @@ SubjectAsset {
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 产品白底图加入视角备注和 AI 补角度</h3>
|
||||
<span class="tag rose">UI</span>
|
||||
<span class="tag cyan">Workflow</span>
|
||||
<span class="tag blue">API</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>只上传产品白底图还不够,生成视频前需要明确每张图是什么视角、有哪些结构重点;如果某些视角没拍到,也需要通过 AI 先补出可用参考图。</p>
|
||||
<p><strong>改动:</strong><code>AudioStoryboardPlanPanel</code> 的产品参考区升级为产品视角工作台:每张图都有视角下拉和备注输入,鼠标悬停显示放大预览;缺失视角显示独立占位,并提供“AI 补角度”。新增 <code>POST /jobs/{id}/assets/product-angle</code> 和前端 <code>generateProductAngleAsset</code>,基于已有产品图生成缺失的白底产品视角。单条视频生成会把每张产品图的视角备注写入 <code>StoryboardScene.product</code>,用于约束左右非对称、厚度、内侧触点和肩颈佩戴比例。</p>
|
||||
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/lib/api.ts</code>、<code>web/components/ad-recreation-board.tsx</code>、<code>docs/source-analysis.html</code>。AI 补角度生成的新图保存在 <code>jobs/<jobId>/assets</code>,作为普通 <code>ImageRef(kind="asset")</code> 参与后续分镜视频生成。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-17 · 压缩分镜行并加入产品白底图与多候选视频槽</h3>
|
||||
|
||||
@@ -965,14 +965,15 @@ function AudioStoryboardPlanPanel({
|
||||
}) {
|
||||
const [busyRow, setBusyRow] = useState<number | null>(null)
|
||||
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
|
||||
const [productRefs, setProductRefs] = useState<ImageRef[]>([])
|
||||
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
|
||||
const [productUploading, setProductUploading] = useState(false)
|
||||
const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
|
||||
const productFileRef = useRef<HTMLInputElement | null>(null)
|
||||
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
|
||||
const orderedFrames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
||||
|
||||
useEffect(() => {
|
||||
setProductRefs([])
|
||||
setProductItems([])
|
||||
}, [job?.id])
|
||||
|
||||
const framesForRow = (row: AudioStoryboardRow) =>
|
||||
@@ -996,7 +997,7 @@ function AudioStoryboardPlanPanel({
|
||||
|
||||
const uploadProductImages = async (files: FileList | null) => {
|
||||
if (!job || !files?.length) return
|
||||
const remaining = Math.max(0, 6 - productRefs.length)
|
||||
const remaining = Math.max(0, 6 - productItems.length)
|
||||
if (remaining === 0) {
|
||||
toast.info("产品白底图最多保留 6 张")
|
||||
return
|
||||
@@ -1005,7 +1006,10 @@ function AudioStoryboardPlanPanel({
|
||||
setProductUploading(true)
|
||||
try {
|
||||
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
|
||||
setProductRefs((prev) => [...prev, ...refs].slice(0, 6))
|
||||
setProductItems((prev) => [
|
||||
...prev,
|
||||
...refs.map((ref, index) => createProductRefItem(ref, prev.length + index)),
|
||||
].slice(0, 6))
|
||||
toast.success(`已上传 ${refs.length} 张产品白底图`)
|
||||
} catch (e) {
|
||||
toast.error("产品白底图上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
@@ -1014,11 +1018,46 @@ function AudioStoryboardPlanPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const patchProductItem = (id: string, patch: Partial<ProductRefItem>) => {
|
||||
setProductItems((prev) => prev.map((item) => item.id === id ? { ...item, ...patch } : item))
|
||||
}
|
||||
|
||||
const removeProductItem = (id: string) => {
|
||||
setProductItems((prev) => prev.filter((item) => item.id !== id))
|
||||
}
|
||||
|
||||
const autoMarkProductViews = () => {
|
||||
setProductItems((prev) => prev.map((item, index) => {
|
||||
const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
|
||||
return { ...item, view: slot.value, note: item.note || slot.hint }
|
||||
}))
|
||||
toast.success("已按产品参考顺序标注视角,可继续手动修正备注")
|
||||
}
|
||||
|
||||
const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
|
||||
if (!job || !productItems.length) return
|
||||
const source = productItems[0]
|
||||
setProductAngleBusy(slot.value)
|
||||
try {
|
||||
const ref = await generateProductAngleAsset(job.id, {
|
||||
source_ref: source.ref,
|
||||
target_view: slot.label,
|
||||
note: slot.hint,
|
||||
})
|
||||
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value)].slice(0, 6))
|
||||
toast.success(`AI 已补全产品视角:${slot.label}`)
|
||||
} catch (e) {
|
||||
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setProductAngleBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const generateRowVideo = async (row: AudioStoryboardRow, refs: KeyFrame[]) => {
|
||||
if (!job || !refs.length || !onGenerateVideo) return
|
||||
const frame = refs[0]
|
||||
const nextFrame = orderedFrames.find((item) => item.timestamp > frame.timestamp) ?? null
|
||||
const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame, productRefs)
|
||||
const scene = buildStoryboardSceneFromAudioRow(row, frame, nextFrame, productItems)
|
||||
setVideoBusyRow(row.index)
|
||||
try {
|
||||
const updated = await updateStoryboard(job.id, frame.index, scene)
|
||||
@@ -1047,52 +1086,69 @@ function AudioStoryboardPlanPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid gap-2 rounded-md border border-white/10 bg-black/24 p-2 xl:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionTitle icon={<Package className="h-4 w-4" />} title="产品白底图" />
|
||||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">建议 5 张,最多 6 张</span>
|
||||
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2.5">
|
||||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionTitle icon={<Package className="h-4 w-4" />} title="产品白底图 / 视角补全" />
|
||||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">建议 5 张,最多 6 张</span>
|
||||
</div>
|
||||
<p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
|
||||
每张图都要标注视角和备注。推荐:正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例;缺口可用 AI 补角度,避免非对称产品被生成成左右镜像。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoMarkProductViews}
|
||||
disabled={!productItems.length}
|
||||
className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
识别视角
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => productFileRef.current?.click()}
|
||||
disabled={!job || productUploading || productItems.length >= 6}
|
||||
className="inline-flex h-9 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
上传白底图
|
||||
</button>
|
||||
<input
|
||||
ref={productFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void uploadProductImages(event.currentTarget.files)
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-white/42">
|
||||
正面、左 45、右 45、侧面厚度、内侧触点/佩戴比例;非对称明显时补背面或底部,避免生成时左右两边被做成一样。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-2">
|
||||
<div className="flex max-w-[300px] gap-1 overflow-x-auto pb-0.5">
|
||||
{productRefs.map((ref, index) => (
|
||||
<img
|
||||
key={`${ref.kind}:${ref.frame_idx}:${ref.element_id ?? ""}:${ref.cutout_id ?? ""}:${index}`}
|
||||
src={resolveImageRefUrl(job.id, ref)}
|
||||
alt={`产品白底图 ${index + 1}`}
|
||||
className="h-12 w-12 shrink-0 rounded-md border border-white/10 bg-white object-contain"
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, Math.min(6, 5 - productRefs.length)) }).map((_, index) => (
|
||||
<div key={`empty-product-${index}`} className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md border border-dashed border-white/12 bg-black/25 text-[10px] text-white/25">
|
||||
{productRefs.length + index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => productFileRef.current?.click()}
|
||||
disabled={!job || productUploading || productRefs.length >= 6}
|
||||
className="inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2.5 text-[11px] font-semibold text-white/72 transition hover:border-white/25 hover:bg-white/[0.1] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
上传白底图
|
||||
</button>
|
||||
<input
|
||||
ref={productFileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void uploadProductImages(event.currentTarget.files)
|
||||
event.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{productItems.map((item) => (
|
||||
<ProductReferenceCard
|
||||
key={item.id}
|
||||
job={job}
|
||||
item={item}
|
||||
onPatch={(patch) => patchProductItem(item.id, patch)}
|
||||
onRemove={() => removeProductItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
{PRODUCT_VIEW_SLOTS.filter((slot) => !productItems.some((item) => item.view === slot.value)).map((slot) => (
|
||||
<MissingProductViewSlot
|
||||
key={slot.value}
|
||||
slot={slot}
|
||||
canGenerate={!!productItems.length && productItems.length < 6}
|
||||
busy={productAngleBusy === slot.value}
|
||||
onGenerate={() => generateMissingProductAngle(slot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1187,6 +1243,89 @@ function AudioStoryboardPlanPanel({
|
||||
)
|
||||
}
|
||||
|
||||
function ProductReferenceCard({
|
||||
job,
|
||||
item,
|
||||
onPatch,
|
||||
onRemove,
|
||||
}: {
|
||||
job: Job
|
||||
item: ProductRefItem
|
||||
onPatch: (patch: Partial<ProductRefItem>) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const src = resolveImageRefUrl(job.id, item.ref)
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-[74px_minmax(0,1fr)_28px] gap-2 rounded-md border border-white/10 bg-black/26 p-2">
|
||||
<div className="group relative h-[74px] w-[74px] overflow-visible rounded-md border border-white/10 bg-white">
|
||||
<img src={src} alt={productViewLabel(item.view)} className="h-full w-full rounded-md object-contain" />
|
||||
<div className="pointer-events-none absolute left-0 top-[82px] z-50 hidden w-60 rounded-lg border border-white/15 bg-black/90 p-2 shadow-2xl group-hover:block">
|
||||
<img src={src} alt="" className="aspect-square w-full rounded-md bg-white object-contain" />
|
||||
<div className="mt-1 text-[11px] text-white/62">{productViewLabel(item.view)} · {item.note}</div>
|
||||
</div>
|
||||
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<select
|
||||
value={item.view}
|
||||
onChange={(event) => onPatch({ view: event.target.value })}
|
||||
className="h-7 w-full rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white outline-none"
|
||||
>
|
||||
{PRODUCT_VIEW_SLOTS.map((slot) => (
|
||||
<option key={slot.value} value={slot.value}>{slot.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={item.note}
|
||||
onChange={(event) => onPatch({ note: event.target.value })}
|
||||
placeholder="视角备注:结构差异、触点、尺寸比例"
|
||||
className="mt-1 h-8 w-full rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-cyan-300/50"
|
||||
/>
|
||||
<div className="mt-1 truncate text-[10px] text-white/32">{item.ref.label || "产品参考图"}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="h-7 w-7 rounded-md border border-white/10 text-white/40 transition hover:border-rose-300/40 hover:text-rose-200"
|
||||
aria-label="移除产品图"
|
||||
>
|
||||
<Trash2 className="mx-auto h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingProductViewSlot({
|
||||
slot,
|
||||
canGenerate,
|
||||
busy,
|
||||
onGenerate,
|
||||
}: {
|
||||
slot: typeof PRODUCT_VIEW_SLOTS[number]
|
||||
canGenerate: boolean
|
||||
busy: boolean
|
||||
onGenerate: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-[94px] rounded-md border border-dashed border-white/12 bg-black/20 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[12px] font-semibold text-white/58">{slot.label}</div>
|
||||
<span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[10px] text-amber-100/72">缺视角</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-[10.5px] leading-snug text-white/34">{slot.hint}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={!canGenerate || busy}
|
||||
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/62 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||||
AI 补角度
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StoryboardPlanCell({ label, children, className = "" }: { label: string; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`min-w-0 border-b border-white/8 p-2 xl:border-b-0 xl:border-r ${className}`}>
|
||||
|
||||
Reference in New Issue
Block a user