auto-save 2026-05-17 19:37 (~4)
This commit is contained in:
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "7bf9e0f",
|
|
||||||
"message": "auto-save 2026-05-15 11:40 (~1)",
|
|
||||||
"ts": "2026-05-15T11:40:31+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 2,
|
"files_changed": 2,
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-15 11:40 (~1)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-15 11:40 (~1)",
|
||||||
@@ -3264,6 +3257,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: tolerate product view model output",
|
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: tolerate product view model output",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-17T19:32:19+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-17 19:32 (~4)",
|
||||||
|
"hash": "96c998c",
|
||||||
|
"files_changed": 4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4380,7 +4380,7 @@ def analyze_product_view(ref_path: Path, index: int) -> dict:
|
|||||||
return fallback_product_view(index)
|
return fallback_product_view(index)
|
||||||
img_b64 = base64.b64encode(ref_path.read_bytes()).decode("ascii")
|
img_b64 = base64.b64encode(ref_path.read_bytes()).decode("ascii")
|
||||||
prompt = (
|
prompt = (
|
||||||
"You are inspecting a product reference image for a SKG neck-and-shoulder wearable massage device. The background may be white, black, or simple studio color. "
|
"You are inspecting one reference image from a same-product image pool for a SKG neck-and-shoulder wearable massage device. Do not classify product identity or compare different products; all uploaded references belong to the same product. The background may be white, black, or simple studio color. "
|
||||||
"Classify the camera/view angle into exactly one enum: front, left_45, right_45, side_thickness, inner_contacts, back_bottom. "
|
"Classify the camera/view angle into exactly one enum: front, left_45, right_45, side_thickness, inner_contacts, back_bottom. "
|
||||||
"Classify background into exactly one enum: white, black, simple, complex, unknown. Do not request or perform background conversion. "
|
"Classify background into exactly one enum: white, black, simple, complex, unknown. Do not request or perform background conversion. "
|
||||||
"Add use_tags from this enum only: hero_packshot, wearing_scale, inner_contact, side_thickness, asymmetry, button_detail, back_bottom, material_texture. "
|
"Add use_tags from this enum only: hero_packshot, wearing_scale, inner_contact, side_thickness, asymmetry, button_detail, back_bottom, material_texture. "
|
||||||
|
|||||||
@@ -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/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/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/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 度/厚度/内侧触点/背底等视角并自动补齐缺失角度,用户只检查视角备注,鼠标悬停可放大预览;补图失败时保留单个缺失视角的重试入口。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会从产品素材池自动挑选最多 6 张相关产品图和备注保存为对应关键帧分镜,不会把全部产品图提交给生视频模型,然后复用现有生视频接口提交 Seedance 候选。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。</td></tr>
|
<tr><td><code>web/components/ad-recreation-board.tsx</code></td><td>信息流广告复刻工作表:左侧素材输入;右侧展示视频下载状态、默认折叠的音频文案依据,以及统一的音频解析结果面板;面板顶部是一行讲话人/节奏/背景音摘要,下方左侧为原视频播放器、右侧为逐句时间轴,底部横向音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点。视频播放时通过 <code>requestAnimationFrame</code> 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传后自动识别正面/左右 45 度/厚度/内侧触点/背底等视角,并标注背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停可放大预览;缺视角补图失败时保留重试入口。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入、参考帧/关键元素和 6 个候选视频槽。单条生成会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 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/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/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>
|
<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
|
-> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
|
||||||
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
|
-> 开始:创建/激活 job → 下载完成后自动触发音频处理
|
||||||
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
|
-> 左侧素材输入列 + 右侧默认折叠的音频文案依据 + 统一音频解析结果面板(声音摘要在上,原视频与逐句时间轴并排,底部连续响度波形联动)
|
||||||
-> 信息流复刻分镜工作台:产品白底图素材池不限量上传 → 自动识别视角 → 自动补齐缺失角度 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
|
-> 信息流复刻分镜工作台:同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 单条生成自动挑选最多 6 张相关产品图 → 逐句时间轴 → 原内容 / 新口播文案 / 画面规划与产品融入 / 参考帧与关键元素 / 6 个候选视频槽
|
||||||
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
|
-> 底部音频条:不再渲染,音频结果集中到右侧工作表
|
||||||
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
|
-> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口)
|
||||||
-> API 契约:web/lib/api.ts
|
-> API 契约:web/lib/api.ts
|
||||||
@@ -839,7 +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>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>产品图库</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>产品图入库到 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-views/analyze</code></td><td><code>analyzeProductViews</code></td><td>读取已上传的产品白底图素材池,不限制只看前 6 张;自动分类为正面、左右 45 度、侧面厚度、内侧触点或背面/底部,并返回中文视角备注和置信度;前端不再要求用户手动选择视角。</td></tr>
|
<tr><td>产品视角识别</td><td><code>POST /jobs/{id}/assets/product-views/analyze</code></td><td><code>analyzeProductViews</code></td><td>读取同一产品素材池,不限制只看前 6 张;自动分类为正面、左右 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>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>角色库</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>角色图入库到 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>
|
||||||
@@ -950,6 +950,19 @@ SubjectAsset {
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<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>产品图识别不能只知道“正面/侧面”,下一步生视频需要知道哪些图适合作为主外观、佩戴比例、触点、厚度、按键或材质参考;同时用户已确认上传图属于同一产品,不需要做不同产品身份识别。</p>
|
||||||
|
<p><strong>改动:</strong><code>POST /jobs/{id}/assets/product-views/analyze</code> 扩展返回 <code>background</code>、<code>use_tags</code>、<code>risk</code>。前端 <code>ProductReferenceCard</code> 展示背景、用途标签和风险标记;<code>selectProductItemsForRow</code> 从单纯按视角轮转改为按分镜角色、视角优先级、用途标签、置信度和风险评分,自动挑选最多 6 张产品参考图。</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">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-17 · 产品视角识别容错解析</h3>
|
<h3>2026-05-17 · 产品视角识别容错解析</h3>
|
||||||
|
|||||||
@@ -413,17 +413,45 @@ function productReferenceNotes(items: ProductRefItem[]) {
|
|||||||
.join(";")
|
.join(";")
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem[]) {
|
function productPriorityForRow(row: AudioStoryboardRow) {
|
||||||
if (!items.length) return []
|
const viewPriorityByRole: Record<string, string[]> = {
|
||||||
const priorityByRole: Record<string, string[]> = {
|
|
||||||
"开场钩子": ["front", "left_45", "right_45", "side_thickness"],
|
"开场钩子": ["front", "left_45", "right_45", "side_thickness"],
|
||||||
"痛点推进": ["front", "side_thickness", "left_45", "right_45"],
|
"痛点推进": ["front", "side_thickness", "left_45", "right_45"],
|
||||||
"利益证明": ["front", "inner_contacts", "side_thickness", "left_45", "right_45", "back_bottom"],
|
"利益证明": ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"],
|
||||||
"方案过渡": ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
|
"方案过渡": ["front", "left_45", "right_45", "inner_contacts", "side_thickness"],
|
||||||
"转化收口": ["front", "left_45", "right_45", "back_bottom", "inner_contacts"],
|
"转化收口": ["front", "back_bottom", "left_45", "right_45", "inner_contacts"],
|
||||||
"节奏承接": ["front", "left_45", "right_45", "side_thickness"],
|
"节奏承接": ["front", "left_45", "right_45", "side_thickness"],
|
||||||
}
|
}
|
||||||
const priority = priorityByRole[row.role] ?? priorityByRole["节奏承接"]
|
const tagPriorityByRole: Record<string, string[]> = {
|
||||||
|
"开场钩子": ["hero_packshot", "asymmetry", "side_thickness"],
|
||||||
|
"痛点推进": ["wearing_scale", "side_thickness", "hero_packshot"],
|
||||||
|
"利益证明": ["inner_contact", "wearing_scale", "button_detail", "side_thickness"],
|
||||||
|
"方案过渡": ["wearing_scale", "hero_packshot", "inner_contact"],
|
||||||
|
"转化收口": ["hero_packshot", "back_bottom", "asymmetry", "material_texture"],
|
||||||
|
"节奏承接": ["hero_packshot", "asymmetry", "side_thickness"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
views: viewPriorityByRole[row.role] ?? viewPriorityByRole["节奏承接"],
|
||||||
|
tags: tagPriorityByRole[row.role] ?? tagPriorityByRole["节奏承接"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreProductItemForRow(row: AudioStoryboardRow, item: ProductRefItem, index: number) {
|
||||||
|
const priority = productPriorityForRow(row)
|
||||||
|
const viewRank = priority.views.indexOf(item.view)
|
||||||
|
const tagScore = item.useTags.reduce((sum, tag) => {
|
||||||
|
const rank = priority.tags.indexOf(tag)
|
||||||
|
return sum + (rank >= 0 ? 18 - rank * 3 : 0)
|
||||||
|
}, 0)
|
||||||
|
const backgroundScore = item.background === "complex" ? -8 : item.background === "unknown" ? -3 : 0
|
||||||
|
const riskScore = item.risk ? -10 : 0
|
||||||
|
const confidenceScore = Math.round((item.confidence ?? 0.5) * 10)
|
||||||
|
const rotationScore = -Math.abs((row.index % Math.max(1, index + 1)) - (index % 3))
|
||||||
|
return (viewRank >= 0 ? 30 - viewRank * 4 : 0) + tagScore + backgroundScore + riskScore + confidenceScore + rotationScore
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem[]) {
|
||||||
|
if (!items.length) return []
|
||||||
const picked: ProductRefItem[] = []
|
const picked: ProductRefItem[] = []
|
||||||
const pickedIds = new Set<string>()
|
const pickedIds = new Set<string>()
|
||||||
const add = (item?: ProductRefItem) => {
|
const add = (item?: ProductRefItem) => {
|
||||||
@@ -432,13 +460,28 @@ function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem
|
|||||||
pickedIds.add(item.id)
|
pickedIds.add(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const view of priority) {
|
const priority = productPriorityForRow(row)
|
||||||
const matches = items.filter((item) => item.view === view)
|
for (const view of priority.views) {
|
||||||
add(matches[row.index % Math.max(matches.length, 1)])
|
const matches = items
|
||||||
|
.map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
|
||||||
|
.filter(({ item }) => item.view === view)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
add(matches[0]?.item)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; picked.length < Math.min(MAX_PRODUCT_REFS_PER_VIDEO, items.length) && i < items.length; i += 1) {
|
for (const tag of priority.tags) {
|
||||||
add(items[(row.index + i) % items.length])
|
const matches = items
|
||||||
|
.map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
|
||||||
|
.filter(({ item }) => item.useTags.includes(tag))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
add(matches[0]?.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = items
|
||||||
|
.map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
for (const { item } of ranked) {
|
||||||
|
add(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
return picked
|
return picked
|
||||||
@@ -449,8 +492,8 @@ function buildStoryboardSceneFromAudioRow(row: AudioStoryboardRow, frame: KeyFra
|
|||||||
const productRefs = selectedProductItems.map((item) => item.ref)
|
const productRefs = selectedProductItems.map((item) => item.ref)
|
||||||
const notes = productReferenceNotes(selectedProductItems)
|
const notes = productReferenceNotes(selectedProductItems)
|
||||||
const productGuidance = productItems.length
|
const productGuidance = productItems.length
|
||||||
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。视角备注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
|
? `产品素材池共有 ${productItems.length} 张,本条只选用 ${selectedProductItems.length} 张最相关参考图,不要把未选素材混入本条画面。所选图片只作为产品结构、角度、比例和细节参考,不要照搬参考图的白底/黑底/棚拍背景。视角标注:${notes}。保留左右非对称细节,不要把两边做成镜像对称;肩颈产品大小必须贴近真实佩戴比例,不能缩成耳机,也不能放大成护颈枕。`
|
||||||
: "未上传产品白底图时使用默认 SKG 产品图;生成前建议先建立产品素材池,锁定左右差异、厚度和佩戴比例。"
|
: "未上传产品图时使用默认 SKG 产品图;生成前建议先建立同一产品素材池,锁定左右差异、厚度和佩戴比例。"
|
||||||
return {
|
return {
|
||||||
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
|
duration: Number(Math.max(3.2, Math.min(6.5, row.end - row.start || 4.5)).toFixed(1)),
|
||||||
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
|
first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${row.index + 1} 参考帧` },
|
||||||
@@ -1118,6 +1161,9 @@ function AudioStoryboardPlanPanel({
|
|||||||
itemSourceForRef(ref),
|
itemSourceForRef(ref),
|
||||||
validView,
|
validView,
|
||||||
analysis?.note,
|
analysis?.note,
|
||||||
|
analysis?.background ?? "unknown",
|
||||||
|
analysis?.use_tags,
|
||||||
|
analysis?.risk ?? "",
|
||||||
analysis?.confidence,
|
analysis?.confidence,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1139,7 +1185,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
})
|
})
|
||||||
working = [
|
working = [
|
||||||
...working,
|
...working,
|
||||||
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1),
|
createProductRefItem(ref, working.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, "", 1),
|
||||||
]
|
]
|
||||||
setProductItems(working)
|
setProductItems(working)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1210,9 +1256,9 @@ function AudioStoryboardPlanPanel({
|
|||||||
try {
|
try {
|
||||||
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
|
const refs = await Promise.all(selected.map((file) => uploadStoryboardAsset(job.id, file)))
|
||||||
await analyzeUploadedProductRefs(refs)
|
await analyzeUploadedProductRefs(refs)
|
||||||
toast.success(`已上传 ${refs.length} 张产品白底图`)
|
toast.success(`已上传 ${refs.length} 张产品图`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("产品白底图上传失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("产品图上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
setProductUploading(false)
|
setProductUploading(false)
|
||||||
}
|
}
|
||||||
@@ -1241,7 +1287,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
target_view: slot.label,
|
target_view: slot.label,
|
||||||
note: slot.hint,
|
note: slot.hint,
|
||||||
})
|
})
|
||||||
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, 1)])
|
setProductItems((prev) => [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, "", 1)])
|
||||||
toast.success(`AI 已补全产品视角:${slot.label}`)
|
toast.success(`AI 已补全产品视角:${slot.label}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("AI 补角度失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
@@ -1287,7 +1333,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SectionTitle icon={<Package className="h-4 w-4" />} title="产品白底图 / 视角补全" />
|
<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">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
|
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/42">{productItems.length ? `${productItems.length} 张素材` : "素材池不限量"}</span>
|
||||||
{(productAnalyzing || productAngleBusy) && (
|
{(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">
|
<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">
|
||||||
@@ -1297,7 +1343,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
|
<p className="mt-1 max-w-[760px] text-[11px] leading-snug text-white/42">
|
||||||
上传后自动识别每张图的角度和视角,并自动补齐缺失角度;产品素材池不限制数量。每条视频生成时只自动挑选最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图,避免把所有素材都塞给模型。
|
上传的图默认属于同一个产品;系统只标注背景、视角、用途和生成风险。每条视频生成时自动挑选最多 {MAX_PRODUCT_REFS_PER_VIDEO} 张相关产品图,避免把所有素材都塞给模型。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
@@ -1317,7 +1363,7 @@ function AudioStoryboardPlanPanel({
|
|||||||
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"
|
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" />}
|
{productUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||||
上传白底图
|
上传产品图
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={productFileRef}
|
ref={productFileRef}
|
||||||
@@ -1459,13 +1505,19 @@ function ProductReferenceCard({
|
|||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const src = resolveImageRefUrl(job.id, item.ref)
|
const src = resolveImageRefUrl(job.id, item.ref)
|
||||||
|
const tagLabels = item.useTags.map((tag) => PRODUCT_USE_TAG_LABELS[tag]).filter(Boolean)
|
||||||
return (
|
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="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">
|
<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" />
|
<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">
|
<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" />
|
<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 className="mt-1 text-[11px] leading-snug text-white/62">
|
||||||
|
{productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ")}
|
||||||
|
<br />
|
||||||
|
{item.note}
|
||||||
|
{item.risk ? <><br />风险:{item.risk}</> : null}
|
||||||
|
</div>
|
||||||
</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>
|
<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>
|
||||||
@@ -1476,6 +1528,13 @@ function ProductReferenceCard({
|
|||||||
{item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
|
{item.source === "ai" ? "自动补图" : item.confidence != null ? `自动识别 ${Math.round(item.confidence * 100)}%` : "自动识别"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 flex min-h-5 flex-wrap gap-1 overflow-hidden">
|
||||||
|
<span className="rounded border border-white/10 bg-white/[0.045] px-1.5 py-0.5 text-[9.5px] leading-none text-white/44">{productBackgroundLabel(item.background)}</span>
|
||||||
|
{tagLabels.slice(0, 3).map((tag) => (
|
||||||
|
<span key={tag} className="rounded border border-cyan-300/14 bg-cyan-300/[0.07] px-1.5 py-0.5 text-[9.5px] leading-none text-cyan-100/58">{tag}</span>
|
||||||
|
))}
|
||||||
|
{item.risk ? <span className="rounded border border-amber-300/18 bg-amber-300/[0.08] px-1.5 py-0.5 text-[9.5px] leading-none text-amber-100/68">有风险</span> : null}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
value={item.note}
|
value={item.note}
|
||||||
onChange={(event) => onPatch({ note: event.target.value })}
|
onChange={(event) => onPatch({ note: event.target.value })}
|
||||||
|
|||||||
Reference in New Issue
Block a user