feat: stream subject packs by generation batch

This commit is contained in:
2026-05-19 21:31:47 +08:00
parent 47299396dc
commit 00df9d01fe
6 changed files with 531 additions and 81 deletions

View File

@@ -214,6 +214,20 @@ type ResolvedSubjectProfile = {
promptSummary: string
payload: SubjectProfilePreference
}
type SubjectAssetPack = {
key: string
id: string
label: string
mode: SubjectReconstructionMode
frame: KeyFrame
element: KeyElement
createdAt: number
assets: SubjectAsset[]
total: number
completed: number
failed: number
running: boolean
}
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "skgCopy" | "skgCopyZh" | "sceneOneLine" | "sceneOneLineZh" | "actionOneLine" | "actionOneLineZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
@@ -1119,9 +1133,46 @@ function buildSimilarSubjectPrompt(
}
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
if (!asset.url && asset.status && asset.status !== "completed") return ""
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}
function subjectAssetStatus(asset: SubjectAsset) {
return asset.status ?? (asset.url ? "completed" : "completed")
}
function subjectAssetIsRunning(asset: SubjectAsset) {
const status = subjectAssetStatus(asset)
return status === "queued" || status === "in_progress"
}
function subjectAssetStatusLabel(asset: SubjectAsset) {
const status = subjectAssetStatus(asset)
if (status === "queued") return "排队中"
if (status === "in_progress") return `生成中 ${asset.progress ?? 0}%`
if (status === "failed") return "失败"
return asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined
}
function subjectAssetPackKey(frame: KeyFrame, element: KeyElement, asset: SubjectAsset) {
return `${frame.index}:${element.id}:${asset.pack_id || `legacy-${element.id}`}`
}
function subjectAssetPackSortAssets(assets: SubjectAsset[]) {
return [...assets].sort((a, b) => {
const ai = SUBJECT_VIEW_ORDER.indexOf(a.view)
const bi = SUBJECT_VIEW_ORDER.indexOf(b.view)
if (ai !== bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
return (a.created_at || 0) - (b.created_at || 0)
})
}
function subjectAssetPackSummary(pack: SubjectAssetPack) {
if (pack.running) return `${pack.completed}/${pack.total} 生成中`
if (pack.failed) return `${pack.completed}/${pack.total} · 失败 ${pack.failed}`
return `${pack.completed || pack.total}`
}
function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) {
if (!character?.images?.length) return null
return character.images.find((image) => image.id === character.primary_image)
@@ -3156,6 +3207,7 @@ function SourceSubjectPipeline({
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState<string | null>(null)
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
const subjectBusy = !!subjectBusyFor
const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
@@ -3182,25 +3234,65 @@ function SourceSubjectPipeline({
}
return items
}, [frames])
const visibleActorAssets = useMemo(() => {
const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }> = []
const subjectAssetPacks = useMemo<SubjectAssetPack[]>(() => {
const packs = new Map<string, SubjectAssetPack>()
for (const source of actorSources) {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of source.element.subject_assets ?? []) {
const key = subjectAssetPackKey(source.frame, source.element, asset)
const rawMode = asset.pack_mode as SubjectReconstructionMode | undefined
const packMode = rawMode && RECONSTRUCTION_MODES.some((item) => item.value === rawMode) ? rawMode : source.mode
const createdAt = asset.pack_created_at || asset.created_at || 0
const existing = packs.get(key)
if (existing) {
existing.assets.push(asset)
existing.createdAt = Math.min(existing.createdAt || createdAt, createdAt)
} else {
packs.set(key, {
key,
id: asset.pack_id || key,
label: asset.pack_label || `${reconstructionModeConfig(packMode).label}套图`,
mode: packMode,
frame: source.frame,
element: source.element,
createdAt,
assets: [asset],
total: 0,
completed: 0,
failed: 0,
running: false,
})
}
}
}
return [...packs.values()].map((pack) => {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of pack.assets) {
const current = latestByView.get(asset.view)
if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
}
for (const asset of latestByView.values()) items.push({ ...source, asset })
}
return items.sort((a, b) => {
const assets = subjectAssetPackSortAssets([...latestByView.values()])
const completed = assets.filter((asset) => subjectAssetStatus(asset) === "completed").length
const failed = assets.filter((asset) => subjectAssetStatus(asset) === "failed").length
const running = assets.some(subjectAssetIsRunning)
return { ...pack, assets, total: assets.length, completed, failed, running }
}).sort((a, b) => {
const mi = RECONSTRUCTION_MODES.findIndex((item) => item.value === a.mode)
const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
if (mi !== mj) return mi - mj
const ai = SUBJECT_VIEW_ORDER.indexOf(a.asset.view)
const bi = SUBJECT_VIEW_ORDER.indexOf(b.asset.view)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
return (b.createdAt || 0) - (a.createdAt || 0)
})
}, [actorSources])
const activeSubjectPack = useMemo(
() => subjectAssetPacks.find((pack) => pack.key === expandedSubjectPackKey) ?? subjectAssetPacks[0] ?? null,
[expandedSubjectPackKey, subjectAssetPacks],
)
const runningActorModes = useMemo(() => {
const next = new Set<SubjectReconstructionMode>()
for (const pack of subjectAssetPacks) {
if (pack.running) next.add(pack.mode)
}
return next
}, [subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
@@ -3210,8 +3302,15 @@ function SourceSubjectPipeline({
setSubjectAssetBusy(null)
setActiveDropMode(null)
setCartoonStyleOpen(false)
setExpandedSubjectPackKey(null)
}, [job.id])
useEffect(() => {
if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
setExpandedSubjectPackKey(null)
}
}, [expandedSubjectPackKey, subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode((current) => {
const next = {} as Record<SubjectReconstructionMode, number[]>
@@ -3233,6 +3332,10 @@ function SourceSubjectPipeline({
toast.warning("主体套图正在生成中,完成后再重生。")
return
}
if (runningActorModes.has(mode)) {
toast.warning(`${reconstructionModeConfig(mode).label}还有主体图正在逐张生成。`)
return
}
const sourceFrames = sourceIndices
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame)
@@ -3288,10 +3391,18 @@ function SourceSubjectPipeline({
views: selectedSubjectViews,
subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
replace_views: true,
replace_views: false,
pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
pack_mode: mode,
})
onJobUpdate(updated)
toast.success(`${reconstructionModeConfig(mode).label}已生成:${selectedSubjectViews.length}`)
const updatedFrame = updated.frames.find((frame) => frame.index === baseFrame.index)
const updatedElement = updatedFrame?.elements?.find((item) => item.id === element.id)
const newestAsset = [...(updatedElement?.subject_assets ?? [])].sort((a, b) => (b.pack_created_at || b.created_at || 0) - (a.pack_created_at || a.created_at || 0))[0]
if (updatedFrame && updatedElement && newestAsset) {
setExpandedSubjectPackKey(subjectAssetPackKey(updatedFrame, updatedElement, newestAsset))
}
toast.success(`${reconstructionModeConfig(mode).label}已提交:${selectedSubjectViews.length} 张会逐张出来`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
@@ -3359,9 +3470,13 @@ function SourceSubjectPipeline({
requestProfile,
),
replace_views: true,
pack_id: asset.pack_id ?? "",
pack_label: asset.pack_label ?? "",
pack_mode: asset.pack_mode ?? mode,
pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
})
onJobUpdate(updated)
toast.success("已重新生成这张主体元素")
toast.success("已提交重生,这张主体元素会生成完成后替换")
} catch (e) {
toast.error("主体元素重生失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -3497,6 +3612,7 @@ function SourceSubjectPipeline({
const canGenerate = mode === "custom"
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
: modeFrames.length > 0
const modeRunning = runningActorModes.has(mode)
return (
<div
key={mode}
@@ -3606,11 +3722,11 @@ function SourceSubjectPipeline({
<button
type="button"
onClick={() => void generateSubjectPack(mode)}
disabled={subjectBusy || !canGenerate}
disabled={subjectBusy || modeRunning || !canGenerate}
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusyFor?.mode === mode ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{subjectBusyFor?.mode === mode ? `生成中 · ${subjectBusyFor.sourceCount || "描述"} 参考` : `生成${modeConfig.label} 6视图`}
{subjectBusyFor?.mode === mode || modeRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{subjectBusyFor?.mode === mode || modeRunning ? "逐张生成中" : `生成${modeConfig.label} 6视图`}
</button>
</div>
)
@@ -3628,7 +3744,7 @@ function SourceSubjectPipeline({
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="主体元素" />
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
{visibleActorAssets.length ? `${visibleActorAssets.length} ` : "待生成"}
{subjectAssetPacks.length ? `${subjectAssetPacks.length} ` : "待生成"}
</span>
</div>
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/32 p-2 2xl:min-h-[500px]">
@@ -3638,52 +3754,90 @@ function SourceSubjectPipeline({
<span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span>
</div>
) : null}
{visibleActorAssets.length ? (
<div className="space-y-3">
{RECONSTRUCTION_MODES.map((modeConfig) => {
const items = visibleActorAssets.filter((item) => item.mode === modeConfig.value)
if (!items.length) return null
return (
<div key={modeConfig.value} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[10px] text-white/44">
<span>{modeConfig.label}</span>
<span>{items.length} </span>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]">
{items.map((item) => {
const { asset } = item
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 || subjectViewLabel(asset.view)}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] bg-white"
objectFit="contain"
title={asset.label || subjectViewLabel(asset.view)}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy || subjectBusy,
onClick: () => void regenerateSubjectAsset(item),
}]}
onDelete={() => void deleteActorAsset(item)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy || subjectBusy}
deleteLabel="删除这一张"
/>
)
})}
{subjectAssetPacks.length ? (
<div className="space-y-2">
{activeSubjectPack ? (
<div className="rounded-md border border-[#d6b36a]/28 bg-[#d6b36a]/[0.07] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[11px] font-semibold text-white">{activeSubjectPack.label}</div>
<div className="mt-0.5 text-[9.5px] text-white/42">
{reconstructionModeConfig(activeSubjectPack.mode).label} · {subjectAssetPackSummary(activeSubjectPack)}
</div>
</div>
<span className="shrink-0 rounded-full border border-white/10 bg-black/35 px-2 py-0.5 text-[9px] text-white/46">
{activeSubjectPack.assets.length}
</span>
</div>
)
})}
<div className="grid max-h-[300px] grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 overflow-y-auto pr-0.5 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]">
{activeSubjectPack.assets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
const status = subjectAssetStatus(asset)
const running = subjectAssetIsRunning(asset)
const failed = status === "failed"
const mediaUrl = subjectAssetUrl(job, asset)
const item = { frame: activeSubjectPack.frame, element: activeSubjectPack.element, mode: activeSubjectPack.mode, asset }
return (
<MediaAssetTile
key={asset.id}
src={mediaUrl || undefined}
href={mediaUrl || undefined}
alt={asset.label || asset.view}
label={asset.label || subjectViewLabel(asset.view)}
meta={subjectAssetStatusLabel(asset)}
className={`aspect-[9/16] ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
objectFit="contain"
busy={running}
emptyText={failed ? "失败" : running ? "生成中" : undefined}
title={asset.label || subjectViewLabel(asset.view)}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy || subjectBusy || running,
onClick: () => void regenerateSubjectAsset(item),
}]}
onDelete={() => void deleteActorAsset(item)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy || subjectBusy || running}
deleteLabel="删除这一张"
/>
)
})}
</div>
</div>
) : null}
<div className="max-h-32 overflow-auto rounded-md border border-white/10 bg-black/24 p-1.5">
<div className="grid grid-cols-[repeat(auto-fill,minmax(112px,1fr))] gap-1.5">
{subjectAssetPacks.map((pack, index) => {
const active = activeSubjectPack?.key === pack.key
return (
<button
key={pack.key}
type="button"
onClick={() => setExpandedSubjectPackKey(pack.key)}
className={`min-w-0 rounded-md border px-2 py-1.5 text-left transition ${
active
? "border-[#d6b36a]/70 bg-[#d6b36a]/14 text-white"
: "border-white/10 bg-black/28 text-white/58 hover:border-white/22 hover:text-white"
}`}
title={pack.label}
>
<div className="flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate text-[10px] font-semibold">{pack.label || `套图 ${index + 1}`}</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[9px] text-white/38">
<span>{reconstructionModeConfig(pack.mode).label}</span>
<span className="font-mono">{subjectAssetPackSummary(pack)}</span>
</div>
</button>
)
})}
</div>
</div>
</div>
) : (
<div className="flex h-40 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
@@ -4022,9 +4176,11 @@ function SourceReferenceBuildPanel({
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
pack_label: `主体视图 ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
pack_mode: "realistic",
})
onJobUpdate(updated)
toast.success(`相似主体 ${selectedSubjectViews.length}高清白底图已生成`)
toast.success(`相似主体已提交:${selectedSubjectViews.length}会逐张出来`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
@@ -4060,9 +4216,13 @@ function SourceReferenceBuildPanel({
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
pack_id: asset.pack_id ?? "",
pack_label: asset.pack_label ?? "",
pack_mode: asset.pack_mode ?? "realistic",
pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
})
onJobUpdate(updated)
toast.success("已重新生成这张主体视图")
toast.success("已提交重生,这张主体视图会生成完成后替换")
} catch (e) {
toast.error("单张主体视图重生失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -4335,16 +4495,22 @@ function SourceReferenceBuildPanel({
<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] : ""
const status = subjectAssetStatus(asset)
const running = subjectAssetIsRunning(asset)
const failed = status === "failed"
const mediaUrl = subjectAssetUrl(job, asset)
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
src={mediaUrl || undefined}
href={mediaUrl || undefined}
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"
meta={subjectAssetStatusLabel(asset)}
className={`aspect-[9/16] w-20 2xl:w-24 ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
objectFit="contain"
busy={running}
emptyText={failed ? "失败" : running ? "生成中" : undefined}
title={asset.label || asset.view}
actions={[{
key: "regen",
@@ -4352,12 +4518,12 @@ function SourceReferenceBuildPanel({
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy,
disabled: !!subjectAssetBusy || running,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy}
deleteDisabled={!!subjectAssetBusy || running}
deleteLabel="删除这一张"
/>
)