auto-save 2026-05-17 23:19 (~2)

This commit is contained in:
2026-05-17 23:19:14 +08:00
parent a1de7f2173
commit cbe7a1b9ef
2 changed files with 110 additions and 49 deletions

View File

@@ -93,6 +93,7 @@ type AudioStoryboardRow = {
}
type ProductRefItem = ProductRefStateItem
type SubjectStyleMode = "transparent_human" | "source_actor"
const PRODUCT_VIEW_SLOTS = [
{ value: "front", label: "正面/外侧", hint: "整体 U 形轮廓、开口宽度、外壳主外观" },
@@ -251,7 +252,33 @@ function closestFrameForTime(frames: KeyFrame[], time: number) {
function isSimilarActorElement(element: KeyElement) {
const name = `${element.name_zh || ""} ${element.name_en || ""}`.toLowerCase()
return name.includes("相似主角") || name.includes("similar ad actor") || name.includes("similar actor")
return name.includes("相似主角") || name.includes("相似主体") || name.includes("similar ad actor") || name.includes("similar actor") || name.includes("similar subject")
}
function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string) {
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.",
"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.",
]
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.",
"Keep transparent skin, visible spine, rib cage, pelvis, arm bones, leg bones, and a friendly non-horror wellness advertising look consistent in every view.",
"Do not generate a normal opaque human, skeleton-only character, medical anatomy, organs, blood, gore, surgery, hospital, or horror imagery.",
)
} else {
base.push(
"The subject must be a normal believable commercial ad actor, not a transparent or skeleton character.",
"Keep wardrobe category, age range, gender presentation, body proportion, and creator-ad styling consistent in every view.",
)
}
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.")
return base.join(" ")
}
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
@@ -1255,13 +1282,19 @@ function SourceReferenceBuildPanel({
const [subjectBusy, setSubjectBusy] = useState(false)
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
const [framePreview, setFramePreview] = useState<{ index: number; left: number; top: number } | null>(null)
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
const [subjectDirection, setSubjectDirection] = 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)),
[frames, selectedFrames],
)
const subjectReferenceFrames = useMemo(
() => selectedReferenceFrames.length ? selectedReferenceFrames : frames,
[frames, selectedReferenceFrames],
)
const actorSource = useMemo(() => {
const pool = selectedReferenceFrames.length ? selectedReferenceFrames : frames
const pool = subjectReferenceFrames
for (const frame of pool) {
const element = frame.elements?.find(isSimilarActorElement)
if (element?.subject_assets?.length) return { frame, element }
@@ -1271,9 +1304,14 @@ function SourceReferenceBuildPanel({
if (element) return { frame, element }
}
return null
}, [frames, selectedReferenceFrames])
}, [subjectReferenceFrames])
const actorAssets = actorSource?.element.subject_assets ?? []
const previewFrame = framePreview ? frames.find((frame) => frame.index === framePreview.index) ?? null : null
const referenceCountLabel = selectedReferenceFrames.length
? `使用已选 ${selectedReferenceFrames.length}`
: frames.length
? `默认使用全部 ${frames.length}`
: "待抽帧"
const extractKeyframes = async () => {
setExtracting(true)
@@ -1292,11 +1330,11 @@ function SourceReferenceBuildPanel({
}
const generateSimilarActor = async () => {
if (!selectedReferenceFrames.length) {
toast.warning("请先 12 张关键帧里选择主角参考帧。")
if (!frames.length) {
toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。")
return
}
const baseFrame = selectedReferenceFrames[0]
const baseFrame = subjectReferenceFrames[0]
if (!baseFrame) return
setSubjectBusy(true)
try {
@@ -1305,9 +1343,9 @@ function SourceReferenceBuildPanel({
let element = workingFrame.elements?.find(isSimilarActorElement)
if (!element) {
workingJob = await addElement(job.id, baseFrame.index, {
name_zh: "相似主角",
name_en: "similar ad actor",
position: "source-video main presenter selected from global keyframes",
name_zh: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
name_en: subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor",
position: "source-video main subject selected from global keyframes",
source: "manual",
})
onJobUpdate(workingJob)
@@ -1315,22 +1353,22 @@ function SourceReferenceBuildPanel({
element = workingFrame.elements?.find(isSimilarActorElement)
?? workingFrame.elements?.[workingFrame.elements.length - 1]
}
if (!element) throw new Error("similar actor element missing")
if (!element) throw new Error("similar subject element missing")
const updated = await generateSubjectAssets(job.id, baseFrame.index, element.id, {
subject_kind: "living",
subject_style: "source_actor",
subject_style: subjectStyle,
reconstruction_mode: "similar",
background: "white",
size: "1024",
source_frame_indices: selectedReferenceFrames.slice(0, 12).map((frame) => frame.index),
source_frame_indices: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index),
views: ["front", "back", "left", "right", "three_quarter_left", "three_quarter_right"],
prompt: "Create a new similar information-feed ad presenter, not a replica of the source person. Keep the creator-ad energy, pose vocabulary, wardrobe category, shot readability, and commercial realism. White background six-view reference sheet output, consistent actor identity across views.",
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection),
})
onJobUpdate(updated)
toast.success("相似主 6 张白底视图已生成")
toast.success("相似主 6 张白底视图已生成")
} catch (e) {
toast.error("相似主重构失败:" + (e instanceof Error ? e.message : String(e)))
toast.error("相似主重构失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectBusy(false)
}
@@ -1385,10 +1423,10 @@ function SourceReferenceBuildPanel({
<div className="min-w-0">
{framePreviewPortal}
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主" />
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主" />
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
{frames.length ? `${frames.length}` : "待抽帧"} · {selectedReferenceFrames.length}
{referenceCountLabel}
</span>
<button
type="button"
@@ -1405,7 +1443,7 @@ function SourceReferenceBuildPanel({
<div className="h-[250px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[290px]">
<div className="flex items-center justify-between gap-2">
<span className="text-[10.5px] text-white/34"></span>
<span className="text-[10.5px] text-white/30"></span>
<span className="text-[10.5px] text-white/30"></span>
</div>
<div className="mt-2 grid grid-cols-6 gap-1.5 md:grid-cols-8 xl:grid-cols-12 2xl:grid-cols-16">
@@ -1459,14 +1497,37 @@ function SourceReferenceBuildPanel({
</div>
<div className="mt-2 border-t border-white/8 pt-2">
<div className="mb-1.5 flex items-center justify-between text-[10px] text-white/36">
<span></span>
<div className="flex items-center gap-2">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
<span></span>
<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">
{[
{ value: "transparent_human" as const, label: "透明骨架" },
{ value: "source_actor" as const, label: "普通真人" },
].map((item) => (
<button
key={item.value}
type="button"
onClick={() => setSubjectStyle(item.value)}
className={`h-6 rounded px-2 text-[10px] font-semibold transition ${
subjectStyle === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
}`}
>
{item.label}
</button>
))}
</div>
<input
value={subjectDirection}
onChange={(event) => setSubjectDirection(event.target.value)}
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>{actorAssets.length}/6</span>
<button
type="button"
onClick={() => void generateSimilarActor()}
disabled={!selectedReferenceFrames.length || subjectBusy}
disabled={!frames.length || subjectBusy}
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" />}
@@ -1491,7 +1552,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">
6
</div>
)}
</div>