auto-save 2026-05-18 07:27 (~6)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
type FrameObject,
|
||||
type GeneratedVideo,
|
||||
type ImageRef,
|
||||
type CharacterLibraryItem,
|
||||
type Job,
|
||||
type KeyElement,
|
||||
type KeyFrame,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
analyzeJob,
|
||||
analyzeProductViews,
|
||||
apiAssetUrl,
|
||||
characterLibraryImageUrl,
|
||||
cutoutElement,
|
||||
deleteSubjectAsset,
|
||||
effectiveFrameUrl,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
generatedImageUrl,
|
||||
getRuntimeHealth,
|
||||
hasCutout,
|
||||
listCharacterLibrary,
|
||||
representativeCutoutUrl,
|
||||
resolveImageRefUrl,
|
||||
rewriteStoryboardScript,
|
||||
@@ -98,7 +101,26 @@ type AudioStoryboardRow = {
|
||||
type ProductRefItem = ProductRefStateItem
|
||||
type SubjectStyleMode = "transparent_human" | "source_actor"
|
||||
|
||||
const SUBJECT_VIEW_ORDER = ["front", "three_quarter_left", "left", "back", "right", "three_quarter_right"]
|
||||
const SUBJECT_ASSET_VIEWS = [
|
||||
{ value: "front", label: "正面" },
|
||||
{ value: "three_quarter_left", label: "左前45" },
|
||||
{ value: "left", label: "左侧" },
|
||||
{ value: "back", label: "背面" },
|
||||
{ value: "right", label: "右侧" },
|
||||
{ value: "three_quarter_right", label: "右前45" },
|
||||
{ value: "bust_front", label: "肩颈正近" },
|
||||
{ value: "bust_left_45", label: "肩颈左近" },
|
||||
{ value: "bust_right_45", label: "肩颈右近" },
|
||||
{ value: "back_neck_detail", label: "后颈肩背" },
|
||||
] as const
|
||||
|
||||
const SUBJECT_VIEW_ORDER = [
|
||||
...SUBJECT_ASSET_VIEWS.map((view) => view.value),
|
||||
"bust",
|
||||
"back_detail",
|
||||
]
|
||||
|
||||
const SUBJECT_ASSET_SIZE = "2048" as const
|
||||
|
||||
type ModelTraceSpec = {
|
||||
title: string
|
||||
@@ -301,14 +323,22 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
|
||||
return null
|
||||
}
|
||||
|
||||
function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string) {
|
||||
function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedCharacter?: CharacterLibraryItem | null) {
|
||||
const base = [
|
||||
"Create a new similar but non-identical information-feed ad subject from the selected reference frames.",
|
||||
"Treat all selected frames as evidence for ONE same subject, not multiple different subjects.",
|
||||
"Lock one consistent character bible before generating: same gender presentation, age range, body proportions, head shape, material, silhouette, commercial style, and visual identity across all six views.",
|
||||
"If the user direction asks to change gender, age, or style, apply that single change uniformly to every view; never mix male/female, young/old, or multiple style identities inside one six-view set.",
|
||||
"Lock one consistent character bible before generating: same gender presentation, age range, body proportions, head shape, material, silhouette, commercial style, and visual identity across the full multi-view set.",
|
||||
"If the user direction asks to change gender, age, or style, apply that single change uniformly to every view; never mix male/female, young/old, or multiple style identities inside one set.",
|
||||
"Keep the pose vocabulary, camera-readability, creator-ad energy, and commercial clarity, but do not copy the exact source identity, face, watermark, captions, platform UI, or pixels.",
|
||||
"This is for SKG neck-and-shoulder wearable massage device videos: keep neck, collarbone, shoulders, side neck, upper back, shoulder blades, and product placement area clean and visible.",
|
||||
"Output high-definition assets suitable for downstream video generation.",
|
||||
]
|
||||
if (selectedCharacter) {
|
||||
base.push(
|
||||
`Built-in creative character selected: ${selectedCharacter.name}.`,
|
||||
"Use the built-in images as planned creative direction only; generate an innovative variation, not a duplicate of that character pack.",
|
||||
)
|
||||
}
|
||||
if (subjectStyle === "transparent_human") {
|
||||
base.push(
|
||||
"The subject must be a transparent humanoid: transparent or translucent skin/body shell wrapping a clean visible white skeleton inside the same body.",
|
||||
@@ -323,7 +353,7 @@ function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: st
|
||||
}
|
||||
const trimmed = direction.trim()
|
||||
if (trimmed) base.push(`User unified subject direction: ${trimmed}`)
|
||||
base.push("Output separate pure white background six-view assets; each image is one view of the same unified subject.")
|
||||
base.push("Output separate pure white background multi-view assets; each image is one view of the same unified subject.")
|
||||
return base.join(" ")
|
||||
}
|
||||
|
||||
@@ -331,6 +361,13 @@ function subjectAssetUrl(job: Job, asset: SubjectAsset) {
|
||||
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
|
||||
}
|
||||
|
||||
function characterPreviewImage(character?: CharacterLibraryItem | null) {
|
||||
if (!character?.images?.length) return null
|
||||
return character.images.find((image) => image.id === character.primary_image)
|
||||
?? character.images.find((image) => image.view === "front")
|
||||
?? character.images[0]
|
||||
}
|
||||
|
||||
function modelValue(value?: string) {
|
||||
return value?.trim() || "待配置"
|
||||
}
|
||||
@@ -383,12 +420,12 @@ function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyl
|
||||
title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体",
|
||||
model: subjectImageModelChain(models),
|
||||
chain: [
|
||||
"参考帧策略:未勾选时使用全部关键帧,勾选后只使用已选关键帧",
|
||||
"参考策略:未勾选关键帧时使用全部关键帧,勾选后只使用已选关键帧;也可叠加内置形象作为创意参考",
|
||||
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
|
||||
`图像生成:${subjectImageModelChain(models)} 逐张生成正、背、左、右、左前 45、右前 45`,
|
||||
"身份锁定:六张必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
|
||||
`图像生成:${subjectImageModelChain(models)} 逐张生成 10 张高清图,包含全身多视角和肩颈/后背特写`,
|
||||
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
|
||||
],
|
||||
note: "这是生成类似主体,不是复制、抠出或复刻源视频人物身份。",
|
||||
note: "这是生成类似但创新的主体,不是复制、抠出或复刻源视频人物身份;内置形象也只作为方向参考。",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,7 +982,7 @@ export function AdRecreationBoard({
|
||||
workingJob = await generateSubjectAssets(job.id, frame.index, element.id, {
|
||||
subject_kind: guessSubjectKind(name),
|
||||
background: "white",
|
||||
size: "1024",
|
||||
size: SUBJECT_ASSET_SIZE,
|
||||
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
|
||||
})
|
||||
data.onJobUpdate(workingJob)
|
||||
@@ -967,13 +1004,13 @@ export function AdRecreationBoard({
|
||||
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
|
||||
subject_kind: guessSubjectKind(element.name_zh || element.name_en || "主体"),
|
||||
background: "white",
|
||||
size: "1024",
|
||||
size: SUBJECT_ASSET_SIZE,
|
||||
source_frame_indices: framesForSegments.slice(0, 6).map((item) => item.index),
|
||||
})
|
||||
data.onJobUpdate(updated)
|
||||
toast.success(`6 视图已生成:${element.name_zh || element.name_en}`)
|
||||
toast.success(`高清视图已生成:${element.name_zh || element.name_en}`)
|
||||
} catch (e) {
|
||||
toast.error("6 视图生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error("高清视图生成失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSixViewBusyKey(null)
|
||||
}
|
||||
@@ -1493,6 +1530,8 @@ function SourceReferenceBuildPanel({
|
||||
const [subjectAssetPreview, setSubjectAssetPreview] = useState<{ id: string; left: number; top: number } | null>(null)
|
||||
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
||||
const [subjectDirection, setSubjectDirection] = useState("")
|
||||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||||
const [selectedCharacterId, setSelectedCharacterId] = useState("")
|
||||
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
|
||||
const selectedReferenceFrames = useMemo(
|
||||
() => frames.filter((frame) => selectedFrames.has(frame.index)),
|
||||
@@ -1506,6 +1545,10 @@ function SourceReferenceBuildPanel({
|
||||
return findSimilarActorSource(subjectReferenceFrames, frames)
|
||||
}, [frames, subjectReferenceFrames])
|
||||
const actorAssets = actorSource?.element.subject_assets ?? []
|
||||
const selectedCharacter = useMemo(
|
||||
() => characterLibrary.find((character) => character.id === selectedCharacterId) ?? null,
|
||||
[characterLibrary, selectedCharacterId],
|
||||
)
|
||||
const visibleActorAssets = useMemo(() => {
|
||||
const latestByView = new Map<string, SubjectAsset>()
|
||||
for (const asset of actorAssets) {
|
||||
@@ -1528,6 +1571,18 @@ function SourceReferenceBuildPanel({
|
||||
? `默认使用全部 ${frames.length} 张`
|
||||
: "待抽帧"
|
||||
|
||||
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 }
|
||||
}, [])
|
||||
|
||||
const extractKeyframes = async () => {
|
||||
setExtracting(true)
|
||||
try {
|
||||
@@ -1558,8 +1613,12 @@ function SourceReferenceBuildPanel({
|
||||
let element = workingFrame.elements?.find(isSimilarActorElement)
|
||||
if (!element) {
|
||||
workingJob = await addElement(job.id, baseFrame.index, {
|
||||
name_zh: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
|
||||
name_en: subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor",
|
||||
name_zh: selectedCharacter
|
||||
? `相似透明骨架主体 · ${selectedCharacter.name}`
|
||||
: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
|
||||
name_en: selectedCharacter
|
||||
? `similar innovative transparent skeleton humanoid subject based on ${selectedCharacter.name}`
|
||||
: subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor",
|
||||
position: "source-video main subject selected from global keyframes",
|
||||
source: "manual",
|
||||
})
|
||||
@@ -1575,14 +1634,15 @@ function SourceReferenceBuildPanel({
|
||||
subject_style: subjectStyle,
|
||||
reconstruction_mode: "similar",
|
||||
background: "white",
|
||||
size: "1024",
|
||||
size: SUBJECT_ASSET_SIZE,
|
||||
source_frame_indices: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index),
|
||||
views: ["front", "back", "left", "right", "three_quarter_left", "three_quarter_right"],
|
||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection),
|
||||
views: SUBJECT_ASSET_VIEWS.map((view) => view.value),
|
||||
character_id: selectedCharacterId,
|
||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter),
|
||||
replace_views: true,
|
||||
})
|
||||
onJobUpdate(updated)
|
||||
toast.success("相似主体 6 张白底视图已生成")
|
||||
toast.success("相似主体 10 张高清白底图已生成")
|
||||
} catch (e) {
|
||||
toast.error("相似主体重构失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
@@ -1612,10 +1672,11 @@ function SourceReferenceBuildPanel({
|
||||
subject_style: subjectStyle,
|
||||
reconstruction_mode: "similar",
|
||||
background: asset.background || "white",
|
||||
size: asset.size || "1024",
|
||||
size: SUBJECT_ASSET_SIZE,
|
||||
source_frame_indices: sourceIndices,
|
||||
views: [asset.view],
|
||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection),
|
||||
character_id: selectedCharacterId,
|
||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter),
|
||||
replace_views: true,
|
||||
})
|
||||
onJobUpdate(updated)
|
||||
@@ -1800,6 +1861,63 @@ function SourceReferenceBuildPanel({
|
||||
<span>相似主体白底视图</span>
|
||||
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
|
||||
</div>
|
||||
<span className="text-[10px] text-white/32">内置形象只做创意参考,不照抄</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-white/38">内置形象选择</span>
|
||||
<span className="text-[9px] text-white/28">{selectedCharacter ? `${selectedCharacter.name} · ${selectedCharacter.images.length} 张参考` : "源视频主角相似创新"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(86px,1fr))] gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCharacterId("")}
|
||||
className={`min-h-[58px] rounded-md border px-2 py-1.5 text-left transition ${
|
||||
!selectedCharacterId ? "border-cyan-200/55 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/25 text-white/45 hover:border-white/22 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10.5px] font-semibold">源视频相似</span>
|
||||
<span className="mt-1 block text-[9px] leading-tight opacity-70">根据关键帧创新</span>
|
||||
</button>
|
||||
{characterLibrary.map((character) => {
|
||||
const preview = characterPreviewImage(character)
|
||||
const active = selectedCharacterId === character.id
|
||||
return (
|
||||
<button
|
||||
key={character.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCharacterId(character.id)
|
||||
setSubjectStyle("transparent_human")
|
||||
}}
|
||||
className={`group flex min-h-[58px] items-center gap-1.5 rounded-md border px-1.5 py-1 text-left transition ${
|
||||
active ? "border-emerald-200/65 bg-emerald-300/12 text-emerald-50" : "border-white/10 bg-black/25 text-white/50 hover:border-emerald-200/35 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<span className="h-12 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-white">
|
||||
{preview ? <img src={characterLibraryImageUrl(preview.filename)} alt={character.name} className="h-full w-full object-cover" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[10px] font-semibold">{character.name}</span>
|
||||
<span className="mt-0.5 block text-[8.5px] opacity-58">7 图参考</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedCharacter?.images?.length ? (
|
||||
<div className="mt-1.5 flex gap-1 overflow-x-auto pb-0.5">
|
||||
{selectedCharacter.images.slice(0, 7).map((image) => (
|
||||
<div key={image.id} className="h-12 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-white" title={image.label}>
|
||||
<img src={characterLibraryImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 flex flex-wrap items-center justify-end gap-2 text-[10px] text-white/36">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
||||
{[
|
||||
@@ -1816,7 +1934,7 @@ function SourceReferenceBuildPanel({
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={subjectDirection}
|
||||
@@ -1824,7 +1942,7 @@ function SourceReferenceBuildPanel({
|
||||
placeholder="统一方向:如年轻女性 / 更运动 / 更高级"
|
||||
className="h-7 w-[240px] min-w-[180px] rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
|
||||
/>
|
||||
<span>{visibleActorAssets.length}/6</span>
|
||||
<span>{visibleActorAssets.length}/{SUBJECT_ASSET_VIEWS.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generateSimilarActor()}
|
||||
@@ -1832,7 +1950,7 @@ function SourceReferenceBuildPanel({
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
生成 6 视图
|
||||
生成 10 张高清图
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1893,7 +2011,7 @@ function SourceReferenceBuildPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
|
||||
可直接用全部关键帧生成;勾选关键帧后会只用已选帧。六视图会围绕同一个统一主体生成。
|
||||
可直接用全部关键帧生成;勾选关键帧后会只用已选帧。选择内置形象后,会围绕同一个统一主体生成全身多视角和肩颈/背部特写。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3006,7 +3124,7 @@ function StoryboardSegmentCard({
|
||||
onClick={() => onGenerateElement(candidate)}
|
||||
disabled={busy}
|
||||
className="rounded-md border border-white/10 bg-white/[0.04] px-1.5 py-1 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="选择该元素并生成提取图 + 6 视图"
|
||||
title="选择该元素并生成提取图 + 高清视图"
|
||||
>
|
||||
{candidate.name}
|
||||
</button>
|
||||
@@ -3027,7 +3145,7 @@ function StoryboardSegmentCard({
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-white/10 px-2 text-[10px] text-white/55 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{busySix ? <Loader2 className="h-3 w-3 animate-spin" /> : <ImageIcon className="h-3 w-3" />}
|
||||
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "6视图"}
|
||||
{element.subject_assets?.length ? `${element.subject_assets.length}视图` : "高清视图"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1075,6 +1075,7 @@ export async function generateSubjectAssets(
|
||||
size?: AssetSize
|
||||
source_frame_indices?: number[]
|
||||
views?: string[]
|
||||
character_id?: string
|
||||
subject_style?: "transparent_human" | "source_actor"
|
||||
reconstruction_mode?: "same" | "similar"
|
||||
prompt?: string
|
||||
@@ -1091,6 +1092,7 @@ export async function generateSubjectAssets(
|
||||
size: body.size ?? "source",
|
||||
source_frame_indices: body.source_frame_indices ?? null,
|
||||
views: body.views ?? null,
|
||||
character_id: body.character_id ?? "",
|
||||
subject_style: body.subject_style ?? "transparent_human",
|
||||
reconstruction_mode: body.reconstruction_mode ?? "same",
|
||||
prompt: body.prompt ?? "",
|
||||
|
||||
Reference in New Issue
Block a user