auto-save 2026-05-14 12:31 (~2)

This commit is contained in:
2026-05-14 12:32:00 +08:00
parent 9ac5f843be
commit 01ab67eb13
2 changed files with 112 additions and 84 deletions

View File

@@ -1,19 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "e56631f",
"message": "auto-save 2026-05-13 05:34 (~1)",
"ts": "2026-05-13T05:34:31+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "39b442d",
"message": "auto-save 2026-05-13 05:40 (~1)",
"ts": "2026-05-13T05:40:25+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "714db7d",
@@ -3290,6 +3276,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-14 12:20 (~4)",
"files_changed": 4
},
{
"ts": "2026-05-14T12:26:29+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 12:26 (~4)",
"hash": "9ac5f84",
"files_changed": 4
},
{
"ts": "2026-05-14T04:28:39Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-14 12:26 (~4)",
"files_changed": 2
}
]
}

View File

@@ -8,13 +8,13 @@ import {
generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, copyProductLibraryAsset,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneAssetRole, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { ProductLibraryPicker } from "@/components/product-library-picker"
import { TRANSPARENT_HUMAN_FRAME_STANDARD, TRANSPARENT_HUMAN_UI_SUMMARY } from "@/lib/workflow-target"
import { toast } from "sonner"
interface Props {
jobId: string
frames: KeyFrame[]
generatedVideos?: NonNullable<Job["generated_videos"]>
activeIndex: number | null
selected: Set<number>
onClose: () => void
@@ -25,6 +25,7 @@ interface Props {
clipboard?: ImageRef | null
onCopyImage?: (ref: ImageRef) => void
onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise<void> | void
onDeleteVideo?: (videoId: string) => void
embedded?: boolean
}
@@ -115,11 +116,10 @@ const DESKTOP_PRODUCT_ANGLE_IDS = [
]
type FusionUploadTarget = {
shotIndex: number
slot: "first_image" | "last_image" | "product_images"
productIndex?: number
slot: "first_image" | "last_image"
}
type FusionFrameRole = "first_image" | "last_image"
const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3", "产品角度 4"]
const FUSION_PROMPT_MARKER_PREFIX = "产品融合镜头ID"
const PRODUCT_FUSION_DESCRIPTION_PRESETS = [
"清晨卧室柔光里,透明骨架人把白色 SKG 颈部按摩仪轻戴到后颈,微微闭眼露出放松微笑。",
"现代客厅沙发旁,透明骨架人双手扶住 SKG 机身两侧,肩线慢慢放低,表情从紧绷变舒适。",
@@ -153,7 +153,7 @@ const createFusionShots = (): ProductFusionShot[] =>
person_image: null,
product_region: null,
scene_image: null,
action_text: "",
action_text: PRODUCT_FUSION_DESCRIPTION_PRESETS[i % PRODUCT_FUSION_DESCRIPTION_PRESETS.length],
duration: 5,
image_model: "gpt-image-2",
video_model: "seedance",
@@ -165,16 +165,17 @@ const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusion
if (!shots?.length) return base
return base.map((item, i) => {
const shot = shots[i] ?? {}
const productImages = shot.product_images?.length
? shot.product_images.slice(0, PRODUCT_ANGLE_COUNT)
: shot.product_image
? [shot.product_image]
: []
return { ...item, ...shot, product_images: productImages, id: shot.id || item.id }
return {
...item,
...shot,
product_images: shot.product_images?.slice(0, PRODUCT_ANGLE_COUNT) ?? [],
action_text: shot.action_text?.trim() || item.action_text,
id: shot.id || item.id,
}
})
}
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, clipboard, onCopyImage, onGenerateProductFusionVideo, embedded = false }: Props) {
export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, clipboard, onCopyImage, onGenerateProductFusionVideo, onDeleteVideo, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaningFrameIds, setCleaningFrameIds] = useState<Set<number>>(new Set())
const [batchCleaning, setBatchCleaning] = useState(false)
@@ -338,12 +339,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
"要求:单一透明骨架人清晰可见,人物占画面主体,首尾帧可连续生成视频;无文字、水印、平台 UI、恐怖解剖感。",
].filter(Boolean).join("\n")
const currentFusionShot = fusionShots[activeFusionShot] ?? fusionShots[0]
const currentFusionProducts = currentFusionShot?.product_images ?? []
const currentFusionProductCount = currentFusionProducts.filter(Boolean).length
const currentFusionFirstUrl = currentFusionShot?.first_image ? resolveImageRefUrl(jobId, currentFusionShot.first_image) : ""
const currentFusionLastUrl = currentFusionShot?.last_image ? resolveImageRefUrl(jobId, currentFusionShot.last_image) : ""
const fusionReadyCount = fusionShots.filter((shot) =>
shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= PRODUCT_ANGLE_COUNT && shot.action_text?.trim()
shot.first_image && shot.last_image && shot.action_text?.trim()
).length
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
@@ -371,14 +368,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const index = target.shotIndex
const current = fusionShots[index]
if (!current) return
if (target.slot === "product_images") {
const productImages = [...(current.product_images ?? [])].slice(0, PRODUCT_ANGLE_COUNT)
const inferredIndex = PRODUCT_ANGLE_LABELS.findIndex((_, idx) => !productImages[idx])
const safeIndex = Math.max(0, Math.min(PRODUCT_ANGLE_COUNT - 1, target.productIndex ?? (inferredIndex >= 0 ? inferredIndex : 0)))
productImages[safeIndex] = ref
updateFusionShot(index, { product_images: productImages, product_image: productImages[0] ?? null, guide_image: null }, true)
return
}
updateFusionShot(index, target.slot === "first_image"
? { first_image: ref, guide_image: null }
: { last_image: ref, guide_image: null }, true)
@@ -431,20 +420,25 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
toast.success(`已套用 6 条动作描述 · 模板 ${start + 1}-${Math.min(start + FUSION_SHOT_COUNT, descriptions.length)}`)
}
const fillDesktopProductAngles = async (scope: "current" | "all") => {
setFusionFillingProducts(scope)
const ensureFixedProductAngles = async (indexes: number[]) => {
setFusionFillingProducts("all")
try {
const refs = await Promise.all(DESKTOP_PRODUCT_ANGLE_IDS.map((id) => copyProductLibraryAsset(jobId, id)))
const reusableRefs = fusionShots.find((shot) => (shot.product_images ?? []).filter(Boolean).length >= PRODUCT_ANGLE_COUNT)
?.product_images?.slice(0, PRODUCT_ANGLE_COUNT)
const refs = reusableRefs?.length === PRODUCT_ANGLE_COUNT
? reusableRefs
: await Promise.all(DESKTOP_PRODUCT_ANGLE_IDS.map((id) => copyProductLibraryAsset(jobId, id)))
const next = fusionShots.map((shot, index) => (
scope === "all" || index === activeFusionShot
indexes.includes(index)
? { ...shot, product_images: refs, product_image: refs[0] ?? null, guide_image: null }
: shot
))
setFusionShots(next)
void persistFusionShots(next)
toast.success(scope === "all" ? "已把桌面 4 个产品角度填入 6 个镜头" : `已填入镜头 ${activeFusionShot + 1} 的 4 个产品角度`)
return next
} catch (e) {
toast.error("桌面产品角度填充失败:" + (e instanceof Error ? e.message : String(e)))
return null
} finally {
setFusionFillingProducts(null)
}
@@ -452,13 +446,15 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const runFusionVideo = async (index: number) => {
const shot = fusionShots[index]
if (!shot?.first_image || !shot.last_image || (shot.product_images ?? []).filter(Boolean).length < PRODUCT_ANGLE_COUNT || !shot.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺首帧尾帧、四张产品角度图或描述词`)
if (!shot?.first_image || !shot.last_image || !shot.action_text?.trim()) {
toast.error(`镜头 ${index + 1} 还缺首帧尾帧`)
return
}
setFusionGenerating(index)
try {
await onGenerateProductFusionVideo?.(f.index, shot)
const next = await ensureFixedProductAngles([index])
if (!next) return
await onGenerateProductFusionVideo?.(f.index, next[index])
} finally {
setFusionGenerating(null)
}
@@ -467,7 +463,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const runAllFusionVideos = async () => {
const indexes = fusionShots
.map((shot, i) => ({ shot, i }))
.filter(({ shot }) => shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= PRODUCT_ANGLE_COUNT && shot.action_text?.trim())
.filter(({ shot }) => shot.first_image && shot.last_image && shot.action_text?.trim())
.map(({ i }) => i)
if (indexes.length === 0) {
toast.error("还没有完整的融合镜头")
@@ -475,8 +471,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
setFusionGenerating("all")
try {
const next = await ensureFixedProductAngles(indexes)
if (!next) return
for (const index of indexes) {
await onGenerateProductFusionVideo?.(f.index, fusionShots[index])
await onGenerateProductFusionVideo?.(f.index, next[index])
}
toast.success(`已提交 ${indexes.length} 条产品融合视频队列`)
} finally {
@@ -869,7 +867,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
: isSceneTab
? { flex: "1 1 430px", minWidth: 280, maxWidth: 560, minHeight: 0 }
: isProductTab
? { flex: "1 1 760px", minWidth: 520, maxWidth: 980, minHeight: 0 }
? { flex: "1 1 100%", minWidth: 520, maxWidth: "none", minHeight: 0 }
: isCleanTab
? { flex: "1 1 500px", minWidth: 300, maxWidth: 600, minHeight: 0 }
: { flex: "1 1 560px", minWidth: 300, maxWidth: 680, minHeight: 0 }}
@@ -1016,23 +1014,34 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
/>
<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 className="flex items-center gap-1.5">
<span className="rounded bg-black/35 px-1.5 py-0.5 text-[9.5px] font-mono text-white/55">
{fusionReadyCount}/6
</span>
<button
type="button"
onClick={() => void runAllFusionVideos()}
disabled={!!fusionGenerating || !onGenerateProductFusionVideo}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-amber-500/70 px-2 text-[9.5px] font-medium text-white transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
>
{fusionGenerating === "all" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
</button>
</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">
6 使 + + + 4 Seedance
使 4 SKG
</div>
<div className="space-y-2">
{fusionShots.map((shot, i) => {
const active = i === activeFusionShot
const productImages = shot.product_images ?? []
const firstUrl = shot.first_image ? resolveImageRefUrl(jobId, shot.first_image) : ""
const lastUrl = shot.last_image ? resolveImageRefUrl(jobId, shot.last_image) : ""
const productUrls = PRODUCT_ANGLE_LABELS.map((_, productIndex) =>
productImages[productIndex] ? resolveImageRefUrl(jobId, productImages[productIndex]) : ""
)
const ready = !!(shot.first_image && shot.last_image && productImages.filter(Boolean).length >= PRODUCT_ANGLE_COUNT && shot.action_text?.trim())
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 ready = !!(shot.first_image && shot.last_image && shot.action_text?.trim())
const busy = fusionGenerating === i || fusionGenerating === "all"
const pasteIntoSlot = (target: FusionUploadTarget, label: string) => {
setActiveFusionShot(i)
@@ -1090,19 +1099,46 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
</div>
)
const productAngleSlots = (
<div className="grid grid-cols-4 gap-1.5">
{PRODUCT_ANGLE_LABELS.map((label, productIndex) => (
<div key={productIndex}>
{imageSlot(
{ shotIndex: i, slot: "product_images", productIndex },
label,
productUrls[productIndex],
productImages[productIndex],
true,
)}
</div>
))}
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>
)}
</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>
</div>
) : (
<div className="flex h-full min-h-[96px] items-center justify-center rounded-md border border-dashed border-white/10 bg-black/20 px-2 text-center text-[9.5px] text-white/32">
</div>
)
return (
@@ -1114,7 +1150,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
: "border-white/10 bg-black/20 hover:border-amber-300/35"
}`}
>
<div className="grid grid-cols-[34px_86px_86px_304px_minmax(150px,1fr)_78px] items-start gap-2">
<div className="grid grid-cols-[34px_92px_92px_minmax(220px,1fr)_78px_190px] items-start gap-2">
<div className="flex flex-col items-center gap-1 pt-1">
<button
type="button"
@@ -1137,16 +1173,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{imageSlot({ shotIndex: i, slot: "last_image" }, "尾帧", lastUrl, shot.last_image)}
<div>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
<span className={`text-[8.5px] ${productImages.filter(Boolean).length >= PRODUCT_ANGLE_COUNT ? "text-emerald-200/70" : "text-white/30"}`}>
{productImages.filter(Boolean).length}/{PRODUCT_ANGLE_COUNT}
</span>
</div>
{productAngleSlots}
</div>
<label className="block">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
@@ -1201,6 +1227,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{active ? "当前" : "编辑"}
</button>
</div>
{resultPanel}
</div>
</div>
)
@@ -1268,7 +1295,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div
className="flex flex-col gap-2.5 overflow-y-auto min-h-0"
style={isSubjectTab || isSceneTab || isProductTab
? { flex: "0 0 360px", width: 360, minWidth: 320 }
? isProductTab
? { display: "none" }
: { flex: "0 0 360px", width: 360, minWidth: 320 }
: { flex: "0 0 320px", width: 320, minWidth: 280, maxWidth: 340 }}
>
{activeTab === "clean" && (