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

@@ -3948,6 +3948,20 @@ class GenerateSceneAssetReq(BaseModel):
product_images: list[dict] = Field(default_factory=list) product_images: list[dict] = Field(default_factory=list)
class SubjectProfilePreference(BaseModel):
mode: Literal["random", "manual"] = "random"
gender: str = ""
age: str = ""
wardrobe: str = ""
region_ethnicity: str = ""
skin_tone: str = ""
body: str = ""
hair: str = ""
mood: str = ""
resolved_summary: str = ""
prompt_summary: str = ""
class GenerateSubjectAssetsReq(BaseModel): class GenerateSubjectAssetsReq(BaseModel):
subject_kind: SubjectKind = "object" subject_kind: SubjectKind = "object"
background: AssetBackground = "white" background: AssetBackground = "white"
@@ -3959,10 +3973,43 @@ class GenerateSubjectAssetsReq(BaseModel):
subject_template_id: str = "" subject_template_id: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
reconstruction_mode: Literal["same", "similar"] = "same" reconstruction_mode: Literal["same", "similar"] = "same"
subject_profile: SubjectProfilePreference | None = None
prompt: str = "" prompt: str = ""
replace_views: bool = False replace_views: bool = False
def _subject_profile_prompt_clause(profile: SubjectProfilePreference | None) -> str:
if not profile:
return ""
prompt_summary = (profile.prompt_summary or "").strip()
resolved_summary = (profile.resolved_summary or "").strip()
if prompt_summary:
body = prompt_summary[:1400]
else:
parts = [
("gender presentation", profile.gender),
("age range", profile.age),
("wardrobe style", profile.wardrobe),
("regional/ethnic appearance cues", profile.region_ethnicity),
("skin tone", profile.skin_tone),
("body proportion", profile.body),
("hair style", profile.hair),
("commercial mood", profile.mood),
]
body = "; ".join(f"{name}: {value.strip()}" for name, value in parts if value and value.strip())[:1400]
if not body and not resolved_summary:
return ""
mode = "random-composed" if profile.mode == "random" else "manually selected"
resolved = f" UI summary: {resolved_summary[:700]}." if resolved_summary else ""
return (
f"Structured subject casting profile ({mode}, locked for this request): {body}. "
"This profile overrides ambiguous source/template traits for gender presentation, age range, wardrobe, regional/ethnic appearance cues, skin tone, body proportion, hair, and commercial mood. "
"Apply the same profile uniformly to every requested view; do not mix different genders, ages, skin tones, wardrobes, or character identities inside the pack."
+ resolved
+ " "
)
class UpdateProductRefsReq(BaseModel): class UpdateProductRefsReq(BaseModel):
items: list[dict] = Field(default_factory=list) items: list[dict] = Field(default_factory=list)
@@ -4490,6 +4537,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
) )
prompt_extra = req.prompt.strip() prompt_extra = req.prompt.strip()
prompt_extra_clause = f"User direction: {prompt_extra[:1200]} " if prompt_extra else "" prompt_extra_clause = f"User direction: {prompt_extra[:1200]} " if prompt_extra else ""
subject_profile_clause = _subject_profile_prompt_clause(req.subject_profile)
identity_lock_clause = ( identity_lock_clause = (
"Identity lock: these API calls generate one high-definition multi-view pack for ONE single subject, but each individual output file must show only its one requested view. " "Identity lock: these API calls generate one high-definition multi-view pack for ONE single subject, but each individual output file must show only its one requested view. "
"Before rendering, infer one consistent character bible from the supplied text brief and generation instructions: gender presentation, age range, body proportions, head shape, face direction cues, material, silhouette, wardrobe/material style, and commercial mood. " "Before rendering, infer one consistent character bible from the supplied text brief and generation instructions: gender presentation, age range, body proportions, head shape, face direction cues, material, silhouette, wardrobe/material style, and commercial mood. "
@@ -4554,6 +4602,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
+ neck_product_clause + neck_product_clause
+ canvas_clause + canvas_clause
+ prompt_extra_clause + prompt_extra_clause
+ subject_profile_clause
+ actor_style_clause + actor_style_clause
+ framing_clause + framing_clause
+ f"Create a high-definition standalone asset on a solid {bg_phrase} background. " + f"Create a high-definition standalone asset on a solid {bg_phrase} background. "

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ import {
type StoryboardScriptRewriteSegment, type StoryboardScriptRewriteSegment,
type StoryboardScene, type StoryboardScene,
type SubjectAsset, type SubjectAsset,
type SubjectProfilePreference,
type SubjectKind, type SubjectKind,
addElement, addElement,
analyzeJob, analyzeJob,
@@ -121,6 +122,18 @@ type SubjectPlanningRef = ImageRef & { view: string; roleHint: string }
type SubjectStyleMode = "transparent_human" | "source_actor" type SubjectStyleMode = "transparent_human" | "source_actor"
type SubjectMode = "template" | "source_similar" type SubjectMode = "template" | "source_similar"
type SubjectViewMode = "all" | "common" | "custom" 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 StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">> 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" 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_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 = { type ModelTraceSpec = {
title: string title: string
model: string model: string
@@ -230,6 +350,61 @@ function shortId(id?: string | null) {
return id ? id.slice(0, 8) : "-" 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) { function formatSeconds(raw?: number) {
if (!raw || Number.isNaN(raw)) return "0.0s" if (!raw || Number.isNaN(raw)) return "0.0s"
return `${raw.toFixed(1)}s` return `${raw.toFixed(1)}s`
@@ -500,7 +675,12 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null 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 = [ const base = [
"Create a new similar but non-identical information-feed ad subject from the selected reference frames.", "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.", "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.", "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") { if (subjectStyle === "transparent_human") {
base.push( 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.", "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: [ chain: [
`视觉 brief${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief失败时继续用用户方向和模板文字`, `视觉 brief${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief失败时继续用用户方向和模板文字`,
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`, `主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
"主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`, `图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致", "身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
], ],
@@ -2147,13 +2334,17 @@ function SourceReferenceBuildPanel({
onJobUpdate: (job: Job) => void onJobUpdate: (job: Job) => void
runtimeModels?: RuntimeModels 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 [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar") const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human") const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all") const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all")
const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES) const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES)
const [subjectDirection, setSubjectDirection] = useState("") 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 [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
const [selectedCharacterId, setSelectedCharacterId] = useState("") const [selectedCharacterId, setSelectedCharacterId] = useState("")
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([]) const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
@@ -2193,6 +2384,11 @@ function SourceReferenceBuildPanel({
if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES
return SUBJECT_ASSET_VIEWS.map((view) => view.value) return SUBJECT_ASSET_VIEWS.map((view) => view.value)
}, [customSubjectViews, subjectViewMode]) }, [customSubjectViews, subjectViewMode])
const subjectProfilePreview = useMemo(() => {
return subjectProfileMode === "random"
? resolveSubjectProfile("random", randomProfileDraft)
: resolveSubjectProfile("manual", subjectProfileDraft)
}, [randomProfileDraft, subjectProfileDraft, subjectProfileMode])
const visibleActorAssets = useMemo(() => { const visibleActorAssets = useMemo(() => {
const latestByView = new Map<string, SubjectAsset>() const latestByView = new Map<string, SubjectAsset>()
for (const asset of actorAssets) { for (const asset of actorAssets) {
@@ -2228,6 +2424,19 @@ function SourceReferenceBuildPanel({
? `用模板生成 ${selectedSubjectViews.length} 张主体视图` ? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
: `从源视频创新生成 ${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) => { const loadSubjectTemplateLibrary = async (silent = false) => {
setTemplateLibraryBusy(true) setTemplateLibraryBusy(true)
try { try {
@@ -2256,6 +2465,7 @@ function SourceReferenceBuildPanel({
useEffect(() => { useEffect(() => {
setTemplateDraftName("") setTemplateDraftName("")
setTemplateDraftNote("") setTemplateDraftNote("")
setLastSubjectProfile(null)
}, [job.id]) }, [job.id])
const generateSimilarActor = async () => { const generateSimilarActor = async () => {
@@ -2270,7 +2480,13 @@ function SourceReferenceBuildPanel({
const baseFrame = subjectReferenceFrames[0] const baseFrame = subjectReferenceFrames[0]
if (!baseFrame) return if (!baseFrame) return
const requestJobId = job.id 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 { try {
let workingJob = job let workingJob = job
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
@@ -2303,7 +2519,8 @@ function SourceReferenceBuildPanel({
views: selectedSubjectViews, views: selectedSubjectViews,
character_id: subjectMode === "template" ? selectedCharacterId : "", character_id: subjectMode === "template" ? selectedCharacterId : "",
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "", subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt), subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true, replace_views: true,
}) })
onJobUpdate(updated) onJobUpdate(updated)
@@ -2322,6 +2539,11 @@ function SourceReferenceBuildPanel({
if (!actorSource) return if (!actorSource) return
setSubjectAssetBusy(`regen:${asset.id}`) setSubjectAssetBusy(`regen:${asset.id}`)
try { try {
const requestProfile = lastSubjectProfile ?? resolveSubjectProfile(
subjectProfileMode,
subjectProfileMode === "random" ? randomProfileDraft : subjectProfileDraft,
{ randomizeRandomValues: subjectProfileMode === "manual" },
)
const sourceIndices = asset.source_frame_indices?.length const sourceIndices = asset.source_frame_indices?.length
? asset.source_frame_indices ? asset.source_frame_indices
: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index) : subjectReferenceFrames.slice(0, 12).map((frame) => frame.index)
@@ -2335,7 +2557,8 @@ function SourceReferenceBuildPanel({
views: [asset.view], views: [asset.view],
character_id: subjectMode === "template" ? selectedCharacterId : "", character_id: subjectMode === "template" ? selectedCharacterId : "",
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "", subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt), subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true, replace_views: true,
}) })
onJobUpdate(updated) onJobUpdate(updated)
@@ -2562,6 +2785,7 @@ function SourceReferenceBuildPanel({
{subjectBusyFor ? ( {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"> <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} {subjectBusyFor.jobLabel} {subjectBusyFor.viewCount}
<span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span>
</div> </div>
) : null} ) : null}
@@ -2599,6 +2823,75 @@ function SourceReferenceBuildPanel({
</div> </div>
) : null} ) : 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="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"> <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 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 { export interface ProductLibraryItem {
id: string id: string
handle: string handle: string
@@ -1190,6 +1204,7 @@ export async function generateSubjectAssets(
subject_template_id?: string subject_template_id?: string
subject_style?: "transparent_human" | "source_actor" subject_style?: "transparent_human" | "source_actor"
reconstruction_mode?: "same" | "similar" reconstruction_mode?: "same" | "similar"
subject_profile?: SubjectProfilePreference | null
prompt?: string prompt?: string
replace_views?: boolean replace_views?: boolean
} = {}, } = {},
@@ -1208,6 +1223,7 @@ export async function generateSubjectAssets(
subject_template_id: body.subject_template_id ?? "", subject_template_id: body.subject_template_id ?? "",
subject_style: body.subject_style ?? "transparent_human", subject_style: body.subject_style ?? "transparent_human",
reconstruction_mode: body.reconstruction_mode ?? "same", reconstruction_mode: body.reconstruction_mode ?? "same",
subject_profile: body.subject_profile ?? null,
prompt: body.prompt ?? "", prompt: body.prompt ?? "",
replace_views: body.replace_views ?? false, replace_views: body.replace_views ?? false,
}), }),