fix: surface resilient subject asset generation

This commit is contained in:
2026-05-18 18:15:45 +08:00
parent cc4c021074
commit 095c6f1c00
8 changed files with 251 additions and 177 deletions

View File

@@ -38,6 +38,7 @@ import {
generateProductAngleAsset,
generateSubjectAssets,
generatedImageUrl,
getJob,
getRuntimeHealth,
hasCutout,
listCharacterLibrary,
@@ -570,9 +571,9 @@ function audioModelTrace(models?: RuntimeModels): ModelTraceSpec {
title: "音频解析",
model: modelList([models?.asr, models?.translate, models?.asr_fallback]),
chain: [
`ASR 转写:优先 ${modelValue(models?.asr)}失败后尝试本机 ${modelValue(models?.local_asr)},再回退 ${modelValue(models?.asr_fallback)}`,
`字幕翻译:${modelValue(models?.translate)} 输出中文逐句时间轴`,
`讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav 做多模态音频分析`,
`ASR 转写:优先 ${modelValue(models?.asr)}失败后尝试本机 ${modelValue(models?.local_asr)};仍失败才回退 ${modelValue(models?.asr_fallback)},并拒绝假字幕/重复时间轴`,
`字幕翻译:${modelValue(models?.translate)} 按 ASR 段落输出中文;失败时保留原文时间轴,中文可为空`,
`讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav + 转写时间轴做多模态分析;失败时用本地时长/段落估算兜底`,
],
note: "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。",
}
@@ -583,9 +584,10 @@ function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
title: "产品视角识别 / 补图",
model: modelList([models?.product_view, models?.image]),
chain: [
`批量视角识别:${modelValue(models?.product_view)} 一次读取同一产品多张图,标注视角、左右、上下、用途和风险`,
`缺角度补图:${imageModelChain(models)} 读取最相关的多张已上传参考图,按同一肩颈按摩仪结构补齐缺失视角`,
"前端只保存标注和 AI 补图结果;后续生成视频时每条最多挑 6 张相关产品图",
`批量视角识别:${modelValue(models?.product_view)} 多图读取同一产品素材,标注视角、佩戴者左右、上下、内外侧、用途和风险`,
"识别兜底:批量失败会按单图重试;仍失败或文件缺失时写入本地默认视角,并在 risk/note 标明兜底原因",
`缺角度补图:${imageModelChain(models)} 走 /images/edits最多读取 6 张已上传参考图补齐缺失视角;失败保留重试入口,不自动换模型`,
"前端只保存标注和 AI 补图结果;后续首尾帧/视频规划每条最多挑 6 张相关产品图",
],
note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
}
@@ -594,11 +596,11 @@ function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec {
return {
title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体",
model: subjectImageModelChain(models),
model: modelList([models?.vision, models?.subject_image]),
chain: [
"参考策略:先用视觉模型把关键帧/模板转成非身份化文字 brief,生图请求不再上传参考图",
`视觉 brief${modelValue(models?.vision)} 把关键帧/模板转成非身份化文字 brief;失败时继续用用户方向和模板文字`,
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张生成高清图,视图数量由“全部/常用/自定义”决定`,
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
],
note: "这是生成类似但创新的主体,不是复制、抠出或复刻源视频人物身份;内置形象也只作为方向参考。",
@@ -611,8 +613,8 @@ function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec {
model: modelList([models?.audio_rewrite, models?.asr_fallback, models?.translate]),
chain: [
`主改写:${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`,
`失败回退:依次尝试 ${modelValue(models?.asr_fallback)}${modelValue(models?.translate)}`,
"返回结果只写入当前分镜文案编辑框;生成视频时再把当前文案写入分镜 action",
`模型回退:依次尝试 ${modelValue(models?.asr_fallback)}${modelValue(models?.translate)};全部失败时用本地模板保留分镜可编辑`,
"返回结果只写入当前分镜文案编辑框;点击保存规划后才写入 frame.storyboard.action",
],
}
}
@@ -625,8 +627,9 @@ function videoModelTrace(models: RuntimeModels | undefined, model: string): Mode
`前端选择:${model}`,
`后端解析:${resolveVideoModelLabel(models, model)}`,
`服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
"输入:已确认的首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出:异步候选视频,完成后回填到对应分镜行",
"当前主工作台暂停直接提交视频;旧入口误触也会被页面层保护",
"开放后输入会包含已确认首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出为异步候选视频完成后回填到对应分镜行Sora 已停用",
],
}
}
@@ -2144,7 +2147,7 @@ function SourceReferenceBuildPanel({
onJobUpdate: (job: Job) => void
runtimeModels?: RuntimeModels
}) {
const [subjectBusy, setSubjectBusy] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
@@ -2220,6 +2223,7 @@ function SourceReferenceBuildPanel({
? `${selectedCharacter.name} · 模板参考`
: "源视频关键帧 · 相似创新"
const templateRequired = subjectMode === "template" && !selectedSubjectTemplate && !selectedCharacter
const subjectBusy = !!subjectBusyFor
const generationCtaLabel = subjectMode === "template"
? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
: `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图`
@@ -2265,13 +2269,14 @@ function SourceReferenceBuildPanel({
}
const baseFrame = subjectReferenceFrames[0]
if (!baseFrame) return
setSubjectBusy(true)
const requestJobId = job.id
setSubjectBusyFor({ jobId: requestJobId, jobLabel: shortId(requestJobId), viewCount: selectedSubjectViews.length })
try {
let workingJob = job
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
let element = workingFrame.elements?.find(isSimilarActorElement)
if (!element) {
workingJob = await addElement(job.id, baseFrame.index, {
workingJob = await addElement(requestJobId, baseFrame.index, {
name_zh: selectedTemplatePrompt
? `相似透明骨架主体 · ${selectedTemplatePrompt.name}`
: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
@@ -2288,7 +2293,7 @@ function SourceReferenceBuildPanel({
}
if (!element) throw new Error("similar subject element missing")
const updated = await generateSubjectAssets(job.id, baseFrame.index, element.id, {
const updated = await generateSubjectAssets(requestJobId, baseFrame.index, element.id, {
subject_kind: "living",
subject_style: subjectStyle,
reconstruction_mode: "similar",
@@ -2304,9 +2309,12 @@ function SourceReferenceBuildPanel({
onJobUpdate(updated)
toast.success(`相似主体 ${selectedSubjectViews.length} 张高清白底图已生成`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
} catch { /* keep original error visible */ }
toast.error("相似主体重构失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectBusy(false)
setSubjectBusyFor(null)
}
}
@@ -2438,7 +2446,8 @@ function SourceReferenceBuildPanel({
))}
</div>
<div className={`transition ${subjectMode === "source_similar" ? "pointer-events-none opacity-38 grayscale" : ""}`}>
{subjectMode === "template" ? (
<div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2">
{subjectTemplateLibrary.map((template) => {
const preview = characterPreviewImage(template)
@@ -2494,6 +2503,11 @@ function SourceReferenceBuildPanel({
})}
</div>
</div>
) : (
<div className="rounded-md border border-cyan-200/18 bg-cyan-300/[0.06] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/62">
</div>
)}
{subjectMode === "template" && (selectedSubjectTemplate?.images?.length || selectedCharacter?.images?.length) ? (
<div className="mt-2 flex gap-1.5 overflow-x-auto pb-0.5">
@@ -2545,6 +2559,45 @@ function SourceReferenceBuildPanel({
</div>
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
</div>
{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}
</div>
) : null}
{visibleActorAssets.length ? (
<div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
{visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
alt={asset.label || asset.view}
label={asset.label || asset.view || "主体视图预览"}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
objectFit="contain"
title={asset.label || asset.view}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy}
deleteLabel="删除这一张"
/>
)
})}
</div>
) : null}
<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">
@@ -2595,7 +2648,7 @@ function SourceReferenceBuildPanel({
className="inline-flex h-9 min-w-[170px] items-center justify-center gap-1 rounded-md bg-white px-3 text-[11px] 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" />}
{generationCtaLabel}
{subjectBusyFor ? `生成中 · ${subjectBusyFor.jobLabel}` : generationCtaLabel}
</button>
</div>
@@ -2623,45 +2676,13 @@ function SourceReferenceBuildPanel({
</div>
) : null}
{visibleActorAssets.length ? (
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
{visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
alt={asset.label || asset.view}
label={asset.label || asset.view || "主体视图预览"}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
objectFit="contain"
title={asset.label || asset.view}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy}
deleteLabel="删除这一张"
/>
)
})}
</div>
) : (
{!visibleActorAssets.length ? (
<div className="mt-2 rounded border border-dashed border-white/12 px-2 py-2 text-[10.5px] leading-snug text-white/32">
{subjectMode === "template"
? "先选主体模板,再生成新主体视图;模板只作为文字化创意方向,不再作为强参考图复制。"
: "直接使用关键帧的文字化主体特征生成创新主体;后端不会上传源图给生图端点。"}
</div>
)}
) : null}
</div>
</div>
</div>

View File

@@ -673,7 +673,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
{key === "videogen" && (
<>
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Seedance / Kling / Veo 3">
<div className="text-[11px] text-[var(--text-soft)]"> /v1/videos ID </div>
<div className="text-[11px] text-[var(--text-soft)]"> VIDEO_CREATE_PATHS ID </div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>