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

@@ -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"