auto-save 2026-05-14 13:10 (~5)
This commit is contained in:
@@ -521,8 +521,10 @@ export default function Home() {
|
||||
const frame = job.frames.find((f) => f.index === frameIdx)
|
||||
if (!frame) return
|
||||
const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 4) as ImageRef[]
|
||||
if (!shot.first_image || !shot.last_image || productRefs.length < 4 || !shot.action_text?.trim()) {
|
||||
toast.error("产品融合镜头缺少首帧、尾帧、固定产品图或描述词")
|
||||
const subjectRefs = (shot.subject_images ?? []).filter(Boolean).slice(0, 7) as ImageRef[]
|
||||
const primarySubject = shot.subject_image ?? subjectRefs[0] ?? null
|
||||
if (!primarySubject || subjectRefs.length < 1 || productRefs.length < 4 || !shot.action_text?.trim()) {
|
||||
toast.error("产品融合镜头缺少内置角色、固定产品图或描述词")
|
||||
return
|
||||
}
|
||||
const duration = shot.duration && shot.duration > 0 ? shot.duration : 5
|
||||
@@ -530,22 +532,21 @@ export default function Home() {
|
||||
try {
|
||||
const prompt = [
|
||||
`产品融合镜头ID:${shot.id || `shot-${frameIdx + 1}`}`,
|
||||
`竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 图生视频。`,
|
||||
"图片模型固定为 GPT Image 2:首帧和尾帧已经由文字生图生成,用来锁定透明骨架人角色、场景构图和动作起止状态。",
|
||||
"视频模型固定为 Seedance:使用首帧作为起始画面、尾帧作为结束画面,并用四张同一 SKG 产品不同角度白底图作为垫图/产品身份参考。",
|
||||
`首帧:${labelOf(shot.first_image, "透明骨架人首帧")}。起始人物形象、姿态、构图和场景氛围以这张图为准。`,
|
||||
`尾帧:${labelOf(shot.last_image, "透明骨架人尾帧")}。结束人物状态、画面落点和场景延续以这张图为准。`,
|
||||
`竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 参考图生视频。`,
|
||||
"没有首帧和尾帧:请根据内置人物角色参考图、固定 SKG 产品图、场景/使用/享受描述直接生成完整视频。",
|
||||
`人物角色:${shot.character_name || "透明骨架人"}。必须保持同一透明/半透明人体外壳、干净白色骨架、体型比例、服装风格和非恐怖广告气质。`,
|
||||
`人物参考图:${subjectRefs.map((ref, index) => `角色图${index + 1}=${labelOf(ref, "透明骨架人参考")}`).join(";")}。`,
|
||||
`产品角度图 1:${labelOf(productRefs[0], "SKG 产品正面/主视角")}。`,
|
||||
`产品角度图 2:${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}。`,
|
||||
`产品角度图 3:${labelOf(productRefs[2], "SKG 产品背面/细节视角")}。`,
|
||||
`产品角度图 4:${labelOf(productRefs[3], "SKG 产品补充/底部或佩戴视角")}。`,
|
||||
"产品使用部位:这是颈部/肩颈按摩仪,只能自然佩戴或贴合在脖子、后颈、颈肩交界处;不要放到手臂、腰、腿、胸口、眼部或背景里。",
|
||||
"比例尺寸:产品应符合真实颈部按摩仪大小,U 形结构环绕后颈但不能巨大化、缩小成饰品、嵌入身体、悬浮或穿透透明人体。",
|
||||
"镜头语言:严格按动作描述里的出场方式、景别、运镜、产品进入方式、佩戴贴合动作和收尾方式执行。",
|
||||
`动作描述:${shot.action_text.trim()}`,
|
||||
"镜头语言:严格按描述里的出场方式、场景、景别、运镜、产品进入方式、佩戴贴合动作、使用过程和收尾方式执行。",
|
||||
`场景/使用/享受描述:${shot.action_text.trim()}`,
|
||||
TRANSPARENT_HUMAN_VIDEO_PROMPT,
|
||||
"融合要求:产品必须自然出现在透明骨架人动作中,尺寸可信,透视一致,只贴合手部拿取和后颈/颈肩使用区域,不能悬浮、漂移、融化、扭曲或变成其他物体。",
|
||||
"首尾连续性:镜头从首帧自然运动到尾帧,中间不要跳切,不换角色,不换产品,不突然改变场景。",
|
||||
"连续性:镜头必须完整连贯,中间不要跳切,不换角色,不换产品,不突然改变场景。",
|
||||
"产品一致性:严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例;四张产品角度图是产品身份真源。",
|
||||
"场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。",
|
||||
"商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。",
|
||||
@@ -555,11 +556,12 @@ export default function Home() {
|
||||
const updated = await generateStoryboardVideo(job.id, frameIdx, {
|
||||
prompt,
|
||||
duration,
|
||||
first_image: shot.first_image,
|
||||
last_image: shot.last_image,
|
||||
first_image: null,
|
||||
last_image: null,
|
||||
product_images: productRefs,
|
||||
subject_image: shot.first_image,
|
||||
scene_image: shot.last_image,
|
||||
subject_image: primarySubject,
|
||||
subject_images: subjectRefs,
|
||||
scene_image: null,
|
||||
product_image: productRefs[0] ?? null,
|
||||
action_image: null,
|
||||
source_ref: null,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"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, Upload, Play } from "lucide-react"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save, Play } from "lucide-react"
|
||||
import {
|
||||
frameUrl, cleanedFrameUrl, apiAssetUrl,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
|
||||
generateSceneAsset, generateSubjectAssets, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, copyProductLibraryAsset,
|
||||
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneAssetRole, type SceneStyle, type SubjectKind,
|
||||
generateSceneAsset, generateSubjectAssets, resolveImageRefUrl, updateStoryboard, copyProductLibraryAsset,
|
||||
listCharacterLibrary, copyCharacterLibraryAssets, characterLibraryImageUrl,
|
||||
type AssetBackground, type AssetSize, type CharacterLibraryItem, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneAssetRole, type SceneStyle, type SubjectKind,
|
||||
} from "@/lib/api"
|
||||
import { TRANSPARENT_HUMAN_FRAME_STANDARD, TRANSPARENT_HUMAN_UI_SUMMARY } from "@/lib/workflow-target"
|
||||
import { toast } from "sonner"
|
||||
@@ -114,6 +115,8 @@ const DESKTOP_PRODUCT_ANGLE_IDS = [
|
||||
"desktop-skg-product-angle-03",
|
||||
"desktop-skg-product-angle-04",
|
||||
]
|
||||
const DEFAULT_CHARACTER_ID = "character-01"
|
||||
const DEFAULT_CHARACTER_NAME = "运动阳光男"
|
||||
type FusionUploadTarget = {
|
||||
shotIndex: number
|
||||
slot: "first_image" | "last_image"
|
||||
@@ -204,6 +207,10 @@ const createFusionShots = (): ProductFusionShot[] =>
|
||||
last_image: null,
|
||||
product_images: [],
|
||||
product_image: null,
|
||||
character_id: DEFAULT_CHARACTER_ID,
|
||||
character_name: DEFAULT_CHARACTER_NAME,
|
||||
subject_image: null,
|
||||
subject_images: [],
|
||||
person_image: null,
|
||||
product_region: null,
|
||||
scene_image: null,
|
||||
@@ -223,6 +230,10 @@ const normalizeFusionShots = (shots?: ProductFusionShot[] | null): ProductFusion
|
||||
...item,
|
||||
...shot,
|
||||
product_images: shot.product_images?.slice(0, PRODUCT_ANGLE_COUNT) ?? [],
|
||||
character_id: shot.character_id || item.character_id,
|
||||
character_name: shot.character_name || item.character_name,
|
||||
subject_image: shot.subject_image ?? item.subject_image,
|
||||
subject_images: shot.subject_images ?? item.subject_images,
|
||||
action_text: shouldUseDefaultFusionDescription(shot.action_text) ? item.action_text : shot.action_text,
|
||||
id: shot.id || item.id,
|
||||
}
|
||||
@@ -251,10 +262,11 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
const [activeTab, setActiveTab] = useState<LightboxTab>("clean")
|
||||
const [fusionShots, setFusionShots] = useState<ProductFusionShot[]>(() => createFusionShots())
|
||||
const [activeFusionShot, setActiveFusionShot] = useState(0)
|
||||
const [fusionUploadTarget, setFusionUploadTarget] = useState<FusionUploadTarget | null>(null)
|
||||
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(null)
|
||||
const [fusionSaving, setFusionSaving] = useState(false)
|
||||
const [fusionPresetPage, setFusionPresetPage] = useState(0)
|
||||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState(DEFAULT_CHARACTER_ID)
|
||||
const [editingElement, setEditingElement] = useState<{
|
||||
frameIndex: number
|
||||
id: string
|
||||
@@ -270,12 +282,23 @@ 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 fusionFileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const loadedFusionKey = useRef("")
|
||||
const activeIndexRef = useRef<number | null>(activeIndex)
|
||||
useEffect(() => setMounted(true), [])
|
||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
listCharacterLibrary()
|
||||
.then((items) => {
|
||||
if (!cancelled) setCharacterLibrary(items)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) toast.error("角色库读取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) {
|
||||
loadedFusionKey.current = ""
|
||||
@@ -286,7 +309,9 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
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))
|
||||
const nextShots = normalizeFusionShots(frame?.storyboard?.product_fusion_shots as ProductFusionShot[] | undefined)
|
||||
setFusionShots(nextShots)
|
||||
setSelectedCharacterId(nextShots[0]?.character_id || DEFAULT_CHARACTER_ID)
|
||||
setActiveFusionShot(0)
|
||||
loadedFusionKey.current = key
|
||||
}, [activeIndex, frames, jobId])
|
||||
@@ -391,9 +416,8 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
sceneExtraKeywords.trim() ? `额外关键词:${sceneExtraKeywords.trim()}。` : "",
|
||||
"要求:单一透明骨架人清晰可见,人物占画面主体,首尾帧可连续生成视频;无文字、水印、平台 UI、恐怖解剖感。",
|
||||
].filter(Boolean).join("\n")
|
||||
const fusionReadyCount = fusionShots.filter((shot) =>
|
||||
shot.first_image && shot.last_image && shot.action_text?.trim()
|
||||
).length
|
||||
const fusionReadyCount = fusionShots.filter((shot) => shot.action_text?.trim()).length
|
||||
const selectedCharacter = characterLibrary.find((item) => item.id === selectedCharacterId) ?? characterLibrary[0]
|
||||
|
||||
const persistFusionShots = async (nextShots: ProductFusionShot[]) => {
|
||||
setFusionSaving(true)
|
||||
@@ -425,47 +449,42 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
: { last_image: 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 = (target: FusionUploadTarget) => {
|
||||
setActiveFusionShot(target.shotIndex)
|
||||
setFusionUploadTarget(target)
|
||||
requestAnimationFrame(() => fusionFileInputRef.current?.click())
|
||||
}
|
||||
|
||||
const ensureFixedProductAngles = async (indexes: number[]) => {
|
||||
const prepareFusionReferences = async (indexes: number[]) => {
|
||||
try {
|
||||
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
|
||||
const productRefs = reusableRefs?.length === PRODUCT_ANGLE_COUNT
|
||||
? reusableRefs
|
||||
: await Promise.all(DESKTOP_PRODUCT_ANGLE_IDS.map((id) => copyProductLibraryAsset(jobId, id)))
|
||||
const reusableSubjectRefs = fusionShots.find((shot) =>
|
||||
shot.character_id === selectedCharacterId && (shot.subject_images ?? []).filter(Boolean).length > 0
|
||||
)?.subject_images?.filter(Boolean)
|
||||
const copiedCharacter = reusableSubjectRefs?.length
|
||||
? {
|
||||
character_id: selectedCharacterId,
|
||||
character_name: selectedCharacter?.name || fusionShots.find((shot) => shot.character_id === selectedCharacterId)?.character_name || DEFAULT_CHARACTER_NAME,
|
||||
images: reusableSubjectRefs,
|
||||
}
|
||||
: await copyCharacterLibraryAssets(jobId, selectedCharacterId)
|
||||
const next = fusionShots.map((shot, index) => (
|
||||
indexes.includes(index)
|
||||
? { ...shot, product_images: refs, product_image: refs[0] ?? null, guide_image: null }
|
||||
? {
|
||||
...shot,
|
||||
product_images: productRefs,
|
||||
product_image: productRefs[0] ?? null,
|
||||
character_id: copiedCharacter.character_id,
|
||||
character_name: copiedCharacter.character_name,
|
||||
subject_image: copiedCharacter.images[0] ?? null,
|
||||
subject_images: copiedCharacter.images,
|
||||
guide_image: null,
|
||||
}
|
||||
: shot
|
||||
))
|
||||
setFusionShots(next)
|
||||
void persistFusionShots(next)
|
||||
return next
|
||||
} catch (e) {
|
||||
toast.error("桌面产品角度填充失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error("内置角色/产品参考准备失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -483,15 +502,29 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
toast.success(`已换第 ${Math.floor(start / FUSION_SHOT_COUNT) + 1} 组镜头语言`)
|
||||
}
|
||||
|
||||
const selectFusionCharacter = (characterId: string) => {
|
||||
const character = characterLibrary.find((item) => item.id === characterId)
|
||||
const next = fusionShots.map((shot) => ({
|
||||
...shot,
|
||||
character_id: characterId,
|
||||
character_name: character?.name || shot.character_name || DEFAULT_CHARACTER_NAME,
|
||||
subject_image: null,
|
||||
subject_images: [],
|
||||
}))
|
||||
setSelectedCharacterId(characterId)
|
||||
setFusionShots(next)
|
||||
void persistFusionShots(next)
|
||||
}
|
||||
|
||||
const runFusionVideo = async (index: number) => {
|
||||
const shot = fusionShots[index]
|
||||
if (!shot?.first_image || !shot.last_image || !shot.action_text?.trim()) {
|
||||
toast.error(`镜头 ${index + 1} 还缺首帧或尾帧`)
|
||||
if (!shot?.action_text?.trim()) {
|
||||
toast.error(`镜头 ${index + 1} 还缺场景/使用描述`)
|
||||
return
|
||||
}
|
||||
setFusionGenerating(index)
|
||||
try {
|
||||
const next = await ensureFixedProductAngles([index])
|
||||
const next = await prepareFusionReferences([index])
|
||||
if (!next) return
|
||||
await onGenerateProductFusionVideo?.(f.index, next[index])
|
||||
} finally {
|
||||
@@ -502,15 +535,15 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
const runAllFusionVideos = async () => {
|
||||
const indexes = fusionShots
|
||||
.map((shot, i) => ({ shot, i }))
|
||||
.filter(({ shot }) => shot.first_image && shot.last_image && shot.action_text?.trim())
|
||||
.filter(({ shot }) => shot.action_text?.trim())
|
||||
.map(({ i }) => i)
|
||||
if (indexes.length === 0) {
|
||||
toast.error("还没有完整的融合镜头")
|
||||
toast.error("还没有可生成的融合镜头")
|
||||
return
|
||||
}
|
||||
setFusionGenerating("all")
|
||||
try {
|
||||
const next = await ensureFixedProductAngles(indexes)
|
||||
const next = await prepareFusionReferences(indexes)
|
||||
if (!next) return
|
||||
for (const index of indexes) {
|
||||
await onGenerateProductFusionVideo?.(f.index, next[index])
|
||||
@@ -1034,23 +1067,7 @@ 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"
|
||||
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 = ""
|
||||
}}
|
||||
/>
|
||||
<section className="rounded-lg border border-amber-300/15 bg-amber-500/[0.06] p-2.5">
|
||||
<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">
|
||||
@@ -1062,7 +1079,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
onClick={rotateFusionDescriptions}
|
||||
disabled={!!fusionGenerating}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-white/10 px-2 text-[9.5px] font-medium text-white/65 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="换一组内置镜头语言,不改变首帧和尾帧"
|
||||
title="换一组内置镜头语言,不改变角色和视频结果"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
换一组
|
||||
@@ -1078,77 +1095,51 @@ 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="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>
|
||||
</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>
|
||||
<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">
|
||||
描述词已预填,产品固定使用桌面 4 张 SKG 角度图;这里只需要填每行的首帧和尾帧。
|
||||
角色和产品已内置;每行只写场景、产品如何在脖子/后颈使用,以及人物舒适享受的状态。
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fusionShots.map((shot, i) => {
|
||||
const active = i === activeFusionShot
|
||||
const firstUrl = shot.first_image ? resolveImageRefUrl(jobId, shot.first_image) : ""
|
||||
const lastUrl = shot.last_image ? resolveImageRefUrl(jobId, shot.last_image) : ""
|
||||
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 ready = !!shot.action_text?.trim()
|
||||
const busy = fusionGenerating === i || fusionGenerating === "all"
|
||||
const lensStageLabel = PRODUCT_FUSION_LENS_STAGES[i] ?? `镜头 ${i + 1}`
|
||||
const pasteIntoSlot = (target: FusionUploadTarget, label: string) => {
|
||||
setActiveFusionShot(i)
|
||||
if (clipboard) {
|
||||
assignFusionImage(target, clipboard)
|
||||
toast.success(`已粘贴到镜头 ${i + 1}「${label}」:${clipboard.label || "剪贴板图片"}`)
|
||||
return
|
||||
}
|
||||
setFusionUploadTarget(target)
|
||||
toast.message(`镜头 ${i + 1} 已选中「${label}」槽位,现在可 Cmd+V 粘贴系统图片`)
|
||||
}
|
||||
const imageSlot = (target: FusionUploadTarget, label: string, url: string, ref?: ImageRef | null, white = false) => (
|
||||
<div className="overflow-hidden rounded-md border border-white/10 bg-black/24">
|
||||
<div className={`relative aspect-[4/5] ${white ? "bg-white" : "bg-black"}`}>
|
||||
{url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveFusionShot(i)}
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
title={`选中镜头 ${i + 1}`}
|
||||
>
|
||||
<img src={url} alt={label} className="h-full w-full object-contain" draggable={false} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFusionUpload(target)}
|
||||
className={`absolute inset-0 flex flex-col items-center justify-center gap-1 text-[9.5px] ${white ? "text-black/35 hover:text-black/65" : "text-white/35 hover:text-white/65"}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-white/10 px-1 py-1">
|
||||
<div className="mb-1 truncate text-[8.5px] text-white/42">{ref?.label || label}</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pasteIntoSlot(target, label)}
|
||||
className={`rounded px-1 py-0.5 text-[8.5px] transition ${
|
||||
clipboard ? "bg-violet-500/60 text-white hover:bg-violet-400/70" : "bg-white/10 text-white/58 hover:bg-white/18 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
粘贴
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openFusionUpload(target)}
|
||||
className="rounded bg-white/10 px-1 py-0.5 text-[8.5px] text-white/65 transition hover:bg-white/18 hover:text-white"
|
||||
>
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const resultPanel = latestShotVideo ? (
|
||||
<div className="overflow-hidden rounded-md border border-white/10 bg-black/30">
|
||||
<div className="relative aspect-video bg-black">
|
||||
@@ -1200,7 +1191,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_92px_92px_minmax(220px,1fr)_78px_190px] items-start gap-2">
|
||||
<div className="grid grid-cols-[34px_minmax(360px,1fr)_78px_190px] items-start gap-2">
|
||||
<div className="flex flex-col items-center gap-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1219,10 +1210,6 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{imageSlot({ shotIndex: i, slot: "first_image" }, "首帧", firstUrl, shot.first_image)}
|
||||
|
||||
{imageSlot({ shotIndex: i, slot: "last_image" }, "尾帧", lastUrl, shot.last_image)}
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-amber-100/65">{lensStageLabel}</span>
|
||||
@@ -1236,7 +1223,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
|
||||
const next = fusionShots.map((item, idx) => (idx === i ? { ...item, action_text: e.currentTarget.value } : item))
|
||||
void persistFusionShots(next)
|
||||
}}
|
||||
placeholder="描述这个镜头里透明骨架人、SKG 产品和动作起止状态。"
|
||||
placeholder="写清场景、产品如何佩戴到脖子/后颈,以及人物舒适享受状态。"
|
||||
className="h-[92px] w-full resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10px] leading-relaxed text-white/75 outline-none placeholder:text-white/25 focus:border-amber-300/45"
|
||||
/>
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user