diff --git a/.memory/worklog.json b/.memory/worklog.json
index 390f4c3..76315f1 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -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
}
]
}
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 2f2b461..a9220a8 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -629,7 +629,7 @@ api/main.py
ProductFusionShot
-
产品融合镜头组的单行数据。每个关键帧最多 6 行,用户选择一个内置角色后只微调场景/产品使用/享受描述和秒数;四张桌面 SKG 产品角度图与所选角色 7 张参考图固定隐藏填充,生成时作为 Seedance 参考图提交。
+
产品融合镜头组的单行数据。每个关键帧最多 6 行,用户选择一个内置角色后只微调场景/产品使用/享受描述和秒数;四张桌面 SKG 产品角度图会在顶部显式展示为真实产品真源,所选角色 7 张参考图作为人物身份参考,生成时作为 Seedance 参考图提交。
ProductFusionShot {
id,
first_image,
@@ -919,6 +919,20 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-14 · 产品融合按真实产品外置合成
+ 产品融合
+ Video Gen
+
+
+
问题:Seedance 生成时可能把颈部按摩仪当成可变形装饰物,导致产品样式变化、穿进透明身体或与骨架融合。
+
改动:产品融合 prompt 改为英文硬约束:四张 SKG 产品图是真实产品照片和唯一实物真源,生成时按外置刚性设备做 product placement / visual compositing;产品必须停留在透明皮肤外侧,后颈外侧承托、两端沿左右颈侧向前,保留遮挡、接触阴影、透视和真实比例,禁止 x-ray blending、穿模、融进骨架或重绘成其他颈带。
+
界面:产品融合顶部常驻显示桌面四张真实产品角度图,并支持鼠标停留放大查看。每行视频结果不再只显示最新一个,而是按历史持续追加横向结果条,鼠标停留任一结果会放大预览。
+
后端:生视频参考图顺序调整为主参考图之后优先传四张产品图,再传其余人物角色图,提高真实产品外观权重。
+
影响:web/app/page.tsx、web/components/lightbox.tsx、api/main.py、docs/source-analysis.html。
+
+
2026-05-14 · 修正 SKG 豆包视频网关路径
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 1cece28..96d6ba8 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -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")
diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx
index 9342de5..2f1b390 100644
--- a/web/components/lightbox.tsx
+++ b/web/components/lightbox.tsx
@@ -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([])
+ const [productLibrary, setProductLibrary] = useState([])
const [selectedCharacterId, setSelectedCharacterId] = useState(DEFAULT_CHARACTER_ID)
+ const [fusionHoverPreview, setFusionHoverPreview] = useState(null)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
id: string
@@ -406,6 +422,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
const [draftRegion, setDraftRegion] = useState(null) // 当前正在拖的
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const imgWrapRef = useRef(null)
+ const fusionPreviewRootRef = useRef(null)
const loadedFusionKey = useRef("")
const activeIndexRef = useRef(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
) : isProductTab ? (
-
+
+ {(() => {
+ if (!fusionHoverPreview) return null
+ if (fusionHoverPreview.kind === "product") {
+ const item = fixedProductItems.find((product) => product.id === fusionHoverPreview.id)
+ if (!item) return null
+ return (
+
+ )
+ }
+ 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 (
+
+ )
+ })()}
产品融合镜头组
@@ -1221,86 +1292,141 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
-
-
+
-
-
角色参考
-
产品固定 4 图 · 无首尾帧
+
+
+
+ 角色参考
+ 人物身份参考
+
+
+ {(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
+
+
})
+
+ ))}
+ {!selectedCharacter?.images?.length && (
+
+ 角色库加载中
+
+ )}
+
-
- {(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
-
-
})
+
+
+
+ 真实产品真源 · 固定 4 图
+ 生成时按实物合成,不自由重绘
+
+
+ {fixedProductItems.length > 0 ? fixedProductItems.map((item, index) => (
+
setFusionHoverPreview({ kind: "product", id: item.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
+ onMouseLeave={() => setFusionHoverPreview(null)}
+ title={`真实产品角度 ${index + 1}`}
+ >
+
+
})
+
+ P{index + 1}
+
+
+
+ {item.width}×{item.height}
+
+
+ )) : DESKTOP_PRODUCT_ANGLE_IDS.map((id, index) => (
+
+ P{index + 1}
))}
- {!selectedCharacter?.images?.length && (
-
- 角色库加载中
-
- )}
- 角色和产品已内置;每行只写场景、产品如何在脖子/后颈使用,以及人物舒适享受的状态。
+ 四张产品图是真实产品真源;生成时按外置刚性实物佩戴到后颈,不把产品融进透明身体。每行可反复生成,结果会继续往后追加。
{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 ? (
-
-
- {latestShotVideo.status === "completed" && latestVideoUrl ? (
-
- ) : (
-
- {latestShotVideo.status === "failed" ? (
- <>
-
- 生成失败
- >
- ) : (
- <>
-
- {latestShotVideo.status === "queued" ? "排队中" : "生成中"} · {latestShotVideo.progress ?? 0}%
- >
- )}
-
- )}
+ const resultPanel = shotVideos.length > 0 ? (
+
+
+ 历史结果
+ {shotVideos.length}
-
-
- {latestShotVideo.status === "completed" ? "已完成" : latestShotVideo.status}
-
- {onDeleteVideo && (
-
- )}
+
+ {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 (
+
setFusionHoverPreview({ kind: "video", id: video.id, ...lightboxPreviewAnchor(fusionPreviewRootRef.current, e.currentTarget) })}
+ onMouseLeave={() => setFusionHoverPreview(null)}
+ title="鼠标停留放大预览"
+ >
+ {completed ? (
+
+ ) : posterUrl ? (
+

+ ) : (
+
+ {video.status === "failed" ? : }
+
+ )}
+ {!completed && posterUrl && (
+
+ {video.status === "failed" ? : }
+
+ )}
+
+
#{shotVideos.length - videoIndex}
+
+ {completed ? `${video.duration.toFixed(0)}s` : video.status === "failed" ? "failed" : `${video.progress ?? 0}%`}
+
+
+ {onDeleteVideo && (
+
+ )}
+
+ )
+ })}
) : (
@@ -1317,7 +1443,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
: "border-white/10 bg-black/20 hover:border-amber-300/35"
}`}
>
-