auto-save 2026-05-14 13:49 (~4)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user