diff --git a/.memory/worklog.json b/.memory/worklog.json index 2998589..8e7c09c 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -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 } ] } diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 9cdaba4..d8b007d 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -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 activeIndex: number | null selected: Set onClose: () => void @@ -25,6 +25,7 @@ interface Props { clipboard?: ImageRef | null onCopyImage?: (ref: ImageRef) => void onGenerateProductFusionVideo?: (frameIdx: number, shot: ProductFusionShot) => Promise | 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>(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 />
产品融合镜头组
- - {fusionReadyCount}/6 可生成 - +
+ + {fusionReadyCount}/6 可生成 + + +
- 6 条视频镜头从上到下排列;每条使用文字描述 + 首帧 + 尾帧 + 同一产品 4 个角度图,作为 Seedance 垫图生成视频。 + 描述词已预填,产品固定使用桌面 4 张 SKG 角度图;这里只需要填每行的首帧和尾帧。
{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
) - const productAngleSlots = ( -
- {PRODUCT_ANGLE_LABELS.map((label, productIndex) => ( -
- {imageSlot( - { shotIndex: i, slot: "product_images", productIndex }, - label, - productUrls[productIndex], - productImages[productIndex], - true, - )} -
- ))} + const resultPanel = latestShotVideo ? ( +
+
+ {latestShotVideo.status === "completed" && latestVideoUrl ? ( +
+
+ + {latestShotVideo.status === "completed" ? "已完成" : latestShotVideo.status} + + {onDeleteVideo && ( + + )} +
+
+ ) : ( +
+ 生成后显示在这里
) 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" }`} > -
+
-
) @@ -1268,7 +1295,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{activeTab === "clean" && (