diff --git a/.memory/worklog.json b/.memory/worklog.json
index 4de0a0c..11b30ff 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,26 +1,5 @@
{
"entries": [
- {
- "files_changed": 1,
- "hash": "3417408",
- "message": "auto-save 2026-05-13 06:57 (~1)",
- "ts": "2026-05-13T06:57:49+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "3472551",
- "message": "auto-save 2026-05-13 07:03 (~1)",
- "ts": "2026-05-13T07:03:42+08:00",
- "type": "commit"
- },
- {
- "files_changed": 1,
- "hash": "cbb8e7f",
- "message": "auto-save 2026-05-13 07:09 (~1)",
- "ts": "2026-05-13T07:09:36+08:00",
- "type": "commit"
- },
{
"files_changed": 1,
"hash": "1e4fd9e",
@@ -3283,6 +3262,25 @@
"message": "auto-save 2026-05-14 12:59 (~1)",
"hash": "887c9a0",
"files_changed": 1
+ },
+ {
+ "ts": "2026-05-14T13:05:12+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-14 13:04 (+1, ~3)",
+ "hash": "69e73d4",
+ "files_changed": 39
+ },
+ {
+ "ts": "2026-05-14T05:06:11Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 2 项未提交变更 · 最近提交:auto-save 2026-05-14 13:04 (+1, ~3)",
+ "files_changed": 2
+ },
+ {
+ "ts": "2026-05-14T05:08:40Z",
+ "type": "session-heartbeat",
+ "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 13:04 (+1, ~3)",
+ "files_changed": 3
}
]
}
diff --git a/api/main.py b/api/main.py
index 32ec6b1..14e179b 100644
--- a/api/main.py
+++ b/api/main.py
@@ -3677,7 +3677,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in raw_product_refs) if p]
subject_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.subject_images[:8]) if p]
reference_ref_paths = []
- seen_ref_paths: set[str] = set()
+ seen_ref_paths: set[str] = {str(ref_path)}
for p in [*subject_ref_paths, *product_ref_paths]:
key = str(p)
if key not in seen_ref_paths:
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index a9e3d83..658473d 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -556,8 +556,8 @@
ProductFusionShot
-
产品融合镜头组的单行数据。每个关键帧最多 6 行,用户只补首帧、尾帧和必要时微调动作描述、秒数;四张桌面 SKG 产品角度图固定隐藏填充,生成时直接把首尾帧和固定产品图作为 Seedance 垫图提交。
+
产品融合镜头组的单行数据。每个关键帧最多 6 行,用户选择一个内置角色后只微调场景/产品使用/享受描述和秒数;四张桌面 SKG 产品角度图与所选角色 7 张参考图固定隐藏填充,生成时作为 Seedance 参考图提交。
ProductFusionShot {
id,
first_image,
@@ -805,7 +805,9 @@ SubjectAsset {
| 首尾帧资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 同一接口兼容旧场景图和新首尾帧;新流程传 asset_role=first_frame/last_frame,后端走文字生图,参考帧只用于理解透明骨架人形象、比例、机位和光线,生成结果仍保存在 scene_assets 并自动填入产品融合镜头。 |
| 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
| 产品图入库到 job | POST /jobs/{id}/assets/product-library | copyProductLibraryAsset | 把一个内置产品图库条目复制为当前 job 的普通 asset,返回 ImageRef(kind="asset"),用于画面工作台产品融合和分镜产品参考组。 |
- | 产品融合引导图 | POST /jobs/{id}/product-fusion/guide | createProductFusionGuide | 旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前首尾帧流程不再主动调用它。 |
+ | 角色库 | GET /character-library/skg | listCharacterLibrary | 读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。 |
+ | 角色图入库到 job | POST /jobs/{id}/assets/character-library | copyCharacterLibraryAssets | 把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。 |
+ | 产品融合引导图 | POST /jobs/{id}/product-fusion/guide | createProductFusionGuide | 旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。 |
| 产品融合描述词 | POST /jobs/{id}/product-fusion/descriptions | generateProductFusionDescriptions | 兼容接口:可生成产品融合动作描述库。当前前端默认直接用本地 36 条镜头语言模板预填 6 行镜头,并通过“换一组”按钮按 6 条一组轮换。 |
| 分镜保存 | PUT /frames/{idx}/storyboard | updateStoryboard | 保存 4 图槽、时长和改造说明。 |
| 生图 | POST /frames/{idx}/generate | generateImage | 基于关键帧或已选生成图做 image-to-image,目前可用。 |
@@ -917,6 +919,20 @@ SubjectAsset {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-14 · 产品融合改为内置角色 + 产品 + 描述生成
+ FrameLightbox
+ 产品融合
+ 角色库
+
+
+
问题:当前产品融合不再需要手动首帧/尾帧,用户要的是从内置透明骨架人角色、场景描述、产品使用方式和享受状态直接生成视频。
+
改动:桌面 skg_anatomy_characters_20260514_120852 的 5 个角色、35 张图内置为 api/character_library/skg-characters。产品融合页新增角色下拉和角色预览,每行只保留场景/产品使用/享受描述、秒数、生成按钮和结果视频;生成前自动复制所选角色 7 张参考图和固定 4 张 SKG 产品图到当前 job。
+
后端:新增 GET /character-library/skg、GET /character-library/skg/images/{filename}、POST /jobs/{job_id}/assets/character-library。视频提交新增 subject_images,无首帧时主人物图以 reference_image role 传入 Ark/Seedance,而不是强制作为 first_frame。
+
影响:api/main.py、api/character_library/skg-characters、web/lib/api.ts、web/app/page.tsx、web/components/lightbox.tsx、docs/source-analysis.html。
+
+
2026-05-14 · 产品融合内置多组镜头语言
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 261230a..b1f5776 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -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,
diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx
index 78f9066..2adea17 100644
--- a/web/components/lightbox.tsx
+++ b/web/components/lightbox.tsx
@@ -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("clean")
const [fusionShots, setFusionShots] = useState(() => createFusionShots())
const [activeFusionShot, setActiveFusionShot] = useState(0)
- const [fusionUploadTarget, setFusionUploadTarget] = useState(null)
const [fusionGenerating, setFusionGenerating] = useState(null)
const [fusionSaving, setFusionSaving] = useState(false)
const [fusionPresetPage, setFusionPresetPage] = useState(0)
+ const [characterLibrary, setCharacterLibrary] = useState([])
+ 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(null) // 当前正在拖的
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const imgWrapRef = useRef(null)
- const fusionFileInputRef = useRef(null)
const loadedFusionKey = useRef("")
const activeIndexRef = useRef(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
) : isProductTab ? (
- {
- if (fusionUploadTarget && e.clipboardData.files?.length) void uploadFusionFiles(e.clipboardData.files)
- }}
- >
- {
- const files = e.target.files
- if (files) void uploadFusionFiles(files)
- e.currentTarget.value = ""
- }}
- />
+
产品融合镜头组
@@ -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="换一组内置镜头语言,不改变角色和视频结果"
>
换一组
@@ -1078,77 +1095,51 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
+
+
+
+
+ 角色参考
+ 产品固定 4 图 · 无首尾帧
+
+
+ {(selectedCharacter?.images ?? []).slice(0, 7).map((image) => (
+
+
})
+
+ ))}
+ {!selectedCharacter?.images?.length && (
+
+ 角色库加载中
+
+ )}
+
+
+
- 描述词已预填,产品固定使用桌面 4 张 SKG 角度图;这里只需要填每行的首帧和尾帧。
+ 角色和产品已内置;每行只写场景、产品如何在脖子/后颈使用,以及人物舒适享受的状态。
{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) => (
-
-
- {url ? (
-
- ) : (
-
- )}
-
-
-
{ref?.label || label}
-
-
-
-
-
-
- )
const resultPanel = latestShotVideo ? (
@@ -1200,7 +1191,7 @@ export function FrameLightbox({ jobId, frames, generatedVideos = [], activeIndex
: "border-white/10 bg-black/20 hover:border-amber-300/35"
}`}
>
-
+
- {imageSlot({ shotIndex: i, slot: "first_image" }, "首帧", firstUrl, shot.first_image)}
-
- {imageSlot({ shotIndex: i, slot: "last_image" }, "尾帧", lastUrl, shot.last_image)}
-