diff --git a/.memory/worklog.json b/.memory/worklog.json index cd5f001..39402ad 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,26 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "9fcc418", - "message": "auto-save 2026-05-13 04:17 (~1)", - "ts": "2026-05-13T04:17:55+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "f3a41e9", - "message": "auto-save 2026-05-13 04:23 (~1)", - "ts": "2026-05-13T04:23:48+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "0cfa960", - "message": "auto-save 2026-05-13 04:29 (~1)", - "ts": "2026-05-13T04:29:41+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "c7af450", @@ -3297,6 +3276,25 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-14 11:47 (~7)", "files_changed": 2 + }, + { + "ts": "2026-05-14T11:53:14+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 11:53 (~4)", + "hash": "801b194", + "files_changed": 4 + }, + { + "ts": "2026-05-14T03:56:10Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 11:53 (~4)", + "files_changed": 3 + }, + { + "ts": "2026-05-14T03:58:39Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 11:53 (~4)", + "files_changed": 4 } ] } diff --git a/api/main.py b/api/main.py index 211574b..49eb549 100644 --- a/api/main.py +++ b/api/main.py @@ -1477,13 +1477,16 @@ def _translate_sync(segments: list[dict]) -> list[str]: '[{"i": 0, "zh": "..."}, ...]\n\n输入:\n' + json.dumps(payload, ensure_ascii=False) ) - resp = llm().chat.completions.create( - model=TRANSLATE_MODEL, - messages=[{"role": "user", "content": prompt}], - response_format={"type": "json_object"}, - temperature=0.2, - ) - content = resp.choices[0].message.content or "[]" + try: + resp = llm().chat.completions.create( + model=TRANSLATE_MODEL, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.2, + ) + content = resp.choices[0].message.content or "[]" + except Exception: + return ["" for _ in segments] try: data = json.loads(content) if isinstance(data, dict): diff --git a/web/app/page.tsx b/web/app/page.tsx index 1bec5be..b328f48 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -19,7 +19,7 @@ import { ThemeToggle } from "@/components/theme-toggle" import { AudioStrip } from "@/components/audio-strip" import { addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, - deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, createProductFusionGuide, triggerTranscribe, + deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, type Job, type ImageRef, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, } from "@/lib/api" import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target" @@ -520,46 +520,43 @@ export default function Home() { if (!job) return const frame = job.frames.find((f) => f.index === frameIdx) if (!frame) return - if (!shot.product_image || !shot.person_image || !shot.scene_image || !shot.product_region || !shot.action_text?.trim()) { - toast.error("产品融合镜头缺少产品图、人物图、区域、场景图或描述词") + const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 3) as ImageRef[] + if (!shot.first_image || !shot.last_image || productRefs.length < 3 || !shot.action_text?.trim()) { + toast.error("产品融合镜头缺少首帧、尾帧、三张产品角度图或描述词") return } const duration = shot.duration && shot.duration > 0 ? shot.duration : 5 const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback try { - toast.info(`生成融合引导图 · GPT Image 2 位置约束 · 镜头 ${shot.id || ""}`) - const guideRef = await createProductFusionGuide(job.id, { - ...shot, - image_model: "gpt-image-2", - video_model: "seedance", - }) - const region = shot.product_region const prompt = [ - `竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 产品融合视频。`, - "图片模型固定为 GPT Image 2:已根据白底人物图和手动画框生成产品融合引导图;引导图是产品尺寸、位置、贴合关系和起始构图的最高优先级参考。", - "视频模型固定为 Seedance:生成单镜头连续视频,不跳切,不换主体,不改变产品身份。", - `产品区域:x=${region.x.toFixed(3)}, y=${region.y.toFixed(3)}, w=${region.w.toFixed(3)}, h=${region.h.toFixed(3)}。产品只能在这个框对应的人物/身体/手部区域内融合,不能漂移到其他位置,也不能明显超出框架。`, - `产品图:${labelOf(shot.product_image, "SKG 白底产品图")}。严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例。`, - `白底人物图:${labelOf(shot.person_image, "人物姿态参考")}。人物姿态、手部接触点和产品佩戴关系以这张图为准。`, - `场景图:${labelOf(shot.scene_image, "场景参考")}。背景、空间、光线和气氛以这张图为准,但不要改变产品框内位置。`, + `竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 图生视频。`, + "图片模型固定为 GPT Image 2:首帧和尾帧已经由文字生图生成,用来锁定透明骨架人角色、场景构图和动作起止状态。", + "视频模型固定为 Seedance:使用首帧作为起始画面、尾帧作为结束画面,并用三张同一 SKG 产品不同角度白底图作为垫图/产品身份参考。", + `首帧:${labelOf(shot.first_image, "透明骨架人首帧")}。起始人物形象、姿态、构图和场景氛围以这张图为准。`, + `尾帧:${labelOf(shot.last_image, "透明骨架人尾帧")}。结束人物状态、画面落点和场景延续以这张图为准。`, + `产品角度图 1:${labelOf(productRefs[0], "SKG 产品正面/主视角")}。`, + `产品角度图 2:${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}。`, + `产品角度图 3:${labelOf(productRefs[2], "SKG 产品背面/细节视角")}。`, `动作描述:${shot.action_text.trim()}`, TRANSPARENT_HUMAN_VIDEO_PROMPT, - "融合要求:产品必须按引导图位置自然贴合人物或手部,尺寸可信,透视一致,边缘清晰,不能悬浮、穿帮、融化、扭曲或变成其他物体。", - "场景要求:把白底人物姿态自然放入场景图的环境中,光线方向和阴影要统一,背景不要出现水印、平台 UI、字幕或竞品包装。", + "融合要求:产品必须自然出现在透明骨架人动作中,尺寸可信,透视一致,贴合身体/手部/使用区域,不能悬浮、漂移、融化、扭曲或变成其他物体。", + "首尾连续性:镜头从首帧自然运动到尾帧,中间不要跳切,不换角色,不换产品,不突然改变场景。", + "产品一致性:严格保持 SKG 产品外观、颜色、材质、U 形结构、按摩触点、按键和比例;三张产品角度图是产品身份真源。", + "场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。", "商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。", - "禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、产品超过指定融合区域。", + "禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、透明衣服但非透明身体。", TRANSPARENT_HUMAN_NEGATIVE_PROMPT, ].join("\n") const updated = await generateStoryboardVideo(job.id, frameIdx, { prompt, duration, - first_image: guideRef, - last_image: null, - product_images: [shot.product_image, shot.person_image, shot.scene_image].filter(Boolean) as ImageRef[], - subject_image: shot.person_image, - scene_image: shot.scene_image, - product_image: shot.product_image, - action_image: guideRef, + first_image: shot.first_image, + last_image: shot.last_image, + product_images: productRefs, + subject_image: shot.first_image, + scene_image: shot.last_image, + product_image: productRefs[0] ?? null, + action_image: null, source_ref: null, model: "seedance", size: "720x1280", diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 6c78c35..1438d82 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -6,7 +6,7 @@ import { frameUrl, cleanedFrameUrl, apiAssetUrl, describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, generateSceneAsset, generateSubjectAssets, generateProductFusionDescriptions, resolveImageRefUrl, uploadStoryboardAsset, updateStoryboard, - type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneMode, type SceneStyle, type SubjectKind, + type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type ProductFusionShot, type SceneAssetRole, type SceneMode, 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" @@ -72,7 +72,7 @@ type LightboxTab = "clean" | "scene" | "subject" | "product" | "review" const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [ { key: "clean", label: "原图/清洗" }, { key: "subject", label: "主体资产" }, - { key: "scene", label: "场景图" }, + { key: "scene", label: "首尾帧" }, { key: "product", label: "产品融合" }, { key: "review", label: "审核" }, ] @@ -112,10 +112,20 @@ const SCENE_REFERENCE_OPTIONS = [ const FUSION_SHOT_COUNT = 6 const FUSION_DURATIONS = [4, 5, 6, 8, 10, 12, 15] +type FusionUploadTarget = { + shotIndex: number + slot: "first_image" | "last_image" | "product_images" + productIndex?: number +} +type FusionFrameRole = "first_image" | "last_image" +const PRODUCT_ANGLE_LABELS = ["产品角度 1", "产品角度 2", "产品角度 3"] const createFusionShots = (): ProductFusionShot[] => Array.from({ length: FUSION_SHOT_COUNT }, (_, i) => ({ id: `shot-${i + 1}`, + first_image: null, + last_image: null, + product_images: [], product_image: null, person_image: null, product_region: null, @@ -130,7 +140,15 @@ const createFusionShots = (): ProductFusionShot[] => 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 })) + return base.map((item, i) => { + const shot = shots[i] ?? {} + const productImages = shot.product_images?.length + ? shot.product_images.slice(0, 3) + : shot.product_image + ? [shot.product_image] + : [] + return { ...item, ...shot, product_images: productImages, id: shot.id || item.id } + }) } export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, clipboard, onCopyImage, onGenerateProductFusionVideo, embedded = false }: Props) { @@ -141,7 +159,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const [batchApplying, setBatchApplying] = useState(false) const [batchApplyProgress, setBatchApplyProgress] = useState<{ done: number; total: number; failed: number } | null>(null) const [applying, setApplying] = useState(false) - const [sceneGenerating, setSceneGenerating] = useState(false) + const [sceneGenerating, setSceneGenerating] = useState(null) const [subjectGenerating, setSubjectGenerating] = useState(null) const [assetSize, setAssetSize] = useState("source") const [sceneMode, setSceneMode] = useState("remove_subject") @@ -156,7 +174,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const [activeTab, setActiveTab] = useState("clean") const [fusionShots, setFusionShots] = useState(() => createFusionShots()) const [activeFusionShot, setActiveFusionShot] = useState(0) - const [fusionUploadTarget, setFusionUploadTarget] = useState<"product_image" | "person_image" | "scene_image" | null>(null) + const [fusionUploadTarget, setFusionUploadTarget] = useState(null) const [fusionGenerating, setFusionGenerating] = useState(null) const [fusionSaving, setFusionSaving] = useState(false) const [fusionDraftRegion, setFusionDraftRegion] = useState<{ x: number; y: number; w: number; h: number } | null>(null) @@ -286,24 +304,29 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const unifiedSubjectName = subjectElementRefs[0]?.element.name_zh || "统一主体" const sceneLocationLabel = SCENE_LOCATION_OPTIONS.find(([value]) => value === sceneLocation)?.[1] ?? sceneLocation const sceneStyleLabel = SCENE_STYLE_OPTIONS.find(([value]) => value === sceneStyle)?.[1] ?? sceneStyle - const sceneModeLabel = SCENE_MODE_OPTIONS.find(([value]) => value === sceneMode)?.[1] ?? sceneMode const sceneReferenceLabels = sceneReferenceKeys .map((key) => SCENE_REFERENCE_OPTIONS.find(([value]) => value === key)?.[1] ?? key) const scenePromptDraft = [ - `主体:移除 ${unifiedSubjectName} 后生成空场景。`, + `目标:为透明骨架人视频生成首帧或尾帧,不再生成空背景板。`, + `人物:保持 ${unifiedSubjectName} 的透明/半透明外壳、干净白色骨架、非恐怖广告角色气质。`, `地点:${sceneLocationLabel}。`, - `生成方式:${sceneModeLabel}。`, `风格:${sceneStyleLabel}。`, `参考帧:${sceneReferenceFrames.map((frame) => `分镜${frame.index + 1}`).join("、") || `分镜${f.index + 1}`}。`, sceneReferenceLabels.length > 0 ? `保留参考:${sceneReferenceLabels.join("、")}。` : "", sceneExtraKeywords.trim() ? `额外关键词:${sceneExtraKeywords.trim()}。` : "", - "要求:无主体、无人物动物产品、无文字水印,保持可用于后续视频生成的干净背景板。", + "要求:单一透明骨架人清晰可见,人物占画面主体,首尾帧可连续生成视频;无文字、水印、平台 UI、恐怖解剖感。", ].filter(Boolean).join("\n") const currentFusionShot = fusionShots[activeFusionShot] ?? fusionShots[0] - const currentFusionProductUrl = currentFusionShot?.product_image ? resolveImageRefUrl(jobId, currentFusionShot.product_image) : "" + 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 currentFusionProductUrl = currentFusionProducts[0] ? resolveImageRefUrl(jobId, currentFusionProducts[0]) : "" 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 fusionReadyCount = fusionShots.filter((shot) => + shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= 3 && shot.action_text?.trim() + ).length const persistFusionShots = async (nextShots: ProductFusionShot[]) => { setFusionSaving(true) @@ -326,8 +349,19 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o 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 assignFusionImage = (target: FusionUploadTarget, ref: ImageRef) => { + const index = target.shotIndex + const current = fusionShots[index] + if (!current) return + if (target.slot === "product_images") { + const productImages = [...(current.product_images ?? [])].slice(0, 3) + const productIndex = Math.max(0, Math.min(2, target.productIndex ?? productImages.findIndex((item) => !item))) + const safeIndex = productIndex >= 0 ? productIndex : 0 + productImages[safeIndex] = ref + updateFusionShot(index, { product_images: productImages, product_image: productImages[0] ?? null, guide_image: null }, true) + return + } + updateFusionShot(index, { [target.slot]: ref, guide_image: null }, true) } const uploadFusionFiles = async (files: FileList | File[]) => { @@ -348,9 +382,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } - const openFusionUpload = (slot: "product_image" | "person_image" | "scene_image", index = activeFusionShot) => { - setActiveFusionShot(index) - setFusionUploadTarget(slot) + const openFusionUpload = (target: FusionUploadTarget) => { + setActiveFusionShot(target.shotIndex) + setFusionUploadTarget(target) requestAnimationFrame(() => fusionFileInputRef.current?.click()) } @@ -419,8 +453,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o 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} 还缺产品图、人物图、区域、场景图或描述词`) + if (!shot?.first_image || !shot.last_image || (shot.product_images ?? []).filter(Boolean).length < 3 || !shot.action_text?.trim()) { + toast.error(`镜头 ${index + 1} 还缺首帧、尾帧、三张产品角度图或描述词`) return } setFusionGenerating(index) @@ -434,7 +468,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o 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()) + .filter(({ shot }) => shot.first_image && shot.last_image && (shot.product_images ?? []).filter(Boolean).length >= 3 && shot.action_text?.trim()) .map(({ i }) => i) if (indexes.length === 0) { toast.error("还没有完整的融合镜头") @@ -548,27 +582,47 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } - const handleGenerateSceneAsset = async () => { + const handleGenerateSceneAsset = async (role: Exclude) => { + const roleLabel = role === "first_frame" ? "首帧" : "尾帧" + const targetSlot: FusionFrameRole = role === "first_frame" ? "first_image" : "last_image" if (!hasSubjectAssets) { - setActiveTab("subject") - toast.message("先生成主体资产,再生成去主体场景图") - return + toast.message("还没有主体资产,也会按当前参考帧理解人物;一致性可能弱一些") } - setSceneGenerating(true) + setSceneGenerating(role) try { const updated = await generateSceneAsset(jobId, f.index, { size: assetSize, - scene_mode: sceneMode, + scene_mode: "similar", scene_style: sceneStyle, - prompt: scenePrompt.trim() || scenePromptDraft, + asset_role: role, + prompt: [ + role === "first_frame" + ? "生成这个产品融合镜头的首帧:人物处于动作开始状态,构图稳定,适合作为视频第一帧。" + : "生成这个产品融合镜头的尾帧:人物处于动作完成状态,与首帧连续但画面不要完全相同。", + scenePrompt.trim() || scenePromptDraft, + ].join("\n"), source_frame_indices: sceneReferenceFrameIndices, }) onJobUpdate?.(updated) - toast.success(`分镜 ${f.index + 1} 场景图已生成`) + const updatedFrame = updated.frames.find((frame) => frame.index === f.index) + const asset = [...(updatedFrame?.scene_assets ?? [])].reverse().find((item) => item.asset_role === role) + if (asset) { + assignFusionImage({ + shotIndex: activeFusionShot, + slot: targetSlot, + }, { + kind: "asset", + frame_idx: f.index, + element_id: asset.id, + cutout_id: asset.id, + label: asset.label, + }) + } + toast.success(`分镜 ${f.index + 1} ${roleLabel}已生成,并填入镜头 ${activeFusionShot + 1}`) } catch (e) { - toast.error("场景图生成失败:" + (e instanceof Error ? e.message : String(e))) + toast.error(`${roleLabel}生成失败:` + (e instanceof Error ? e.message : String(e))) } finally { - setSceneGenerating(false) + setSceneGenerating(null) } } @@ -875,7 +929,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o ) : isSceneTab ? (
-
场景参考图
+
首尾帧参考图
{selectedFrameIndices.length > 0 ? `${selectedFrameIndices.length} 已选参考` : "默认当前帧"} @@ -939,7 +993,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o })}
- 左侧显示全部关键帧;点图片设为生成目标,点“选”加入场景参考。未选择时默认只参考当前目标帧。 + 左侧显示全部关键帧;点图片设为生成目标,点“选”加入人物/机位参考。未选择时默认只参考当前目标帧。
) : isProductTab ? ( @@ -967,75 +1021,90 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
- 6 条视频镜头从上到下排列;每条都能直接看到产品图、白底人物、融合区域、场景、描述和秒数。 + 6 条视频镜头从上到下排列;每条使用文字描述 + 首帧 + 尾帧 + 同一产品 3 个角度图,作为 Seedance 垫图生成视频。
{fusionShots.map((shot, i) => { const active = i === activeFusionShot - const productUrl = shot.product_image ? resolveImageRefUrl(jobId, shot.product_image) : "" - const personUrl = shot.person_image ? resolveImageRefUrl(jobId, shot.person_image) : "" - const sceneUrl = shot.scene_image ? resolveImageRefUrl(jobId, shot.scene_image) : "" - const ready = !!(shot.product_image && shot.person_image && shot.product_region && shot.scene_image && shot.action_text?.trim()) + 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 = [0, 1, 2].map((productIndex) => + productImages[productIndex] ? resolveImageRefUrl(jobId, productImages[productIndex]) : "" + ) + const ready = !!(shot.first_image && shot.last_image && productImages.filter(Boolean).length >= 3 && shot.action_text?.trim()) const busy = fusionGenerating === i || fusionGenerating === "all" - const pasteIntoSlot = (slot: "product_image" | "person_image" | "scene_image", label: string) => { + const pasteIntoSlot = (target: FusionUploadTarget, label: string) => { setActiveFusionShot(i) if (clipboard) { - assignFusionImage(slot, clipboard, i) + assignFusionImage(target, clipboard) toast.success(`已粘贴到镜头 ${i + 1}「${label}」:${clipboard.label || "剪贴板图片"}`) return } - setFusionUploadTarget(slot) + setFusionUploadTarget(target) toast.message(`镜头 ${i + 1} 已选中「${label}」槽位,现在可 Cmd+V 粘贴系统图片`) } - const imageSlot = (slot: "product_image" | "scene_image", label: string, url: string) => { - const ref = shot[slot] - return ( -
-
- {url ? ( - - ) : ( - - )} -
-
-
{ref?.label || label}
-
- - -
+ const imageSlot = (target: FusionUploadTarget, label: string, url: string, ref?: ImageRef | null, white = false) => ( +
+
+ {url ? ( + + ) : ( + + )} +
+
+
{ref?.label || label}
+
+ +
- ) - } +
+ ) + const productAngleSlots = ( +
+ {[0, 1, 2].map((productIndex) => ( +
+ {imageSlot( + { shotIndex: i, slot: "product_images", productIndex }, + PRODUCT_ANGLE_LABELS[productIndex], + productUrls[productIndex], + productImages[productIndex], + true, + )} +
+ ))} +
+ ) return (
-
+
- {imageSlot("product_image", "产品图", productUrl)} + {imageSlot({ shotIndex: i, slot: "first_image" }, "首帧", firstUrl, shot.first_image)} -
-
{ - setActiveFusionShot(i) - if (active) onFusionRegionDown(ev) - } : undefined} - onMouseMove={active ? onFusionRegionMove : undefined} - onMouseUp={active ? onFusionRegionUp : undefined} - onMouseLeave={active ? onFusionRegionUp : undefined} - className={`relative aspect-[4/5] bg-white ${ - personUrl ? (active ? "cursor-crosshair" : "cursor-pointer") : "" - }`} - title={personUrl ? (active ? "拖动画出产品融合区域" : "点击后编辑此镜头区域") : "上传白底人物图"} - > - {personUrl ? ( - <> - 白底人物图 - {!active && ( -
- 点此编辑区域 -
- )} - - ) : ( - - )} - {[shot.product_region, active ? fusionDraftRegion : null].filter(Boolean).map((region, regionIdx) => region && ( -
- ))} -
-
-
- {shot.person_image?.label || (shot.product_region ? "人物图 · 已画区域" : "白底人物图")} -
-
- - -
+ {imageSlot({ shotIndex: i, slot: "last_image" }, "尾帧", lastUrl, shot.last_image)} + +
+
+ 同一产品 · 三个角度 + = 3 ? "text-emerald-200/70" : "text-white/30"}`}> + {productImages.filter(Boolean).length}/3 +
+ {productAngleSlots}
- {imageSlot("scene_image", "场景图", sceneUrl)} -