feat: add subject profile controls

This commit is contained in:
2026-05-18 18:38:10 +08:00
parent 87ffa6bac7
commit 33c3aef669
4 changed files with 377 additions and 6 deletions

View File

@@ -24,6 +24,7 @@ import {
type StoryboardScriptRewriteSegment,
type StoryboardScene,
type SubjectAsset,
type SubjectProfilePreference,
type SubjectKind,
addElement,
analyzeJob,
@@ -121,6 +122,18 @@ type SubjectPlanningRef = ImageRef & { view: string; roleHint: string }
type SubjectStyleMode = "transparent_human" | "source_actor"
type SubjectMode = "template" | "source_similar"
type SubjectViewMode = "all" | "common" | "custom"
type SubjectProfileMode = "random" | "manual"
type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood"
type SubjectProfileDraft = Record<SubjectProfileFieldKey, string>
type SubjectProfileOption = { value: string; label: string; prompt: string }
type SubjectProfileCategory = { key: SubjectProfileFieldKey; label: string; options: SubjectProfileOption[] }
type ResolvedSubjectProfile = {
mode: SubjectProfileMode
values: SubjectProfileDraft
summary: string
promptSummary: string
payload: SubjectProfilePreference
}
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
@@ -164,6 +177,113 @@ const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarte
const SUBJECT_ASSET_SIZE = "2048" as const
const SUBJECT_PROFILE_CATEGORIES: SubjectProfileCategory[] = [
{
key: "gender",
label: "性别表现",
options: [
{ value: "random", label: "随机", prompt: "gender presentation selected randomly for this request" },
{ value: "female", label: "女性", prompt: "female-presenting commercial ad subject" },
{ value: "male", label: "男性", prompt: "male-presenting commercial ad subject" },
{ value: "neutral", label: "中性", prompt: "androgynous or gender-neutral commercial ad subject" },
],
},
{
key: "age",
label: "年龄段",
options: [
{ value: "random", label: "随机", prompt: "age range selected randomly for this request" },
{ value: "young_adult", label: "年轻成人", prompt: "young adult, fresh short-video creator energy" },
{ value: "adult", label: "成熟成人", prompt: "adult, polished and reliable commercial look" },
{ value: "middle_aged", label: "中年", prompt: "middle-aged adult, credible wellness and work-stress context" },
{ value: "senior", label: "银发", prompt: "active senior adult, friendly wellness lifestyle context" },
],
},
{
key: "wardrobe",
label: "着装风格",
options: [
{ value: "random", label: "随机", prompt: "wardrobe style selected randomly for this request" },
{ value: "athleisure", label: "运动休闲", prompt: "clean athleisure outfit with visible neck and shoulders" },
{ value: "office", label: "通勤职场", prompt: "simple office-casual outfit, clean neckline, no bulky collar" },
{ value: "home_loungewear", label: "居家舒适", prompt: "soft home loungewear, relaxed wellness ad mood" },
{ value: "premium_minimal", label: "高级极简", prompt: "premium minimal styling, refined neutral clothing, clear shoulder line" },
{ value: "street_casual", label: "街头日常", prompt: "modern street-casual styling, creator-ad friendly" },
{ value: "wellness", label: "健康护理", prompt: "wellness-care styling, clean and professional but not medical" },
],
},
{
key: "region_ethnicity",
label: "地域人种",
options: [
{ value: "random", label: "随机", prompt: "regional and ethnic appearance selected randomly for this request" },
{ value: "east_asian", label: "东亚", prompt: "East Asian appearance cues, contemporary commercial styling" },
{ value: "southeast_asian", label: "东南亚", prompt: "Southeast Asian appearance cues, warm creator-ad styling" },
{ value: "south_asian", label: "南亚", prompt: "South Asian appearance cues, clear commercial readability" },
{ value: "black", label: "黑人/非洲裔", prompt: "Black or African-diaspora appearance cues, polished ad styling" },
{ value: "white", label: "白人/欧美", prompt: "White or European appearance cues, contemporary lifestyle ad styling" },
{ value: "latino", label: "拉丁裔", prompt: "Latino appearance cues, energetic lifestyle ad styling" },
{ value: "middle_eastern", label: "中东", prompt: "Middle Eastern appearance cues, premium lifestyle ad styling" },
{ value: "mixed_global", label: "混合国际化", prompt: "mixed or globally ambiguous appearance cues, international ad campaign feel" },
],
},
{
key: "skin_tone",
label: "肤色",
options: [
{ value: "random", label: "随机", prompt: "skin tone selected randomly for this request" },
{ value: "fair", label: "白皙", prompt: "fair skin tone" },
{ value: "light", label: "浅肤色", prompt: "light skin tone" },
{ value: "medium", label: "中等肤色", prompt: "medium skin tone" },
{ value: "tan", label: "小麦/棕肤", prompt: "tan or warm brown skin tone" },
{ value: "deep", label: "深肤色", prompt: "deep skin tone" },
],
},
{
key: "body",
label: "体型比例",
options: [
{ value: "random", label: "随机", prompt: "body proportion selected randomly for this request" },
{ value: "slim", label: "偏瘦", prompt: "slim body proportion with clear neck and shoulder silhouette" },
{ value: "average", label: "自然匀称", prompt: "average natural body proportion, believable short-video creator" },
{ value: "athletic", label: "运动型", prompt: "athletic body proportion, wellness and mobility context" },
{ value: "soft", label: "亲和微胖", prompt: "soft approachable body proportion, friendly lifestyle realism" },
{ value: "broad_shoulder", label: "肩颈明显", prompt: "slightly broader shoulder line, useful for neck-and-shoulder product placement" },
],
},
{
key: "hair",
label: "发型",
options: [
{ value: "random", label: "随机", prompt: "hair style selected randomly for this request" },
{ value: "short", label: "短发", prompt: "short tidy hair that does not cover the neck" },
{ value: "shoulder_length", label: "齐肩发", prompt: "shoulder-length hair kept away from the neck placement area" },
{ value: "ponytail", label: "马尾/束发", prompt: "ponytail or tied-back hair, neck and shoulders clearly visible" },
{ value: "curly", label: "卷发", prompt: "curly hair controlled away from shoulder product placement area" },
{ value: "buzz", label: "极短发", prompt: "very short hair, clean neck silhouette" },
{ value: "business_neat", label: "利落商务", prompt: "neat business hairstyle, polished creator-ad look" },
],
},
{
key: "mood",
label: "气质场景",
options: [
{ value: "random", label: "随机", prompt: "commercial mood selected randomly for this request" },
{ value: "energetic", label: "开场钩子", prompt: "energetic short-video hook performance" },
{ value: "premium_calm", label: "高级克制", prompt: "premium calm product-ad presence" },
{ value: "friendly_creator", label: "亲和达人", prompt: "friendly creator speaking-to-camera energy" },
{ value: "wellness_pro", label: "健康专业", prompt: "wellness professional credibility without medical or hospital cues" },
{ value: "urban_commute", label: "通勤疲惫", prompt: "urban commute and office fatigue context" },
{ value: "home_relax", label: "居家放松", prompt: "home relaxation and stress-relief context" },
],
},
]
const DEFAULT_SUBJECT_PROFILE_DRAFT: SubjectProfileDraft = SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => {
acc[category.key] = "random"
return acc
}, {} as SubjectProfileDraft)
type ModelTraceSpec = {
title: string
model: string
@@ -230,6 +350,61 @@ function shortId(id?: string | null) {
return id ? id.slice(0, 8) : "-"
}
function subjectProfileOption(category: SubjectProfileCategory, value: string) {
return category.options.find((option) => option.value === value) ?? category.options[0]
}
function randomSubjectProfileDraft(): SubjectProfileDraft {
return SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => {
const concrete = category.options.filter((option) => option.value !== "random")
const picked = concrete[Math.floor(Math.random() * concrete.length)] ?? category.options[0]
acc[category.key] = picked.value
return acc
}, {} as SubjectProfileDraft)
}
function resolveSubjectProfile(
mode: SubjectProfileMode,
draft: SubjectProfileDraft,
options: { randomizeRandomValues?: boolean } = {},
): ResolvedSubjectProfile {
const values = { ...DEFAULT_SUBJECT_PROFILE_DRAFT }
const labelParts: string[] = []
const promptParts: string[] = []
for (const category of SUBJECT_PROFILE_CATEGORIES) {
const rawValue = draft[category.key] || "random"
let option = subjectProfileOption(category, rawValue)
if (option.value === "random" && options.randomizeRandomValues) {
const concrete = category.options.filter((item) => item.value !== "random")
option = concrete[Math.floor(Math.random() * concrete.length)] ?? option
}
values[category.key] = option.value
labelParts.push(`${category.label}${option.label}`)
promptParts.push(`${category.label}: ${option.prompt}`)
}
const summary = labelParts.join(" / ")
const promptSummary = promptParts.join("; ")
return {
mode,
values,
summary,
promptSummary,
payload: {
mode,
gender: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[0], values.gender).label,
age: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[1], values.age).label,
wardrobe: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[2], values.wardrobe).label,
region_ethnicity: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[3], values.region_ethnicity).label,
skin_tone: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[4], values.skin_tone).label,
body: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[5], values.body).label,
hair: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[6], values.hair).label,
mood: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[7], values.mood).label,
resolved_summary: summary,
prompt_summary: promptSummary,
},
}
}
function formatSeconds(raw?: number) {
if (!raw || Number.isNaN(raw)) return "0.0s"
return `${raw.toFixed(1)}s`
@@ -500,7 +675,12 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null
function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedTemplate?: SubjectTemplatePromptSource) {
function buildSimilarSubjectPrompt(
subjectStyle: SubjectStyleMode,
direction: string,
selectedTemplate?: SubjectTemplatePromptSource,
subjectProfile?: ResolvedSubjectProfile | 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.",
@@ -516,6 +696,12 @@ function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: st
"Use the template images as planned creative direction only; generate an innovative variation, not a duplicate of that subject pack.",
)
}
if (subjectProfile?.promptSummary) {
base.push(
`Locked subject casting and styling profile for this request: ${subjectProfile.promptSummary}.`,
"Apply this one profile uniformly to every generated view; do not randomize gender, age, region, skin tone, body type, hair, wardrobe, or mood differently between views.",
)
}
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.",
@@ -600,6 +786,7 @@ function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyl
chain: [
`视觉 brief${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief失败时继续用用户方向和模板文字`,
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
"主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
],
@@ -2147,13 +2334,17 @@ function SourceReferenceBuildPanel({
onJobUpdate: (job: Job) => void
runtimeModels?: RuntimeModels
}) {
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number } | null>(null)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; profileLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all")
const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES)
const [subjectDirection, setSubjectDirection] = useState("")
const [subjectProfileMode, setSubjectProfileMode] = useState<SubjectProfileMode>("random")
const [subjectProfileDraft, setSubjectProfileDraft] = useState<SubjectProfileDraft>({ ...DEFAULT_SUBJECT_PROFILE_DRAFT })
const [randomProfileDraft, setRandomProfileDraft] = useState<SubjectProfileDraft>(() => randomSubjectProfileDraft())
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
const [selectedCharacterId, setSelectedCharacterId] = useState("")
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
@@ -2193,6 +2384,11 @@ function SourceReferenceBuildPanel({
if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES
return SUBJECT_ASSET_VIEWS.map((view) => view.value)
}, [customSubjectViews, subjectViewMode])
const subjectProfilePreview = useMemo(() => {
return subjectProfileMode === "random"
? resolveSubjectProfile("random", randomProfileDraft)
: resolveSubjectProfile("manual", subjectProfileDraft)
}, [randomProfileDraft, subjectProfileDraft, subjectProfileMode])
const visibleActorAssets = useMemo(() => {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of actorAssets) {
@@ -2228,6 +2424,19 @@ function SourceReferenceBuildPanel({
? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
: `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图`
const buildSubjectProfileForRequest = () => {
if (subjectProfileMode === "random") {
const randomized = randomSubjectProfileDraft()
setRandomProfileDraft(randomized)
const resolved = resolveSubjectProfile("random", randomized)
setLastSubjectProfile(resolved)
return resolved
}
const resolved = resolveSubjectProfile("manual", subjectProfileDraft, { randomizeRandomValues: true })
setLastSubjectProfile(resolved)
return resolved
}
const loadSubjectTemplateLibrary = async (silent = false) => {
setTemplateLibraryBusy(true)
try {
@@ -2256,6 +2465,7 @@ function SourceReferenceBuildPanel({
useEffect(() => {
setTemplateDraftName("")
setTemplateDraftNote("")
setLastSubjectProfile(null)
}, [job.id])
const generateSimilarActor = async () => {
@@ -2270,7 +2480,13 @@ function SourceReferenceBuildPanel({
const baseFrame = subjectReferenceFrames[0]
if (!baseFrame) return
const requestJobId = job.id
setSubjectBusyFor({ jobId: requestJobId, jobLabel: shortId(requestJobId), viewCount: selectedSubjectViews.length })
const requestProfile = buildSubjectProfileForRequest()
setSubjectBusyFor({
jobId: requestJobId,
jobLabel: shortId(requestJobId),
viewCount: selectedSubjectViews.length,
profileLabel: requestProfile.summary,
})
try {
let workingJob = job
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
@@ -2303,7 +2519,8 @@ function SourceReferenceBuildPanel({
views: selectedSubjectViews,
character_id: subjectMode === "template" ? selectedCharacterId : "",
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
})
onJobUpdate(updated)
@@ -2322,6 +2539,11 @@ function SourceReferenceBuildPanel({
if (!actorSource) return
setSubjectAssetBusy(`regen:${asset.id}`)
try {
const requestProfile = lastSubjectProfile ?? resolveSubjectProfile(
subjectProfileMode,
subjectProfileMode === "random" ? randomProfileDraft : subjectProfileDraft,
{ randomizeRandomValues: subjectProfileMode === "manual" },
)
const sourceIndices = asset.source_frame_indices?.length
? asset.source_frame_indices
: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index)
@@ -2335,7 +2557,8 @@ function SourceReferenceBuildPanel({
views: [asset.view],
character_id: subjectMode === "template" ? selectedCharacterId : "",
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
})
onJobUpdate(updated)
@@ -2562,6 +2785,7 @@ function SourceReferenceBuildPanel({
{subjectBusyFor ? (
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
{subjectBusyFor.jobLabel} {subjectBusyFor.viewCount}
<span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span>
</div>
) : null}
@@ -2599,6 +2823,75 @@ function SourceReferenceBuildPanel({
</div>
) : null}
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<div>
<div className="text-[10.5px] font-semibold text-white/70"></div>
<div className="mt-0.5 max-w-3xl text-[9.5px] leading-snug text-white/34">
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
{[
{ value: "random" as const, label: "随机组合" },
{ value: "manual" as const, label: "手动指定" },
].map((item) => (
<button
key={item.value}
type="button"
onClick={() => setSubjectProfileMode(item.value)}
className={`h-7 rounded px-2 text-[10px] font-semibold transition ${
subjectProfileMode === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
}`}
>
{item.label}
</button>
))}
</div>
{subjectProfileMode === "random" ? (
<button
type="button"
onClick={() => setRandomProfileDraft(randomSubjectProfileDraft())}
className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10px] font-semibold text-white/56 transition hover:border-cyan-300/35 hover:text-cyan-100"
>
<RefreshCw className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div className="rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/64">
{subjectProfileMode === "random" ? "当前随机预览:" : "当前手动设定:"}{subjectProfilePreview.summary}
{lastSubjectProfile ? (
<span className="mt-1 block text-cyan-50/44">{lastSubjectProfile.summary}</span>
) : null}
</div>
{subjectProfileMode === "manual" ? (
<div className="mt-2 grid gap-1.5 sm:grid-cols-2 xl:grid-cols-4">
{SUBJECT_PROFILE_CATEGORIES.map((category) => (
<label key={category.key} className="min-w-0">
<span className="mb-1 block text-[9px] font-semibold text-white/40">{category.label}</span>
<select
value={subjectProfileDraft[category.key]}
onChange={(event) => {
const value = event.target.value
setSubjectProfileDraft((current) => ({ ...current, [category.key]: value }))
}}
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white outline-none transition focus:border-cyan-300/50"
>
{category.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
))}
</div>
) : null}
</div>
<div className="grid gap-2 xl:grid-cols-[auto_auto_minmax(220px,1fr)_auto] xl:items-start">
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
{[

View File

@@ -522,6 +522,20 @@ export interface SubjectAsset {
created_at: number
}
export interface SubjectProfilePreference {
mode?: "random" | "manual"
gender?: string
age?: string
wardrobe?: string
region_ethnicity?: string
skin_tone?: string
body?: string
hair?: string
mood?: string
resolved_summary?: string
prompt_summary?: string
}
export interface ProductLibraryItem {
id: string
handle: string
@@ -1190,6 +1204,7 @@ export async function generateSubjectAssets(
subject_template_id?: string
subject_style?: "transparent_human" | "source_actor"
reconstruction_mode?: "same" | "similar"
subject_profile?: SubjectProfilePreference | null
prompt?: string
replace_views?: boolean
} = {},
@@ -1208,6 +1223,7 @@ export async function generateSubjectAssets(
subject_template_id: body.subject_template_id ?? "",
subject_style: body.subject_style ?? "transparent_human",
reconstruction_mode: body.reconstruction_mode ?? "same",
subject_profile: body.subject_profile ?? null,
prompt: body.prompt ?? "",
replace_views: body.replace_views ?? false,
}),