feat(diy): add image adjustment, resizing and template layout tools
This commit is contained in:
parent
09f36608de
commit
cadb917d90
@ -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 })
|
||||||
|
}
|
||||||
@ -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 })
|
||||||
|
}
|
||||||
@ -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 })
|
||||||
|
}
|
||||||
@ -1,15 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
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 Button from '@/components/ui/Button'
|
||||||
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
|
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
|
||||||
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
|
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
|
||||||
import { RenderRecordGalleryLightbox } from '@/app/ai-render-workbench/RenderRecordGalleryLightbox'
|
import { RenderRecordGalleryLightbox } from '@/app/ai-render-workbench/RenderRecordGalleryLightbox'
|
||||||
import DiyAiDeleteConfirmation from './DiyAiDeleteConfirmation'
|
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 }) {
|
export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[]; ai: DiyAiRenderController }) {
|
||||||
const [large,setLarge] = useState<string|null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!large) return
|
if (!large) return
|
||||||
const close = (event:KeyboardEvent) => { if(event.key==='Escape')setLarge(null) }
|
const close = (event:KeyboardEvent) => { if(event.key==='Escape')setLarge(null) }
|
||||||
@ -17,10 +26,17 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[
|
|||||||
},[large])
|
},[large])
|
||||||
return <div className="space-y-3" aria-label="DIY 出图结果">
|
return <div className="space-y-3" aria-label="DIY 出图结果">
|
||||||
<DiyAiDeleteConfirmation deletion={ai.deletion}/>
|
<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)}/>}
|
{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">
|
{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">
|
<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>
|
<span>{new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-[#5a6062]/60">{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门{record.schemeCode ? ' · 已保存方案' : ' · 生成时方案'}</p>
|
<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>}
|
<Trash2 size={13}/>删除失败记录</Button>}
|
||||||
<div className="grid grid-cols-1 gap-2.5">
|
<div className="grid grid-cols-1 gap-2.5">
|
||||||
{record.images.map((image,index) => {
|
{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">
|
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 */}
|
{/* 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"/>
|
<img src={image.thumbnailUrl} alt={`${record.name} ${index+1}`} loading="lazy" className="aspect-[16/10] w-full object-contain"/>
|
||||||
</button>
|
</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 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">
|
<div className="flex items-center gap-1">
|
||||||
{ai.access.canDownload && <Button size="small" variant="plain" loading={ai.downloading===image.originalUrl} disabled={Boolean(ai.downloading)}
|
{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>}
|
onClick={() => void ai.download(image.originalUrl,filename)}><Download size={13}/>下载</Button>}
|
||||||
{ai.access.canDelete && <Button type="button" size="small" variant="plain" tone="danger"
|
{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'?'生成中的任务暂不能删除':'删除这张图片'}
|
title={record.status==='PENDING'?'生成中的任务暂不能删除':'删除这张图片'}
|
||||||
onClick={() => ai.deletion.requestDelete(record,{ target:'image',storageKey:image.storageKey })}><Trash2 size={13}/>删除</Button>}
|
onClick={() => ai.deletion.requestDelete(record,{ target:'image',storageKey:image.storageKey })}><Trash2 size={13}/>删除</Button>}
|
||||||
</div>
|
</div>
|
||||||
@ -51,6 +73,7 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{record.referenceImageUrl && <button type="button" className="text-[10px] text-[#5a6062]/60 hover:text-[#2d3335]" onClick={() => setLarge(record.referenceImageUrl!)}>查看生成时参考图</button>}
|
{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>)}
|
</section>)}
|
||||||
{!records.length && <p className="py-8 text-center text-[11px] text-[#5a6062]/45">还没有{ai.mode==='scene'?'效果图':'白底图'}记录</p>}
|
{!records.length && <p className="py-8 text-center text-[11px] text-[#5a6062]/45">还没有{ai.mode==='scene'?'效果图':'白底图'}记录</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -22,7 +22,7 @@ export function useDiyAiDeletion({ canDelete, records, onDeleted, refresh, setEr
|
|||||||
if (!canDelete || lock.current || record.status === 'PENDING') return
|
if (!canDelete || lock.current || record.status === 'PENDING') return
|
||||||
setError('')
|
setError('')
|
||||||
setSelection({ recordId: record.id, input, label: input.target === 'record'
|
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 cancel = () => { if (!lock.current) setSelection(null) }
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
|
|||||||
63
furniture-3d-system/src/app/diy/hooks/useDiyImageEditor.ts
Normal file
63
furniture-3d-system/src/app/diy/hooks/useDiyImageEditor.ts
Normal 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>
|
||||||
15
furniture-3d-system/src/app/diy/hooks/useDiyLayoutBatch.ts
Normal file
15
furniture-3d-system/src/app/diy/hooks/useDiyLayoutBatch.ts
Normal 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>
|
||||||
22
furniture-3d-system/src/app/diy/lib/image-tools-api.ts
Normal file
22
furniture-3d-system/src/app/diy/lib/image-tools-api.ts
Normal 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
|
||||||
|
}
|
||||||
96
furniture-3d-system/src/app/diy/lib/layout-replace-batch.ts
Normal file
96
furniture-3d-system/src/app/diy/lib/layout-replace-batch.ts
Normal 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()
|
||||||
|
}
|
||||||
@ -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 DiyAiRenderImage { originalUrl: string; thumbnailUrl: string; storageKey: string; width: number; height: number }
|
||||||
export interface DiyAiRenderDTO {
|
export interface DiyAiRenderDTO {
|
||||||
id: string; name: string; schemeCode: string | null; compositionKey: string
|
id: string; name: string; schemeCode: string | null; compositionKey: string
|
||||||
|
operation?: 'layout-replace'; templateUrl?: string | null
|
||||||
mode: DiyAiRenderMode; status: 'PENDING' | 'SUCCEEDED' | 'FAILED'
|
mode: DiyAiRenderMode; status: 'PENDING' | 'SUCCEEDED' | 'FAILED'
|
||||||
referenceImageUrl: string | null; images: DiyAiRenderImage[]; userPrompt: string
|
referenceImageUrl: string | null; images: DiyAiRenderImage[]; userPrompt: string
|
||||||
errorMessage: string | null; createdAt: string; snapshot: DiySnapshot; cameraView: DiyAiCameraView
|
errorMessage: string | null; createdAt: string; snapshot: DiySnapshot; cameraView: DiyAiCameraView
|
||||||
|
|||||||
19
furniture-3d-system/src/lib/diy/image-tools.ts
Normal file
19
furniture-3d-system/src/lib/diy/image-tools.ts
Normal 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 }
|
||||||
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
furniture-3d-system/src/lib/diy/image-tools/LICENSE
Normal file
13
furniture-3d-system/src/lib/diy/image-tools/LICENSE
Normal 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.
|
||||||
9
furniture-3d-system/src/lib/diy/image-tools/NOTICE.md
Normal file
9
furniture-3d-system/src/lib/diy/image-tools/NOTICE.md
Normal 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 配置及持久任务;模型原生合同须支持两张参考图,不迁移旧平台供应商排序、积分或数据库。
|
||||||
180
furniture-3d-system/src/lib/diy/image-tools/adjustments.ts
Normal file
180
furniture-3d-system/src/lib/diy/image-tools/adjustments.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
63
furniture-3d-system/src/lib/diy/image-tools/layout-prompt.ts
Normal file
63
furniture-3d-system/src/lib/diy/image-tools/layout-prompt.ts
Normal 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");
|
||||||
|
}
|
||||||
29
furniture-3d-system/src/lib/diy/image-tools/resize.ts
Normal file
29
furniture-3d-system/src/lib/diy/image-tools/resize.ts
Normal 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); }
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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, '原图读取失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import { decodeReferenceImageDataUrl, toRecord } from './assisted-assembly-ai-re
|
|||||||
export function toDiyAiRenderDTO(row: AssistedAssemblyAiRender): DiyAiRenderDTO {
|
export function toDiyAiRenderDTO(row: AssistedAssemblyAiRender): DiyAiRenderDTO {
|
||||||
const params = toRecord(row.params), workspace = toRecord(row.workspaceSnapshot)
|
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,
|
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',
|
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'],
|
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(),
|
userPrompt: String(params.userPrompt || ''), errorMessage: row.errorMessage, createdAt: row.createdAt.toISOString(),
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import type { DiyAiRenderImage } from '@/lib/diy/ai-render'
|
|||||||
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
|
import type { ExternalImageGenerateImageDTO } from './external-image-generate.service'
|
||||||
import { fetchImageBuffer } from './assisted-assembly-ai-render.temp-storage'
|
import { fetchImageBuffer } from './assisted-assembly-ai-render.temp-storage'
|
||||||
import type { DecodedReferenceImage } from './assisted-assembly-ai-render.validation'
|
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) {
|
export async function uploadDiyAiReference(image: DecodedReferenceImage, userId: string, renderId: string) {
|
||||||
return uploadBufferToObjectStorageWithMetadata(image.buffer,
|
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[]) {
|
export async function storeDiyAiResults(images: ExternalImageGenerateImageDTO[], userId: string, renderId: string, keys: string[]) {
|
||||||
const stored: DiyAiRenderImage[] = []
|
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()) {
|
for (const [index, image] of images.entries()) {
|
||||||
const fetched = await fetchImageBuffer(image.originalUrl)
|
const fetched = await fetchImageBuffer(image.originalUrl)
|
||||||
// Keep every cabinet, including separated cabinets; no largest-component crop or whitening.
|
// Keep every cabinet, including separated cabinets; no largest-component crop or whitening.
|
||||||
const { data, info } = await sharp(fetched.buffer, { limitInputPixels: 36_000_000 }).rotate()
|
const pipeline = sharp(fetched.buffer, { limitInputPixels: 36_000_000 }).rotate().toColorspace('srgb')
|
||||||
.toColorspace('srgb').png().toBuffer({ resolveWithObject: true })
|
if (alignTemplate) pipeline.resize(width, height, { fit: 'contain', background: '#ffffff' })
|
||||||
|
const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true })
|
||||||
const upload = await uploadBufferToObjectStorageWithMetadata(data,
|
const upload = await uploadBufferToObjectStorageWithMetadata(data,
|
||||||
`diy/ai-renders/${userId}/${renderId}/result-${index + 1}.png`, 'image/png')
|
`diy/ai-renders/${userId}/${renderId}/result-${index + 1}.png`, 'image/png')
|
||||||
keys.push(upload.storageKey)
|
keys.push(upload.storageKey)
|
||||||
|
|||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user