mirror of
https://github.com/ostris/ai-toolkit.git
synced 2026-03-07 11:39:48 +00:00
Add a Download button on samples to download all the samples as a zip file
This commit is contained in:
@@ -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',
|
||||
|
||||
78
ui/src/app/api/zip/route.ts
Normal file
78
ui/src/app/api/zip/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user