auto-save 2026-05-17 16:43 (~4)

This commit is contained in:
2026-05-17 16:43:24 +08:00
parent 9600bb4925
commit 9a4d983a3f
4 changed files with 181 additions and 51 deletions

View File

@@ -1,19 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "c3ee829",
"message": "auto-save 2026-05-15 10:15 (~1)",
"ts": "2026-05-15T10:15:18+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "a889e4c",
"message": "auto-save 2026-05-15 10:20 (~1)",
"ts": "2026-05-15T10:20:51+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-15 10:20 (~1)",
@@ -3274,6 +3260,19 @@
"message": "auto-save 2026-05-17 16:32 (~3)",
"hash": "2b0afee",
"files_changed": 3
},
{
"ts": "2026-05-17T16:38:02+08:00",
"type": "commit",
"message": "auto-save 2026-05-17 16:37 (~2)",
"hash": "9600bb4",
"files_changed": 2
},
{
"ts": "2026-05-17T08:38:26Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交auto-save 2026-05-17 16:37 (~2)",
"files_changed": 1
}
]
}

View File

@@ -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> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图、识别/标注视角、填写视角备注、鼠标悬停放大预览,并对缺失的正面/左右 45 度/厚度/内侧触点/背底视角提供 AI 补角度入口每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会先把该行规划、已上传/补全的产品图和视角备注保存为对应关键帧分镜,再复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区可上传产品白底图,上传后自动识别正面/左右 45 度/厚度/内侧触点/背底视角并自动补齐缺失角度,用户只检查视角备注,鼠标悬停可放大预览;补图失败时保留单个缺失视角的重试入口每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 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 → 下载完成后自动触发音频处理
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
-> 信息流复刻分镜工作台:产品白底图上传 / 视角备注 / AI 补角度(建议 5、最多 6→ 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
-> 信息流复刻分镜工作台:产品白底图上传 → 自动识别视角 → 自动补齐缺失角度 → 人工检查备注(建议 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>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><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>视角自动识别调用 <code>analyzeProductViews</code>,缺角度自动补图调<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,7 +839,8 @@ 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>POST /jobs/{id}/assets/product-views/analyze</code></td><td><code>analyzeProductViews</code></td><td>读取已上传的产品白底图,自动分类为正面、左右 45 度、侧面厚度、内侧触点或背面/底部,并返回中文视角备注和置信度;前端不再要求用户手动选择视角</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>
@@ -949,6 +950,19 @@ SubjectAsset {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-17 · 产品图上传后自动识别视角并补齐缺角度</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>产品图上传后还让用户手动选择正面、45 度、侧面等视角,操作成本高,也容易把后续生视频的产品结构约束标错;缺失角度也不应该再让用户逐个判断后点击。</p>
<p><strong>改动:</strong>新增 <code>POST /jobs/{id}/assets/product-views/analyze</code> 和前端 <code>analyzeProductViews</code><code>AudioStoryboardPlanPanel</code> 在上传产品白底图后自动识别每张图的视角、写入中文备注和置信度,再自动调用 <code>generateProductAngleAsset</code> 补齐缺失视角。<code>ProductReferenceCard</code> 移除视角下拉,改为只读“自动识别/自动补图”标签,用户只检查备注;<code>MissingProductViewSlot</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>。后续描述需求时应说“自动识别/补图后的备注是否准确”,不要再按“手选产品视角”理解这个区域。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-17 · 产品白底图加入视角备注和 AI 补角度</h3>

View File

@@ -15,9 +15,11 @@ import {
type Job,
type KeyElement,
type KeyFrame,
type ProductViewAnalysisItem,
type StoryboardScene,
type SubjectKind,
addElement,
analyzeProductViews,
apiAssetUrl,
cutoutElement,
effectiveFrameUrl,
@@ -89,6 +91,7 @@ type ProductRefItem = {
view: string
note: string
source: "upload" | "ai"
confidence?: number
}
const PRODUCT_VIEW_SLOTS = [
@@ -312,18 +315,36 @@ function productRefKey(ref: ImageRef, index: number) {
return `${ref.kind}:${ref.frame_idx}:${ref.element_id ?? ""}:${ref.cutout_id ?? ""}:${index}`
}
function sameImageRef(a: ImageRef, b: ImageRef) {
return (
a.kind === b.kind &&
a.frame_idx === b.frame_idx &&
(a.element_id ?? "") === (b.element_id ?? "") &&
(a.cutout_id ?? "") === (b.cutout_id ?? "")
)
}
function productViewLabel(view: string) {
return PRODUCT_VIEW_SLOTS.find((slot) => slot.value === view)?.label ?? view
}
function createProductRefItem(ref: ImageRef, index: number, source: ProductRefItem["source"] = "upload", view?: string): ProductRefItem {
function createProductRefItem(
ref: ImageRef,
index: number,
source: ProductRefItem["source"] = "upload",
view?: string,
note?: string,
confidence?: number,
): ProductRefItem {
const slot = PRODUCT_VIEW_SLOTS[index] ?? PRODUCT_VIEW_SLOTS[PRODUCT_VIEW_SLOTS.length - 1]
const targetSlot = PRODUCT_VIEW_SLOTS.find((item) => item.value === view) ?? slot
return {
id: productRefKey(ref, index),
ref,
view: view ?? slot.value,
note: slot.hint,
view: view ?? targetSlot.value,
note: note ?? targetSlot.hint,
source,
confidence,
}
}
@@ -967,6 +988,7 @@ function AudioStoryboardPlanPanel({
const [videoBusyRow, setVideoBusyRow] = useState<number | null>(null)
const [productItems, setProductItems] = useState<ProductRefItem[]>([])
const [productUploading, setProductUploading] = useState(false)
const [productAnalyzing, setProductAnalyzing] = useState(false)
const [productAngleBusy, setProductAngleBusy] = useState<string | null>(null)
const productFileRef = useRef<HTMLInputElement | null>(null)
const rows = useMemo(() => buildAudioStoryboardRows(job), [job])
@@ -995,6 +1017,76 @@ function AudioStoryboardPlanPanel({
}
}
const itemSourceForRef = (ref: ImageRef) => productItems.find((item) => sameImageRef(item.ref, ref))?.source ?? "upload"
const buildAnalyzedProductItems = (refs: ImageRef[], analysisItems: ProductViewAnalysisItem[] = []) => refs.map((ref, index) => {
const analysis = analysisItems.find((item) => item.index === index)
const validView = analysis && PRODUCT_VIEW_SLOTS.some((slot) => slot.value === analysis.view) ? analysis.view : undefined
return createProductRefItem(
ref,
index,
itemSourceForRef(ref),
validView,
analysis?.note,
analysis?.confidence,
)
})
const completeMissingProductAngles = async (seedItems: ProductRefItem[]) => {
if (!job || !seedItems.length) return seedItems
let working = seedItems.slice(0, 6)
const failures: string[] = []
const missing = PRODUCT_VIEW_SLOTS
.filter((slot) => !working.some((item) => item.view === slot.value))
.slice(0, Math.max(0, 6 - working.length))
for (const slot of missing) {
setProductAngleBusy(slot.value)
try {
const ref = await generateProductAngleAsset(job.id, {
source_ref: working[0].ref,
target_view: slot.label,
note: slot.hint,
})
working = [
...working,
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1),
].slice(0, 6)
setProductItems(working)
} catch (e) {
failures.push(`${slot.label}${e instanceof Error ? e.message : String(e)}`)
}
}
setProductAngleBusy(null)
if (failures.length) {
toast.warning(`部分产品视角自动补图失败:${failures.map((item) => item.split("")[0]).join("、")}`)
}
return working
}
const analyzeAndCompleteProductViews = async (refs: ImageRef[]) => {
if (!job || !refs.length) return
const limitedRefs = refs.slice(0, 6)
setProductAnalyzing(true)
setProductItems(limitedRefs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref), undefined, "正在自动识别视角...")))
try {
const analysis = await analyzeProductViews(job.id, limitedRefs)
const analyzed = buildAnalyzedProductItems(limitedRefs, analysis.items)
setProductItems(analyzed)
const completed = await completeMissingProductAngles(analyzed)
toast.success(completed.length > analyzed.length ? "产品视角已自动识别并补齐缺失角度" : "产品视角已自动识别")
} catch (e) {
const fallback = limitedRefs.map((ref, index) => createProductRefItem(ref, index, itemSourceForRef(ref)))
setProductItems(fallback)
await completeMissingProductAngles(fallback)
toast.warning("产品视角识别失败,已按默认顺序标注并尝试自动补图:" + (e instanceof Error ? e.message : String(e)))
} finally {
setProductAnalyzing(false)
setProductAngleBusy(null)
}
}
const uploadProductImages = async (files: FileList | null) => {
if (!job || !files?.length) return
const remaining = Math.max(0, 6 - productItems.length)
@@ -1006,10 +1098,8 @@ function AudioStoryboardPlanPanel({
setProductUploading(true)
try {
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
setProductItems((prev) => [
...prev,
...refs.map((ref, index) => createProductRefItem(ref, prev.length + index)),
].slice(0, 6))
const nextRefs = [...productItems.map((item) => item.ref), ...refs].slice(0, 6)
await analyzeAndCompleteProductViews(nextRefs)
toast.success(`已上传 ${refs.length} 张产品白底图`)
} catch (e) {
toast.error("产品白底图上传失败:" + (e instanceof Error ? e.message : String(e)))
@@ -1026,12 +1116,9 @@ function AudioStoryboardPlanPanel({
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 reanalyzeProductViews = async () => {
if (!productItems.length) return
await analyzeAndCompleteProductViews(productItems.map((item) => item.ref))
}
const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
@@ -1044,7 +1131,7 @@ function AudioStoryboardPlanPanel({
target_view: slot.label,
note: slot.hint,
})
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value)].slice(0, 6))
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)].slice(0, 6))
toast.success(`AI 已补全产品视角:${slot.label}`)
} catch (e) {
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
@@ -1092,25 +1179,31 @@ function AudioStoryboardPlanPanel({
<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>
{(productAnalyzing || productAngleBusy) && (
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-300/20 bg-cyan-300/[0.08] px-2 py-0.5 text-[10px] text-cyan-100/75">
<Loader2 className="h-3 w-3 animate-spin" />
{productAnalyzing ? "识别视角中" : `补图中:${productViewLabel(productAngleBusy ?? "")}`}
</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}
onClick={() => void reanalyzeProductViews()}
disabled={!productItems.length || productAnalyzing || !!productAngleBusy}
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" />
{productAnalyzing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => productFileRef.current?.click()}
disabled={!job || productUploading || productItems.length >= 6}
disabled={!job || productUploading || productAnalyzing || !!productAngleBusy || 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" />}
@@ -1146,6 +1239,7 @@ function AudioStoryboardPlanPanel({
slot={slot}
canGenerate={!!productItems.length && productItems.length < 6}
busy={productAngleBusy === slot.value}
blocked={productAnalyzing || !!productAngleBusy}
onGenerate={() => generateMissingProductAngle(slot)}
/>
))}
@@ -1266,19 +1360,16 @@ function ProductReferenceCard({
<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>
<div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white/78">
<span className="truncate font-semibold">{productViewLabel(item.view)}</span>
<span className="shrink-0 text-[10px] text-white/34">
{item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
</span>
</div>
<input
value={item.note}
onChange={(event) => onPatch({ note: event.target.value })}
placeholder="视角备注:结构差异、触点、尺寸比例"
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>
@@ -1299,11 +1390,13 @@ function MissingProductViewSlot({
slot,
canGenerate,
busy,
blocked,
onGenerate,
}: {
slot: typeof PRODUCT_VIEW_SLOTS[number]
canGenerate: boolean
busy: boolean
blocked: boolean
onGenerate: () => void
}) {
return (
@@ -1313,14 +1406,15 @@ function MissingProductViewSlot({
<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>
<p className="mt-1 text-[10px] leading-snug text-white/28">{canGenerate ? "自动补图失败时可重试。" : "上传后会自动识别并补齐。"}</p>
<button
type="button"
onClick={onGenerate}
disabled={!canGenerate || busy}
disabled={!canGenerate || busy || blocked}
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>
)

View File

@@ -163,6 +163,29 @@ export async function generateProductAngleAsset(
return res.json()
}
export interface ProductViewAnalysisItem {
index: number
view: string
note: string
confidence: number
}
export async function analyzeProductViews(
jobId: string,
refs: ImageRef[],
): Promise<{ items: ProductViewAnalysisItem[]; missing_views: string[] }> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-views/analyze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refs }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`analyzeProductViews ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function listProductLibrary(): Promise<ProductLibraryItem[]> {
const res = await fetch(`${API_BASE}/product-library/skg`)
if (!res.ok) {