auto-save 2026-05-14 11:58 (~4)

This commit is contained in:
2026-05-14 11:58:48 +08:00
parent 801b194bff
commit f0c6c5b916
4 changed files with 220 additions and 215 deletions

View File

@@ -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
}
]
}

View File

@@ -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):

View File

@@ -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",

View File

@@ -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<SceneAssetRole | null>(null)
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
const [assetSize, setAssetSize] = useState<AssetSize>("source")
const [sceneMode, setSceneMode] = useState<SceneMode>("remove_subject")
@@ -156,7 +174,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [activeTab, setActiveTab] = useState<LightboxTab>("clean")
const [fusionShots, setFusionShots] = useState<ProductFusionShot[]>(() => createFusionShots())
const [activeFusionShot, setActiveFusionShot] = useState(0)
const [fusionUploadTarget, setFusionUploadTarget] = useState<"product_image" | "person_image" | "scene_image" | null>(null)
const [fusionUploadTarget, setFusionUploadTarget] = useState<FusionUploadTarget | null>(null)
const [fusionGenerating, setFusionGenerating] = useState<number | "all" | null>(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<SceneAssetRole, "scene">) => {
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 ? (
<section className="rounded-lg border border-emerald-300/15 bg-emerald-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="text-[12px] font-semibold text-white"></div>
<span className="text-[9.5px] font-mono text-white/38">
{selectedFrameIndices.length > 0 ? `${selectedFrameIndices.length} 已选参考` : "默认当前帧"}
</span>
@@ -939,7 +993,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
})}
</div>
<div className="mt-2 text-[10px] leading-relaxed text-white/38">
/
</div>
</section>
) : isProductTab ? (
@@ -967,75 +1021,90 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</span>
</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
6 使 + + + 3 Seedance
</div>
<div className="space-y-2">
{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 (
<div className="overflow-hidden rounded-md border border-white/10 bg-black/24">
<div className="relative aspect-square bg-white">
{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(slot, i)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-[9.5px] text-black/35 hover:text-black/65"
>
<Upload className="h-3.5 w-3.5" />
</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(slot, 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(slot, i)}
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>
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 productAngleSlots = (
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2].map((productIndex) => (
<div key={productIndex}>
{imageSlot(
{ shotIndex: i, slot: "product_images", productIndex },
PRODUCT_ANGLE_LABELS[productIndex],
productUrls[productIndex],
productImages[productIndex],
true,
)}
</div>
))}
</div>
)
return (
<div
key={shot.id}
@@ -1045,7 +1114,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_82px_112px_82px_minmax(150px,1fr)_78px] items-start gap-2">
<div className="grid grid-cols-[34px_86px_86px_240px_minmax(150px,1fr)_78px] items-start gap-2">
<div className="flex flex-col items-center gap-1 pt-1">
<button
type="button"
@@ -1064,82 +1133,20 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</span>
</div>
{imageSlot("product_image", "产品图", productUrl)}
{imageSlot({ shotIndex: i, slot: "first_image" }, "首帧", firstUrl, shot.first_image)}
<div className="overflow-hidden rounded-md border border-white/10 bg-black/24">
<div
ref={active ? fusionPersonWrapRef : undefined}
onMouseDown={personUrl ? (ev) => {
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 ? (
<>
<img src={personUrl} alt="白底人物图" className="h-full w-full select-none object-contain" draggable={false} />
{!active && (
<div className="absolute inset-x-1 bottom-1 rounded bg-black/55 px-1 py-0.5 text-center text-[8px] text-white/75">
</div>
)}
</>
) : (
<button
type="button"
onClick={() => openFusionUpload("person_image", i)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 text-[9.5px] text-black/35 hover:text-black/65"
>
<Upload className="h-3.5 w-3.5" />
</button>
)}
{[shot.product_region, active ? fusionDraftRegion : null].filter(Boolean).map((region, regionIdx) => region && (
<div
key={regionIdx}
className={`absolute pointer-events-none border-2 ${regionIdx === 0 ? "border-amber-300 bg-amber-300/10" : "border-dashed border-cyan-300"}`}
style={{
left: `${region.x * 100}%`,
top: `${region.y * 100}%`,
width: `${region.w * 100}%`,
height: `${region.h * 100}%`,
}}
/>
))}
</div>
<div className="border-t border-white/10 px-1 py-1">
<div className="mb-1 truncate text-[8.5px] text-white/42">
{shot.person_image?.label || (shot.product_region ? "人物图 · 已画区域" : "白底人物图")}
</div>
<div className="grid grid-cols-2 gap-1">
<button
type="button"
onClick={() => pasteIntoSlot("person_image", "白底人物图")}
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("person_image", i)}
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>
{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 >= 3 ? "text-emerald-200/70" : "text-white/30"}`}>
{productImages.filter(Boolean).length}/3
</span>
</div>
{productAngleSlots}
</div>
{imageSlot("scene_image", "场景图", sceneUrl)}
<label className="block">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9px] text-white/38"> · </span>
@@ -1153,7 +1160,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const next = fusionShots.map((item, idx) => (idx === i ? { ...item, action_text: e.currentTarget.value } : item))
void persistFusionShots(next)
}}
placeholder="描述这个镜头里人物和产品动作、位置、节奏。"
placeholder="描述这个镜头里透明骨架人、SKG 产品动作起止状态。"
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>