feat: stream subject packs by generation batch
This commit is contained in:
@@ -892,7 +892,12 @@ export default function Home() {
|
||||
.filter((item) => {
|
||||
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||
const runningAudio = item.audio_script?.status === "rewriting"
|
||||
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
|
||||
const runningSubject = item.frames.some((frame) =>
|
||||
frame.elements?.some((element) =>
|
||||
element.subject_assets?.some((asset) => asset.status === "queued" || asset.status === "in_progress"),
|
||||
),
|
||||
)
|
||||
return runningVideo || runningAudio || runningSubject || !TERMINAL.includes(item.status)
|
||||
})
|
||||
.map((item) => item.id)
|
||||
|
||||
@@ -913,7 +918,14 @@ export default function Home() {
|
||||
}, [
|
||||
job?.id,
|
||||
job?.status,
|
||||
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
|
||||
jobs.map((item) => {
|
||||
const subjectState = item.frames.flatMap((frame) =>
|
||||
frame.elements?.flatMap((element) =>
|
||||
element.subject_assets?.map((asset) => `${asset.id}:${asset.status ?? "completed"}:${asset.progress ?? 100}:${asset.url ?? ""}`) ?? [],
|
||||
) ?? [],
|
||||
).join(",")
|
||||
return `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}:${subjectState}`
|
||||
}).join("|"),
|
||||
])
|
||||
|
||||
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
|
||||
|
||||
@@ -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="删除这一张"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -701,6 +701,7 @@ export type AssetBackground = "white" | "black"
|
||||
export type AssetSize = "source" | "1024" | "1536" | "2048"
|
||||
export type SubjectKind = "object" | "living"
|
||||
export type SubjectView = string
|
||||
export type SubjectAssetStatus = "queued" | "in_progress" | "completed" | "failed"
|
||||
export type SceneMode = "remove_subject" | "similar" | "style"
|
||||
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
|
||||
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
|
||||
@@ -754,6 +755,13 @@ export interface SubjectAsset {
|
||||
size: AssetSize
|
||||
source_frame_indices?: number[]
|
||||
ai_completed?: boolean
|
||||
status?: SubjectAssetStatus
|
||||
progress?: number
|
||||
error?: string
|
||||
pack_id?: string
|
||||
pack_label?: string
|
||||
pack_mode?: string
|
||||
pack_created_at?: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
@@ -1500,6 +1508,10 @@ export async function generateSubjectAssets(
|
||||
subject_profile?: SubjectProfilePreference | null
|
||||
prompt?: string
|
||||
replace_views?: boolean
|
||||
pack_id?: string
|
||||
pack_label?: string
|
||||
pack_mode?: string
|
||||
pack_created_at?: number
|
||||
} = {},
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
|
||||
@@ -1519,6 +1531,10 @@ export async function generateSubjectAssets(
|
||||
subject_profile: body.subject_profile ?? null,
|
||||
prompt: body.prompt ?? "",
|
||||
replace_views: body.replace_views ?? false,
|
||||
pack_id: body.pack_id ?? "",
|
||||
pack_label: body.pack_label ?? "",
|
||||
pack_mode: body.pack_mode ?? "",
|
||||
pack_created_at: body.pack_created_at ?? 0,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user