feat: add subject profile controls
This commit is contained in:
@@ -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">
|
||||
{[
|
||||
|
||||
Reference in New Issue
Block a user