feat: add active pack image downloads

This commit is contained in:
2026-05-20 20:42:45 +08:00
parent 3f087edd60
commit 0869c7402b
5 changed files with 206 additions and 80 deletions

View File

@@ -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',
},
});
}

View File

@@ -961,6 +961,32 @@ input, textarea {
display: none; 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 { .gallery-rail-thumb {
box-shadow: 0 12px 32px -22px rgba(230, 245, 120, 0.65); box-shadow: 0 12px 32px -22px rgba(230, 245, 120, 0.65);
} }

View File

@@ -16,7 +16,6 @@ import type {
LockCharacterResponse, LockCharacterResponse,
PackKind, PackKind,
ProjectFromUploadResponse, ProjectFromUploadResponse,
RegenerateAssetResponse,
UploadImageResponse, UploadImageResponse,
UploadedImageRole, UploadedImageRole,
VideoGenerationResponse, VideoGenerationResponse,
@@ -138,11 +137,7 @@ function ProjectPackOverview({
const progress = Math.round((count / total) * 100); const progress = Math.round((count / total) * 100);
function handleGenerate() { function handleGenerate() {
if (locked || running) return; if (locked || running || pack) return;
if (pack) {
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${count} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
if (!ok) return;
}
onGeneratePack(sourceImage, kind); onGeneratePack(sourceImage, kind);
} }
@@ -165,14 +160,16 @@ function ProjectPackOverview({
<span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}> <span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}>
{complete ? '完成' : locked ? '锁定' : '可生成'} {complete ? '完成' : locked ? '锁定' : '可生成'}
</span> </span>
{!pack && (
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={running || locked} disabled={running || locked}
className="project-pack-action" className="project-pack-action"
title={locked ? '先完成前置步骤' : pack ? '重新生成该包' : '生成该包'} title={locked ? '先完成前置步骤' : '生成该包'}
> >
{running ? '...' : pack ? '重做' : '生成'} {running ? '...' : '生成'}
</button> </button>
)}
<button <button
type="button" type="button"
className="project-pack-jump" className="project-pack-jump"
@@ -500,22 +497,6 @@ export default function Home() {
} }
} }
async function handleRegenerateAsset(assetId: string, userRefinement?: string) {
if (!current) return;
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }),
});
if (!r.ok) {
alert('单张重做失败:' + (await r.text()));
return;
}
const d: RegenerateAssetResponse = await r.json();
setProvider(d.provider);
await reloadCurrent(current.id);
}
function resolveVideoAnchor(image: GenImage) { function resolveVideoAnchor(image: GenImage) {
const packs = current?.packs ?? []; const packs = current?.packs ?? [];
const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front'); const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front');
@@ -649,7 +630,6 @@ export default function Home() {
activeNav={activeAssetPanel} activeNav={activeAssetPanel}
onActiveNavChange={setActiveAssetPanel} onActiveNavChange={setActiveAssetPanel}
videoLoading={videoLoading} videoLoading={videoLoading}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo} onGenerateVideo={handleGenerateVideo}
/> />
</section> </section>

View File

@@ -93,14 +93,11 @@ function AssetTile({ template, asset, onOpen }: {
); );
} }
function AssetDetailDrawer({ detail, onClose, onRegenerate }: { function AssetDetailDrawer({ detail, onClose }: {
detail: AssetDetail | null; detail: AssetDetail | null;
onClose: () => void; onClose: () => void;
onRegenerate: (assetId: string, userRefinement?: string) => Promise<void>;
}) { }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [refinement, setRefinement] = useState('');
const [regenerating, setRegenerating] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -108,7 +105,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
useEffect(() => { useEffect(() => {
if (!detail) return; if (!detail) return;
setRefinement('');
function handleKey(event: KeyboardEvent) { function handleKey(event: KeyboardEvent) {
if (event.key === 'Escape') onClose(); if (event.key === 'Escape') onClose();
} }
@@ -116,21 +112,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
return () => window.removeEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey);
}, [detail, onClose]); }, [detail, onClose]);
async function handleRedo() {
const asset = detail?.asset;
if (!asset || !onRegenerate || regenerating) return;
const ok = window.confirm('重新生成这 1 张会再次调用图片模型并产生费用。确认继续?');
if (!ok) return;
setRegenerating(true);
try {
await onRegenerate(asset.id, refinement);
setRefinement('');
onClose();
} finally {
setRegenerating(false);
}
}
if (!mounted) return null; if (!mounted) return null;
const template = detail?.template; const template = detail?.template;
@@ -206,28 +187,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
</div> </div>
</div> </div>
)} )}
{ready && (
<div className="asset-detail-block">
<div className="asset-detail-label"></div>
<div className="flex gap-2">
<input
value={refinement}
onChange={event => setRefinement(event.target.value)}
placeholder="补充重做要求"
className="min-w-0 flex-1 rounded-[8px] bg-black/30 px-3 py-2 text-[12px] text-white/82 outline-none ring-1 ring-white/[0.09] focus:ring-[#e6f578]/40"
/>
<button
type="button"
onClick={handleRedo}
disabled={regenerating}
className="rounded-[8px] bg-[#d6b36a]/18 px-4 py-2 text-[12px] font-semibold text-[#f2d38c] ring-1 ring-[#d6b36a]/24 transition-colors hover:bg-[#d6b36a]/26 disabled:opacity-40"
>
{regenerating ? '...' : '确认'}
</button>
</div>
</div>
)}
</div> </div>
</> </>
)} )}
@@ -248,13 +207,12 @@ function DetailItem({ label, value }: { label: string; value: string | number })
} }
/* ── Pack Section ─────────────────────────────── */ /* ── Pack Section ─────────────────────────────── */
function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: { function PackSection({ kind, pack, locked, lockReason, stepIndex }: {
kind: PackKind; kind: PackKind;
pack: AssetPack | undefined; pack: AssetPack | undefined;
locked: boolean; locked: boolean;
lockReason?: string; lockReason?: string;
stepIndex: number; stepIndex: number;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
}) { }) {
const [detail, setDetail] = useState<AssetDetail | null>(null); const [detail, setDetail] = useState<AssetDetail | null>(null);
const accent = PACK_ACCENT[kind]; const accent = PACK_ACCENT[kind];
@@ -301,7 +259,7 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs
})} })}
</div> </div>
</div> </div>
<AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} onRegenerate={onRegenerateAsset} /> <AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} />
</section> </section>
); );
} }
@@ -516,14 +474,12 @@ export default function PackPanel({
activeNav, activeNav,
onActiveNavChange, onActiveNavChange,
videoLoading, videoLoading,
onRegenerateAsset,
onGenerateVideo, onGenerateVideo,
}: { }: {
session: GenSession; session: GenSession;
activeNav: string; activeNav: string;
onActiveNavChange: (id: string) => void; onActiveNavChange: (id: string) => void;
videoLoading: boolean; videoLoading: boolean;
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void; onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
}) { }) {
const selectedImages = session.images.filter(image => image.status === 'selected'); const selectedImages = session.images.filter(image => image.status === 'selected');
@@ -564,7 +520,7 @@ export default function PackPanel({
<span className="section-eyebrow">Asset Details</span> <span className="section-eyebrow">Asset Details</span>
<h2 className="mt-1 text-sm font-semibold text-white"></h2> <h2 className="mt-1 text-sm font-semibold text-white"></h2>
</div> </div>
<span className="shrink-0 text-[10px] text-white/35"></span> <span className="shrink-0 text-[10px] text-white/35"></span>
</div> </div>
<SectionNav active={activeNav} onChange={onActiveNavChange} /> <SectionNav active={activeNav} onChange={onActiveNavChange} />
</section> </section>
@@ -579,7 +535,6 @@ export default function PackPanel({
locked={locked} locked={locked}
lockReason={lockReason} lockReason={lockReason}
stepIndex={index + 1} stepIndex={index + 1}
onRegenerateAsset={onRegenerateAsset}
/> />
); );
})() : ( })() : (

View File

@@ -23,6 +23,7 @@ type GalleryItem = {
type GalleryPanel = { type GalleryPanel = {
mode: 'pack' | 'empty' | 'project'; mode: 'pack' | 'empty' | 'project';
kind?: PackKind;
label: string; label: string;
description: string; description: string;
total: number; total: number;
@@ -64,6 +65,7 @@ function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel {
})) ?? []; })) ?? [];
return { return {
mode: 'pack' as const, mode: 'pack' as const,
kind,
label: PACK_LABELS[kind], label: PACK_LABELS[kind],
description: `${PACK_LABELS[kind]}当前区块图片`, description: `${PACK_LABELS[kind]}当前区块图片`,
total: PACK_TEMPLATES[kind].length, total: PACK_TEMPLATES[kind].length,
@@ -99,6 +101,9 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [preview, setPreview] = useState<GalleryItem | null>(null); const [preview, setPreview] = useState<GalleryItem | null>(null);
const gallery = galleryForPanel(session, activeNav); 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(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -191,6 +196,22 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
<span className="mt-0.5 text-[9px] font-semibold"></span> <span className="mt-0.5 text-[9px] font-semibold"></span>
</button> </button>
{downloadHref && (
<a
href={downloadHref}
className="gallery-download-button"
title={`打包下载${gallery.label}图片`}
aria-label={`打包下载${gallery.label}图片`}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
<path d="M12 4v10" strokeLinecap="round" />
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 19h14" strokeLinecap="round" />
</svg>
<span></span>
</a>
)}
<div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42"> <div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42">
<b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b> <b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
<span>/ {gallery.total}</span> <span>/ {gallery.total}</span>