feat(ai-render): bind render tasks to sku

This commit is contained in:
WuYongJie 2026-06-16 17:10:16 +08:00
parent fce3f46a04
commit c2f0898784
3 changed files with 261 additions and 60 deletions

View File

@ -24,6 +24,7 @@ import { UvSettingsSubmenu } from '@/app/asset-center/PreviewSubmenus'
import { RenderInspector } from './RenderInspector'
import type {
AiRenderNodeMaterial,
AiRenderTaskState,
AiRenderSku,
AiRenderSpu,
AiRenderUvEditorFormState,
@ -56,6 +57,16 @@ const CURVE_DETAIL_ENHANCEMENT_PROMPT =
const CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT =
'changed product structure, changed dimensions, extra holes, missing holes, missing slots, distorted straight edges, melted corners, warped panels, altered material grain direction, changed part boundaries, blurry product'
const AI_RENDER_TASK_PROGRESS_CAP = 88
const AI_RENDER_TASK_PROGRESS_STAGES = [
{ progress: 12, stage: '正在抓取当前 3D 参考图' },
{ progress: 24, stage: '正在提交 AI 生图请求' },
{ progress: 42, stage: 'AI 正在锁定产品结构' },
{ progress: 58, stage: '正在生成图像细节' },
{ progress: 72, stage: '正在增强曲面和材质表现' },
{ progress: 84, stage: '等待 AI 返回结果' },
] as const
function mergeNegativePrompt(...parts: Array<string | null | undefined>): string | null {
const normalized = parts
.map((part) => part?.trim())
@ -64,6 +75,19 @@ function mergeNegativePrompt(...parts: Array<string | null | undefined>): string
return normalized.length > 0 ? normalized.join(', ') : null
}
function getTaskModeLabel(mode: LocalRenderRecord['mode']): string {
return mode === 'white-background' ? '白底图' : '场景效果图'
}
function buildTaskStartedMessage(params: {
mode: LocalRenderRecord['mode']
skuName: string
imageCount: number
}) {
const modeLabel = getTaskModeLabel(params.mode)
return `${modeLabel}已进入后台生成:${params.skuName},共 ${params.imageCount} 张。你可以继续切换 SKU 或编辑参数。`
}
type StoredWorkbenchState = {
spus: AiRenderSpu[]
selectedSpuId: string | null
@ -262,6 +286,7 @@ function normalizeRecords(value: unknown): LocalRenderRecord[] {
return {
id: typeof record.id === 'string' && record.id.trim() ? record.id : createId('render'),
skuId: typeof record.skuId === 'string' ? record.skuId : '',
skuName: typeof record.skuName === 'string' ? record.skuName : '未命名 SKU',
mode,
imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : '',
@ -271,7 +296,6 @@ function normalizeRecords(value: unknown): LocalRenderRecord[] {
}
})
.filter((record) => record.imageUrl.trim())
.slice(0, 18)
}
function readStoredWorkbenchState(): StoredWorkbenchState | null {
@ -503,7 +527,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
const [imageCount, setImageCount] = useState(storedWorkbenchState?.imageCount ?? 1)
const [records, setRecords] = useState<LocalRenderRecord[]>(() => storedWorkbenchState?.records ?? [])
const [message, setMessage] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [renderTask, setRenderTask] = useState<AiRenderTaskState | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [optimExplanation, setOptimExplanation] = useState('')
const [uploadingSkuId, setUploadingSkuId] = useState<string | null>(null)
@ -531,6 +555,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
() => buildSkuMaterialAssignments(selectedSku, modelNodeNames),
[modelNodeNames, selectedSku],
)
const selectedSkuRecords = useMemo(() => {
if (!selectedSku) return []
return records.filter((record) => record.skuId === selectedSku.id)
}, [records, selectedSku])
const generating = renderTask?.status === 'running'
const materialPanelTargetNodeNames = useMemo(() => {
if (!materialPanel) return []
const targetNodeNames = materialPanel.scope === 'all'
@ -619,6 +648,39 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
setSpus((current) => (isLegacySampleOnly(current) ? [] : current))
}, [])
useEffect(() => {
if (!renderTask || renderTask.status !== 'running') return
const intervalId = window.setInterval(() => {
setRenderTask((current) => {
if (!current || current.id !== renderTask.id || current.status !== 'running') return current
const elapsedMs = Date.now() - new Date(current.startedAt).getTime()
const stage = [...AI_RENDER_TASK_PROGRESS_STAGES]
.reverse()
.find((item) => elapsedMs >= item.progress * 320)
?? AI_RENDER_TASK_PROGRESS_STAGES[0]
const drift = Math.floor(elapsedMs / 1800)
const nextProgress = Math.min(
AI_RENDER_TASK_PROGRESS_CAP,
Math.max(current.progress, stage.progress + drift),
)
if (nextProgress === current.progress && stage.stage === current.stage) {
return current
}
return {
...current,
progress: nextProgress,
stage: stage.stage,
}
})
}, 700)
return () => window.clearInterval(intervalId)
}, [renderTask])
useEffect(() => {
if (typeof window === 'undefined') return
@ -1041,10 +1103,13 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
const appendRenderedRecords = useCallback((params: {
mode: LocalRenderRecord['mode']
images: Array<{ originalUrl?: string | null; thumbnailUrl?: string | null }>
skuId: string
skuName?: string
prompt?: string
status?: LocalRenderRecord['status']
}) => {
if (!selectedSku) return 0
const skuId = params.skuId
const skuName = params.skuName ?? selectedSku?.name ?? '未命名 SKU'
const createdAt = new Date().toISOString()
const nextRecords = params.images
.map((image) => {
@ -1057,7 +1122,8 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
if (!imageUrl) return null
return {
id: createId('render'),
skuName: selectedSku.name || '未命名 SKU',
skuId,
skuName,
mode: params.mode,
imageUrl,
prompt: params.prompt ?? prompt,
@ -1069,13 +1135,56 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
if (nextRecords.length === 0) return 0
setRecords((current) => [...nextRecords, ...current].slice(0, 18))
setRecords((current) => {
const sameSkuRecords = [...nextRecords, ...current.filter((record) => record.skuId === skuId)].slice(0, 18)
const otherSkuRecords = current.filter((record) => record.skuId !== skuId)
return [...sameSkuRecords, ...otherSkuRecords]
})
return nextRecords.length
}, [prompt, selectedSku])
const startRenderTask = useCallback((params: {
mode: LocalRenderRecord['mode']
skuId: string
skuName: string
imageCount: number
}) => {
const task: AiRenderTaskState = {
id: createId('render-task'),
skuId: params.skuId,
skuName: params.skuName,
mode: params.mode,
imageCount: params.imageCount,
progress: 12,
stage: AI_RENDER_TASK_PROGRESS_STAGES[0].stage,
status: 'running',
startedAt: new Date().toISOString(),
}
setRenderTask(task)
return task
}, [])
const settleRenderTask = useCallback((taskId: string, nextStatus: 'succeeded' | 'failed', stage: string) => {
setRenderTask((current) =>
current?.id === taskId
? {
...current,
status: nextStatus,
progress: 100,
stage,
}
: current,
)
window.setTimeout(() => {
setRenderTask((current) => (current?.id === taskId ? null : current))
}, 1400)
}, [])
const handleCaptureWhiteBackground = useCallback(async () => {
if (generating) return
if (!selectedSku || !selectedSku.modelUrl) {
const taskSku = selectedSku
const taskSpu = selectedSpu
if (!taskSku || !taskSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
}
@ -1089,53 +1198,65 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。')
return
}
const taskImageCount = imageCount
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskNegativePrompt = negativePrompt
const taskNodeNames = [...modelNodeNames]
const lockedPrompt = buildWhiteBackgroundPrompt({
spu: selectedSpu,
sku: selectedSku,
spu: taskSpu,
sku: taskSku,
userPrompt: prompt,
aspectRatio,
aspectRatio: taskAspectRatio,
})
setGenerating(true)
setMessage('正在基于当前 3D 参考图生成白底图,并增强低分段圆弧细节...')
const task = startRenderTask({
mode: 'white-background',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: taskImageCount,
})
setMessage(buildTaskStartedMessage(task))
try {
const render = await createAssistedAssemblyAiRender({
prompt: lockedPrompt,
negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
imageCount,
aspectRatio,
imageSize,
negativePrompt: mergeNegativePrompt(taskNegativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
imageCount: taskImageCount,
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
referenceImageDataUrl: dataUrl,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: selectedSpu,
sku: selectedSku,
spu: taskSpu,
sku: taskSku,
prompt: lockedPrompt,
mode: 'white-background',
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: selectedSpu,
sku: selectedSku,
nodeNames: modelNodeNames,
spu: taskSpu,
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
const savedCount = appendRenderedRecords({
mode: 'white-background',
images: render.resultImages,
skuId: task.skuId,
skuName: task.skuName,
prompt: lockedPrompt,
})
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessage(
savedCount > 1
? `精准白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。`
: '精准白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。',
? `白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。`
: '白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。',
)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
} else {
setMessage(render.errorMessage || 'AI 白底图请求已返回,但没有可用结果图。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
}
} catch (error) {
setMessage(error instanceof Error ? error.message : 'AI 白底图生成失败')
} finally {
setGenerating(false)
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')
}
}, [
appendRenderedRecords,
@ -1147,8 +1268,10 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
modelNodeNames,
negativePrompt,
prompt,
settleRenderTask,
selectedSku,
selectedSpu,
startRenderTask,
])
const handleOptimizePrompt = useCallback(async () => {
@ -1203,7 +1326,10 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
}, [aspectRatio, capturePreview, optimizing, prompt])
const handleGenerateScene = useCallback(async () => {
if (!selectedSku || !selectedSku.modelUrl) {
if (generating) return
const taskSku = selectedSku
const taskSpu = selectedSpu
if (!taskSku || !taskSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
}
@ -1222,61 +1348,76 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
setMessage('请先填写场景效果图提示词。')
return
}
setGenerating(true)
setMessage('正在请求 AI 生图服务...')
const taskImageCount = imageCount
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskNegativePrompt = negativePrompt
const taskNodeNames = [...modelNodeNames]
const lockedScenePrompt = buildLockedScenePrompt({
spu: taskSpu,
sku: taskSku,
userPrompt: normalizedPrompt,
aspectRatio: taskAspectRatio,
})
const task = startRenderTask({
mode: 'scene',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: taskImageCount,
})
setMessage(buildTaskStartedMessage(task))
try {
const lockedScenePrompt = buildLockedScenePrompt({
spu: selectedSpu,
sku: selectedSku,
userPrompt: normalizedPrompt,
aspectRatio,
})
const render = await createAssistedAssemblyAiRender({
prompt: lockedScenePrompt,
negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
imageCount,
aspectRatio,
imageSize,
negativePrompt: mergeNegativePrompt(taskNegativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
imageCount: taskImageCount,
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
referenceImageDataUrl,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: selectedSpu,
sku: selectedSku,
spu: taskSpu,
sku: taskSku,
prompt: lockedScenePrompt,
mode: 'scene',
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: selectedSpu,
sku: selectedSku,
nodeNames: modelNodeNames,
spu: taskSpu,
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
const savedCount = appendRenderedRecords({
mode: 'scene',
images: render.resultImages,
skuId: task.skuId,
skuName: task.skuName,
prompt: lockedScenePrompt,
})
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessage(savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。')
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
} else {
setMessage(render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
}
} catch (error) {
setMessage(error instanceof Error ? error.message : 'AI 生图失败')
} finally {
setGenerating(false)
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')
}
}, [
appendRenderedRecords,
aspectRatio,
capturePreview,
generating,
imageCount,
imageSize,
modelNodeNames,
negativePrompt,
prompt,
settleRenderTask,
selectedSku,
selectedSpu,
startRenderTask,
])
const handleDeleteRecord = useCallback((recordId: string) => {
@ -1419,7 +1560,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
>
<AlertCircle size={14} strokeWidth={1.8} />
{selectedSku?.modelUrl
? '精准白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。'
? '白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。'
: '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'}
</div>
}
@ -1492,10 +1633,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
aspectRatio={aspectRatio}
imageCount={imageCount}
generating={generating}
renderTask={renderTask}
optimizing={optimizing}
optimExplanation={optimExplanation}
message={message}
records={records}
records={selectedSkuRecords}
onSelectSpu={handleSelectSpu}
onCreateSpu={handleCreateSpu}
onRenameSpu={(spuId, name) => updateSpu(spuId, { name })}

View File

@ -22,7 +22,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import type { AiRenderSku, AiRenderSpu, LocalRenderRecord } from './types'
import type { AiRenderSku, AiRenderSpu, AiRenderTaskState, LocalRenderRecord } from './types'
interface RenderInspectorProps {
spus: AiRenderSpu[]
@ -36,6 +36,7 @@ interface RenderInspectorProps {
aspectRatio: string
imageCount: number
generating: boolean
renderTask: AiRenderTaskState | null
optimizing: boolean
optimExplanation: string
message: string | null
@ -144,6 +145,9 @@ function formatRecordTime(value: string) {
})
}
function getRenderTaskModeLabel(mode: LocalRenderRecord['mode']) {
return mode === 'white-background' ? '白底图' : '场景效果图'
}
export function RenderInspector({
spus,
selectedSpuId,
@ -156,6 +160,7 @@ export function RenderInspector({
aspectRatio,
imageCount,
generating,
renderTask,
optimizing,
optimExplanation,
message,
@ -539,7 +544,6 @@ export function RenderInspector({
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
disabled={generating}
placeholder="描述你想要的效果,如:保留产品结构,生成真实白底产品摄影或真实室内场景,材质自然,灯光柔和"
className="min-h-[60px] w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#2d3335] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
/>
@ -552,7 +556,6 @@ export function RenderInspector({
<select
value={imageCount}
onChange={(event) => onImageCountChange(Number(event.target.value))}
disabled={generating}
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
>
{[1, 2, 3, 4].map((c) => <option key={c} value={c}>{c} 张</option>)}
@ -560,7 +563,6 @@ export function RenderInspector({
<select
value={aspectRatio}
onChange={(event) => onAspectRatioChange(event.target.value)}
disabled={generating}
className="h-7 rounded-[5px] bg-white/70 px-2 text-[10px] font-bold text-[#2d3335] outline-none ring-1 ring-black/[0.04]"
>
{['16:9', '4:3', '1:1', '3:4'].map((r) => <option key={r} value={r}>{r}</option>)}
@ -603,7 +605,6 @@ export function RenderInspector({
<button
type="button"
onClick={onRefreshRecords}
disabled={generating}
className="flex h-7 shrink-0 items-center rounded-[5px] bg-white/70 px-1.5 text-[#5a6062]/55 ring-1 ring-black/[0.04] transition hover:text-[#2d3335]"
>
<RotateCcw className="h-3 w-3" />
@ -688,7 +689,6 @@ export function RenderInspector({
value={negativePrompt}
onChange={(event) => onNegativePromptChange(event.target.value)}
rows={2}
disabled={generating}
placeholder="不想出现的内容,可选"
className="w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
/>
@ -702,12 +702,58 @@ export function RenderInspector({
<span className="line-clamp-2">{message}</span>
</div>
) : null}
{renderTask ? (
<div
className={cn(
'rounded-[8px] px-3 py-2.5 ring-1',
renderTask.status === 'failed'
? 'bg-[#b75b5b]/7 ring-[#b75b5b]/12'
: 'bg-[#47646b]/6 ring-[#47646b]/10',
)}
>
<div className="flex items-center gap-2">
<Loader2
className={cn(
'h-3.5 w-3.5 text-[#47646b]',
renderTask.status === 'running' && 'animate-spin',
renderTask.status === 'failed' && 'text-[#9f4f4f]',
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-black text-[#2d3335]">
<span>{getRenderTaskModeLabel(renderTask.mode)}</span>
<span className="rounded-[4px] bg-white/60 px-1.5 py-0.5 text-[8px] text-[#5a6062]/60">
{renderTask.imageCount} 张
</span>
</div>
<div className="mt-0.5 truncate text-[10px] font-medium text-[#5a6062]/70">
{renderTask.skuName}
</div>
</div>
<span className="font-mono text-[11px] font-black text-[#47646b]">
{Math.round(renderTask.progress)}%
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/70">
<div
className={cn(
'h-full rounded-full transition-[width] duration-500 ease-out',
renderTask.status === 'failed' ? 'bg-[#9f4f4f]' : 'bg-[#47646b]',
)}
style={{ width: `${renderTask.progress}%` }}
/>
</div>
<div className="mt-1.5 text-[10px] font-medium text-[#5a6062]/70">
{renderTask.stage}
</div>
</div>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={onOptimizePrompt}
disabled={optimizing || generating || !prompt.trim()}
disabled={optimizing || !prompt.trim()}
className={cn(
'flex h-9 shrink-0 items-center gap-1.5 rounded-[6px] px-3 text-[11px] font-bold transition ring-1',
optimizing
@ -730,12 +776,12 @@ export function RenderInspector({
) : (
<ImagePlus className="mr-1.5 h-3.5 w-3.5" />
)}
{generating ? '生成中...' : '生成图片'}
{generating ? '后台生成中' : '生成图片'}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mb-2 text-[12px] font-black text-[#2d3335]">本地生图记录</div>
<div className="mb-2 text-[12px] font-black text-[#2d3335]">当前 SKU 生图记录</div>
{records.length === 0 ? (
<div className="py-8 text-center text-[10px] font-medium text-[#5a6062]/35">
暂无记录
@ -769,7 +815,7 @@ export function RenderInspector({
{record.status === 'SUCCEEDED' ? '已完成' : '失败'}
</span>
<span className="shrink-0 rounded-[4px] bg-black/[0.035] px-1.5 py-0.5 text-[8px] font-black text-[#5a6062]/55">
{record.mode === 'white-background' ? '精准白底图' : '场景图'}
{record.mode === 'white-background' ? '白底图' : '场景图'}
</span>
<span className="text-[8px] text-[#5a6062]/35">
{formatRecordTime(record.createdAt)}
@ -824,7 +870,7 @@ export function RenderInspector({
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-black/[0.035] text-[11px] font-black text-[#2d3335] transition hover:bg-black/[0.06] disabled:opacity-40"
>
<ImageIcon className="h-3.5 w-3.5" />
精准白底图
白底图
</button>
<button
type="button"
@ -833,7 +879,7 @@ export function RenderInspector({
className="flex h-10 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[11px] font-black text-white transition hover:opacity-90 disabled:opacity-40"
>
<Sparkles className="h-3.5 w-3.5" />
{generating ? '生成中...' : '场景效果图'}
{generating ? '后台生成中' : '场景效果图'}
</button>
</div>
</div>

View File

@ -55,6 +55,7 @@ export type AiRenderOutputMode = 'white-background' | 'scene'
export interface LocalRenderRecord {
id: string
skuId: string
skuName: string
mode: AiRenderOutputMode
imageUrl: string
@ -63,6 +64,18 @@ export interface LocalRenderRecord {
createdAt: string
}
export interface AiRenderTaskState {
id: string
skuId: string
skuName: string
mode: AiRenderOutputMode
imageCount: number
progress: number
stage: string
status: 'running' | 'succeeded' | 'failed'
startedAt: string
}
export interface AiRenderSkuMaterialAssignment {
nodeName: string
material: MaterialConfig