auto-save 2026-05-14 11:58 (~4)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
17
api/main.py
17
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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user