auto-save 2026-05-14 07:23 (~4)

This commit is contained in:
2026-05-14 07:23:13 +08:00
parent 76412d2395
commit a6773a8690
4 changed files with 456 additions and 25 deletions

View File

@@ -1,12 +1,12 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save } from "lucide-react"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save, Upload, Play } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SceneMode, type SceneStyle, type SubjectKind,
generateSceneAsset, generateSubjectAssets, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneMode, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { ProductLibraryPicker } from "@/components/product-library-picker"
import { toast } from "sonner"
@@ -22,6 +22,7 @@ interface Props {
onJobUpdate?: (job: Job) => void
onSwitchPanel?: (key: string) => void
onCopyImage?: (ref: ImageRef) => void
onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise<void> | void
embedded?: boolean
}
@@ -107,7 +108,30 @@ const SCENE_REFERENCE_OPTIONS = [
["social media realism", "真实生活感"],
]
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, embedded = false }: Props) {
const FUSION_SHOT_COUNT = 6
const FUSION_DURATIONS = [4, 5, 6, 8, 10, 12, 15]
const createFusionShots = (): ProductFusionShot[] =>
Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({
id: `shot-${i + 1}`,
product_image: null,
person_image: null,
product_region: null,
scene_image: null,
action_text: "",
duration: 5,
image_model: "gpt-image-2",
video_model: "seedance",
guide_image: null,
}))
const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusionShot[] => {
const base = createFusionShots()
if (!shots?.length) return base
return base.map((item, i) => ({ ...item, ...(shots[i] ?? {}), id: shots[i]?.id || item.id }))
}
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, onGenerateProductFusionVideo, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaningFrameIds, setCleaningFrameIds] = useState<Set<number>>(new Set())
const [batchCleaning, setBatchCleaning] = useState(false)
@@ -126,6 +150,13 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [subjectBackgrounds, setSubjectBackgrounds] = useState<Record<string, AssetBackground>>({})
const [subjectViews, setSubjectViews] = useState<Record<string, string[]>>({})
const [activeTab, setActiveTab] = useState<LightboxTab>("clean")
const [fusionShots, setFusionShots] = useState<ProductFusionShot[]>(() => createFusionShots())
const [activeFusionShot, setActiveFusionShot] = useState(0)
const [fusionUploadTarget, setFusionUploadTarget] = useState<"product_image" | "person_image" | "scene_image" | null>(null)
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionDraftRegion, setFusionDraftRegion] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [fusionDragStart, setFusionDragStart] = useState<{ x: number; y: number } | null>(null)
const [editingElement, setEditingElement] = useState<{
frameIndex: number
id: string
@@ -141,10 +172,28 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [draftRegion, setDraftRegion] = useState<Region | null>(null) // 当前正在拖的
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const imgWrapRef = useRef<HTMLDivElement>(null)
const fusionPersonWrapRef = useRef<HTMLDivElement>(null)
const fusionFileInputRef = useRef<HTMLInputElement | null>(null)
const loadedFusionKey = useRef("")
const activeIndexRef = useRef<number | null>(activeIndex)
useEffect(() => setMounted(true), [])
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
useEffect(() => {
if (activeIndex === null) {
loadedFusionKey.current = ""
setFusionShots(createFusionShots())
setActiveFusionShot(0)
return
}
const key = `${jobId}:${activeIndex}`
if (loadedFusionKey.current === key) return
const frame = frames.find((x) => x.index === activeIndex)
setFusionShots(normalizeFusionShots(frame?.storyboard?.product_fusion_shots as ProductFusionShot[] | undefined))
setActiveFusionShot(0)
loadedFusionKey.current = key
}, [activeIndex, frames, jobId])
// 切换分镜时清空选区
useEffect(() => {
setCropMode(false)
@@ -244,6 +293,149 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
sceneExtraKeywords.trim() ? `额外关键词:${sceneExtraKeywords.trim()}` : "",
"要求:无主体、无人物动物产品、无文字水印,保持可用于后续视频生成的干净背景板。",
].filter(Boolean).join("\n")
const currentFusionShot = fusionShots[activeFusionShot] ?? fusionShots[0]
const currentFusionProductUrl = currentFusionShot?.product_image ? resolveImageRefUrl(jobId, currentFusionShot.product_image) : ""
const currentFusionPersonUrl = currentFusionShot?.person_image ? resolveImageRefUrl(jobId, currentFusionShot.person_image) : ""
const currentFusionSceneUrl = currentFusionShot?.scene_image ? resolveImageRefUrl(jobId, currentFusionShot.scene_image) : ""
const fusionReadyCount = fusionShots.filter((shot) => shot.product_image && shot.person_image && shot.product_region && shot.scene_image && shot.action_text?.trim()).length
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
setFusionSaving(true)
try {
const updated = await updateStoryboard(jobId, f.index, {
...(f.storyboard ?? { duration: 0 }),
product_fusion_shots: nextShots,
})
onJobUpdate?.(updated)
} catch (e) {
toast.error("产品融合镜头保存失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setFusionSaving(false)
}
}
const updateFusionShot = (index: number, patch: Partial<ProductFusionShot>, persist = false) => {
const next = fusionShots.map((shot, i) => (i === index ? { ...shot, ...patch } : shot))
setFusionShots(next)
if (persist) void persistFusionShots(next)
}
const assignFusionImage = (slot: "product_image" | "person_image" | "scene_image", ref: ImageRef, index = activeFusionShot) => {
updateFusionShot(index, { [slot]: ref, guide_image: null }, true)
}
const uploadFusionFiles = async (files: FileList | File[]) => {
if (!fusionUploadTarget) return
const file = Array.from(files).find((item) => item.type.startsWith("image/"))
if (!file) {
toast.error("请上传图片文件")
return
}
try {
const ref = await uploadStoryboardAsset(jobId, file)
assignFusionImage(fusionUploadTarget, ref)
toast.success("已加入当前融合镜头")
} catch (e) {
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setFusionUploadTarget(null)
}
}
const openFusionUpload = (slot: "product_image" | "person_image" | "scene_image") => {
setFusionUploadTarget(slot)
fusionFileInputRef.current?.click()
}
const draftFusionDescriptions = () => {
const actions = [
"人物双手拿起 SKG 颈部按摩仪,准备戴到脖子上,镜头轻微推近产品。",
"人物把 SKG 按摩仪贴合到肩颈位置,手部轻轻调整两侧机身角度。",
"人物坐在场景中轻按侧边控制区,产品保持在画框指定区域内清晰可见。",
"人物闭眼放松,肩颈从紧绷变舒展,产品佩戴位置稳定不漂移。",
"镜头靠近展示 SKG 产品材质、按键和内侧触点,手部不要遮挡产品主体。",
"使用后的放松状态收尾,人物自然抬头,产品仍保持白色 U 形外观和真实比例。",
]
const next = fusionShots.map((shot, i) => ({
...shot,
action_text: shot.action_text?.trim() || actions[i],
}))
setFusionShots(next)
void persistFusionShots(next)
toast.success("已生成 6 条动作描述草稿,可继续手工修改")
}
const fusionPointerPosition = (ev: React.MouseEvent<HTMLDivElement>) => {
const rect = fusionPersonWrapRef.current?.getBoundingClientRect()
if (!rect || rect.width <= 0 || rect.height <= 0) return null
return {
x: Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)),
y: Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height)),
}
}
const onFusionRegionDown = (ev: React.MouseEvent<HTMLDivElement>) => {
if (activeTab !== "product" || !currentFusionPersonUrl) return
ev.preventDefault()
const p = fusionPointerPosition(ev)
if (!p) return
setFusionDragStart(p)
setFusionDraftRegion({ x: p.x, y: p.y, w: 0, h: 0 })
}
const onFusionRegionMove = (ev: React.MouseEvent<HTMLDivElement>) => {
if (!fusionDragStart) return
const p = fusionPointerPosition(ev)
if (!p) return
setFusionDraftRegion({
x: Math.min(fusionDragStart.x, p.x),
y: Math.min(fusionDragStart.y, p.y),
w: Math.abs(p.x - fusionDragStart.x),
h: Math.abs(p.y - fusionDragStart.y),
})
}
const onFusionRegionUp = () => {
if (!fusionDraftRegion || !fusionDragStart) return
const region = fusionDraftRegion.w >= 0.02 && fusionDraftRegion.h >= 0.02 ? fusionDraftRegion : null
if (region) updateFusionShot(activeFusionShot, { product_region: region, guide_image: null }, true)
setFusionDraftRegion(null)
setFusionDragStart(null)
}
const runFusionVideo = async (index: number) => {
const shot = fusionShots[index]
if (!shot?.product_image || !shot.person_image || !shot.scene_image || !shot.product_region || !shot.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺产品图、人物图、区域、场景图或描述词`)
return
}
setFusionGenerating(index)
try {
await onGenerateProductFusionVideo?.(f.index, shot)
} finally {
setFusionGenerating(null)
}
}
const runAllFusionVideos = async () => {
const indexes = fusionShots
.map((shot, i) => ({ shot, i }))
.filter(({ shot }) => shot.product_image && shot.person_image && shot.scene_image && shot.product_region && shot.action_text?.trim())
.map(({ i }) => i)
if (indexes.length === 0) {
toast.error("还没有完整的融合镜头")
return
}
setFusionGenerating("all")
try {
for (const index of indexes) {
await onGenerateProductFusionVideo?.(f.index, fusionShots[index])
}
toast.success(`已提交 ${indexes.length} 条产品融合视频队列`)
} finally {
setFusionGenerating(null)
}
}
const handleDescribe = async () => {
setDescribing(true)
@@ -705,13 +897,124 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
</section>
) : isProductTab ? (
<ProductLibraryPicker
jobId={jobId}
buttonLabel="复制"
title="产品融合 · SKG 白底图库"
disabled={!onCopyImage}
onPick={(ref) => onCopyImage?.(ref)}
/>
<section
className="rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5"
onPaste={(e) => {
if (fusionUploadTarget && e.clipboardData.files?.length) void uploadFusionFiles(e.clipboardData.files)
}}
>
<input
ref={fusionFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files) void uploadFusionFiles(files)
e.currentTarget.value = ""
}}
/>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white"></div>
<span className="rounded bg-black/35 px-1.5 py-0.5 text-[9.5px] font-mono text-white/55">
{fusionReadyCount}/6
</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">
</div>
<div className="mb-3 space-y-1.5">
{fusionShots.map((shot, i) => {
const active = i === activeFusionShot
const ready = !!(shot.product_image && shot.person_image && shot.product_region && shot.scene_image && shot.action_text?.trim())
return (
<button
key={shot.id}
type="button"
onClick={() => setActiveFusionShot(i)}
className={`grid w-full grid-cols-[40px_1fr_1fr_54px] items-center gap-1.5 rounded-md border px-2 py-1.5 text-left transition ${
active
? "border-amber-300/70 bg-amber-500/18 text-white"
: "border-white/10 bg-black/22 text-white/55 hover:border-amber-300/35 hover:text-white"
}`}
>
<span className="font-mono text-[10px]">#{i + 1}</span>
<span className="truncate text-[10px]">{shot.product_image?.label || "产品图空"}</span>
<span className="truncate text-[10px]">{shot.scene_image?.label || "场景图空"}</span>
<span className={`rounded px-1 py-0.5 text-center text-[9px] ${ready ? "bg-emerald-400/80 text-black" : "bg-white/10 text-white/45"}`}>
{ready ? "就绪" : "待补"}
</span>
</button>
)
})}
</div>
<div className="grid grid-cols-3 gap-2">
{([
["product_image", "产品图", currentFusionProductUrl],
["person_image", "白底人物图", currentFusionPersonUrl],
["scene_image", "场景图", currentFusionSceneUrl],
] as const).map(([slot, label, url]) => (
<div key={slot} className="overflow-hidden rounded-md border border-white/10 bg-black/25">
<div className="relative bg-white" style={{ aspectRatio: "1/1" }}>
{url ? (
<img src={url} alt={label} className="absolute inset-0 h-full w-full object-contain" />
) : (
<button
type="button"
onClick={() => openFusionUpload(slot)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-[10px] text-black/35 hover:text-black/65"
>
<Upload className="h-4 w-4" />
/
</button>
)}
</div>
<div className="flex items-center justify-between gap-1 border-t border-white/10 px-1.5 py-1">
<span className="truncate text-[9.5px] text-white/55">{label}</span>
<button
type="button"
onClick={() => openFusionUpload(slot)}
className="rounded bg-white/10 px-1.5 py-0.5 text-[9px] text-white/70 hover:bg-white/18 hover:text-white"
>
</button>
</div>
</div>
))}
</div>
<div className="mt-3 rounded-lg border border-white/10 bg-black/30 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-white"> · </div>
<span className="text-[9px] text-white/35"></span>
</div>
<div
ref={fusionPersonWrapRef}
onMouseDown={onFusionRegionDown}
onMouseMove={onFusionRegionMove}
onMouseUp={onFusionRegionUp}
onMouseLeave={onFusionRegionUp}
className={`relative overflow-hidden rounded-md border border-white/10 bg-white ${currentFusionPersonUrl ? "cursor-crosshair" : ""}`}
>
{currentFusionPersonUrl ? (
<img src={currentFusionPersonUrl} alt="fusion person" className="block w-full select-none object-contain" draggable={false} />
) : (
<div className="flex h-64 items-center justify-center text-[11px] text-black/35"></div>
)}
{[currentFusionShot?.product_region, fusionDraftRegion].filter(Boolean).map((region, i) => region && (
<div
key={i}
className={`absolute pointer-events-none border-2 ${i === 0 ? "border-amber-300 bg-amber-300/10" : "border-dashed border-cyan-300"}`}
style={{
left: `${region.x * 100}%`,
top: `${region.y * 100}%`,
width: `${region.w * 100}%`,
height: `${region.h * 100}%`,
}}
/>
))}
</div>
</div>
</section>
) : (
<div
ref={imgWrapRef}