From 0869c7402b27897f346245a187d04075a0ee4b54 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 20 May 2026 20:42:45 +0800 Subject: [PATCH] feat: add active pack image downloads --- src/app/api/packs/download/route.ts | 144 ++++++++++++++++++++++++ src/app/globals.css | 26 +++++ src/app/page.tsx | 42 ++----- src/components/PackPanel.tsx | 53 +-------- src/components/ProjectGalleryDrawer.tsx | 21 ++++ 5 files changed, 206 insertions(+), 80 deletions(-) create mode 100644 src/app/api/packs/download/route.ts diff --git a/src/app/api/packs/download/route.ts b/src/app/api/packs/download/route.ts new file mode 100644 index 0000000..07284e0 --- /dev/null +++ b/src/app/api/packs/download/route.ts @@ -0,0 +1,144 @@ +import { NextResponse } from 'next/server'; +import { PACK_ORDER } from '@/lib/templates'; +import { loadSession, readImageUrl } from '@/lib/storage'; +import type { PackKind, ToyAsset } from '@/lib/types'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const CRC_TABLE = new Uint32Array(256).map((_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + return value >>> 0; +}); + +function crc32(buffer: Buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function safeName(input: string) { + return input + .trim() + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, '_') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || 'asset'; +} + +function extFromAsset(asset: ToyAsset, sourceFilename: string, mimeType: string) { + const fromName = sourceFilename.match(/\.([a-z0-9]+)$/i)?.[1]; + if (fromName) return fromName.toLowerCase() === 'jpeg' ? 'jpg' : fromName.toLowerCase(); + if (mimeType.includes('jpeg')) return 'jpg'; + if (mimeType.includes('webp')) return 'webp'; + if (mimeType.includes('svg')) return 'svg'; + return asset.url.match(/\.([a-z0-9]+)(?:\?|$)/i)?.[1] ?? 'png'; +} + +function makeZip(files: Array<{ name: string; data: Buffer }>) { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + const dosTime = 0; + const dosDate = 33; + + for (const file of files) { + const name = Buffer.from(file.name, 'utf8'); + const data = file.data; + const crc = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, name, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, name); + + offset += local.length + name.length + data.length; + } + + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + + return Buffer.concat([...localParts, ...centralParts, end]); +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const sessionId = url.searchParams.get('sessionId')?.trim(); + const kind = url.searchParams.get('kind')?.trim() as PackKind | null; + + if (!sessionId || !kind || !PACK_ORDER.includes(kind)) { + return NextResponse.json({ error: 'bad request' }, { status: 400 }); + } + + const session = await loadSession(sessionId); + if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 }); + + const selectedImage = session.images.find(image => image.status === 'selected') ?? session.images[0]; + const pack = session.packs?.find(item => item.kind === kind && (!selectedImage || item.sourceImageId === selectedImage.id)) + ?? session.packs?.find(item => item.kind === kind); + + if (!pack?.assets.length) { + return NextResponse.json({ error: 'pack has no images' }, { status: 404 }); + } + + const files = await Promise.all(pack.assets.map(async (asset, index) => { + const image = await readImageUrl(asset.url); + const ext = extFromAsset(asset, image.filename, image.type); + const fileName = `${String(index + 1).padStart(2, '0')}_${safeName(asset.templateId)}.${ext}`; + return { name: fileName, data: image.buf }; + })); + + const zip = makeZip(files); + const filename = `${safeName(session.characterSpec?.name ?? session.prompt ?? session.id)}_${kind}_${pack.assets.length}张.zip`; + + return new Response(zip, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': String(zip.length), + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index bcacb9c..0224ccf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -961,6 +961,32 @@ input, textarea { display: none; } +.gallery-download-button { + display: flex; + width: 100%; + min-height: 38px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.72); + font-size: 9px; + font-weight: 700; + line-height: 1; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10); + transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.gallery-download-button:hover, +.gallery-download-button:focus-visible { + outline: none; + background: rgba(230, 245, 120, 0.14); + color: #e6f578; + box-shadow: inset 0 0 0 1px rgba(230, 245, 120, 0.32); +} + .gallery-rail-thumb { box-shadow: 0 12px 32px -22px rgba(230, 245, 120, 0.65); } diff --git a/src/app/page.tsx b/src/app/page.tsx index ae947e5..2647168 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,7 +16,6 @@ import type { LockCharacterResponse, PackKind, ProjectFromUploadResponse, - RegenerateAssetResponse, UploadImageResponse, UploadedImageRole, VideoGenerationResponse, @@ -138,11 +137,7 @@ function ProjectPackOverview({ const progress = Math.round((count / total) * 100); function handleGenerate() { - if (locked || running) return; - if (pack) { - const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${count} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`); - if (!ok) return; - } + if (locked || running || pack) return; onGeneratePack(sourceImage, kind); } @@ -165,14 +160,16 @@ function ProjectPackOverview({ {complete ? '完成' : locked ? '锁定' : '可生成'} - + {!pack && ( + + )} - - - )} )} @@ -248,13 +207,12 @@ function DetailItem({ label, value }: { label: string; value: string | number }) } /* ── Pack Section ─────────────────────────────── */ -function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: { +function PackSection({ kind, pack, locked, lockReason, stepIndex }: { kind: PackKind; pack: AssetPack | undefined; locked: boolean; lockReason?: string; stepIndex: number; - onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise; }) { const [detail, setDetail] = useState(null); const accent = PACK_ACCENT[kind]; @@ -301,7 +259,7 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs })} - setDetail(null)} onRegenerate={onRegenerateAsset} /> + setDetail(null)} /> ); } @@ -516,14 +474,12 @@ export default function PackPanel({ activeNav, onActiveNavChange, videoLoading, - onRegenerateAsset, onGenerateVideo, }: { session: GenSession; activeNav: string; onActiveNavChange: (id: string) => void; videoLoading: boolean; - onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise; onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void; }) { const selectedImages = session.images.filter(image => image.status === 'selected'); @@ -564,7 +520,7 @@ export default function PackPanel({ Asset Details

生成明细

- 查看与单张重做 + 查看明细 @@ -579,7 +535,6 @@ export default function PackPanel({ locked={locked} lockReason={lockReason} stepIndex={index + 1} - onRegenerateAsset={onRegenerateAsset} /> ); })() : ( diff --git a/src/components/ProjectGalleryDrawer.tsx b/src/components/ProjectGalleryDrawer.tsx index f073c56..5c859cf 100644 --- a/src/components/ProjectGalleryDrawer.tsx +++ b/src/components/ProjectGalleryDrawer.tsx @@ -23,6 +23,7 @@ type GalleryItem = { type GalleryPanel = { mode: 'pack' | 'empty' | 'project'; + kind?: PackKind; label: string; description: string; total: number; @@ -64,6 +65,7 @@ function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel { })) ?? []; return { mode: 'pack' as const, + kind, label: PACK_LABELS[kind], description: `${PACK_LABELS[kind]}当前区块图片`, total: PACK_TEMPLATES[kind].length, @@ -99,6 +101,9 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P const [mounted, setMounted] = useState(false); const [preview, setPreview] = useState(null); const gallery = galleryForPanel(session, activeNav); + const downloadHref = gallery.kind && gallery.images.length + ? `/api/packs/download?sessionId=${encodeURIComponent(session.id)}&kind=${encodeURIComponent(gallery.kind)}` + : null; useEffect(() => { setMounted(true); @@ -191,6 +196,22 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P 图库 + {downloadHref && ( + + + 下载 + + )} +
{gallery.images.length} / {gallery.total}