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