feat: add active pack image downloads
This commit is contained in:
144
src/app/api/packs/download/route.ts
Normal file
144
src/app/api/packs/download/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}>
|
||||
{complete ? '完成' : locked ? '锁定' : '可生成'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={running || locked}
|
||||
className="project-pack-action"
|
||||
title={locked ? '先完成前置步骤' : pack ? '重新生成该包' : '生成该包'}
|
||||
>
|
||||
{running ? '...' : pack ? '重做' : '生成'}
|
||||
</button>
|
||||
{!pack && (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={running || locked}
|
||||
className="project-pack-action"
|
||||
title={locked ? '先完成前置步骤' : '生成该包'}
|
||||
>
|
||||
{running ? '...' : '生成'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
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) {
|
||||
const packs = current?.packs ?? [];
|
||||
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}
|
||||
onActiveNavChange={setActiveAssetPanel}
|
||||
videoLoading={videoLoading}
|
||||
onRegenerateAsset={handleRegenerateAsset}
|
||||
onGenerateVideo={handleGenerateVideo}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -93,14 +93,11 @@ function AssetTile({ template, asset, onOpen }: {
|
||||
);
|
||||
}
|
||||
|
||||
function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
||||
function AssetDetailDrawer({ detail, onClose }: {
|
||||
detail: AssetDetail | null;
|
||||
onClose: () => void;
|
||||
onRegenerate: (assetId: string, userRefinement?: string) => Promise<void>;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [refinement, setRefinement] = useState('');
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -108,7 +105,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
setRefinement('');
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
@@ -116,21 +112,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [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;
|
||||
|
||||
const template = detail?.template;
|
||||
@@ -206,28 +187,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -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<void>;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<AssetDetail | null>(null);
|
||||
const accent = PACK_ACCENT[kind];
|
||||
@@ -301,7 +259,7 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} onRegenerate={onRegenerateAsset} />
|
||||
<AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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({
|
||||
<span className="section-eyebrow">Asset Details</span>
|
||||
<h2 className="mt-1 text-sm font-semibold text-white">生成明细</h2>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] text-white/35">查看与单张重做</span>
|
||||
<span className="shrink-0 text-[10px] text-white/35">查看明细</span>
|
||||
</div>
|
||||
<SectionNav active={activeNav} onChange={onActiveNavChange} />
|
||||
</section>
|
||||
@@ -579,7 +535,6 @@ export default function PackPanel({
|
||||
locked={locked}
|
||||
lockReason={lockReason}
|
||||
stepIndex={index + 1}
|
||||
onRegenerateAsset={onRegenerateAsset}
|
||||
/>
|
||||
);
|
||||
})() : (
|
||||
|
||||
@@ -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<GalleryItem | null>(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
|
||||
<span className="mt-0.5 text-[9px] font-semibold">图库</span>
|
||||
</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">
|
||||
<b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
|
||||
<span>/ {gallery.total}</span>
|
||||
|
||||
Reference in New Issue
Block a user