auto-save 2026-05-14 12:09 (+4, ~6)

This commit is contained in:
2026-05-14 12:09:52 +08:00
parent 9f3e28d230
commit 04679b0f2f
10 changed files with 195 additions and 61 deletions

View File

@@ -1,26 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "fed62f1",
"message": "auto-save 2026-05-13 04:41 (~1)",
"ts": "2026-05-13T04:41:28+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "3e34b63",
"message": "auto-save 2026-05-13 04:47 (~1)",
"ts": "2026-05-13T04:47:22+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "efc3214",
"message": "auto-save 2026-05-13 04:53 (~1)",
"ts": "2026-05-13T04:53:15+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "5396b55",
@@ -3295,6 +3274,25 @@
"message": "auto-save 2026-05-14 11:58 (~4)",
"hash": "f0c6c5b",
"files_changed": 4
},
{
"ts": "2026-05-14T12:04:20+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 12:04 (~4)",
"hash": "9f3e28d",
"files_changed": 4
},
{
"ts": "2026-05-14T04:06:10Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-14 12:04 (~4)",
"files_changed": 1
},
{
"ts": "2026-05-14T04:08:39Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 5 项未提交变更 · 最近提交auto-save 2026-05-14 12:04 (~4)",
"files_changed": 5
}
]
}

View File

