auto-save 2026-05-14 13:49 (~4)

This commit is contained in:
2026-05-14 13:49:22 +08:00
parent 7797de4438
commit ffe12f428b
4 changed files with 233 additions and 92 deletions

View File

@@ -1,25 +1,5 @@
{
"entries": [
{
"files_changed": 2,
"hash": "0599cd8",
"message": "auto-save 2026-05-13 09:20 (+1, ~1)",
"ts": "2026-05-13T09:20:35+08:00",
"type": "commit"
},
{
"files_changed": 2,
"hash": "e1143a5",
"message": "auto-save 2026-05-13 09:25 (~2)",
"ts": "2026-05-13T09:26:08+08:00",
"type": "commit"
},
{
"files_changed": 1,
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-13 09:25 (~2)",
"ts": "2026-05-13T01:27:36Z",
"type": "session-heartbeat"
},
{
"files_changed": 3,
"hash": "fdc3162",
@@ -3276,6 +3256,25 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-14 13:38 (~1)",
"files_changed": 1
},
{
"ts": "2026-05-14T13:43:52+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 13:43 (~4)",
"hash": "7797de4",
"files_changed": 4
},
{
"ts": "2026-05-14T05:46:12Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-14 13:43 (~4)",
"files_changed": 2
},
{
"ts": "2026-05-14T05:48:40Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-14 13:43 (~4)",
"files_changed": 4
}
]
}

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 行镜头工作表:顶部选择 5 个内置透明骨架人角色之一,每行只显示已预填场景/产品使用/享受描述、秒数、生成按钮和对应视频结果;描述词内置 36 条镜头语言模板,按“建立出场、产品入画、佩戴贴合、使用感受、生活延展、收尾记忆”排列,并且会按角色自动改写场景气质、使用动作和享受状态。每行还内置角色参考图调度:例如正面/半身用于出场,侧面/背部特写用于佩戴贴合,半身/背部特写用于收尾产品记忆点。点击“换一组”只刷新 6 行描述词。四张桌面 SKG 产品图所选角色 7 张参考图作为固定参考,生成时分别通过 <code>copyProductLibraryAsset</code><code>copyCharacterLibraryAssets</code> 自动写入当前 job不再暴露产品角度槽、产品融合辅助栏、产品图库选择器或首尾帧槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页保留给旧流程/单独生图,不再是产品融合必填步骤。相关接口包括 <code>cleanupFrame</code><code>applyCleanedFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>copyProductLibraryAsset</code><code>copyCharacterLibraryAssets</code></span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、首尾帧、产品融合、审核”五个页签组织;左侧只放主图/框选画布,但主体资产页左侧改为全部已清洗/已选参考帧网格,首尾帧页左侧显示全部关键帧并可勾选人物/机位参考。主体识别页会显示透明骨架人目标和 Vision 验收分数。清洗页右侧支持一键清洗未处理帧、单张替换清洗版和一键替换全部待应用清洗版;批量替换顺序调用 <code>applyCleanedFrame</code>,不新增后端接口。产品融合页左侧是纵向 6 行镜头工作表:顶部选择 5 个内置透明骨架人角色之一,并常驻显示桌面四张真实 SKG 产品角度图;每行只显示已预填场景/产品使用/享受描述、秒数、生成按钮和可横向追加的视频历史结果,产品图和结果视频都支持鼠标停留放大预览。描述词内置 36 条镜头语言模板,按“建立出场、产品入画、佩戴贴合、使用感受、生活延展、收尾记忆”排列,并且会按角色自动改写场景气质、使用动作和享受状态。每行还内置角色参考图调度:例如正面/半身用于出场,侧面/背部特写用于佩戴贴合,半身/背部特写用于收尾产品记忆点。点击“换一组”只刷新 6 行描述词。四张桌面 SKG 产品图是真实产品真源,所选角色 7 张参考图是人物身份参考,生成时分别通过 <code>copyProductLibraryAsset</code><code>copyCharacterLibraryAssets</code> 自动写入当前 job;视频 prompt 要求产品作为外置刚性实物合成到后颈外侧,禁止穿模、融进透明身体或重绘产品。不再暴露产品角度槽、产品融合辅助栏、产品图库选择器或首尾帧槽。主体资产页只确认一个统一主体,后端按参考重绘六张纯背景、占满画面的标准站立透明骨架人资产图;首尾帧页保留给旧流程/单独生图,不再是产品融合必填步骤。相关接口包括 <code>cleanupFrame</code><code>applyCleanedFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code><code>copyProductLibraryAsset</code><code>copyCharacterLibraryAssets</code></span></div>
<div><strong>适合怎么描述</strong><span>“这一组关键帧如何共同生成一个统一主体包;某张关键帧的水印、去主体场景图、产品融合镜头组和质量风险应该如何审核”。</span></div>
</div>
<div class="flow-row">
@@ -748,7 +748,7 @@ SubjectAsset {
</div>
<div class="card">
<h3>ProductFusionShot</h3>
<p>产品融合镜头组的单行数据。每个关键帧最多 6 行,用户选择一个内置角色后只微调场景/产品使用/享受描述和秒数;四张桌面 SKG 产品角度图所选角色 7 张参考图固定隐藏填充,生成时作为 Seedance 参考图提交。</p>
<p>产品融合镜头组的单行数据。每个关键帧最多 6 行,用户选择一个内置角色后只微调场景/产品使用/享受描述和秒数;四张桌面 SKG 产品角度图会在顶部显式展示为真实产品真源,所选角色 7 张参考图作为人物身份参考,生成时作为 Seedance 参考图提交。</p>
<pre>ProductFusionShot {
id,
first_image,
@@ -919,6 +919,20 @@ SubjectAsset {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 产品融合按真实产品外置合成</h3>
<span class="tag orange">产品融合</span>
<span class="tag blue">Video Gen</span>
</header>
<div class="body">
<p><strong>问题:</strong>Seedance 生成时可能把颈部按摩仪当成可变形装饰物,导致产品样式变化、穿进透明身体或与骨架融合。</p>
<p><strong>改动:</strong>产品融合 prompt 改为英文硬约束:四张 SKG 产品图是真实产品照片和唯一实物真源,生成时按外置刚性设备做 product placement / visual compositing产品必须停留在透明皮肤外侧后颈外侧承托、两端沿左右颈侧向前保留遮挡、接触阴影、透视和真实比例禁止 x-ray blending、穿模、融进骨架或重绘成其他颈带。</p>
<p><strong>界面:</strong>产品融合顶部常驻显示桌面四张真实产品角度图,并支持鼠标停留放大查看。每行视频结果不再只显示最新一个,而是按历史持续追加横向结果条,鼠标停留任一结果会放大预览。</p>
<p><strong>后端:</strong>生视频参考图顺序调整为主参考图之后优先传四张产品图,再传其余人物角色图,提高真实产品外观权重。</p>
<p><strong>影响:</strong><code>web/app/page.tsx</code><code>web/components/lightbox.tsx</code><code>api/main.py</code><code>docs/source-analysis.html</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-14 · 修正 SKG 豆包视频网关路径</h3>

View File

@@ -64,7 +64,9 @@ const PRODUCT_FUSION_WEARING_PROMPT = [
const PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT = [
"Product identity is strict:",
"The four SKG product reference images are real product photographs, not concept art and not style inspiration. Treat them as the immutable physical object to insert into the shot.",
"The four SKG product reference images are the single source of truth for the object. Preserve the same white U-shaped body, rounded arms, inner massage pads/nodes, side buttons, seams, glossy plastic material, thickness, proportions, and viewing angles.",
"Use visual compositing behavior: place the real product object onto the character externally, then match lighting, shadow, scale, and perspective around it. Do not redraw a new product from memory.",
"Do not redesign, stylize, simplify, melt, inflate, shrink, recolor, add logos/text/screens/wires/extra parts, or turn it into a generic neckband/headphone/medical brace.",
"If the product and character conflict, prioritize preserving the product shape and place it externally on the neck rather than merging it into the character.",
].join("\n")

View File

@@ -118,12 +118,25 @@ const DESKTOP_PRODUCT_ANGLE_IDS = [
]
const DEFAULT_CHARACTER_ID = "character-01"
const DEFAULT_CHARACTER_NAME = "运动阳光男"
type FusionPreviewAnchor = { kind: "product" | "video"; id: string; x: number; y: number }
type FusionUploadTarget = {
shotIndex: number
slot: "first_image" | "last_image"
}
type FusionFrameRole = "first_image" | "last_image"
const FUSION_PROMPT_MARKER_PREFIX = "产品融合镜头ID"
function lightboxPreviewAnchor(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return { x: 320, y: 0 }
const rootRect = root.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
if (rootRect.width <= 0 || rootRect.height <= 0) return { x: root.clientWidth / 2, y: 0 }
return {
x: targetRect.left + targetRect.width / 2 - rootRect.left,
y: targetRect.top - rootRect.top,
}
}
const LEGACY_PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
"现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
@@ -208,12 +221,12 @@ const FUSION_STAGE_PLANS = [
},
{
refs: "侧面 + 背部特写 + 产品侧面/背面",
product: "SKG 准确落到脖子、后颈、颈肩交界处,双手扶两端微调贴合",
result: "重点解决佩戴位置、产品尺寸、透视和后颈贴合真实性",
product: "SKG 作为外置刚性实物准确落到后颈外侧,两端沿颈侧向前,双手扶两端微调接触角度",
result: "重点解决佩戴位置、产品真实尺寸、透视、遮挡关系和不穿模",
},
{
refs: "半身近景 + 侧面",
product: "产品已经稳定佩戴,人物轻按控制区或保持佩戴状态",
product: "产品已经稳定外置佩戴,人物轻按控制区或保持佩戴状态",
result: "表现闭眼、肩部下沉、呼吸放慢、舒适享受,但不做医疗疗效承诺",
},
{
@@ -223,7 +236,7 @@ const FUSION_STAGE_PLANS = [
},
{
refs: "半身近景 + 背部特写 + 产品主视角",
product: "收尾停在肩颈和产品清楚可辨的位置,手不遮挡产品关键轮廓",
product: "收尾停在肩颈和真实产品清楚可辨的位置,手不遮挡产品关键轮廓",
result: "形成广告记忆点:角色舒适、产品清楚、画面干净高级",
},
]
@@ -313,7 +326,8 @@ const fusionDescriptionForCharacter = (characterId: string, presetIndex: number)
`自主图像编排:本镜头主要参考角色图【${stagePlan.refs}】,场景生成【${scene}】。`,
`产品调度:${stagePlan.product}`,
`镜头目标:${stagePlan.result}`,
`产品使用:${profile.usage};产品只能在脖子、后颈、颈肩交界处使用,尺寸必须符合真实颈部按摩仪比例`,
`真实产品合成:四张 SKG 产品图是真实实物真源,只能作为外置刚性设备佩戴在透明身体外侧;后颈外侧承托,两端沿左右颈侧向前,不能穿进透明皮肤、骨架、喉咙或肩部`,
`产品使用:${profile.usage};产品只能在脖子、后颈、颈肩交界处外置使用,尺寸必须符合真实颈部按摩仪比例。`,
`享受状态:${profile.enjoyment};不要医疗治疗承诺,不要恐怖解剖感。`,
].join("\n")
}
@@ -390,7 +404,9 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionPresetPage, setFusionPresetPage] = useState(0)
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
const [productLibrary, setProductLibrary] = useState<ProductLibraryItem[]>([])
const [selectedCharacterId, setSelectedCharacterId] = useState(DEFAULT_CHARACTER_ID)
const [fusionHoverPreview, setFusionHoverPreview] = useState<FusionPreviewAnchor | null>(null)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
id: string
@@ -406,6 +422,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
const [draftRegion, setDraftRegion] = useState<Region | null>(null) // 当前正在拖的
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const imgWrapRef = useRef<HTMLDivElement>(null)
const fusionPreviewRootRef = useRef<HTMLDivElement>(null)
const loadedFusionKey = useRef("")
const activeIndexRef = useRef<number | null>(activeIndex)
useEffect(() => setMounted(true), [])
@@ -423,6 +440,18 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
listProductLibrary()
.then((items) => {
if (!cancelled) setProductLibrary(items)
})
.catch((e) => {
if (!cancelled) toast.error("产品图读取失败:" + (e instanceof Error ? e.message : String(e)))
})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (activeIndex === null) {
loadedFusionKey.current = ""
@@ -542,6 +571,9 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
].filter(Boolean).join("\n")
const fusionReadyCount = fusionShots.filter((shot) => shot.action_text?.trim()).length
const selectedCharacter = characterLibrary.find((item) => item.id === selectedCharacterId) ?? characterLibrary[0]
const fixedProductItems = DESKTOP_PRODUCT_ANGLE_IDS
.map((id) => productLibrary.find((item) => item.id === id))
.filter((item): item is ProductLibraryItem => Boolean(item))
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
setFusionSaving(true)
@@ -1193,7 +1225,46 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
</div>
</section>
) : isProductTab ? (
<section className="rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5">
<section ref={fusionPreviewRootRef} className="relative overflow-visible rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5">
{(() => {
if (!fusionHoverPreview) return null
if (fusionHoverPreview.kind === "product") {
const item = fixedProductItems.find((product) => product.id === fusionHoverPreview.id)
if (!item) return null
return (
<HoverPreview
imgSrc={apiAssetUrl(item.url)}
aspect="1/1"
label={`真实产品图 ${item.image_index}`}
caption={`${item.width}×${item.height}`}
borderClass="border-amber-300/70"
visible
anchorX={fusionHoverPreview.x}
anchorY={fusionHoverPreview.y}
/>
)
}
const item = generatedVideos.find((video) => video.id === fusionHoverPreview.id)
if (!item) return null
const videoSrc = apiAssetUrl(item.url)
const posterSrc = apiAssetUrl(item.poster_url)
const ready = item.status === "completed" && !!videoSrc
if (!ready && !posterSrc) return null
return (
<HoverPreview
videoSrc={ready ? videoSrc : undefined}
imgSrc={!ready ? posterSrc : undefined}
poster={posterSrc}
aspect="9/16"
label={`产品融合 · 分镜 ${item.frame_idx + 1}`}
caption={item.status === "completed" ? `${item.duration.toFixed(0)}s` : `${item.status} · ${item.progress ?? 0}%`}
borderClass={ready ? "border-emerald-300/70" : "border-amber-300/70"}
visible
anchorX={fusionHoverPreview.x}
anchorY={fusionHoverPreview.y}
/>
)
})()}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<div className="flex items-center gap-1.5">
@@ -1221,86 +1292,141 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
</button>
</div>
</div>
<div className="mb-2 grid grid-cols-[minmax(220px,300px)_1fr] gap-2 rounded-md border border-white/10 bg-black/25 p-2">
<label className="block">
<div className="mb-1 text-[9px] text-white/38"></div>
<select
value={selectedCharacterId}
onChange={(e) => selectFusionCharacter(e.target.value)}
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white/80 outline-none focus:border-amber-300/45"
>
{(characterLibrary.length ? characterLibrary : [{ id: DEFAULT_CHARACTER_ID, name: DEFAULT_CHARACTER_NAME } as CharacterLibraryItem]).map((character) => (
<option key={character.id} value={character.id}>{character.name}</option>
))}
</select>
</label>
<div className="mb-2 grid grid-cols-[minmax(260px,1fr)_minmax(390px,520px)] gap-2 rounded-md border border-white/10 bg-black/25 p-2">
<div className="min-w-0">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"></span>
<span className="text-[8.5px] text-white/30"> 4 · </span>
<label className="block">
<div className="mb-1 text-[9px] text-white/38"></div>
<select
value={selectedCharacterId}
onChange={(e) => selectFusionCharacter(e.target.value)}
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white/80 outline-none focus:border-amber-300/45"
>
{(characterLibrary.length ? characterLibrary : [{ id: DEFAULT_CHARACTER_ID, name: DEFAULT_CHARACTER_NAME } as CharacterLibraryItem]).map((character) => (
<option key={character.id} value={character.id}>{character.name}</option>
))}
</select>
</label>
<div className="mt-2">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"></span>
<span className="text-[8.5px] text-white/30"></span>
</div>
<div className="flex gap-1.5 overflow-hidden">
{(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
<div key={image.id} className="h-12 w-10 overflow-hidden rounded border border-white/10 bg-black/35">
<img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
))}
{!selectedCharacter?.images?.length && (
<div className="flex h-12 items-center rounded border border-dashed border-white/10 px-2 text-[9.5px] text-white/32">
</div>
)}
</div>
</div>
<div className="flex gap-1.5 overflow-hidden">
{(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
<div key={image.id} className="h-12 w-10 overflow-hidden rounded border border-white/10 bg-black/35">
<img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" draggable={false} />
</div>
<div className="min-w-0 rounded-md border border-amber-300/25 bg-amber-300/[0.08] p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-amber-50"> · 4 </span>
<span className="text-[8.5px] text-amber-100/45"></span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{fixedProductItems.length > 0 ? fixedProductItems.map((item, index) => (
<div
key={item.id}
className="group relative overflow-visible rounded-md border border-white/15 bg-white shadow-[0_8px_24px_rgba(0,0,0,0.22)]"
onMouseEnter={(e) => setFusionHoverPreview({ kind: "product", id: item.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
onMouseLeave={() => setFusionHoverPreview(null)}
title={`真实产品角度 ${index + 1}`}
>
<div className="relative aspect-square">
<img src={apiAssetUrl(item.url)} alt={`真实产品角度 ${index + 1}`} className="absolute inset-0 h-full w-full object-contain p-1.5" draggable={false} />
<div className="absolute left-1 top-1 rounded bg-black/75 px-1 py-0.5 text-[8px] font-mono text-white">
P{index + 1}
</div>
</div>
<div className="border-t border-black/8 bg-black/5 px-1 py-0.5 text-center text-[8px] font-mono text-black/55">
{item.width}×{item.height}
</div>
</div>
)) : DESKTOP_PRODUCT_ANGLE_IDS.map((id, index) => (
<div key={id} className="flex aspect-square items-center justify-center rounded-md border border-dashed border-white/15 bg-black/25 text-[9px] text-white/30">
P{index + 1}
</div>
))}
{!selectedCharacter?.images?.length && (
<div className="flex h-12 items-center rounded border border-dashed border-white/10 px-2 text-[9.5px] text-white/32">
</div>
)}
</div>
</div>
</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">
/使
</div>
<div className="space-y-2">
{fusionShots.map((shot, i) => {
const active = i === activeFusionShot
const shotMarker = `${FUSION_PROMPT_MARKER_PREFIX}${shot.id}`
const shotVideos = generatedVideos.filter((video) => video.frame_idx === f.index && video.prompt.includes(shotMarker))
const latestShotVideo = shotVideos[0]
const latestVideoUrl = latestShotVideo?.url ? apiAssetUrl(latestShotVideo.url) : ""
const shotVideos = generatedVideos
.filter((video) => video.frame_idx === f.index && video.prompt.includes(shotMarker))
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
const ready = !!shot.action_text?.trim()
const busy = fusionGenerating === i || fusionGenerating === "all"
const lensStageLabel = PRODUCT_FUSION_LENS_STAGES[i] ?? `镜头 ${i + 1}`
const resultPanel = latestShotVideo ? (
<div className="overflow-hidden rounded-md border border-white/10 bg-black/30">
<div className="relative aspect-video bg-black">
{latestShotVideo.status === "completed" && latestVideoUrl ? (
<video src={latestVideoUrl} controls muted playsInline preload="metadata" className="h-full w-full object-contain" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 px-2 text-center text-[9.5px] text-white/42">
{latestShotVideo.status === "failed" ? (
<>
<X className="h-4 w-4 text-rose-300" />
<span className="text-rose-100/75"></span>
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin text-amber-200/80" />
<span>{latestShotVideo.status === "queued" ? "排队中" : "生成中"} · {latestShotVideo.progress ?? 0}%</span>
</>
)}
</div>
)}
const resultPanel = shotVideos.length > 0 ? (
<div className="rounded-md border border-white/10 bg-black/30 p-1.5">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[8.5px] text-white/42"></span>
<span className="rounded bg-white/8 px-1 py-0.5 text-[8px] font-mono text-white/45">{shotVideos.length}</span>
</div>
<div className="flex items-center justify-between gap-1 border-t border-white/10 px-1.5 py-1">
<span className="truncate text-[8.5px] text-white/42">
{latestShotVideo.status === "completed" ? "已完成" : latestShotVideo.status}
</span>
{onDeleteVideo && (
<button
type="button"
onClick={() => onDeleteVideo(latestShotVideo.id)}
className="inline-flex h-5 w-5 items-center justify-center rounded bg-white/10 text-white/55 hover:bg-rose-500/70 hover:text-white"
title="删除此视频"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
<div className="flex max-w-full gap-1.5 overflow-x-auto pb-1">
{shotVideos.map((video, videoIndex) => {
const videoUrl = video.url ? apiAssetUrl(video.url) : ""
const posterUrl = video.poster_url ? apiAssetUrl(video.poster_url) : ""
const completed = video.status === "completed" && !!videoUrl
return (
<div
key={video.id}
className={`group relative h-[124px] w-[74px] shrink-0 overflow-hidden rounded-md border bg-black ${
completed ? "border-emerald-300/55" : video.status === "failed" ? "border-rose-300/65" : "border-amber-300/55"
}`}
onMouseEnter={(e) => setFusionHoverPreview({ kind: "video", id: video.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
onMouseLeave={() => setFusionHoverPreview(null)}
title="鼠标停留放大预览"
>
{completed ? (
<video src={videoUrl} muted loop playsInline preload="metadata" className="h-full w-full object-cover" />
) : posterUrl ? (
<img src={posterUrl} alt="" className="h-full w-full object-cover opacity-75" draggable={false} />
) : (
<div className="flex h-full items-center justify-center bg-black/40">
{video.status === "failed" ? <X className="h-4 w-4 text-rose-200" /> : <Loader2 className="h-4 w-4 animate-spin text-amber-200" />}
</div>
)}
{!completed && posterUrl && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
{video.status === "failed" ? <X className="h-4 w-4 text-rose-200" /> : <Loader2 className="h-4 w-4 animate-spin text-amber-200" />}
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/92 to-transparent px-1 py-1">
<div className="truncate text-[8.5px] font-semibold text-white">#{shotVideos.length - videoIndex}</div>
<div className="truncate text-[7.5px] font-mono text-white/65">
{completed ? `${video.duration.toFixed(0)}s` : video.status === "failed" ? "failed" : `${video.progress ?? 0}%`}
</div>
</div>
{onDeleteVideo && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteVideo(video.id)
}}
className="absolute right-1 top-1 inline-flex h-5 w-5 items-center justify-center rounded-full bg-black/65 text-white/65 opacity-0 transition hover:bg-rose-500 hover:text-white group-hover:opacity-100"
title="删除此视频"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
)
})}
</div>
</div>
) : (
@@ -1317,7 +1443,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
: "border-white/10 bg-black/20 hover:border-amber-300/35"
}`}
>
<div className="grid grid-cols-[34px_minmax(360px,1fr)_78px_190px] items-start gap-2">
<div className="grid grid-cols-[34px_minmax(340px,1fr)_78px_minmax(240px,300px)] items-start gap-2">
<div className="flex flex-col items-center gap-1 pt-1">
<button
type="button"