auto-save 2026-05-18 07:27 (~6)

This commit is contained in:
2026-05-18 07:27:45 +08:00
parent 4653108baf
commit 9790e5bedb
6 changed files with 213 additions and 60 deletions

View File

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

View File

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