Add a Download button on samples to download all the samples as a zip file

This commit is contained in:
Jaret Burkett
2025-08-27 09:12:46 -06:00
parent 5ad190b11d
commit fd13bd73a6
7 changed files with 634 additions and 58 deletions

View File

@@ -50,6 +50,7 @@ export async function GET(request: NextRequest, { params }: { params: { filePath
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.safetensors': 'application/octet-stream',
'.zip': 'application/zip',
// Videos
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',

View File

@@ -0,0 +1,78 @@
/* eslint-disable */
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import fsp from 'fs/promises';
import path from 'path';
import archiver from 'archiver';
import { getTrainingFolder } from '@/server/settings';
export const runtime = 'nodejs'; // ensure Node APIs are available
export const dynamic = 'force-dynamic'; // long-running, non-cached
type PostBody = {
zipTarget: 'samples'; //only samples for now
jobName: string;
};
async function resolveSafe(p: string) {
// resolve symlinks + normalize
return await fsp.realpath(p);
}
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as PostBody;
if (!body || !body.jobName) {
return NextResponse.json({ error: 'jobName is required' }, { status: 400 });
}
const trainingRoot = await resolveSafe(await getTrainingFolder());
const folderPath = await resolveSafe(path.join(trainingRoot, body.jobName, 'samples'));
const outputPath = path.resolve(trainingRoot, body.jobName, 'samples.zip');
// Must be a directory
let stat: fs.Stats;
try {
stat = await fsp.stat(folderPath);
} catch {
return new NextResponse('Folder not found', { status: 404 });
}
if (!stat.isDirectory()) {
return new NextResponse('Not a directory', { status: 400 });
}
// delete current one if it exists
if (fs.existsSync(outputPath)) {
await fsp.unlink(outputPath);
}
// Create write stream & archive
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => resolve());
output.on('error', reject);
archive.on('error', reject);
archive.pipe(output);
// Add the directory contents (place them under the folder's base name in the zip)
const rootName = path.basename(folderPath);
archive.directory(folderPath, rootName);
archive.finalize().catch(reject);
});
// Return the absolute path so your existing /api/files/[...filePath] can serve it
// Example download URL (client-side): `/api/files/${encodeURIComponent(resolvedOutPath)}`
return NextResponse.json({
ok: true,
zipPath: outputPath,
fileName: path.basename(outputPath),
});
} catch (err) {
console.error('Zip error:', err);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@@ -5,7 +5,7 @@ import { FaChevronLeft } from 'react-icons/fa';
import { Button } from '@headlessui/react';
import { TopBar, MainContent } from '@/components/layout';
import useJob from '@/hooks/useJob';
import SampleImages from '@/components/SampleImages';
import SampleImages, {SampleImagesMenu} from '@/components/SampleImages';
import JobOverview from '@/components/JobOverview';
import { redirect } from 'next/navigation';
import JobActionBar from '@/components/JobActionBar';
@@ -18,6 +18,7 @@ interface Page {
name: string;
value: PageKey;
component: React.ComponentType<{ job: Job }>;
menuItem?: React.ComponentType<{ job?: Job | null }> | null;
mainCss?: string;
}
@@ -32,6 +33,7 @@ const pages: Page[] = [
name: 'Samples',
value: 'samples',
component: SampleImages,
menuItem: SampleImagesMenu,
mainCss: 'pt-24',
},
{
@@ -48,6 +50,8 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
const { job, status, refreshJob } = useJob(jobID, 5000);
const [pageKey, setPageKey] = useState<PageKey>('overview');
const page = pages.find(p => p.value === pageKey);
return (
<>
{/* Fixed top bar */}
@@ -94,6 +98,15 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
{page.name}
</Button>
))}
{
page?.menuItem && (
<>
<div className='flex-grow'>
</div>
<page.menuItem job={job} />
</>
)
}
</div>
</>
);

View File

@@ -48,7 +48,6 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
const handleLoad = (): void => {
setLoaded(true);
};
console.log('imgurl',imageUrl.toLowerCase().slice(-4))
return (
<div className={`flex flex-col ${className}`}>

View File

@@ -1,9 +1,64 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import useSampleImages from '@/hooks/useSampleImages';
import SampleImageCard from './SampleImageCard';
import { Job } from '@prisma/client';
import { JobConfig } from '@/types';
import { LuImageOff, LuLoader, LuBan } from 'react-icons/lu';
import { Button } from '@headlessui/react';
import { FaDownload } from 'react-icons/fa';
import { apiClient } from '@/utils/api';
import classNames from 'classnames';
interface SampleImagesMenuProps {
job?: Job | null;
}
export const SampleImagesMenu = ({ job }: SampleImagesMenuProps) => {
const [isZipping, setIsZipping] = useState(false);
const downloadZip = async () => {
if (isZipping) return;
setIsZipping(true);
try {
const res = await apiClient.post('/api/zip', {
zipTarget: 'samples',
jobName: job?.name,
});
const zipPath = res.data.zipPath; // e.g. /mnt/Train2/out/ui/.../samples.zip
if (!zipPath) throw new Error('No zipPath in response');
const downloadPath = `/api/files/${encodeURIComponent(zipPath)}`;
const a = document.createElement('a');
a.href = downloadPath;
// optional: suggest filename (browser may ignore if server sets Content-Disposition)
a.download = 'samples.zip';
document.body.appendChild(a);
a.click();
a.remove();
} catch (err) {
console.error('Error downloading zip:', err);
} finally {
setIsZipping(false);
}
};
return (
<Button
onClick={downloadZip}
className={classNames(`px-4 py-1 h-8 hover:bg-gray-200 dark:hover:bg-gray-700`, {
'opacity-50 cursor-not-allowed': isZipping,
})}
>
{isZipping ? (
<LuLoader className="animate-spin inline-block mr-2" />
) : (
<FaDownload className="inline-block mr-2" />
)}
{isZipping ? 'Preparing' : 'Download'}
</Button>
);
};
interface SampleImagesProps {
job: Job;