@@ -3722,15 +3722,15 @@ def generate_product_fusion_descriptions(job_id: str, req: ProductFusionDescript
last = (shot.last_image or {}).get("label") or "尾帧未填"
products = [
(ref or {}).get("label") or f"产品角度{idx + 1}未填"
for idx, ref in enumerate((shot.product_images or [])[:3])
for idx, ref in enumerate((shot.product_images or [])[:4])
]
while len(products) < 3:
while len(products) < 4:
products.append(f"产品角度{len(products) + 1}未填")
shot_lines.append(f"{i}. 首帧={first};尾帧={last};产品角度={products[0]} / {products[1]} / {products[2]};已有描述={shot.action_text or ''}")
shot_lines.append(f"{i}. 首帧={first};尾帧={last};产品角度={products[0]} / {products[1]} / {products[2]} / {products[3]};已有描述={shot.action_text or ''}")
prompt = (
"你是 SKG 产品短视频分镜导演。请为 6 条产品融合镜头各写一条中文动作描述,"
"每条 20-45 字,必须说明透明骨架人在做什么、产品如何佩戴/展示、动作如何从首帧自然过渡到尾帧。"
"产品是 SKG 白色 U 形颈部/肩颈按摩仪,张产品角度图是同一产品的身份真源;不要写医疗治疗承诺,不要出现竞品。"
"产品是 SKG 白色 U 形颈部/肩颈按摩仪,张产品角度图是同一产品的身份真源;不要写医疗治疗承诺,不要出现竞品。"
"输出 JSON{\"descriptions\":[\"...\", \"...\"]}。\n\n"
+ "\n".join(shot_lines)
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -1,8 +1,96 @@
{
"source": "/Users/kangwan/Desktop/skg/skg_product_downloads",
"filter": "all_products/gallery only; border white_score >= 0.78 or white_score >= 0.62 and near_white_score >= 0.90",
"count": 41,
"count": 45,
"items": [
{
"id": "desktop-skg-product-angle-01",
"handle": "desktop-skg-product-four-angle",
"title": "Desktop SKG Product Four-Angle Set",
"product_type": "Neck Massager",
"image_type": "desktop-angle",
"image_index": 1,
"filename": "images/skg-desktop-product-angle-01.jpg",
"url": "/product-library/skg/images/skg-desktop-product-angle-01.jpg",
"width": 1300,
"height": 1300,
"source_path": "/Users/kangwan/Desktop/skg产品1.jpg",
"white_score": 1.0,
"near_white_score": 1.0,
"has_people": false,
"tags": [
"white-bg",
"desktop-angle",
"four-angle-set",
"Neck Massager"
]
},
{
"id": "desktop-skg-product-angle-02",
"handle": "desktop-skg-product-four-angle",
"title": "Desktop SKG Product Four-Angle Set",
"product_type": "Neck Massager",
"image_type": "desktop-angle",
"image_index": 2,
"filename": "images/skg-desktop-product-angle-02.jpg",
"url": "/product-library/skg/images/skg-desktop-product-angle-02.jpg",
"width": 1300,
"height": 1300,
"source_path": "/Users/kangwan/Desktop/skg产品2.jpg",
"white_score": 1.0,
"near_white_score": 1.0,
"has_people": false,
"tags": [
"white-bg",
"desktop-angle",
"four-angle-set",
"Neck Massager"
]
},
{
"id": "desktop-skg-product-angle-03",
"handle": "desktop-skg-product-four-angle",
"title": "Desktop SKG Product Four-Angle Set",
"product_type": "Neck Massager",
"image_type": "desktop-angle",
"image_index": 3,
"filename": "images/skg-desktop-product-angle-03.jpg",
"url": "/product-library/skg/images/skg-desktop-product-angle-03.jpg",
"width": 1300,
"height": 1300,
"source_path": "/Users/kangwan/Desktop/skg产品3.jpg",
"white_score": 1.0,
"near_white_score": 1.0,
"has_people": false,
"tags": [
"white-bg",
"desktop-angle",
"four-angle-set",
"Neck Massager"
]
},
{
"id": "desktop-skg-product-angle-04",
"handle": "desktop-skg-product-four-angle",
"title": "Desktop SKG Product Four-Angle Set",
"product_type": "Neck Massager",
"image_type": "desktop-angle",
"image_index": 4,
"filename": "images/skg-desktop-product-angle-04.jpg",
"url": "/product-library/skg/images/skg-desktop-product-angle-04.jpg",
"width": 1300,
"height": 1300,
"source_path": "/Users/kangwan/Desktop/skg产品4.jpg",
"white_score": 1.0,
"near_white_score": 1.0,
"has_people": false,
"tags": [
"white-bg",
"desktop-angle",
"four-angle-set",
"Neck Massager"
]
},
{
"id": "g7-pro-fold-neck-massager-01",
"handle": "g7-pro-fold-neck-massager",

View File

@@ -629,7 +629,7 @@ api/main.py
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>关键帧素材审核面板</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行直接显示首帧、尾帧、同一产品 3 个角度图、描述词、秒数和单条生成按钮,便于一次看完 6 条视频。产品融合槽位的“粘贴”优先使用应用内 <code>clipboard</code>,也支持选中槽位后 Cmd+V 粘贴系统图片。右侧保留 GPT Image 2 / Seedance 固定模型、当前镜头状态、AI 描述草稿、批量排队和产品图库选用;产品图库选中后会填入当前镜头下一个产品角度槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 prompt 做文字生图,生成结果写入 <code>scene_assets</code> 但以 <code>asset_role=first_frame/last_frame</code> 标记,并自动传入当前产品融合镜头。相关接口包括 <code>cleanupFrame</code><code>applyCleanedFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>listProductLibrary</code><code>copyProductLibraryAsset</code><code>generateProductFusionDescriptions</code></span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:每行直接显示首帧、尾帧、同一产品 4 个角度图、描述词、秒数和单条生成按钮,便于一次看完 6 条视频。产品融合槽位的“粘贴”优先使用应用内 <code>clipboard</code>,也支持选中槽位后 Cmd+V 粘贴系统图片。右侧保留 GPT Image 2 / Seedance 固定模型、当前镜头状态、AI 描述草稿、批量排队和产品图库选用;产品图库选中后会填入当前镜头下一个产品角度槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页通过地点、风格、参考要素和可编辑 prompt 做文字生图,生成结果写入 <code>scene_assets</code> 但以 <code>asset_role=first_frame/last_frame</code> 标记,并自动传入当前产品融合镜头。相关接口包括 <code>cleanupFrame</code><code>applyCleanedFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>listProductLibrary</code><code>copyProductLibraryAsset</code><code>generateProductFusionDescriptions</code></span></div>
<div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、产品融合镜头组和质量风险应该如何审核”。</span></div>
</div>
<div class="flow-row">
@@ -748,12 +748,12 @@ SubjectAsset {
</div>
<div class="card">
<h3>ProductFusionShot</h3>
<p>产品融合镜头组的单行数据。每个关键帧最多 6 行,首帧、尾帧、张同一产品不同角度图、动作描述和秒数一一对应;生成时直接把首尾帧和产品角度图作为 Seedance 垫图提交。</p>
<p>产品融合镜头组的单行数据。每个关键帧最多 6 行,首帧、尾帧、张同一产品不同角度图、动作描述和秒数一一对应;生成时直接把首尾帧和产品角度图作为 Seedance 垫图提交。</p>
<pre>ProductFusionShot {
id,
first_image,
last_image,
product_images[3],
product_images[4],
action_text,
duration,
image_model: gpt-image-2,
@@ -806,7 +806,7 @@ SubjectAsset {
<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}/product-fusion/guide</code></td><td><code>createProductFusionGuide</code></td><td>旧流程兼容接口:读取产品图和白底人物图,按 <code>product_region</code> 合成位置引导图。当前首尾帧流程不再主动调用它。</td></tr>
<tr><td>产品融合描述词</td><td><code>POST /jobs/{id}/product-fusion/descriptions</code></td><td><code>generateProductFusionDescriptions</code></td><td>为 6 行产品融合镜头生成动作描述草稿;输入重点变为首帧、尾帧和张产品角度图,有 LLM 配置时用 <code>REWRITE_MODEL</code> 生成 JSON无配置或失败时回退到本地镜头模板。</td></tr>
<tr><td>产品融合描述词</td><td><code>POST /jobs/{id}/product-fusion/descriptions</code></td><td><code>generateProductFusionDescriptions</code></td><td>为 6 行产品融合镜头生成动作描述草稿;输入重点变为首帧、尾帧和张产品角度图,有 LLM 配置时用 <code>REWRITE_MODEL</code> 生成 JSON无配置或失败时回退到本地镜头模板。</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>
@@ -925,8 +925,8 @@ SubjectAsset {
</header>
<div class="body">
<p><strong>问题:</strong>原产品融合依赖白底人物、手动画区域、场景图和融合引导图,但当前透明骨架人二创流程更需要文字生成首尾帧,再把产品真源作为垫图传给视频模型。</p>
<p><strong>改动:</strong>“场景图”页签改名为“首尾帧”,右侧用地点、风格、参考要素和 prompt 生成首帧/尾帧,生成后自动填入当前产品融合镜头。产品融合 6 行工作表改为首帧、尾帧、张同一产品不同角度图、描述词、秒数和生成按钮。</p>
<p><strong>后端:</strong><code>generateSceneAsset</code> 新增 <code>asset_role</code><code>first_frame/last_frame</code> 走文字生图并标记资产角色;<code>ProductFusionShot</code> 新增 <code>first_image</code><code>last_image</code><code>product_images</code>,视频提交直接把首尾帧和张产品图交给 Seedance。</p>
<p><strong>改动:</strong>“场景图”页签改名为“首尾帧”,右侧用地点、风格、参考要素和 prompt 生成首帧/尾帧,生成后自动填入当前产品融合镜头。产品融合 6 行工作表改为首帧、尾帧、张同一产品不同角度图、描述词、秒数和生成按钮。</p>
<p><strong>后端:</strong><code>generateSceneAsset</code> 新增 <code>asset_role</code><code>first_frame/last_frame</code> 走文字生图并标记资产角色;<code>ProductFusionShot</code> 新增 <code>first_image</code><code>last_image</code><code>product_images</code>,视频提交直接把首尾帧和张产品图交给 Seedance。</p>
<p><strong>影响:</strong><code>api/main.py</code><code>web/lib/api.ts</code><code>web/app/page.tsx</code><code>web/components/lightbox.tsx</code><code>docs/source-analysis.html</code></p>
</div>
</article>

View File

@@ -520,9 +520,9 @@ export default function Home() {
if (!job) return
const frame = job.frames.find((f) => f.index === frameIdx)
if (!frame) return
const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 3) as ImageRef[]
if (!shot.first_image || !shot.last_image || productRefs.length < 3 || !shot.action_text?.trim()) {
toast.error("产品融合镜头缺少首帧、尾帧、张产品角度图或描述词")
const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 4) as ImageRef[]
if (!shot.first_image || !shot.last_image || productRefs.length < 4 || !shot.action_text?.trim()) {
toast.error("产品融合镜头缺少首帧、尾帧、张产品角度图或描述词")
return
}
const duration = shot.duration && shot.duration > 0 ? shot.duration : 5
@@ -531,17 +531,18 @@ export default function Home() {
const prompt = [
`竖屏 9:16${duration.toFixed(1)}Seedance 图生视频。`,
"图片模型固定为 GPT Image 2首帧和尾帧已经由文字生图生成用来锁定透明骨架人角色、场景构图和动作起止状态。",
"视频模型固定为 Seedance使用首帧作为起始画面、尾帧作为结束画面并用张同一 SKG 产品不同角度白底图作为垫图/产品身份参考。",
"视频模型固定为 Seedance使用首帧作为起始画面、尾帧作为结束画面并用张同一 SKG 产品不同角度白底图作为垫图/产品身份参考。",
`首帧:${labelOf(shot.first_image, "透明骨架人首帧")}。起始人物形象、姿态、构图和场景氛围以这张图为准。`,
`尾帧:${labelOf(shot.last_image, "透明骨架人尾帧")}。结束人物状态、画面落点和场景延续以这张图为准。`,
`产品角度图 1${labelOf(productRefs[0], "SKG 产品正面/主视角")}`,
`产品角度图 2${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}`,
`产品角度图 3${labelOf(productRefs[2], "SKG 产品背面/细节视角")}`,
`产品角度图 4${labelOf(productRefs[3], "SKG 产品补充/底部或佩戴视角")}`,
`动作描述:${shot.action_text.trim()}`,
TRANSPARENT_HUMAN_VIDEO_PROMPT,
"融合要求:产品必须自然出现在透明骨架人动作中,尺寸可信,透视一致,贴合身体/手部/使用区域,不能悬浮、漂移、融化、扭曲或变成其他物体。",
"首尾连续性:镜头从首帧自然运动到尾帧,中间不要跳切,不换角色,不换产品,不突然改变场景。",
"产品一致性:严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例;张产品角度图是产品身份真源。",
"产品一致性:严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例;张产品角度图是产品身份真源。",
"场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。",
"商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
"禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、透明衣服但非透明身体。",

View File

@@ -5,7 +5,7 @@ import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, Ref
import {
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard,
generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, copyProductLibraryAsset,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneAssetRole, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { ProductLibraryPicker } from "@/components/product-library-picker"
@@ -106,13 +106,20 @@ const SCENE_REFERENCE_OPTIONS = [
const FUSION_SHOT_COUNT = 6
const FUSION_DURATIONS = [4, 5, 6, 8, 10, 12, 15]
const PRODUCT_ANGLE_COUNT = 4
const DESKTOP_PRODUCT_ANGLE_IDS = [
"desktop-skg-product-angle-01",
"desktop-skg-product-angle-02",
"desktop-skg-product-angle-03",
"desktop-skg-product-angle-04",
]
type FusionUploadTarget = {
shotIndex: number
slot: "first_image" | "last_image" | "product_images"
productIndex?: number
}
type FusionFrameRole = "first_image" | "last_image"
const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3"]
const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3", "产品角度 4"]
const createFusionShots = (): ProductFusionShot[] =>
Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({
@@ -137,7 +144,7 @@ const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusion
return base.map((item, i) => {
const shot = shots[i] ?? {}
const productImages = shot.product_images?.length
? shot.product_images.slice(0, 3)
? shot.product_images.slice(0, PRODUCT_ANGLE_COUNT)
: shot.product_image
? [shot.product_image]
: []
@@ -170,6 +177,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [fusionUploadTarget, setFusionUploadTarget] = useState<FusionUploadTarget | null>(null)
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionFillingProducts, setFusionFillingProducts] = useState<"current" | "all" | null>(null)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
id: string
@@ -312,7 +320,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const currentFusionFirstUrl = currentFusionShot?.first_image ? resolveImageRefUrl(jobId, currentFusionShot.first_image) : ""
const currentFusionLastUrl = currentFusionShot?.last_image ? resolveImageRefUrl(jobId, currentFusionShot.last_image) : ""
const fusionReadyCount = fusionShots.filter((shot) =>
shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= 3 && shot.action_text?.trim()
shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= PRODUCT_ANGLE_COUNT && shot.action_text?.trim()
).length
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
@@ -341,9 +349,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const current = fusionShots[index]
if (!current) return
if (target.slot === "product_images") {
const productImages = [...(current.product_images ?? [])].slice(0, 3)
const inferredIndex = [0, 1, 2].find((idx) => !productImages[idx]) ?? 0
const safeIndex = Math.max(0, Math.min(2, target.productIndex ?? inferredIndex))
const productImages = [...(current.product_images ?? [])].slice(0, PRODUCT_ANGLE_COUNT)
const inferredIndex = PRODUCT_ANGLE_LABELS.findIndex((_, idx) => !productImages[idx])
const safeIndex = Math.max(0, Math.min(PRODUCT_ANGLE_COUNT - 1, target.productIndex ?? (inferredIndex >= 0 ? inferredIndex : 0)))
productImages[safeIndex] = ref
updateFusionShot(index, { product_images: productImages, product_image: productImages[0] ?? null, guide_image: null }, true)
return
@@ -402,10 +410,29 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
toast.success("已生成 6 条动作描述草稿,可继续手工修改")
}
const fillDesktopProductAngles = async (scope: "current" | "all") => {
setFusionFillingProducts(scope)
try {
const refs = await Promise.all(DESKTOP_PRODUCT_ANGLE_IDS.map((id) => copyProductLibraryAsset(jobId, id)))
const next = fusionShots.map((shot, index) => (
scope === "all" || index === activeFusionShot
? { ...shot, product_images: refs, product_image: refs[0] ?? null, guide_image: null }
: shot
))
setFusionShots(next)
void persistFusionShots(next)
toast.success(scope === "all" ? "已把桌面 4 个产品角度填入 6 个镜头" : `已填入镜头 ${activeFusionShot + 1} 的 4 个产品角度`)
} catch (e) {
toast.error("桌面产品角度填充失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setFusionFillingProducts(null)
}
}
const runFusionVideo = async (index: number) => {
const shot = fusionShots[index]
if (!shot?.first_image || !shot.last_image || (shot.product_images ?? []).filter(Boolean).length < 3 || !shot.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺首帧、尾帧、张产品角度图或描述词`)
if (!shot?.first_image || !shot.last_image || (shot.product_images ?? []).filter(Boolean).length < PRODUCT_ANGLE_COUNT || !shot.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺首帧、尾帧、张产品角度图或描述词`)
return
}
setFusionGenerating(index)
@@ -419,7 +446,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const runAllFusionVideos = async () => {
const indexes = fusionShots
.map((shot, i) => ({ shot, i }))
.filter(({ shot }) => shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= 3 && shot.action_text?.trim())
.filter(({ shot }) => shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= PRODUCT_ANGLE_COUNT && shot.action_text?.trim())
.map(({ i }) => i)
if (indexes.length === 0) {
toast.error("还没有完整的融合镜头")
@@ -973,7 +1000,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</span>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5 text-[10px] leading-relaxed text-white/50">
6 使 + + + 3 Seedance
6 使 + + + 4 Seedance
</div>
<div className="space-y-2">
{fusionShots.map((shot, i) => {
@@ -981,10 +1008,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const productImages = shot.product_images ?? []
const firstUrl = shot.first_image ? resolveImageRefUrl(jobId, shot.first_image) : ""
const lastUrl = shot.last_image ? resolveImageRefUrl(jobId, shot.last_image) : ""
const productUrls = [0, 1, 2].map((productIndex) =>
const productUrls = PRODUCT_ANGLE_LABELS.map((_, productIndex) =>
productImages[productIndex] ? resolveImageRefUrl(jobId, productImages[productIndex]) : ""
)
const ready = !!(shot.first_image && shot.last_image && productImages.filter(Boolean).length >= 3 && shot.action_text?.trim())
const ready = !!(shot.first_image && shot.last_image && productImages.filter(Boolean).length >= PRODUCT_ANGLE_COUNT && shot.action_text?.trim())
const busy = fusionGenerating === i || fusionGenerating === "all"
const pasteIntoSlot = (target: FusionUploadTarget, label: string) => {
setActiveFusionShot(i)
@@ -1043,12 +1070,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
)
const productAngleSlots = (
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2].map((productIndex) => (
<div className="grid grid-cols-4 gap-1.5">
{PRODUCT_ANGLE_LABELS.map((label, productIndex) => (
<div key={productIndex}>
{imageSlot(
{ shotIndex: i, slot: "product_images", productIndex },
PRODUCT_ANGLE_LABELS[productIndex],
label,
productUrls[productIndex],
productImages[productIndex],
true,
@@ -1066,7 +1093,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
: "border-white/10 bg-black/20 hover:border-amber-300/35"
}`}
>
<div className="grid grid-cols-[34px_86px_86px_240px_minmax(150px,1fr)_78px] items-start gap-2">
<div className="grid grid-cols-[34px_86px_86px_304px_minmax(150px,1fr)_78px] items-start gap-2">
<div className="flex flex-col items-center gap-1 pt-1">
<button
type="button"
@@ -1091,9 +1118,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
<span className={`text-[8.5px] ${productImages.filter(Boolean).length >= 3 ? "text-emerald-200/70" : "text-white/30"}`}>
{productImages.filter(Boolean).length}/3
<span className="text-[9px] text-white/38"> · </span>
<span className={`text-[8.5px] ${productImages.filter(Boolean).length >= PRODUCT_ANGLE_COUNT ? "text-emerald-200/70" : "text-white/30"}`}>
{productImages.filter(Boolean).length}/{PRODUCT_ANGLE_COUNT}
</span>
</div>
{productAngleSlots}
@@ -1576,13 +1603,33 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[9.5px]">
<span className={currentFusionFirstUrl ? "text-emerald-200/80" : "text-white/35"}></span>
<span className={currentFusionLastUrl ? "text-emerald-200/80" : "text-white/35"}></span>
<span className={currentFusionProductCount >= 3 ? "text-emerald-200/80" : "text-white/35"}> {currentFusionProductCount}/3</span>
<span className={currentFusionProductCount >= PRODUCT_ANGLE_COUNT ? "text-emerald-200/80" : "text-white/35"}> {currentFusionProductCount}/{PRODUCT_ANGLE_COUNT}</span>
<span className={currentFusionShot?.action_text?.trim() ? "text-emerald-200/80" : "text-white/35"}></span>
</div>
<div className={`mt-1 truncate text-[9.5px] ${currentFusionShot?.action_text?.trim() ? "text-white/58" : "text-white/32"}`}>
{currentFusionShot?.action_text?.trim() || "描述词未填写"}
</div>
</div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => void fillDesktopProductAngles("current")}
disabled={!!fusionFillingProducts}
className="rounded-md border border-amber-300/20 bg-amber-500/15 px-2 py-1.5 text-[10.5px] font-medium text-amber-50 transition hover:bg-amber-500/25 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
>
{fusionFillingProducts === "current" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
</button>
<button
type="button"
onClick={() => void fillDesktopProductAngles("all")}
disabled={!!fusionFillingProducts}
className="rounded-md border border-amber-300/20 bg-amber-500/15 px-2 py-1.5 text-[10.5px] font-medium text-amber-50 transition hover:bg-amber-500/25 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
>
{fusionFillingProducts === "all" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
6
</button>
</div>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
@@ -1609,11 +1656,11 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
buttonLabel="选用"
title={`镜头 ${activeFusionShot + 1} 产品角度图`}
onPick={(ref) => {
const nextEmpty = [0, 1, 2].find((idx) => !currentFusionProducts[idx]) ?? 0
const nextEmpty = PRODUCT_ANGLE_LABELS.findIndex((_, idx) => !currentFusionProducts[idx])
assignFusionImage({
shotIndex: activeFusionShot,
slot: "product_images",
productIndex: nextEmpty,
productIndex: nextEmpty >= 0 ? nextEmpty : 0,
}, ref)
}}
/>