feat(diy): add image adjustment, resizing and template layout tools

This commit is contained in:
WuYongJie 2026-10-20 09:37:36 +08:00
parent 09f36608de
commit cadb917d90
28 changed files with 1122 additions and 8 deletions

View File

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { handleRequest } from '@/server/core/response'
import { readOwnedDiyAiRenderImage } from '@/server/services/diy-ai-render.image'
export const dynamic = 'force-dynamic'
export const maxDuration = 60
export async function GET(request: NextRequest) {
return handleRequest(request, async ctx => {
const image = await readOwnedDiyAiRenderImage({
recordId: request.nextUrl.searchParams.get('recordId'),
storageKey: request.nextUrl.searchParams.get('storageKey'),
}, ctx.user)
return new NextResponse(new Uint8Array(image.buffer), {
headers: {
'Content-Type': image.contentType,
'Content-Length': String(image.buffer.byteLength),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,20 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { createDiyLayoutReplace } from '@/server/services/diy-layout-replace.service'
import { diyAiRenderService } from '@/server/services/diy-ai-render.service'
export const maxDuration = 300
export async function GET(request: NextRequest) {
return handleRequest(request, async ctx => jsonSuccess(await diyAiRenderService.findForRequest(request.nextUrl.searchParams.get('requestId') || '', ctx.user), 200, ctx.requestId), { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
const text = await request.text()
if (Buffer.byteLength(text) > 12_000) return jsonError(ErrorCode.FILE_TOO_LARGE, '排版参数过大', 413, ctx.requestId)
let input: unknown
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '排版参数格式无效', 422, ctx.requestId) }
const record = await createDiyLayoutReplace(input, ctx.user)
return jsonSuccess(record, record.status === 'PENDING' ? 202 : 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,13 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { uploadDiyLayoutTemplate } from '@/server/services/diy-image-template.service'
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
if (Number(request.headers.get('content-length')) > 21 * 1024 * 1024) return jsonError(ErrorCode.FILE_TOO_LARGE, '模板不能超过20MB', 413, ctx.requestId)
const form = await request.formData(), file = form.get('file')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_FAILED, '请选择模板图片', 422, ctx.requestId)
return jsonSuccess(await uploadDiyLayoutTemplate(file, ctx.user), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -1,15 +1,24 @@
'use client'
import { useEffect, useState } from 'react'
import { Download, Trash2 } from 'lucide-react'
import dynamic from 'next/dynamic'
import { Download, SlidersHorizontal, Trash2, Wand2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
import { RenderRecordGalleryLightbox } from '@/app/ai-render-workbench/RenderRecordGalleryLightbox'
import DiyAiDeleteConfirmation from './DiyAiDeleteConfirmation'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { useDiyLayoutBatch } from '../hooks/useDiyLayoutBatch'
const DiyImageEditor = dynamic(() => import('./image-tools/DiyImageEditor'), { ssr: false })
const DiyLayoutReplaceDialog = dynamic(() => import('./image-tools/DiyLayoutReplaceDialog'), { ssr: false })
const recordLabel = (record: DiyAiRenderDTO) => record.operation === 'layout-replace' ? '排版图' : record.mode === 'scene' ? '效果图' : '白底图'
export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[]; ai: DiyAiRenderController }) {
const [large,setLarge] = useState<string|null>(null)
const [editing, setEditing] = useState<DiyImageSelection | null>(null), [layout, setLayout] = useState<DiyImageSelection | null>(null)
const batch = useDiyLayoutBatch(ai.refresh)
useEffect(() => {
if (!large) return
const close = (event:KeyboardEvent) => { if(event.key==='Escape')setLarge(null) }
@ -17,10 +26,17 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[
},[large])
return <div className="space-y-3" aria-label="DIY 出图结果">
<DiyAiDeleteConfirmation deletion={ai.deletion}/>
{editing && <DiyImageEditor key={editing.storageKey} source={editing} canDownload={ai.access.canDownload} onClose={() => setEditing(null)}/>}
{layout && <DiyLayoutReplaceDialog key={layout.storageKey} source={layout} records={ai.records} batch={batch} onClose={() => setLayout(null)}/>}
{batch.items.length > 0 && <div role="status" className="space-y-1 rounded-md bg-[#47646b]/5 px-3 py-2 text-[11px] text-[#47646b]">
<p>{batch.notice || '排版处理中…'}</p>
{batch.items.filter(item => item.error).map(item => <p key={item.input.requestId} className="text-red-600">{item.name}:{item.error}</p>)}
{batch.items.some(item => item.status !== 'succeeded' && item.status !== 'failed') && !batch.running && <Button size="small" variant="plain" onClick={() => void batch.resume()}>继续核对</Button>}
</div>}
{large && <RenderRecordGalleryLightbox imageUrl={large} onClose={() => setLarge(null)}/>}
{records.map(record => <section key={record.id} className="space-y-2 border-b border-black/[0.04] pb-3">
<div className="flex flex-wrap items-center justify-between gap-1 text-[10px] text-[#5a6062]/60">
<span className="font-semibold text-[#2d3335]">{record.name} · {record.mode==='scene'?'效果图':'白底图'}</span>
<span className="font-semibold text-[#2d3335]">{record.name} · {recordLabel(record)}</span>
<span>{new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</span>
</div>
<p className="text-[10px] text-[#5a6062]/60">{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门{record.schemeCode ? ' · 已保存方案' : ' · 生成时方案'}</p>
@ -31,18 +47,24 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[
<Trash2 size={13}/>删除失败记录</Button>}
<div className="grid grid-cols-1 gap-2.5">
{record.images.map((image,index) => {
const filename = `${record.name}-${record.mode==='scene'?'效果图':'白底图'}-${index+1}.png`
const filename = `${record.name}-${recordLabel(record)}-${index+1}.png`
const selection = { recordId: record.id, storageKey: image.storageKey, url: image.originalUrl, name: `${record.name}-${recordLabel(record)}-${index + 1}`, width: image.width, height: image.height }
return <div key={image.storageKey} className="overflow-hidden rounded-[7px] border border-black/[0.04] bg-white">
<button type="button" className="block w-full cursor-zoom-in bg-[#eef2f2]" aria-label={`查看${record.mode==='scene'?'效果图':'白底图'} ${index+1}`} onClick={() => setLarge(image.originalUrl)}>
<button type="button" className="block w-full cursor-zoom-in bg-[#eef2f2]" aria-label={`查看${recordLabel(record)} ${index+1}`} onClick={() => setLarge(image.originalUrl)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.thumbnailUrl} alt={`${record.name} ${index+1}`} loading="lazy" className="aspect-[16/10] w-full object-contain"/>
</button>
<div className="flex flex-wrap gap-1 border-b border-black/[0.04] px-2 py-1">
<Button size="small" variant="plain" onClick={() => setEditing(selection)}><SlidersHorizontal size={13}/>编辑图片</Button>
{ai.access.canGenerate && record.mode === 'white-background' && !record.operation && record.status === 'SUCCEEDED' &&
<Button size="small" variant="plain" onClick={() => setLayout(selection)}><Wand2 size={13}/>排版替换</Button>}
</div>
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-zinc-400"><span>{image.width} × {image.height}</span>
<div className="flex items-center gap-1">
{ai.access.canDownload && <Button size="small" variant="plain" loading={ai.downloading===image.originalUrl} disabled={Boolean(ai.downloading)}
onClick={() => void ai.download(image.originalUrl,filename)}><Download size={13}/>下载</Button>}
{ai.access.canDelete && <Button type="button" size="small" variant="plain" tone="danger"
aria-label={`删除${record.mode==='scene'?'效果图':'白底图'} ${index+1}`} disabled={record.status==='PENDING'||ai.deletion.deleting||Boolean(ai.downloading)}
aria-label={`删除${recordLabel(record)} ${index+1}`} disabled={record.status==='PENDING'||ai.deletion.deleting||Boolean(ai.downloading)}
title={record.status==='PENDING'?'生成中的任务暂不能删除':'删除这张图片'}
onClick={() => ai.deletion.requestDelete(record,{ target:'image',storageKey:image.storageKey })}><Trash2 size={13}/>删除</Button>}
</div>
@ -51,6 +73,7 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[
})}
</div>
{record.referenceImageUrl && <button type="button" className="text-[10px] text-[#5a6062]/60 hover:text-[#2d3335]" onClick={() => setLarge(record.referenceImageUrl!)}>查看生成时参考图</button>}
{record.templateUrl && <button type="button" className="ml-3 text-[10px] text-[#5a6062]/60 hover:text-[#2d3335]" onClick={() => setLarge(record.templateUrl!)}>查看排版模板</button>}
</section>)}
{!records.length && <p className="py-8 text-center text-[11px] text-[#5a6062]/45">还没有{ai.mode==='scene'?'效果图':'白底图'}记录</p>}
</div>

View File

@ -0,0 +1,26 @@
'use client'
import { useState } from 'react'
import Button from '@/components/ui/Button'
import { DEFAULT_IMAGE_ADJUSTMENTS, IMAGE_ADJUST_SECTIONS, type ImageAdjustPanelTab } from '@/lib/diy/image-tools/adjustments'
import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor'
const tabs: { key: ImageAdjustPanelTab; label: string }[] = [
{ key: 'light', label: '光线' }, { key: 'color', label: '颜色' }, { key: 'detail', label: '细节' }, { key: 'effects', label: '效果' },
]
export default function DiyImageAdjustControls({ editor }: { editor: DiyImageEditorController }) {
const [tab, setTab] = useState<ImageAdjustPanelTab>('light')
return <div className="space-y-5">
<div className="flex gap-1 rounded-full bg-zinc-100 p-1" role="tablist" aria-label="调色分类">
{tabs.map(item => <button key={item.key} type="button" role="tab" aria-selected={tab === item.key}
onClick={() => setTab(item.key)} className={`flex-1 rounded-full py-1.5 text-xs ${tab === item.key ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'}`}>{item.label}</button>)}
</div>
{IMAGE_ADJUST_SECTIONS[tab].map(control => <label key={control.key} className="block space-y-2 text-xs text-zinc-600">
<span className="flex justify-between"><span>{control.label}</span><span className="tabular-nums">{editor.adjustments[control.key]}</span></span>
<input type="range" min={control.min} max={control.max} value={editor.adjustments[control.key]} className="w-full accent-[#47646b]"
onChange={event => editor.setAdjustments(previous => ({ ...previous, [control.key]: Number(event.target.value) }))}/>
</label>)}
<Button variant="plain" size="small" onClick={() => editor.setAdjustments({ ...DEFAULT_IMAGE_ADJUSTMENTS })}>重置调色</Button>
<p className="text-xs leading-5 text-zinc-400">调色仅用于实时预览,尺寸导出使用原图。</p>
</div>
}

View File

@ -0,0 +1,35 @@
'use client'
import { useRef } from 'react'
import { buildImageAdjustmentFilterStyle, isDefaultImageAdjustments } from '@/lib/diy/image-tools/adjustments'
import { ImageNodeAdjustOverlays } from '@/lib/diy/image-tools/AdjustmentOverlays'
import { resizeGeometry } from '@/lib/diy/image-tools/resize'
import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor'
export default function DiyImageCanvasPreview({ editor, draggable }: { editor: DiyImageEditorController; draggable: boolean }) {
const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number; factor: number } | null>(null)
const { image, options, adjustments, validation } = editor
if (!image) return <div className="flex min-h-80 items-center justify-center text-sm text-zinc-400">{editor.error || '正在加载原图…'}</div>
if (validation) return <div className="flex min-h-80 items-center justify-center text-sm text-red-600">{validation}</div>
const rect = resizeGeometry(image.naturalWidth, image.naturalHeight, options)
return <div className="flex min-h-80 items-center justify-center rounded-md bg-zinc-100 p-5 sm:min-h-[440px]">
<div className={`relative isolate overflow-hidden shadow-sm ${draggable ? 'cursor-move' : ''}`} style={{
width: `min(100%, ${55 * options.width / options.height}vh)`, aspectRatio: `${options.width}/${options.height}`, touchAction: draggable ? 'none' : 'auto',
backgroundColor: options.background || '#fff', backgroundImage: options.background ? undefined : 'conic-gradient(#e4e4e7 25%, white 0 50%, #e4e4e7 0 75%, white 0)', backgroundSize: '16px 16px',
}} onPointerDown={event => {
if (!draggable || event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { x: event.clientX, y: event.clientY, offsetX: options.offsetX || 0, offsetY: options.offsetY || 0, factor: options.width / event.currentTarget.getBoundingClientRect().width }
}} onPointerMove={event => {
const start = drag.current
if (!start) return
editor.setOptions(previous => ({ ...previous, offsetX: Math.round(start.offsetX + (event.clientX - start.x) * start.factor), offsetY: Math.round(start.offsetY + (event.clientY - start.y) * start.factor) }))
}} onPointerUp={() => { drag.current = null }} onPointerCancel={() => { drag.current = null }}>
<div className="absolute isolate" style={{ left: `${rect.x / options.width * 100}%`, top: `${rect.y / options.height * 100}%`, width: `${rect.width / options.width * 100}%`, height: `${rect.height / options.height * 100}%` }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt="编辑图片预览" draggable={false} className="h-full w-full select-none" style={buildImageAdjustmentFilterStyle(adjustments)}/>
<ImageNodeAdjustOverlays adjustments={adjustments} hasActiveAdjustments={!isDefaultImageAdjustments(adjustments)} style={{ inset: 0 }}/>
</div>
</div>
</div>
}

View File

@ -0,0 +1,32 @@
'use client'
import { useState } from 'react'
import { Download } from 'lucide-react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { useDiyImageEditor } from '../../hooks/useDiyImageEditor'
import DiyImageAdjustControls from './DiyImageAdjustControls'
import DiyImageResizeControls from './DiyImageResizeControls'
import DiyImageCanvasPreview from './DiyImageCanvasPreview'
export default function DiyImageEditor({ source, canDownload, onClose }: { source: DiyImageSelection; canDownload: boolean; onClose: () => void }) {
const [tab, setTab] = useState<'adjust' | 'resize'>('adjust')
const editor = useDiyImageEditor(source)
return <Modal isOpen onClose={() => { if (!editor.exporting) onClose() }} title="编辑图片" size="xlarge" variant="compact" bodyClassName="pt-4!"
footer={<div className="flex items-center justify-between gap-3"><span className="text-xs text-zinc-400">原图保留 · 导出为新 PNG</span>
<Button size="small" loading={editor.exporting} disabled={!canDownload || !editor.image || Boolean(editor.validation)} onClick={() => void editor.download()}><Download size={14}/>导出 PNG</Button></div>}>
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_280px]">
<DiyImageCanvasPreview editor={editor} draggable={tab === 'resize'}/>
<div className="min-w-0 space-y-5">
<div className="flex gap-1 border-b border-zinc-100 pb-3" role="tablist" aria-label="图片工具">
<Button variant={tab === 'adjust' ? 'tinted' : 'plain'} size="small" role="tab" aria-selected={tab === 'adjust'} onClick={() => setTab('adjust')}>调色预览</Button>
<Button variant={tab === 'resize' ? 'tinted' : 'plain'} size="small" role="tab" aria-selected={tab === 'resize'} onClick={() => setTab('resize')}>尺寸调整</Button>
</div>
{tab === 'adjust' ? <DiyImageAdjustControls editor={editor}/> : <DiyImageResizeControls editor={editor}/>}
</div>
</div>
{editor.error && <p role="alert" className="mt-3 text-xs text-red-600">{editor.error}</p>}
{editor.notice && <p role="status" className="mt-3 text-xs text-[#47646b]">{editor.notice}</p>}
</Modal>
}

View File

@ -0,0 +1,33 @@
'use client'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor'
export default function DiyImageResizeControls({ editor }: { editor: DiyImageEditorController }) {
const { options, setOptions } = editor
return <div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{(['width', 'height'] as const).map((key, index) => <Input key={key} label={index ? '高度(px)' : '宽度(px)'} type="number" min={1} max={8192} step={1}
value={options[key]} onChange={event => setOptions(previous => ({ ...previous, [key]: Number(event.target.value) }))}/>)}
</div>
<div className="flex flex-wrap gap-1">
{[800, 1024, 1600, 2048].map(size => <Button key={size} size="small" variant="plain" onClick={() => setOptions(previous => ({ ...previous, width: size, height: size }))}>{size}²</Button>)}
</div>
<Input label="等比缩放(%)" type="number" min={1} max={1600} step={1} value={Math.round((options.scale ?? 1) * 10000) / 100}
onChange={event => setOptions(previous => ({ ...previous, scale: Number(event.target.value) / 100 }))}/>
<div className="grid grid-cols-2 gap-3">
{(['offsetX', 'offsetY'] as const).map((key, index) => <Input key={key} label={index ? '垂直位移(px)' : '水平位移(px)'} type="number" step={1} value={options[key] || 0}
onChange={event => setOptions(previous => ({ ...previous, [key]: Number(event.target.value) }))}/>)}
</div>
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-2"><input type="checkbox" checked={options.background === null} onChange={event => setOptions(previous => ({ ...previous, background: event.target.checked ? null : '#ffffff' }))}/>透明画布</label>
{options.background !== null && <input type="color" aria-label="画布颜色" className="h-8 w-10 cursor-pointer" value={options.background || '#ffffff'} onChange={event => setOptions(previous => ({ ...previous, background: event.target.value }))}/>}
</div>
<div className="flex gap-2">
<Button size="small" variant="tinted" onClick={editor.fit}>适应画布</Button>
<Button size="small" variant="plain" onClick={() => setOptions(previous => ({ ...previous, offsetX: 0, offsetY: 0 }))}>居中</Button>
</div>
<p className="text-xs leading-5 text-zinc-400">可拖动图片调整位置。透明画布保留原图背景,不自动去除白底。单边最大 8192 像素,总像素不超过 3200 万。</p>
</div>
}

View File

@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { Wand2 } from 'lucide-react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
import type { DiyImageSelection, DiyLayoutTemplate } from '@/lib/diy/image-tools'
import type { DiyLayoutBatchController } from '../../hooks/useDiyLayoutBatch'
import DiyLayoutTemplateUpload from './DiyLayoutTemplateUpload'
export default function DiyLayoutReplaceDialog({ source, records, batch, onClose }: {
source: DiyImageSelection; records: DiyAiRenderDTO[]; batch: DiyLayoutBatchController; onClose: () => void
}) {
const [selected, setSelected] = useState([source.storageKey]), [template, setTemplate] = useState<DiyLayoutTemplate | null>(null)
const [prompt, setPrompt] = useState(''), [uploading, setUploading] = useState(false), [error, setError] = useState('')
const candidates = records.filter(record => record.mode === 'white-background' && !record.operation && record.status === 'SUCCEEDED')
.flatMap(record => record.images.map((image, index) => ({ recordId: record.id, storageKey: image.storageKey, url: image.thumbnailUrl, name: `${record.name}-${index + 1}` })))
const blocked = batch.running || batch.items.some(item => ['queued', 'submitting', 'generating', 'needs-review'].includes(item.status))
const start = () => {
if (!template || blocked || uploading || !selected.length) return
setError('')
try {
const sources = selected.map(key => candidates.find(item => item.storageKey === key))
if (sources.some(item => !item)) throw new Error('所选原图已更新,请重新选择')
batch.start(sources.map(item => ({ name: item!.name, input: { requestId: crypto.randomUUID(), sourceRecordId: item!.recordId,
sourceStorageKey: item!.storageKey, templateStorageKey: template.storageKey, supplementalPrompt: prompt.trim() } })))
} catch (err) { setError(err instanceof Error ? err.message : '提交失败') }
}
return <Modal isOpen title="排版替换" size="large" variant="compact" onClose={() => { if (!uploading) onClose() }} bodyClassName="pt-4!"
footer={<div className="flex items-center justify-between gap-3"><span className="text-xs text-zinc-400">原图保留 · 结果存入出图记录</span>
<Button size="small" loading={batch.running} disabled={blocked || uploading || !template || !selected.length} onClick={start}><Wand2 size={14}/>生成 {selected.length} 张排版图</Button></div>}>
<div className="grid gap-5 md:grid-cols-2">
<section className="space-y-3">
<h3 className="text-sm font-semibold">商品白底图 <span className="font-normal text-zinc-400">{selected.length}/5</span></h3>
<div className="grid max-h-80 grid-cols-3 gap-2 overflow-y-auto p-1">
{candidates.map(item => <label key={item.storageKey} className={`relative min-w-0 cursor-pointer rounded-md border p-1 ${selected.includes(item.storageKey) ? 'border-[#47646b] bg-[#47646b]/5' : 'border-zinc-100'}`}>
<input type="checkbox" aria-label={`选择 ${item.name}`} className="absolute left-2 top-2 accent-[#47646b]" checked={selected.includes(item.storageKey)}
disabled={blocked || (!selected.includes(item.storageKey) && selected.length >= 5)} onChange={event => setSelected(previous => event.target.checked ? [...previous, item.storageKey] : previous.filter(key => key !== item.storageKey))}/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt={item.name} className="aspect-square w-full object-contain"/>
<span className="block truncate px-1 py-1 text-[10px] text-zinc-500">{item.name}</span>
</label>)}
</div>
<label className="block space-y-2 text-xs text-zinc-600"><span>补充要求(可选)</span>
<textarea value={prompt} maxLength={600} disabled={blocked} onChange={event => setPrompt(event.target.value)} rows={4}
placeholder="填写需要保留或调整的排版细节" className="w-full resize-y rounded-md border border-zinc-200 p-3 text-sm outline-none focus:border-[#47646b]"/>
</label>
</section>
<section className="space-y-3"><h3 className="text-sm font-semibold">模板图片</h3>
<DiyLayoutTemplateUpload value={template} disabled={blocked} onChange={setTemplate} onBusyChange={setUploading}/>
<p className="text-xs leading-5 text-zinc-400">保留商品结构、颜色和视角,按模板的场景与构图生成。每批 1–5 张,逐张处理;失败不影响其他结果。</p>
</section>
</div>
{batch.notice && <p role="status" className="mt-4 text-xs text-[#47646b]">{batch.notice}</p>}
{batch.items.length > 0 && <ul className="mt-3 space-y-1 text-xs text-zinc-500">
{batch.items.map(item => <li key={item.input.requestId}>{item.name} · {{ queued: '等待处理', submitting: '正在提交', generating: '正在生成', succeeded: '已完成', failed: '失败', 'needs-review': '待核对' }[item.status]}
{item.error && <span className="ml-2 text-red-600">{item.error}</span>}</li>)}
</ul>}
{error && <p role="alert" className="mt-3 text-xs text-red-600">{error}</p>}
</Modal>
}

View File

@ -0,0 +1,41 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { UploadCloud } from 'lucide-react'
import type { DiyLayoutTemplate } from '@/lib/diy/image-tools'
import { uploadDiyLayoutTemplate } from '../../lib/image-tools-api'
export default function DiyLayoutTemplateUpload({ value, disabled, onChange, onBusyChange }: {
value: DiyLayoutTemplate | null; disabled: boolean; onChange: (value: DiyLayoutTemplate) => void; onBusyChange: (value: boolean) => void
}) {
const input = useRef<HTMLInputElement>(null), lock = useRef(false)
const [busy, setBusy] = useState(false), [error, setError] = useState('')
const upload = useCallback(async (file: File) => {
if (lock.current || disabled) return
lock.current = true; setBusy(true); onBusyChange(true); setError('')
try {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 20 * 1024 * 1024) throw new Error('请选择20MB以内的 PNG、JPG、WebP 模板')
onChange(await uploadDiyLayoutTemplate(file))
} catch (err) { setError(err instanceof Error ? err.message : '上传失败') }
finally { lock.current = false; setBusy(false); onBusyChange(false) }
}, [disabled, onChange, onBusyChange])
useEffect(() => {
const paste = (event: ClipboardEvent) => {
const file = Array.from(event.clipboardData?.items || []).find(item => item.type.startsWith('image/'))?.getAsFile()
if (file && !disabled && !lock.current) { event.preventDefault(); void upload(file) }
}
document.addEventListener('paste', paste)
return () => document.removeEventListener('paste', paste)
}, [disabled, upload])
return <div className="space-y-2">
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" aria-label="上传排版模板" className="sr-only" disabled={disabled || busy}
onChange={event => { const file = event.target.files?.[0]; event.target.value = ''; if (file) void upload(file) }}/>
<button type="button" disabled={disabled || busy} onClick={() => input.current?.click()} className="flex min-h-44 w-full flex-col items-center justify-center gap-3 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-4 text-xs text-zinc-500 disabled:opacity-50">
{/* eslint-disable-next-line @next/next/no-img-element */}
{value ? <img src={value.url} alt="排版模板" className="max-h-64 w-full object-contain"/> : <UploadCloud size={24}/>}
<span>{busy ? '正在上传模板…' : value ? '更换模板 · 支持 Ctrl+V' : '上传模板图片 · 支持 Ctrl+V 粘贴'}</span>
</button>
{value && <p className="text-xs text-zinc-400">输出尺寸:{value.width} × {value.height} px</p>}
{error && <p role="alert" className="text-xs text-red-600">{error}</p>}
</div>
}

View File

@ -22,7 +22,7 @@ export function useDiyAiDeletion({ canDelete, records, onDeleted, refresh, setEr
if (!canDelete || lock.current || record.status === 'PENDING') return
setError('')
setSelection({ recordId: record.id, input, label: input.target === 'record'
? `失败的出图记录“${record.name}”` : `这张${record.mode === 'scene' ? '效果图' : '白底图'}` })
? `失败的出图记录“${record.name}”` : `这张${record.operation === 'layout-replace' ? '排版图' : record.mode === 'scene' ? '效果图' : '白底图'}` })
}
const cancel = () => { if (!lock.current) setSelection(null) }
const confirm = async () => {

View File

@ -0,0 +1,63 @@
'use client'
import { useEffect, useState } from 'react'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { DEFAULT_IMAGE_ADJUSTMENTS } from '@/lib/diy/image-tools/adjustments'
import { resizeGeometry, resizeImage, type ResizeOptions } from '@/lib/diy/image-tools/resize'
export function useDiyImageEditor(source: Pick<DiyImageSelection, 'recordId' | 'storageKey' | 'name'>) {
const { recordId, storageKey, name: filename } = source
const [image, setImage] = useState<HTMLImageElement | null>(null)
const [adjustments, setAdjustments] = useState({ ...DEFAULT_IMAGE_ADJUSTMENTS })
const [options, setOptions] = useState<ResizeOptions>({ width: 1024, height: 1024, scale: 1, offsetX: 0, offsetY: 0, background: null })
const [error, setError] = useState(''), [notice, setNotice] = useState(''), [exporting, setExporting] = useState(false)
useEffect(() => {
const controller = new AbortController()
let objectUrl = '', active = true
void (async () => {
try {
const query = new URLSearchParams({ recordId, storageKey })
const response = await fetch(`/api/diy/ai-renders/image?${query}`, {
signal: controller.signal, credentials: 'same-origin', cache: 'no-store', redirect: 'error',
})
if (!response.ok) {
const result = await response.json().catch(() => null)
throw new Error(result?.error?.message || '原图加载失败,请关闭后重试')
}
if (!response.headers.get('content-type')?.startsWith('image/')) throw new Error('原图格式无效,请关闭后重试')
objectUrl = URL.createObjectURL(await response.blob())
const loaded = new Image()
loaded.src = objectUrl
await loaded.decode()
if (!active) return
setImage(loaded)
setOptions({ width: loaded.naturalWidth, height: loaded.naturalHeight, scale: 1, offsetX: 0, offsetY: 0, background: null })
} catch (err) { if (active) setError(err instanceof Error ? err.message : '原图加载失败') }
})()
return () => { active = false; controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl) }
}, [recordId, storageKey])
let validation = ''
if (image) {
try { resizeGeometry(image.naturalWidth, image.naturalHeight, options) }
catch (err) { validation = err instanceof Error ? err.message : '尺寸无效' }
}
const fit = () => {
if (!image || options.width <= 0 || options.height <= 0) return
setOptions(previous => ({ ...previous, scale: Math.min(previous.width / image.naturalWidth, previous.height / image.naturalHeight), offsetX: 0, offsetY: 0 }))
}
const download = async () => {
if (!image || validation || exporting) return
setExporting(true); setError(''); setNotice('正在导出 PNG…')
try {
const blob = await resizeImage(image, options)
const href = URL.createObjectURL(blob), anchor = document.createElement('a')
anchor.href = href; anchor.download = `${filename}-${options.width}x${options.height}.png`
document.body.appendChild(anchor); anchor.click(); anchor.remove()
setTimeout(() => URL.revokeObjectURL(href), 30_000)
setNotice('PNG 已开始下载')
} catch (err) { setError(err instanceof Error ? err.message : '导出失败'); setNotice('') }
finally { setExporting(false) }
}
return { image, adjustments, setAdjustments, options, setOptions, error, notice, exporting, validation, fit, download }
}
export type DiyImageEditorController = ReturnType<typeof useDiyImageEditor>

View File

@ -0,0 +1,15 @@
'use client'
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { emptyLayoutBatch, getLayoutBatch, initializeLayoutBatch, resumeLayoutBatch, startLayoutBatch, subscribeLayoutBatch } from '../lib/layout-replace-batch'
export function useDiyLayoutBatch(refresh: () => Promise<void>) {
const state = useSyncExternalStore(subscribeLayoutBatch, getLayoutBatch, () => emptyLayoutBatch)
const liveRefresh = useRef(refresh)
useEffect(() => { liveRefresh.current = refresh }, [refresh])
useEffect(() => { initializeLayoutBatch() }, [])
const progress = state.items.map(item => `${item.input.requestId}:${item.status}`).join(',')
useEffect(() => { if (progress) void liveRefresh.current() }, [progress])
return { ...state, start: startLayoutBatch, resume: resumeLayoutBatch }
}
export type DiyLayoutBatchController = ReturnType<typeof useDiyLayoutBatch>

View File

@ -0,0 +1,22 @@
import { submitRecoverableImageRequest } from '@/lib/recoverable-image-submission'
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
import type { DiyLayoutReplaceInput, DiyLayoutTemplate } from '@/lib/diy/image-tools'
export async function uploadDiyLayoutTemplate(file: File): Promise<DiyLayoutTemplate> {
const body = new FormData(); body.set('file', file)
const response = await fetch('/api/diy/ai-renders/layout-template', { method: 'POST', body })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '模板上传失败')
return payload.data
}
export const submitDiyLayoutReplace = (input: DiyLayoutReplaceInput, onUncertain: (message: string) => void) =>
submitRecoverableImageRequest<DiyAiRenderDTO, DiyAiRenderDTO | null>({
url: '/api/diy/ai-renders/layout-replace', scope: input.requestId, group: input.requestId, input,
readResult: data => data ?? null, onUncertain,
})
export async function readDiyLayoutReplace(requestId: string): Promise<DiyAiRenderDTO | null> {
const response = await fetch(`/api/diy/ai-renders/layout-replace?requestId=${encodeURIComponent(requestId)}`, { cache: 'no-store', signal: AbortSignal.timeout(20_000) })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '读取排版进度失败')
return payload.data
}

View File

@ -0,0 +1,96 @@
'use client'
import { z } from 'zod'
import { diyLayoutReplaceSchema, type DiyLayoutReplaceInput } from '@/lib/diy/image-tools'
import { readDiyLayoutReplace, submitDiyLayoutReplace } from './image-tools-api'
const itemSchema = z.object({ input: diyLayoutReplaceSchema, name: z.string().max(120),
status: z.enum(['queued', 'submitting', 'generating', 'succeeded', 'failed', 'needs-review']), error: z.string().max(2000).optional() })
type BatchItem = z.infer<typeof itemSchema>
export interface DiyLayoutBatchState { items: BatchItem[]; running: boolean; notice: string }
export const emptyLayoutBatch: DiyLayoutBatchState = { items: [], running: false, notice: '' }
const storageKey = 'diy-layout-batch:v1'
let state = emptyLayoutBatch, initialized = false, executing = false
const listeners = new Set<() => void>()
const pause = () => new Promise<void>(resolve => setTimeout(resolve, 5000))
export const getLayoutBatch = () => state
export const subscribeLayoutBatch = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } }
function publish(next: DiyLayoutBatchState) { state = next; for (const listener of listeners) listener() }
function save() { sessionStorage.setItem(storageKey, JSON.stringify(state.items)) }
function update(index: number, patch: Partial<BatchItem>, notice = state.notice) {
publish({ ...state, items: state.items.map((item, i) => i === index ? { ...item, ...patch } : item), notice })
save()
}
/** A single runner survives panel changes. Only IDs, keys and instructions are persisted. */
export async function resumeLayoutBatch() {
if (executing || !state.items.some(item => ['queued', 'submitting', 'generating', 'needs-review'].includes(item.status))) return
executing = true
publish({ ...state, running: true, notice: '正在核对排版任务…' })
try {
for (let index = 0; index < state.items.length; index++) {
const item = state.items[index]!
if (item.status === 'succeeded' || item.status === 'failed') continue
update(index, { status: 'submitting', error: undefined }, `正在提交 ${index + 1}/${state.items.length}`)
let record
try {
if (item.status === 'generating' || item.status === 'needs-review') {
try { record = await readDiyLayoutReplace(item.input.requestId) }
catch {
update(index, { status: 'needs-review', error: '暂时无法核对原任务,请稍后继续核对' })
return
}
if (!record) { update(index, { status: 'failed', error: '排版记录已删除' }); continue }
} else {
record = await submitDiyLayoutReplace(item.input, notice => publish({ ...state, notice }))
}
} catch (err) {
update(index, { status: 'failed', error: err instanceof Error ? err.message : '提交失败' })
continue
}
update(index, { status: 'generating' }, `正在生成 ${index + 1}/${state.items.length}`)
let removed = false
while (record.status === 'PENDING' && !record.errorMessage) {
await pause()
try {
const latest = await readDiyLayoutReplace(item.input.requestId)
if (latest) record = latest
else { removed = true; break }
} catch {
publish({ ...state, notice: '进度读取中断,正在重新连接;请勿重复提交' })
}
}
if (removed) { update(index, { status: 'failed', error: '排版记录已删除' }); continue }
if (record.status === 'PENDING') {
update(index, { status: 'needs-review', error: record.errorMessage || '任务待核对' }, '当前任务待核对,后续图片尚未提交')
return
}
update(index, { status: record.status === 'SUCCEEDED' ? 'succeeded' : 'failed', error: record.errorMessage || undefined })
}
const succeeded = state.items.filter(item => item.status === 'succeeded').length
publish({ ...state, notice: `排版完成:成功 ${succeeded} 张,失败 ${state.items.length - succeeded} 张` })
} catch (err) {
publish({ ...state, notice: err instanceof Error ? err.message : '批次暂停,请重新核对' })
} finally {
executing = false
publish({ ...state, running: false })
}
}
export function initializeLayoutBatch() {
if (initialized) return
initialized = true
try {
const raw = sessionStorage.getItem(storageKey)
if (!raw) return
const parsed = z.array(itemSchema).max(5).parse(JSON.parse(raw))
publish({ items: parsed, running: false, notice: '已恢复上次排版批次' })
void resumeLayoutBatch()
} catch { publish({ ...state, notice: '上次批次记录不可读取,请先核对出图记录' }) }
}
export function startLayoutBatch(items: { input: DiyLayoutReplaceInput; name: string }[]) {
if (executing || state.items.some(item => ['queued', 'submitting', 'generating', 'needs-review'].includes(item.status))) throw new Error('请先完成或核对当前排版批次')
const checked = z.array(itemSchema).min(1).max(5).parse(items.map(item => ({ ...item, status: 'queued' })))
// Storage must be available before any paid request is submitted.
sessionStorage.setItem(storageKey, JSON.stringify(checked))
publish({ items: checked, running: false, notice: '' })
void resumeLayoutBatch()
}

View File

@ -35,6 +35,7 @@ export interface DiyAiRenderDeleteResult { record: DiyAiRenderDTO | null; storag
export interface DiyAiRenderImage { originalUrl: string; thumbnailUrl: string; storageKey: string; width: number; height: number }
export interface DiyAiRenderDTO {
id: string; name: string; schemeCode: string | null; compositionKey: string
operation?: 'layout-replace'; templateUrl?: string | null
mode: DiyAiRenderMode; status: 'PENDING' | 'SUCCEEDED' | 'FAILED'
referenceImageUrl: string | null; images: DiyAiRenderImage[]; userPrompt: string
errorMessage: string | null; createdAt: string; snapshot: DiySnapshot; cameraView: DiyAiCameraView

View File

@ -0,0 +1,19 @@
import { z } from 'zod'
import { diyAiRenderIdSchema } from './ai-render'
export const diyAiRenderImageReadSchema = z.object({
recordId: diyAiRenderIdSchema,
storageKey: z.string().trim().min(1).max(1024),
}).strict()
export type DiyAiRenderImageReadInput = z.infer<typeof diyAiRenderImageReadSchema>
export const diyLayoutReplaceSchema = z.object({
requestId: z.string().uuid(),
sourceRecordId: diyAiRenderIdSchema,
sourceStorageKey: z.string().min(1).max(1024),
templateStorageKey: z.string().min(1).max(1024),
supplementalPrompt: z.string().trim().max(600).default(''),
}).strict()
export type DiyLayoutReplaceInput = z.infer<typeof diyLayoutReplaceSchema>
export interface DiyLayoutTemplate { url: string; storageKey: string; width: number; height: number }
export interface DiyImageSelection { recordId: string; storageKey: string; url: string; name: string; width: number; height: number }

View File

@ -0,0 +1,120 @@
import type React from "react";
const IMAGE_ADJUST_NOISE_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.18' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(#n)' opacity='1'/></svg>"
)}`;
export interface ImageAdjustmentOverlayState {
temperature: number;
tint: number;
grain: number;
vignette: number;
softLight: number;
glow: number;
}
interface ImageNodeAdjustOverlaysProps {
adjustments: ImageAdjustmentOverlayState;
hasActiveAdjustments: boolean;
style: React.CSSProperties;
}
export function ImageNodeAdjustOverlays({
adjustments,
hasActiveAdjustments,
style,
}: ImageNodeAdjustOverlaysProps) {
if (!hasActiveAdjustments) return null;
const temperatureOpacity = Math.min(
0.38,
Math.abs(adjustments.temperature) / 260
);
const tintOpacity = Math.min(0.28, Math.abs(adjustments.tint) / 300);
const grainOpacity = Math.min(0.28, adjustments.grain / 340);
const vignetteOpacity = Math.min(0.42, adjustments.vignette / 180);
const softLightOpacity = Math.min(0.24, adjustments.softLight / 320);
const glowOpacity = Math.min(0.22, adjustments.glow / 280);
return (
<>
{temperatureOpacity > 0 ? (
<div
style={{
...style,
position: "absolute", pointerEvents: "none",
background:
adjustments.temperature >= 0
? "linear-gradient(135deg, rgba(255,188,109,0.9), rgba(255,232,194,0.42))"
: "linear-gradient(135deg, rgba(109,180,255,0.86), rgba(219,240,255,0.36))",
mixBlendMode: "soft-light",
opacity: temperatureOpacity,
}}
/>
) : null}
{tintOpacity > 0 ? (
<div
style={{
...style,
position: "absolute", pointerEvents: "none",
background:
adjustments.tint >= 0
? "linear-gradient(135deg, rgba(244,105,214,0.78), rgba(255,203,236,0.28))"
: "linear-gradient(135deg, rgba(76,214,141,0.76), rgba(194,255,221,0.28))",
mixBlendMode: "overlay",
opacity: tintOpacity,
}}
/>
) : null}
{softLightOpacity > 0 ? (
<div
style={{
...style,
position: "absolute", pointerEvents: "none",
background:
"linear-gradient(180deg, rgba(255,247,229,0.84) 0%, rgba(255,255,255,0.08) 34%, rgba(255,219,168,0.42) 100%)",
mixBlendMode: "soft-light",
opacity: softLightOpacity,
}}
/>
) : null}
{glowOpacity > 0 ? (
<div
style={{
...style,
position: "absolute", pointerEvents: "none",
boxShadow:
"inset 0 0 72px rgba(255,255,255,0.78), inset 0 0 24px rgba(255,236,201,0.66)",
mixBlendMode: "screen",
opacity: glowOpacity,
}}
/>
) : null}
{vignetteOpacity > 0 ? (
<div
style={{
...style,
position: "absolute", pointerEvents: "none",
background:
"radial-gradient(circle at center, rgba(0,0,0,0) 38%, rgba(0,0,0,0.06) 58%, rgba(0,0,0,0.34) 100%)",
mixBlendMode: "multiply",
opacity: vignetteOpacity,
}}
/>
) : null}
{grainOpacity > 0 ? (
<div
style={{
...style,
position: "absolute", pointerEvents: "none",
backgroundImage: `url("${IMAGE_ADJUST_NOISE_DATA_URI}")`,
backgroundRepeat: "repeat",
backgroundSize: "140px 140px",
mixBlendMode: "overlay",
opacity: grainOpacity,
}}
/>
) : null}
</>
);
}

View File

@ -0,0 +1,13 @@
Copyright 2024 Vercel, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,9 @@
# 来源与能力边界
迁移自 `画布功能开发交接包-20261019`,原始源码许可证为 Apache-2.0,见同目录 LICENSE(Copyright 2024 Vercel, Inc.)。
- adjustments.ts、AdjustmentOverlays.tsx:沿用交接包的 CSS 调色预览。
- resize.ts:沿用等比缩放、像素位移、背景和 PNG 导出算法;导出原图,不烘焙调色预览。
- layout-prompt.ts:沿用交接包排版替换提示词;将强制指定桌子抽屉、四条桌腿的旧商品描述改为核对当前柜体分区。
宿主适配:使用本系统鉴权、本人出图记录、对象存储、AI 配置及持久任务;模型原生合同须支持两张参考图,不迁移旧平台供应商排序、积分或数据库。

View File

@ -0,0 +1,180 @@
import type React from "react";
export type ImageAdjustPanelTab = "light" | "color" | "detail" | "effects";
export type ImageAdjustmentState = {
light: number;
exposure: number;
contrast: number;
highlights: number;
shadows: number;
whites: number;
blacks: number;
vibrance: number;
saturation: number;
temperature: number;
tint: number;
sharpen: number;
clarity: number;
grain: number;
vignette: number;
softLight: number;
glow: number;
};
export type ImageAdjustmentKey = keyof ImageAdjustmentState;
export const DEFAULT_IMAGE_ADJUSTMENTS: ImageAdjustmentState = {
light: 0,
exposure: 0,
contrast: 0,
highlights: 0,
shadows: 0,
whites: 0,
blacks: 0,
vibrance: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpen: 0,
clarity: 0,
grain: 0,
vignette: 0,
softLight: 0,
glow: 0,
};
export const IMAGE_ADJUSTMENT_KEYS = Object.keys(
DEFAULT_IMAGE_ADJUSTMENTS
) as ImageAdjustmentKey[];
export const IMAGE_ADJUST_SECTIONS: Record<
ImageAdjustPanelTab,
Array<{
key: ImageAdjustmentKey;
label: string;
min: number;
max: number;
trackClassName?: string;
}>
> = {
light: [
{ key: "light", label: "光线", min: -100, max: 100 },
{ key: "exposure", label: "曝光", min: -100, max: 100 },
{ key: "contrast", label: "对比度", min: -100, max: 100 },
{ key: "highlights", label: "高光", min: -100, max: 100 },
{ key: "shadows", label: "阴影", min: -100, max: 100 },
{ key: "whites", label: "白色", min: -100, max: 100 },
{ key: "blacks", label: "黑色", min: -100, max: 100 },
],
color: [
{ key: "vibrance", label: "自然饱和度", min: -100, max: 100 },
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
{
key: "temperature",
label: "色温",
min: -100,
max: 100,
trackClassName: "h-[4px] bg-[linear-gradient(90deg,#4aa7ff,#f2c23a)]",
},
{
key: "tint",
label: "色调",
min: -100,
max: 100,
trackClassName: "h-[4px] bg-[linear-gradient(90deg,#ff66d7,#41d278)]",
},
],
detail: [
{ key: "sharpen", label: "锐化", min: 0, max: 100 },
{ key: "clarity", label: "清晰度", min: 0, max: 100 },
{ key: "grain", label: "颗粒", min: 0, max: 100 },
],
effects: [
{ key: "vignette", label: "晕影", min: 0, max: 100 },
{ key: "softLight", label: "柔光", min: 0, max: 100 },
{ key: "glow", label: "辉光", min: 0, max: 100 },
],
};
const clampImageAdjustmentNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, Number.isFinite(value) ? value : min));
export function normalizeImageAdjustments(value: unknown): ImageAdjustmentState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<Record<ImageAdjustmentKey, unknown>>)
: {};
return {
light: clampImageAdjustmentNumber(Number(raw.light ?? 0), -100, 100),
exposure: clampImageAdjustmentNumber(Number(raw.exposure ?? 0), -100, 100),
contrast: clampImageAdjustmentNumber(Number(raw.contrast ?? 0), -100, 100),
highlights: clampImageAdjustmentNumber(Number(raw.highlights ?? 0), -100, 100),
shadows: clampImageAdjustmentNumber(Number(raw.shadows ?? 0), -100, 100),
whites: clampImageAdjustmentNumber(Number(raw.whites ?? 0), -100, 100),
blacks: clampImageAdjustmentNumber(Number(raw.blacks ?? 0), -100, 100),
vibrance: clampImageAdjustmentNumber(Number(raw.vibrance ?? 0), -100, 100),
saturation: clampImageAdjustmentNumber(Number(raw.saturation ?? 0), -100, 100),
temperature: clampImageAdjustmentNumber(Number(raw.temperature ?? 0), -100, 100),
tint: clampImageAdjustmentNumber(Number(raw.tint ?? 0), -100, 100),
sharpen: clampImageAdjustmentNumber(Number(raw.sharpen ?? 0), 0, 100),
clarity: clampImageAdjustmentNumber(Number(raw.clarity ?? 0), 0, 100),
grain: clampImageAdjustmentNumber(Number(raw.grain ?? 0), 0, 100),
vignette: clampImageAdjustmentNumber(Number(raw.vignette ?? 0), 0, 100),
softLight: clampImageAdjustmentNumber(Number(raw.softLight ?? 0), 0, 100),
glow: clampImageAdjustmentNumber(Number(raw.glow ?? 0), 0, 100),
};
}
export function areImageAdjustmentsEqual(
left: ImageAdjustmentState,
right: ImageAdjustmentState
) {
return IMAGE_ADJUSTMENT_KEYS.every((key) => left[key] === right[key]);
}
export function isDefaultImageAdjustments(value: ImageAdjustmentState) {
return areImageAdjustmentsEqual(value, DEFAULT_IMAGE_ADJUSTMENTS);
}
export function buildImageAdjustmentFilterStyle(
adjustments: ImageAdjustmentState
): React.CSSProperties {
const brightnessLift =
adjustments.light * 0.34 +
adjustments.exposure * 0.52 +
adjustments.shadows * 0.2 +
adjustments.whites * 0.12 -
adjustments.blacks * 0.16 -
adjustments.highlights * 0.06;
const brightness = clampImageAdjustmentNumber(1 + brightnessLift / 100, 0.35, 2.2);
const contrastBoost =
adjustments.contrast * 0.78 +
adjustments.clarity * 0.18 +
adjustments.sharpen * 0.1 +
adjustments.highlights * 0.06 -
adjustments.shadows * 0.04;
const contrast = clampImageAdjustmentNumber(1 + contrastBoost / 100, 0.45, 2.1);
const saturationBoost =
adjustments.saturation * 0.9 +
adjustments.vibrance * 0.55 +
adjustments.sharpen * 0.04;
const saturate = clampImageAdjustmentNumber(1 + saturationBoost / 100, 0.1, 2.4);
const hueRotate = clampImageAdjustmentNumber(adjustments.tint * 0.35, -36, 36);
const sepia = clampImageAdjustmentNumber(
adjustments.temperature > 0 ? adjustments.temperature * 0.18 : 0,
0,
22
);
return {
filter: [
`brightness(${brightness.toFixed(3)})`,
`contrast(${contrast.toFixed(3)})`,
`saturate(${saturate.toFixed(3)})`,
`hue-rotate(${hueRotate.toFixed(1)}deg)`,
`sepia(${sepia.toFixed(1)}%)`,
].join(" "),
willChange: "filter",
};
}

View File

@ -0,0 +1,63 @@
export function buildCanvasLayoutReplacePrompt(params: {
referenceHeight?: number;
referenceWidth?: number;
sourceProductAspectRatio?: number;
supplementalPrompt?: string;
}) {
const geometry =
params.referenceWidth && params.referenceHeight
? `${params.referenceWidth}x${params.referenceHeight}`
: "与图2完全相同";
const supplement = String(params.supplementalPrompt || "").trim();
const sourceProductAspectRatio =
Number.isFinite(params.sourceProductAspectRatio) &&
Number(params.sourceProductAspectRatio) > 0
? Number(params.sourceProductAspectRatio)
: null;
const sourceProductRatioConstraint = sourceProductAspectRatio
? `【商品外框比例硬约束】图1透明轮廓测得商品有效外框宽高比为 ${sourceProductAspectRatio.toFixed(3)}:1。结果中完整商品外框的宽高比必须保持在 ${(sourceProductAspectRatio * 0.96).toFixed(3)} 至 ${(sourceProductAspectRatio * 1.04).toFixed(3)} 之间。若与图2原商品占位冲突,应缩短商品整体高度或扩大整体宽度,绝对不能压窄商品宽度或纵向拉长。生成前必须测量外框复核。`
: "";
const targetCanvasAspectRatio =
params.referenceWidth && params.referenceHeight
? params.referenceWidth / params.referenceHeight
: null;
const targetProductHeightCoverage = 0.76;
const targetProductWidthCoverage =
sourceProductAspectRatio && targetCanvasAspectRatio
? Math.max(
0.52,
Math.min(
0.86,
(sourceProductAspectRatio / targetCanvasAspectRatio) *
targetProductHeightCoverage,
),
)
: null;
const targetProductLeftEdge = targetProductWidthCoverage
? (1 - targetProductWidthCoverage) / 2
: null;
const targetProductRightEdge = targetProductLeftEdge
? 1 - targetProductLeftEdge
: null;
const targetProductCoverageConstraint = targetProductWidthCoverage
? `【商品画布占比硬约束】新商品完整外框应占最终画布宽度约 ${Math.round(targetProductWidthCoverage * 100)}%,允许范围 ${Math.round((targetProductWidthCoverage - 0.03) * 100)}% 至 ${Math.round((targetProductWidthCoverage + 0.03) * 100)}%;左外沿应到达画布 x≈${Math.round(Number(targetProductLeftEdge) * 100)}% 处,右外沿应到达 x≈${Math.round(Number(targetProductRightEdge) * 100)}% 处,两侧都不得向中间收窄;外框高度约占画布 ${Math.round(targetProductHeightCoverage * 100)}%。图2旧商品原本偏窄的占位不得继承,这是唯一允许突破图2原商品边界的地方。扩大新商品时,边柜、台灯、花瓶、摆件和椅子保持原位,并继自然遮挡在新商品前方。`
: "";
return [
"执行单一商品换模版任务。图1是新商品身份图,图2是目标场景与构图模版,最终只输出一张完整成图。",
"【两图职责】以图2为最终画面底图和唯一场景真值;识别并完整移除图2中的原商品,只在原商品占据的合理空间内放入图1的新商品。图1只定义新商品本身,不得把图1的纯色/透明背景带入结果。",
"【商品刚性身份锁定,最高优先级】把图1商品视为已经抠好的真实商品像素层,优先完整复用,不要重新绘制或重新设计。只允许对整个商品做等比缩放和整体平移;禁止透视拉伸、视角重构、横向或纵向拉伸、压扁、加宽、缩短、局部缩放、局部重绘、拼接、融合旧商品、改款、简化或增删部件。宁可保留图1的原始轻微侧视角,也不得把它强行改成正视图。",
"【结构逐项核对】结果必须保持图1商品的完整外轮廓和长宽高比例,以及每块板件、柜门、玻璃、滑轨、把手、层板、洞洞板、抽屉、圆形拉手、桌面、横梁及腿部(如有)的数量、形状、位置、尺寸关系、颜色及材质。不得合并柜体、格口或柜门分区。商品完整出现一次,不得裁切、复制或被旧商品残影穿插。",
sourceProductRatioConstraint,
targetProductCoverageConstraint,
"【场景与前景物件锁定】逐像素保留图2的相机机位、画幅、墙面、地面、边柜、台灯、花瓶、椅子、光影和空间关系,也必须保留兔子摆件、书、玩偶、相框、杯子及桌面小物的原样、数量和位置。先把这些独立前景物件与椅子视为不可编辑覆盖层,只移除其后方的旧商品,再让它们按原遮挡关系覆盖到新商品前方;不得把摆件当作旧商品的一部分清除。",
`【尺寸与放置】输出画布为 ${geometry}。根据图2原商品的地面接触点和整体占位,对图1商品只做统一等比缩放与整体平移;不做局部透视变形。宁可让边缘占位略有差异,也要优先保持图1商品的真实比例和结构。`,
"【融合要求】清除图2旧商品后补全被遮挡背景,再放入图1商品;添加与图2一致的自然接触阴影、环境反射、景深和色温,但这些融合不得改变商品结构、材质纹理或深浅配色。",
supplement
? `【用户补充】${supplement}。补充要求只能约束替换细节,不得放宽上述商品刚性身份锁定与场景锁定。`
: "",
"【生成前自检】逐项比较图1与结果中的商品轮廓、比例、柜体分区、孔洞、门轨、抽屉和腿;任一结构发生拉伸、缺失、增生、错位或与旧商品融合,都必须先修正再输出。不要输出对比图、拼图、说明文字、边框、水印或多个方案。",
]
.filter(Boolean)
.join("\n\n");
}

View File

@ -0,0 +1,29 @@
/** 从 image-annotator 的居中、缩放、位移和背景导出逻辑解耦。单位为输出像素。 */
export interface ResizeOptions {
width: number; height: number; scale?: number;
offsetX?: number; offsetY?: number; background?: string | null;
}
export function resizeGeometry(imageWidth: number, imageHeight: number, o: ResizeOptions) {
if (![imageWidth, imageHeight, o.width, o.height].every(v => Number.isFinite(v) && v > 0))
throw new Error("图片及输出宽高必须为正数");
if (!Number.isInteger(o.width) || !Number.isInteger(o.height) || o.width > 8192 || o.height > 8192 || o.width * o.height > 32_000_000)
throw new Error("输出须为整数像素,单边不超过8192且总像素不超过3200万");
const scale = o.scale ?? Math.min(o.width / imageWidth, o.height / imageHeight);
if (!Number.isFinite(scale) || scale <= 0 || !Number.isFinite(o.offsetX ?? 0) || !Number.isFinite(o.offsetY ?? 0))
throw new Error("缩放和位移无效");
const width = imageWidth * scale, height = imageHeight * scale;
return { x: (o.width - width) / 2 + (o.offsetX ?? 0), y: (o.height - height) / 2 + (o.offsetY ?? 0), width, height };
}
export async function resizeImage(image: HTMLImageElement, options: ResizeOptions): Promise<Blob> {
const r = resizeGeometry(image.naturalWidth, image.naturalHeight, options);
const canvas = document.createElement("canvas");
canvas.width = options.width; canvas.height = options.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("浏览器不支持 Canvas 2D");
if (options.background) { ctx.fillStyle = options.background; ctx.fillRect(0, 0, canvas.width, canvas.height); }
ctx.drawImage(image, r.x, r.y, r.width, r.height);
return new Promise((resolve, reject) => {
try { canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error("PNG导出失败")), "image/png"); }
catch (error) { reject(error); }
});
}

View File

@ -0,0 +1,34 @@
import { diyAiRenderImageReadSchema } from '@/lib/diy/image-tools'
import { normalizeObjectStorageKey } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repository'
import { toRecord } from './assisted-assembly-ai-render.validation'
import { assertDiyAiRenderAccess } from './diy-ai-render.access'
import { readDiyToolImage } from './diy-image-template.service'
/** Returns bytes only for an image belonging to the current user's DIY record. */
export async function readOwnedDiyAiRenderImage(raw: unknown, user: AuthUser) {
await assertDiyAiRenderAccess(user)
const parsed = diyAiRenderImageReadSchema.safeParse(raw)
if (!parsed.success) throw new AppError(ErrorCode.VALIDATION_FAILED, '图片读取参数无效')
const { recordId, storageKey } = parsed.data
const record = await diyAiRenderRepository.findOwned(recordId, user.id)
if (!record || !Array.isArray(record.resultImages) ||
!record.resultImages.some(image => toRecord(image).storageKey === storageKey)) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '出图记录或图片不存在')
}
let key: string
try { key = normalizeObjectStorageKey(storageKey) }
catch { throw new AppError(ErrorCode.FILE_NOT_FOUND, '出图图片不可用') }
const prefix = normalizeObjectStorageKey(`diy/ai-renders/${user.id}/${record.id}/`)
if (!key.startsWith(prefix)) throw new AppError(ErrorCode.FILE_NOT_FOUND, '出图图片不可用')
try { return await readDiyToolImage(key) }
catch (error) {
if (error instanceof AppError) throw error
throw new AppError(ErrorCode.SYSTEM_STORAGE, '原图读取失败,请稍后重试')
}
}

View File

@ -16,6 +16,7 @@ import { decodeReferenceImageDataUrl, toRecord } from './assisted-assembly-ai-re
export function toDiyAiRenderDTO(row: AssistedAssemblyAiRender): DiyAiRenderDTO {
const params = toRecord(row.params), workspace = toRecord(row.workspaceSnapshot)
return { id: row.id, name: String(params.name || '组合方案'), schemeCode: typeof params.schemeCode === 'string' ? params.schemeCode : null,
...(params.operation === 'layout-replace' ? { operation: 'layout-replace' as const, templateUrl: typeof params.templateUrl === 'string' ? params.templateUrl : null } : {}),
compositionKey: String(params.compositionKey || ''), mode: row.outputMode === 'SCENE' ? 'scene' : 'white-background',
status: row.status, referenceImageUrl: row.referenceImageUrl, images: row.resultImages as unknown as DiyAiRenderDTO['images'],
userPrompt: String(params.userPrompt || ''), errorMessage: row.errorMessage, createdAt: row.createdAt.toISOString(),

View File

@ -5,6 +5,8 @@ import type { DiyAiRenderImage } from '@/lib/diy/ai-render'
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
import { fetchImageBuffer } from './assisted-assembly-ai-render.temp-storage'
import type { DecodedReferenceImage } from './assisted-assembly-ai-render.validation'
import { toRecord } from './assisted-assembly-ai-render.validation'
import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repository'
export async function uploadDiyAiReference(image: DecodedReferenceImage, userId: string, renderId: string) {
return uploadBufferToObjectStorageWithMetadata(image.buffer,
@ -12,11 +14,16 @@ export async function uploadDiyAiReference(image: DecodedReferenceImage, userId:
}
export async function storeDiyAiResults(images: ExternalImageGenerateImageDTO[], userId: string, renderId: string, keys: string[]) {
const stored: DiyAiRenderImage[] = []
const params = toRecord((await diyAiRenderRepository.findOwned(renderId, userId))?.params)
const width = Number(params.outputWidth), height = Number(params.outputHeight)
const alignTemplate = params.operation === 'layout-replace' && Number.isInteger(width) && Number.isInteger(height)
&& width > 0 && height > 0 && width <= 8192 && height <= 8192 && width * height <= 32_000_000
for (const [index, image] of images.entries()) {
const fetched = await fetchImageBuffer(image.originalUrl)
// Keep every cabinet, including separated cabinets; no largest-component crop or whitening.
const { data, info } = await sharp(fetched.buffer, { limitInputPixels: 36_000_000 }).rotate()
.toColorspace('srgb').png().toBuffer({ resolveWithObject: true })
const pipeline = sharp(fetched.buffer, { limitInputPixels: 36_000_000 }).rotate().toColorspace('srgb')
if (alignTemplate) pipeline.resize(width, height, { fit: 'contain', background: '#ffffff' })
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true })
const upload = await uploadBufferToObjectStorageWithMetadata(data,
`diy/ai-renders/${userId}/${renderId}/result-${index + 1}.png`, 'image/png')
keys.push(upload.storageKey)

View File

@ -0,0 +1,55 @@
import sharp from 'sharp'
import { randomUUID } from 'node:crypto'
import { buildStableObjectAccessUrl, createObjectReadUrl, normalizeObjectStorageKey, uploadBufferToObjectStorageWithMetadata } from '@/lib/storage'
import type { DiyLayoutTemplate } from '@/lib/diy/image-tools'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { assertDiyAiRenderAccess } from './diy-ai-render.access'
const maxBytes = 20 * 1024 * 1024
export function assertOwnedDiyTemplate(key: string, userId: string) {
const normalized = normalizeObjectStorageKey(key)
const prefix = normalizeObjectStorageKey(`diy/layout-templates/${userId}/`)
if (!normalized.startsWith(prefix) || !/^[a-f0-9-]+\.png$/.test(normalized.slice(prefix.length))) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '请使用自己上传的排版模板')
}
return normalized
}
/** Only reads keys already authorized by a business record or the uploader namespace. */
export async function readDiyToolImage(key: string) {
const response = await fetch(createObjectReadUrl(key), { signal: AbortSignal.timeout(30_000), redirect: 'error' })
if (!response.ok || Number(response.headers.get('content-length')) > maxBytes || !response.body) {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '图片已删除、不可读取或超过20MB')
}
const reader = response.body.getReader(), chunks: Uint8Array[] = []
let length = 0
try {
for (;;) {
const chunk = await reader.read()
if (chunk.done) break
length += chunk.value.byteLength
if (length > maxBytes) throw new AppError(ErrorCode.FILE_TOO_LARGE, '图片不能超过20MB')
chunks.push(chunk.value)
}
} finally { await reader.cancel().catch(() => undefined) }
const buffer = Buffer.concat(chunks)
const info = await sharp(buffer, { limitInputPixels: 32_000_000 }).metadata()
if (!['png', 'jpeg', 'webp'].includes(info.format || '') || !info.width || !info.height || (info.pages || 1) > 1) {
throw new AppError(ErrorCode.FILE_TYPE_NOT_ALLOWED, '仅支持单帧 PNG、JPG、WebP 图片')
}
return { buffer, contentType: `image/${info.format}`, width: info.width, height: info.height }
}
export async function uploadDiyLayoutTemplate(file: File, user: AuthUser): Promise<DiyLayoutTemplate> {
await assertDiyAiRenderAccess(user, true)
if (!file.size || file.size > maxBytes) throw new AppError(ErrorCode.FILE_TOO_LARGE, '请选择20MB以内的模板图片')
const buffer = Buffer.from(await file.arrayBuffer())
const image = sharp(buffer, { limitInputPixels: 32_000_000 })
const metadata = await image.metadata()
if (!['png', 'jpeg', 'webp'].includes(metadata.format || '') || (metadata.pages || 1) > 1) {
throw new AppError(ErrorCode.FILE_TYPE_NOT_ALLOWED, '模板支持 PNG、JPG、WebP 图片')
}
const { data, info } = await image.rotate().toColorspace('srgb').png().toBuffer({ resolveWithObject: true })
if (data.length > maxBytes) throw new AppError(ErrorCode.FILE_TOO_LARGE, '模板转换后超过20MB,请缩小图片')
const upload = await uploadBufferToObjectStorageWithMetadata(data, `diy/layout-templates/${user.id}/${randomUUID()}.png`, 'image/png')
return { url: buildStableObjectAccessUrl(upload.storageKey), storageKey: upload.storageKey, width: info.width, height: info.height }
}

View File

@ -0,0 +1,79 @@
import type { Prisma } from '@prisma/client'
import { diyLayoutReplaceSchema } from '@/lib/diy/image-tools'
import type { DiyAiRenderImage } from '@/lib/diy/ai-render'
import { buildCanvasLayoutReplacePrompt } from '@/lib/diy/image-tools/layout-prompt'
import { buildStableObjectAccessUrl, normalizeObjectStorageKey, readObjectStorageKeyFromUrl } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repository'
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
import { nativeImageGenerationRepository } from '@/server/repositories/native-image-generation.repository'
import { assertDiyAiRenderAccess } from './diy-ai-render.access'
import { toDiyAiRenderDTO } from './diy-ai-render.service'
import { assertNativeRenderRequestMatches, nativeRenderRequestFingerprint } from './native-render-request'
import { enqueueNativeImageGeneration } from './native-image-generation.service'
import { assertOwnedDiyTemplate, readDiyToolImage } from './diy-image-template.service'
import { discardDiyAiUploads, uploadDiyAiReference } from './diy-ai-render.storage'
import { getExternalImageApiConfig } from './external-image-generate.service'
import { toRecord } from './assisted-assembly-ai-render.validation'
const json = (value: unknown) => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue
function nearestAspectRatio(width: number, height: number) {
return ['1:1', '4:3', '3:4', '16:9', '9:16'].sort((a, b) => {
const ratio = (text: string) => { const [w, h] = text.split(':').map(Number); return w! / h! }
return Math.abs(Math.log(ratio(a) / (width / height))) - Math.abs(Math.log(ratio(b) / (width / height)))
})[0]!
}
export async function createDiyLayoutReplace(raw: unknown, user: AuthUser) {
await assertDiyAiRenderAccess(user, true)
const parsed = diyLayoutReplaceSchema.safeParse(raw)
if (!parsed.success) throw new AppError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message || '排版参数无效')
const input = parsed.data
const fingerprintInput = { operation: 'layout-replace', ...input }
const fingerprint = nativeRenderRequestFingerprint(fingerprintInput)
const existing = await diyAiRenderRepository.findOwned(input.requestId, user.id)
if (existing) { assertNativeRenderRequestMatches(existing, user.id, fingerprint); return toDiyAiRenderDTO(existing) }
if (await nativeImageGenerationRepository.findById(`render:${input.requestId}`)) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '原排版记录已删除,不能重复提交同一任务')
}
const config = await getExternalImageApiConfig('ai-render')
if (!config.enabled || !config.baseUrl || !config.apiKey || !config.model) throw new AppError(ErrorCode.VALIDATION_FAILED, '请先在 AI 出图中配置生图服务')
const source = await diyAiRenderRepository.findOwned(input.sourceRecordId, user.id)
if (!source || source.status !== 'SUCCEEDED' || source.outputMode !== 'WHITE_BACKGROUND' || toRecord(source.params).operation === 'layout-replace') {
throw new AppError(ErrorCode.FILE_NOT_FOUND, '请选择自己的已完成白底图')
}
const image = (source.resultImages as unknown as DiyAiRenderImage[]).find(item => item.storageKey === input.sourceStorageKey)
const sourceKey = image && readObjectStorageKeyFromUrl(image.originalUrl)
const sourcePrefix = normalizeObjectStorageKey(`diy/ai-renders/${user.id}/${source.id}/`)
if (!sourceKey || !normalizeObjectStorageKey(sourceKey).startsWith(sourcePrefix)) throw new AppError(ErrorCode.FILE_NOT_FOUND, '原图已删除或不可用')
const templateKey = assertOwnedDiyTemplate(input.templateStorageKey, user.id)
const [product, template] = await Promise.all([readDiyToolImage(sourceKey), readDiyToolImage(templateKey)])
if (template.width > 8192 || template.height > 8192 || template.width * template.height > 32_000_000) throw new AppError(ErrorCode.VALIDATION_FAILED, '模板单边不超过8192像素,总像素不超过3200万')
const prompt = buildCanvasLayoutReplacePrompt({ referenceWidth: template.width, referenceHeight: template.height,
supplementalPrompt: input.supplementalPrompt.replace(/\s+/g, ' ') })
const sourceParams = toRecord(source.params), keys: string[] = []
const params = { source: 'diy', operation: 'layout-replace', requestFingerprint: fingerprint, name: sourceParams.name || '组合方案',
compositionKey: sourceParams.compositionKey, schemeCode: sourceParams.schemeCode || null, userPrompt: input.supplementalPrompt,
mode: 'white-background', imageCount: 1, imageSize: '2K', aspectRatio: nearestAspectRatio(template.width, template.height),
sourceRecordId: source.id, sourceStorageKey: input.sourceStorageKey, templateUrl: buildStableObjectAccessUrl(templateKey),
outputWidth: template.width, outputHeight: template.height, stage: 'generating' }
try {
const reference = await uploadDiyAiReference({ ...product, extension: product.contentType === 'image/jpeg' ? 'jpg' : product.contentType.split('/')[1]! }, user.id, input.requestId)
keys.push(reference.storageKey)
await enqueueNativeImageGeneration({ id: `render:${input.requestId}`, createdBy: user.id,
input: { serviceType: 'ai-render', prompt, imageCount: 1, imageSize: '2K', aspectRatio: params.aspectRatio,
referenceImage: { ...product, fileName: 'product.png' }, additionalReferenceImages: [{ ...template, fileName: 'layout-template.png' }] },
completion: { kind: 'render', renderId: input.requestId, source: 'diy' },
}, persist => nativeRenderRepository.createWithJob({ id: input.requestId, createdBy: user.id, prompt, outputMode: 'WHITE_BACKGROUND',
storageClass: 'PERMANENT', status: 'PENDING', referenceImageUrl: reference.url, params: json(params),
workspaceSnapshot: source.workspaceSnapshot as Prisma.InputJsonValue, assetSnapshot: source.assetSnapshot as Prisma.InputJsonValue }, persist))
const row = await diyAiRenderRepository.findOwned(input.requestId, user.id)
if (!row) throw new Error('排版任务未保存')
return toDiyAiRenderDTO(row)
} catch (error) {
const saved = await diyAiRenderRepository.findOwned(input.requestId, user.id)
if (saved) { assertNativeRenderRequestMatches(saved, user.id, fingerprint); return toDiyAiRenderDTO(saved) }
await discardDiyAiUploads(keys)
throw error
}
}