feat: add subject image model controls

This commit is contained in:
2026-05-20 09:16:28 +08:00
parent b4a7968c1b
commit c245bff4b8
5 changed files with 226 additions and 16 deletions

View File

@@ -28,6 +28,7 @@ import {
type StoryboardScriptRewriteSegment,
type StoryboardScene,
type SubjectAsset,
type SubjectImageModelPreference,
type SubjectProfilePreference,
type SubjectKind,
addElement,
@@ -317,6 +318,15 @@ const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: str
},
]
const SUBJECT_IMAGE_MODEL_OPTIONS: Array<{ value: SubjectImageModelPreference; label: string; detail: string }> = [
{ value: "auto", label: "自动", detail: "GPT 失败才兜底" },
{ value: "gpt-image-2", label: "GPT", detail: "只用 gpt-image-2" },
{ value: "gemini-3-pro-image-preview", label: "Gemini", detail: "直接用 Gemini" },
]
const SUBJECT_MODEL_MEMORY_KEY = "skg:subject-image-model:v1"
const SUBJECT_PROMPT_MEMORY_KEY = "skg:subject-prompt-memory:v1"
const SUBJECT_PROMPT_MEMORY_LIMIT = 28
const SUBJECT_ASSET_SIZE = "2048" as const
const SUBJECT_PROFILE_CATEGORIES: SubjectProfileCategory[] = [
@@ -639,6 +649,77 @@ function resolveSubjectProfile(
}
}
function emptySubjectPromptMemory(): Record<SubjectReconstructionMode, string[]> {
return { realistic: [], cartoon: [], elements: [], custom: [] }
}
function loadSubjectPromptMemory(): Record<SubjectReconstructionMode, string[]> {
if (typeof window === "undefined") return emptySubjectPromptMemory()
try {
const parsed = JSON.parse(window.localStorage.getItem(SUBJECT_PROMPT_MEMORY_KEY) || "{}") as Partial<Record<SubjectReconstructionMode, string[]>>
const next = emptySubjectPromptMemory()
for (const mode of Object.keys(next) as SubjectReconstructionMode[]) {
next[mode] = Array.isArray(parsed[mode]) ? parsed[mode]!.filter(Boolean).slice(0, SUBJECT_PROMPT_MEMORY_LIMIT) : []
}
return next
} catch {
return emptySubjectPromptMemory()
}
}
function saveSubjectPromptMemory(memory: Record<SubjectReconstructionMode, string[]>) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(SUBJECT_PROMPT_MEMORY_KEY, JSON.stringify(memory))
} catch {
/* localStorage may be unavailable */
}
}
function loadSubjectImageModelPreference(): SubjectImageModelPreference {
if (typeof window === "undefined") return "auto"
const raw = window.localStorage.getItem(SUBJECT_MODEL_MEMORY_KEY)
return SUBJECT_IMAGE_MODEL_OPTIONS.some((item) => item.value === raw) ? raw as SubjectImageModelPreference : "auto"
}
function saveSubjectImageModelPreference(value: SubjectImageModelPreference) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(SUBJECT_MODEL_MEMORY_KEY, value)
} catch {
/* localStorage may be unavailable */
}
}
function subjectPromptChipsFromText(text: string): string[] {
const normalized = text.replace(/[,。;;、\n]/g, ",").replace(/\s+/g, " ").trim()
const rawParts = normalized.split(",").map((item) => item.trim()).filter(Boolean)
const chips: string[] = []
const add = (value: string) => {
const clean = value.replace(/^需要|^保持|^统一|^加上|^加入|^改成|^不要变/g, "").trim()
if (clean.length < 2 || clean.length > 22) return
if (!chips.includes(clean)) chips.push(clean)
}
for (const part of rawParts) {
add(part)
const matches = part.match(/(不要[^,,。;;、]{1,12}|同一套?[^,,。;;、]{1,10}|统一[^,,。;;、]{1,10}|白色[^,,。;;、]{1,10}|黑色[^,,。;;、]{1,10}|运动[^,,。;;、]{1,10}|亚洲|欧美|女性|男性|年轻|中年|短发|长发|马尾|背心|T恤|瑜伽服|运动装|商业广告感|高级感|科技感|可爱|极简)/g)
matches?.forEach(add)
}
return chips.slice(0, 14)
}
function mergeSubjectPromptMemory(current: string[], text: string) {
const chips = subjectPromptChipsFromText(text)
return [...chips, ...current.filter((item) => !chips.includes(item))].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT)
}
function appendSubjectPromptChip(text: string, chip: string) {
const trimmed = text.trim()
if (!trimmed) return chip
if (trimmed.includes(chip)) return trimmed
return `${trimmed}${chip}`
}
function formatSeconds(raw?: number) {
if (!raw || Number.isNaN(raw)) return "0.0s"
return `${raw.toFixed(1)}s`
@@ -1091,8 +1172,11 @@ function buildSimilarSubjectPrompt(
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 the full multi-view set.",
"Default casting rule: inherit the reference frames' broad gender presentation, regional/ethnic appearance category, skin-tone family, body-proportion category, and role energy unless the user explicitly overrides them.",
"Lock one consistent character bible before generating: same newly designed person or character, same gender presentation, age range, body proportions, face design, hair design, skin tone, material, silhouette, commercial style, and visual identity across the full multi-view set.",
"Lock one wardrobe bible before generating: same garment type, same color palette, same neckline, same sleeve or strap structure, same fabric/material, same fit, and same visible accessories across every view.",
"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.",
"Never change outfit between views. Do not switch clothing category from front to side to back.",
"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.",
@@ -3203,9 +3287,11 @@ function SourceSubjectPipeline({
const [activeDropMode, setActiveDropMode] = useState<SubjectReconstructionMode | null>(null)
const [conversionFrameIndicesByMode, setConversionFrameIndicesByMode] = useState<Record<SubjectReconstructionMode, number[]>>(() => ({ ...EMPTY_RECONSTRUCTION_FRAME_MAP }))
const [reconstructionDirections, setReconstructionDirections] = useState<Record<SubjectReconstructionMode, string>>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS }))
const [subjectImageModelPreference, setSubjectImageModelPreference] = useState<SubjectImageModelPreference>(() => loadSubjectImageModelPreference())
const [promptMemoryByMode, setPromptMemoryByMode] = useState<Record<SubjectReconstructionMode, string[]>>(() => loadSubjectPromptMemory())
const [cartoonStyle, setCartoonStyle] = useState<CartoonReconstructionStyle>("3d_animation")
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string; modelLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState<string | null>(null)
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
@@ -3305,6 +3391,14 @@ function SourceSubjectPipeline({
setExpandedSubjectPackKey(null)
}, [job.id])
useEffect(() => {
saveSubjectImageModelPreference(subjectImageModelPreference)
}, [subjectImageModelPreference])
useEffect(() => {
saveSubjectPromptMemory(promptMemoryByMode)
}, [promptMemoryByMode])
useEffect(() => {
if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
setExpandedSubjectPackKey(null)
@@ -3327,6 +3421,28 @@ function SourceSubjectPipeline({
return resolved
}
const rememberPromptForMode = (mode: SubjectReconstructionMode, text = reconstructionDirections[mode]) => {
setPromptMemoryByMode((current) => ({
...current,
[mode]: mergeSubjectPromptMemory(current[mode] || [], text),
}))
}
const applyPromptChip = (mode: SubjectReconstructionMode, chip: string) => {
setReconstructionDirections((current) => ({
...current,
[mode]: appendSubjectPromptChip(current[mode], chip),
}))
setPromptMemoryByMode((current) => ({
...current,
[mode]: [chip, ...(current[mode] || []).filter((item) => item !== chip)].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT),
}))
}
const subjectModelLabel = (value: SubjectImageModelPreference) => {
return SUBJECT_IMAGE_MODEL_OPTIONS.find((item) => item.value === value)?.label ?? "自动"
}
const generateSubjectPack = async (mode: SubjectReconstructionMode, sourceIndices = conversionFrameIndicesByMode[mode]) => {
if (subjectBusyFor) {
toast.warning("主体套图正在生成中,完成后再重生。")
@@ -3354,6 +3470,7 @@ function SourceSubjectPipeline({
: buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
const userDirection = buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle)
rememberPromptForMode(mode, reconstructionDirections[mode])
const modeName = reconstructionElementName(mode)
setSubjectBusyFor({
jobId: requestJobId,
@@ -3362,6 +3479,7 @@ function SourceSubjectPipeline({
viewCount: selectedSubjectViews.length,
sourceCount: sourceFrames.length,
profileLabel: requestProfile?.summary ?? "按自主描述",
modelLabel: subjectModelLabel(subjectImageModelPreference),
})
try {
let workingJob = job
@@ -3391,6 +3509,7 @@ function SourceSubjectPipeline({
views: selectedSubjectViews,
subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
image_model_preference: subjectImageModelPreference,
replace_views: false,
pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
pack_mode: mode,
@@ -3454,6 +3573,7 @@ function SourceSubjectPipeline({
? null
: lastSubjectProfile ?? buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
rememberPromptForMode(mode, reconstructionDirections[mode])
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: "living",
subject_style: subjectStyle,
@@ -3469,6 +3589,7 @@ function SourceSubjectPipeline({
null,
requestProfile,
),
image_model_preference: subjectImageModelPreference,
replace_views: true,
pack_id: asset.pack_id ?? "",
pack_label: asset.pack_label ?? "",
@@ -3601,6 +3722,30 @@ function SourceSubjectPipeline({
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectBusyFor?.mode === "cartoon" ? "cartoon_subject" : "source_actor")} compact />
</div>
<div className="max-h-[520px] min-h-[410px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:max-h-[600px] 2xl:min-h-[500px]">
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-1.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-white/62"></span>
<span className="text-[9px] text-white/34"></span>
</div>
<div className="grid grid-cols-3 gap-1">
{SUBJECT_IMAGE_MODEL_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setSubjectImageModelPreference(option.value)}
className={`min-h-9 rounded-md border px-1.5 py-1 text-left transition ${
subjectImageModelPreference === option.value
? "border-[#d6b36a]/70 bg-[#d6b36a]/16 text-white"
: "border-white/10 bg-black/28 text-white/48 hover:border-white/22 hover:text-white"
}`}
title={option.detail}
>
<span className="block text-[10px] font-semibold">{option.label}</span>
<span className="mt-0.5 block truncate text-[8.5px] text-white/36">{option.detail}</span>
</button>
))}
</div>
</div>
<div className="mb-2 rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62">
1-3
</div>
@@ -3608,6 +3753,9 @@ function SourceSubjectPipeline({
{RECONSTRUCTION_MODES.map((modeConfig) => {
const mode = modeConfig.value
const modeFrames = conversionFramesByMode[mode]
const promptChips = [...subjectPromptChipsFromText(reconstructionDirections[mode]), ...(promptMemoryByMode[mode] || [])]
.filter((chip, index, list) => chip && list.indexOf(chip) === index)
.slice(0, 10)
const dropActive = activeDropMode === mode
const canGenerate = mode === "custom"
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
@@ -3715,10 +3863,26 @@ function SourceSubjectPipeline({
<textarea
value={reconstructionDirections[mode]}
onChange={(event) => setReconstructionDirections((current) => ({ ...current, [mode]: event.target.value }))}
onBlur={(event) => rememberPromptForMode(mode, event.target.value)}
placeholder={modeConfig.placeholder}
rows={2}
className="mt-2 min-h-[48px] w-full resize-none rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[10.5px] leading-snug text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
/>
{promptChips.length ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{promptChips.map((chip) => (
<button
key={chip}
type="button"
onClick={() => applyPromptChip(mode, chip)}
className="h-6 rounded-full border border-white/10 bg-black/28 px-2 text-[9.5px] text-white/52 transition hover:border-[#d6b36a]/50 hover:bg-[#d6b36a]/12 hover:text-white"
title="点击加入提示词"
>
{chip}
</button>
))}
</div>
) : null}
<button
type="button"
onClick={() => void generateSubjectPack(mode)}
@@ -3752,6 +3916,7 @@ function SourceSubjectPipeline({
<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">
{reconstructionModeConfig(subjectBusyFor.mode).label} {subjectBusyFor.viewCount} {subjectBusyFor.sourceCount || "自主描述"}
<span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span>
<span className="mt-1 block text-cyan-50/50">{subjectBusyFor.modelLabel}</span>
</div>
) : null}
{subjectAssetPacks.length ? (