Add support for ACE-Step 1.5 and ACE-Step 1.5 XL. Also added dataset captioning through the UI. (#785)
* Base ace step 1.5 xl added. Generating, still wip on training and ui * Base training code done * Fix some issues with caching text embeddings. Update sample cards to show audio * Fix issue with quantizing ace step * Add album artwork to samples with waveform. * Cleanup logs * Add album art endpoint to speed up album art loading * Made an make video with artwork script * Make ui handle basic audio models. Make multi line adjustments to the editor and better syntax hilighting. * Add prompt tagging system for special tagged models. * prompt tagging processing for ui working. * Moved default samples to a special file so we can add more when needed and they can be adjusted for a specific model * Add a captioner job with music captioner that is prepped for use with the ui * Add basit ui setup for captioning modal and handeling captioning jobs * Starting captioning job from ui working. Still better management for it. * Better filtering of job options in the job view for captioning jobs * Added qwen3 vl as a captioner for images * Have an indicator when a dataset is being captioned. * Adjust the way caption jobs look in the queue * Fix a few issues. Adjust defaults. * Version bump * Added ace step to the readme.
This commit is contained in:
184
ui/src/app/api/audio/art/[...audioPath]/route.ts
Normal file
184
ui/src/app/api/audio/art/[...audioPath]/route.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/* eslint-disable */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getDatasetsRoot, getTrainingFolder, getDataRoot } from '@/server/settings';
|
||||
|
||||
/**
|
||||
* Serves embedded album art from an MP3 file's ID3v2 tag.
|
||||
* Reads only the tag header from disk (no full-file buffering).
|
||||
* Returns the raw image bytes with correct Content-Type.
|
||||
*/
|
||||
|
||||
function synchsafeToInt(b0: number, b1: number, b2: number, b3: number) {
|
||||
return ((b0 & 0x7f) << 21) | ((b1 & 0x7f) << 14) | ((b2 & 0x7f) << 7) | (b3 & 0x7f);
|
||||
}
|
||||
|
||||
function deUnsync(bytes: Buffer) {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
out.push(bytes[i]);
|
||||
if (bytes[i] === 0xff && i + 1 < bytes.length && bytes[i + 1] === 0x00) i += 1;
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function readNullTerminated(buf: Buffer, start: number, wide: boolean): { text: string; next: number } {
|
||||
if (wide) {
|
||||
let i = start;
|
||||
while (i + 1 < buf.length && !(buf[i] === 0 && buf[i + 1] === 0)) i += 2;
|
||||
return { text: buf.slice(start, i).toString('utf16le'), next: i + 2 };
|
||||
}
|
||||
let i = start;
|
||||
while (i < buf.length && buf[i] !== 0) i++;
|
||||
return { text: buf.slice(start, i).toString('latin1'), next: i + 1 };
|
||||
}
|
||||
|
||||
type ArtResult = { mime: string; data: Buffer } | null;
|
||||
|
||||
function extractArtFromTag(buf: Buffer): ArtResult {
|
||||
if (buf.length < 10) return null;
|
||||
if (buf[0] !== 0x49 || buf[1] !== 0x44 || buf[2] !== 0x33) return null; // "ID3"
|
||||
|
||||
const verMajor = buf[3]; // 2, 3, or 4
|
||||
const flags = buf[5];
|
||||
const tagSize = synchsafeToInt(buf[6], buf[7], buf[8], buf[9]);
|
||||
const tagEnd = Math.min(10 + tagSize, buf.length);
|
||||
|
||||
let tagData = buf.slice(10, tagEnd);
|
||||
if ((flags & 0x80) !== 0) tagData = deUnsync(tagData);
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// Skip extended header
|
||||
if ((verMajor === 3 || verMajor === 4) && (flags & 0x40) !== 0 && tagData.length >= 4) {
|
||||
const extSize =
|
||||
verMajor === 4
|
||||
? synchsafeToInt(tagData[0], tagData[1], tagData[2], tagData[3])
|
||||
: (tagData[0] << 24) | (tagData[1] << 16) | (tagData[2] << 8) | tagData[3];
|
||||
offset += 4 + Math.max(0, extSize);
|
||||
}
|
||||
|
||||
while (offset < tagData.length) {
|
||||
if (tagData[offset] === 0x00) break;
|
||||
|
||||
if (verMajor === 2) {
|
||||
// ID3v2.2: 3-byte frame ID, 3-byte size
|
||||
if (offset + 6 > tagData.length) break;
|
||||
const id = tagData.slice(offset, offset + 3).toString('latin1');
|
||||
const size = (tagData[offset + 3] << 16) | (tagData[offset + 4] << 8) | tagData[offset + 5];
|
||||
offset += 6;
|
||||
if (!id.trim() || size <= 0 || offset + size > tagData.length) break;
|
||||
|
||||
if (id === 'PIC' && size > 6) {
|
||||
const frame = tagData.slice(offset, offset + size);
|
||||
const fmt = frame.slice(1, 4).toString('latin1').toLowerCase();
|
||||
const mime = fmt === 'png' ? 'image/png' : 'image/jpeg';
|
||||
// skip: encoding(1) + format(3) + pictureType(1) = 5, then null-terminated description
|
||||
let p = 5;
|
||||
const enc = frame[0];
|
||||
const wide = enc === 1 || enc === 2;
|
||||
const desc = readNullTerminated(frame as any, p, wide);
|
||||
p = desc.next;
|
||||
if (p < frame.length) {
|
||||
const img = frame.slice(p);
|
||||
if (img.length > 64) return { mime, data: Buffer.from(img) };
|
||||
}
|
||||
}
|
||||
offset += size;
|
||||
} else {
|
||||
// ID3v2.3/v2.4: 4-byte frame ID, 4-byte size, 2-byte flags
|
||||
if (offset + 10 > tagData.length) break;
|
||||
const id = tagData.slice(offset, offset + 4).toString('latin1');
|
||||
let size =
|
||||
verMajor === 4
|
||||
? synchsafeToInt(tagData[offset + 4], tagData[offset + 5], tagData[offset + 6], tagData[offset + 7])
|
||||
: (tagData[offset + 4] << 24) |
|
||||
(tagData[offset + 5] << 16) |
|
||||
(tagData[offset + 6] << 8) |
|
||||
tagData[offset + 7];
|
||||
const flag2 = tagData[offset + 9];
|
||||
offset += 10;
|
||||
if (!id.trim() || size <= 0 || offset + size > tagData.length) break;
|
||||
|
||||
if (id === 'APIC') {
|
||||
let frame = tagData.slice(offset, offset + size);
|
||||
if (verMajor === 4 && (flag2 & 0x02) !== 0) frame = deUnsync(frame);
|
||||
|
||||
const enc = frame[0];
|
||||
// mime type: null-terminated latin1
|
||||
const mimeZ = readNullTerminated(frame as any, 1, false);
|
||||
const mime = mimeZ.text || 'image/jpeg';
|
||||
let p = mimeZ.next;
|
||||
if (p < frame.length) p += 1; // picture type byte
|
||||
const wide = enc === 1 || enc === 2;
|
||||
const desc = readNullTerminated(frame as any, p, wide);
|
||||
p = desc.next;
|
||||
if (p < frame.length) {
|
||||
const img = frame.slice(p);
|
||||
if (img.length > 64) return { mime, data: Buffer.from(img) };
|
||||
}
|
||||
}
|
||||
offset += size;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { audioPath: string } }) {
|
||||
const { audioPath } = await params;
|
||||
try {
|
||||
const filepath = decodeURIComponent(audioPath);
|
||||
|
||||
// Security check
|
||||
const datasetRoot = await getDatasetsRoot();
|
||||
const trainingRoot = await getTrainingFolder();
|
||||
const dataRoot = await getDataRoot();
|
||||
const allowedDirs = [datasetRoot, trainingRoot, dataRoot];
|
||||
const isAllowed = allowedDirs.some(d => filepath.startsWith(d)) && !filepath.includes('..');
|
||||
if (!isAllowed) {
|
||||
return new NextResponse('Access denied', { status: 403 });
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(filepath).catch(() => null);
|
||||
if (!stat || !stat.isFile()) {
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
}
|
||||
|
||||
// Read only the ID3 tag (first min(tagSize, 4MB) bytes).
|
||||
// First read 10 bytes to get tag size, then read the full tag.
|
||||
const fd = await fs.promises.open(filepath, 'r');
|
||||
try {
|
||||
const headerBuf = Buffer.alloc(10);
|
||||
await fd.read(headerBuf, 0, 10, 0);
|
||||
|
||||
if (headerBuf[0] !== 0x49 || headerBuf[1] !== 0x44 || headerBuf[2] !== 0x33) {
|
||||
return new NextResponse('No ID3 tag', { status: 404 });
|
||||
}
|
||||
|
||||
const tagSize = synchsafeToInt(headerBuf[6], headerBuf[7], headerBuf[8], headerBuf[9]);
|
||||
const totalRead = Math.min(10 + tagSize, 4_000_000);
|
||||
|
||||
const tagBuf = Buffer.alloc(totalRead);
|
||||
await fd.read(tagBuf, 0, totalRead, 0);
|
||||
|
||||
const art = extractArtFromTag(tagBuf);
|
||||
if (!art) {
|
||||
return new NextResponse('No album art found', { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(art.data, {
|
||||
headers: {
|
||||
'Content-Type': art.mime,
|
||||
'Content-Length': String(art.data.length),
|
||||
'Cache-Control': 'public, max-age=604800, immutable',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting album art:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ const prisma = new PrismaClient();
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const job_ref = searchParams.get('job_ref');
|
||||
const job_type = searchParams.get('job_type');
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
@@ -15,8 +17,16 @@ export async function GET(request: Request) {
|
||||
});
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
if (job_ref) {
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { job_ref },
|
||||
orderBy: { updated_at: 'desc' },
|
||||
});
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: job_type ? { job_type } : undefined,
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
return NextResponse.json({ jobs: jobs });
|
||||
@@ -36,6 +46,15 @@ export async function POST(request: Request) {
|
||||
gpu_ids = "mps";
|
||||
}
|
||||
|
||||
const extra: any = {};
|
||||
if ("job_ref" in body) {
|
||||
extra["job_ref"] = body.job_ref;
|
||||
}
|
||||
|
||||
if ("job_type" in body) {
|
||||
extra["job_type"] = body.job_type;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
// Update existing training
|
||||
const training = await prisma.job.update({
|
||||
@@ -44,6 +63,7 @@ export async function POST(request: Request) {
|
||||
name,
|
||||
gpu_ids,
|
||||
job_config: JSON.stringify(job_config),
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(training);
|
||||
@@ -63,6 +83,7 @@ export async function POST(request: Request) {
|
||||
gpu_ids,
|
||||
job_config: JSON.stringify(job_config),
|
||||
queue_position: newQueuePosition,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(training);
|
||||
|
||||
@@ -8,12 +8,18 @@ import { Button } from '@headlessui/react';
|
||||
import AddImagesModal, { openImagesModal, useOpenImagesModalOnDrag } from '@/components/AddImagesModal';
|
||||
import { TopBar, MainContent } from '@/components/layout';
|
||||
import { apiClient } from '@/utils/api';
|
||||
import { CaptionDatasetModal, openCaptionDatasetModal } from '@/components/CaptionDatasetModal';
|
||||
import useSettings from '@/hooks/useSettings';
|
||||
import { pathJoin } from '@/utils/basic';
|
||||
import AutoCaptionButton from '@/components/AutoCaptionButton';
|
||||
|
||||
export default function DatasetPage({ params }: { params: { datasetName: string } }) {
|
||||
const [imgList, setImgList] = useState<{ img_path: string }[]>([]);
|
||||
const [isAutoCaptioning, setIsAutoCaptioning] = useState(false);
|
||||
const usableParams = use(params as any) as { datasetName: string };
|
||||
const datasetName = usableParams.datasetName;
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const { settings, isSettingsLoaded } = useSettings();
|
||||
|
||||
const refreshImageList = (dbName: string) => {
|
||||
setStatus('loading');
|
||||
@@ -105,6 +111,10 @@ export default function DatasetPage({ params }: { params: { datasetName: string
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
<div>
|
||||
<AutoCaptionButton
|
||||
datasetPath={`${pathJoin(settings.DATASETS_FOLDER, datasetName)}`}
|
||||
setIsAutoCaptioning={setIsAutoCaptioning}
|
||||
/>
|
||||
<Button
|
||||
className="text-white bg-slate-600 px-3 py-1 rounded-md"
|
||||
onClick={() => openImagesModal(datasetName, () => refreshImageList(datasetName))}
|
||||
@@ -121,6 +131,7 @@ export default function DatasetPage({ params }: { params: { datasetName: string
|
||||
<DatasetImageCard
|
||||
key={img.img_path}
|
||||
alt="image"
|
||||
isAutoCaptioning={isAutoCaptioning}
|
||||
imageUrl={img.img_path}
|
||||
onDelete={() => refreshImageList(datasetName)}
|
||||
/>
|
||||
@@ -129,6 +140,7 @@ export default function DatasetPage({ params }: { params: { datasetName: string
|
||||
)}
|
||||
</MainContent>
|
||||
<AddImagesModal />
|
||||
<CaptionDatasetModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Page {
|
||||
component: React.ComponentType<{ job: Job }>;
|
||||
menuItem?: React.ComponentType<{ job?: Job | null }> | null;
|
||||
mainCss?: string;
|
||||
jobTypes?: string[]; // if specified, only show this page for these job types
|
||||
}
|
||||
|
||||
const pages: Page[] = [
|
||||
@@ -40,6 +41,7 @@ const pages: Page[] = [
|
||||
component: SampleImages,
|
||||
menuItem: SampleImagesMenu,
|
||||
mainCss: 'pt-24',
|
||||
jobTypes: ['train'],
|
||||
},
|
||||
{
|
||||
name: 'Loss Graph',
|
||||
@@ -47,6 +49,7 @@ const pages: Page[] = [
|
||||
icon: MdShowChart,
|
||||
component: JobLossGraph,
|
||||
mainCss: 'pt-24',
|
||||
jobTypes: ['train'],
|
||||
},
|
||||
{
|
||||
name: 'Config File',
|
||||
@@ -65,6 +68,13 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
|
||||
|
||||
const page = pages.find(p => p.value === pageKey);
|
||||
|
||||
const jobType = job?.job_type || 'unknown';
|
||||
|
||||
let title = `Job: ${job?.name || 'Loading...'}`;
|
||||
if (jobType === 'caption') {
|
||||
title = `Captioning: ${job?.job_ref || 'Loading...'}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Fixed top bar */}
|
||||
@@ -75,7 +85,7 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg">Job: {job?.name}</h1>
|
||||
<h1 className="text-lg">{title}</h1>
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
{job && (
|
||||
@@ -103,16 +113,21 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
|
||||
)}
|
||||
</MainContent>
|
||||
<div className="bg-gray-800 absolute top-12 left-0 w-full h-8 flex items-center px-2 text-sm">
|
||||
{pages.map(page => (
|
||||
<Button
|
||||
key={page.value}
|
||||
onClick={() => setPageKey(page.value)}
|
||||
className={`px-4 py-1 h-8 flex items-center gap-1.5 ${page.value === pageKey ? 'bg-gray-300 dark:bg-gray-700 text-white' : ''}`}
|
||||
>
|
||||
<page.icon className="text-sm" />
|
||||
{page.name}
|
||||
</Button>
|
||||
))}
|
||||
{pages.map(page => {
|
||||
if (page.jobTypes && !page.jobTypes.includes(jobType)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page.value}
|
||||
onClick={() => setPageKey(page.value)}
|
||||
className={`px-4 py-1 h-8 flex items-center gap-1.5 ${page.value === pageKey ? 'bg-gray-300 dark:bg-gray-700 text-white' : ''}`}
|
||||
>
|
||||
<page.icon className="text-sm" />
|
||||
{page.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{page?.menuItem && (
|
||||
<>
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
@@ -35,23 +35,38 @@ const yamlConfig: YAML.DocumentOptions &
|
||||
directives: true,
|
||||
};
|
||||
|
||||
function toYaml(obj: any): string {
|
||||
const doc = new YAML.Document(obj, yamlConfig);
|
||||
YAML.visit(doc, {
|
||||
Scalar(_key, node) {
|
||||
if (typeof node.value === 'string' && node.value.includes('\n')) {
|
||||
node.type = YAML.Scalar.BLOCK_LITERAL;
|
||||
}
|
||||
},
|
||||
});
|
||||
return doc.toString(yamlConfig);
|
||||
}
|
||||
|
||||
export default function AdvancedJob({ jobConfig, setJobConfig, settings }: Props) {
|
||||
const { theme } = useTheme();
|
||||
const [editorValue, setEditorValue] = useState<string>('');
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const lastJobConfigUpdateStringRef = useRef('');
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const monacoRef = useRef<any>(null);
|
||||
|
||||
// Track if the editor has been mounted
|
||||
const isEditorMounted = useRef(false);
|
||||
|
||||
// Handler for editor mounting
|
||||
const handleEditorDidMount: OnMount = editor => {
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
isEditorMounted.current = true;
|
||||
|
||||
// Initial content setup
|
||||
try {
|
||||
const yamlContent = YAML.stringify(jobConfig, yamlConfig);
|
||||
const yamlContent = toYaml(jobConfig);
|
||||
setEditorValue(yamlContent);
|
||||
lastJobConfigUpdateStringRef.current = JSON.stringify(jobConfig);
|
||||
} catch (e) {
|
||||
@@ -78,7 +93,7 @@ export default function AdvancedJob({ jobConfig, setJobConfig, settings }: Props
|
||||
const scrollTop = editor.getScrollTop();
|
||||
|
||||
// Update content
|
||||
const yamlContent = YAML.stringify(jobConfig, yamlConfig);
|
||||
const yamlContent = toYaml(jobConfig);
|
||||
|
||||
// Only update if the content is actually different
|
||||
if (yamlContent !== editor.getValue()) {
|
||||
@@ -98,11 +113,29 @@ export default function AdvancedJob({ jobConfig, setJobConfig, settings }: Props
|
||||
}
|
||||
}, [jobConfig]);
|
||||
|
||||
const setMarkers = (errors: { message: string; line: number }[]) => {
|
||||
const monaco = monacoRef.current;
|
||||
const model = editorRef.current?.getModel();
|
||||
if (!monaco || !model) return;
|
||||
const markers = errors.map(err => ({
|
||||
severity: monaco.MarkerSeverity.Error,
|
||||
message: err.message,
|
||||
startLineNumber: err.line,
|
||||
startColumn: 1,
|
||||
endLineNumber: err.line,
|
||||
endColumn: model.getLineMaxColumn(err.line),
|
||||
}));
|
||||
monaco.editor.setModelMarkers(model, 'yaml', markers);
|
||||
};
|
||||
|
||||
const handleChange = (value: string | undefined) => {
|
||||
if (value === undefined) return;
|
||||
|
||||
try {
|
||||
const parsed = YAML.parse(value);
|
||||
setHasError(false);
|
||||
setMarkers([]);
|
||||
|
||||
// Don't update jobConfig if the change came from the editor itself
|
||||
// to avoid a circular update loop
|
||||
if (JSON.stringify(parsed) !== lastJobConfigUpdateStringRef.current) {
|
||||
@@ -121,14 +154,21 @@ export default function AdvancedJob({ jobConfig, setJobConfig, settings }: Props
|
||||
migrateJobConfig(parsed);
|
||||
setJobConfig(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
// Don't update on parsing errors
|
||||
console.warn(e);
|
||||
} catch (e: any) {
|
||||
setHasError(true);
|
||||
const line = e?.linePos?.[0]?.line ?? e?.linePos?.line ?? 1;
|
||||
setMarkers([{ message: e?.message ?? 'Invalid YAML', line }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-full w-full">
|
||||
{hasError && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 pointer-events-none rounded-sm"
|
||||
style={{ boxShadow: 'inset 0 0 12px 2px rgba(239, 68, 68, 0.5)' }}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
height="100%"
|
||||
width="100%"
|
||||
@@ -143,6 +183,6 @@ export default function AdvancedJob({ jobConfig, setJobConfig, settings }: Props
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,20 @@ import {
|
||||
quantizationOptions,
|
||||
defaultQtype,
|
||||
jobTypeOptions,
|
||||
SampleTags,
|
||||
} from './options';
|
||||
import { defaultDatasetConfig } from './jobConfig';
|
||||
import { GroupedSelectOption, JobConfig, SelectOption } from '@/types';
|
||||
import { objectCopy } from '@/utils/basic';
|
||||
import { TextInput, SelectInput, Checkbox, FormGroup, NumberInput, SliderInput } from '@/components/formInputs';
|
||||
import { objectCopy, tagsToObj, objToTags } from '@/utils/basic';
|
||||
import {
|
||||
TextInput,
|
||||
TextAreaInput,
|
||||
SelectInput,
|
||||
Checkbox,
|
||||
FormGroup,
|
||||
NumberInput,
|
||||
SliderInput,
|
||||
} from '@/components/formInputs';
|
||||
import Card from '@/components/Card';
|
||||
import { X, Copy } from 'lucide-react';
|
||||
import AddSingleImageModal, { openAddImageModal } from '@/components/AddSingleImageModal';
|
||||
@@ -68,6 +77,44 @@ export default function SimpleJob({
|
||||
}, [modelArch, jobType]);
|
||||
|
||||
const isVideoModel = !!(modelArch?.group === 'video');
|
||||
const isAudioModel = !!(modelArch?.group === 'audio');
|
||||
|
||||
const taggedSampleArr: Record<string, any>[] | null = useMemo(() => {
|
||||
if (!modelArch) return null;
|
||||
if (!modelArch.sampleTags) return null;
|
||||
if (!jobConfig.config.process[0].sample.samples) return null;
|
||||
let sampleArr: any[] = [];
|
||||
for (let i = 0; i < jobConfig.config.process[0].sample.samples.length; i++) {
|
||||
const taggedPrompt = jobConfig.config.process[0].sample.samples[i].prompt;
|
||||
const tagsObj = tagsToObj(taggedPrompt);
|
||||
sampleArr.push(tagsObj);
|
||||
}
|
||||
return sampleArr;
|
||||
}, [modelArch, jobConfig.config.process[0].sample.samples]);
|
||||
|
||||
const modelArchTagSections: SampleTags[] | null = useMemo(() => {
|
||||
if (!modelArch?.sampleTags) return null;
|
||||
const maxPerGroup = 5;
|
||||
let sections: SampleTags[] = [];
|
||||
let subSection: SampleTags = {};
|
||||
for (const [tagKey, tag] of Object.entries(modelArch.sampleTags)) {
|
||||
if ((tag.full && Object.keys(subSection).length > 0) || Object.keys(subSection).length >= maxPerGroup) {
|
||||
// reset the sub section build if the next tag is full or max per group is reached
|
||||
sections.push(subSection);
|
||||
subSection = {};
|
||||
}
|
||||
subSection[tagKey] = tag;
|
||||
if (tag.full) {
|
||||
// if the tag is full, push the section immediately and reset the sub section build
|
||||
sections.push(subSection);
|
||||
subSection = {};
|
||||
}
|
||||
}
|
||||
if (Object.keys(subSection).length > 0) {
|
||||
sections.push(subSection);
|
||||
}
|
||||
return sections.length > 0 ? sections : null;
|
||||
}, [modelArch]);
|
||||
|
||||
const numTopCards = useMemo(() => {
|
||||
let count = 4; // job settings, model config, target config, save config
|
||||
@@ -149,6 +196,26 @@ export default function SimpleJob({
|
||||
|
||||
const showGPUSelect = !isMac();
|
||||
|
||||
let numDatasetCols = 4;
|
||||
let numSampleTopCols = 4;
|
||||
let datasetStyleClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6';
|
||||
let sampleTopStyleClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6';
|
||||
if (isVideoModel) {
|
||||
numSampleTopCols += 1;
|
||||
}
|
||||
if (isAudioModel) {
|
||||
numDatasetCols -= 1;
|
||||
numSampleTopCols -= 1;
|
||||
}
|
||||
if (numDatasetCols == 3) {
|
||||
datasetStyleClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6';
|
||||
}
|
||||
if (numSampleTopCols == 5) {
|
||||
sampleTopStyleClass = 'grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6';
|
||||
}
|
||||
if (numSampleTopCols == 3) {
|
||||
sampleTopStyleClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6';
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
@@ -790,7 +857,7 @@ export default function SimpleJob({
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold mb-4">Dataset {i + 1}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className={datasetStyleClass}>
|
||||
<div>
|
||||
<SelectInput
|
||||
label="Target Dataset"
|
||||
@@ -971,53 +1038,57 @@ export default function SimpleJob({
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
<FormGroup label="Flipping" docKey={'datasets.flip'} className="mt-2">
|
||||
<Checkbox
|
||||
label={
|
||||
<>
|
||||
Flip X <FlipHorizontal2 className="inline-block w-4 h-4 ml-1" />
|
||||
</>
|
||||
}
|
||||
checked={dataset.flip_x || false}
|
||||
onChange={value => setJobConfig(value, `config.process[0].datasets[${i}].flip_x`)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<>
|
||||
Flip Y <FlipVertical2 className="inline-block w-4 h-4 ml-1" />
|
||||
</>
|
||||
}
|
||||
checked={dataset.flip_y || false}
|
||||
onChange={value => setJobConfig(value, `config.process[0].datasets[${i}].flip_y`)}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div>
|
||||
<FormGroup label="Resolutions" className="pt-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
[256, 512, 768, 1024],
|
||||
[1280, 1328, 1536],
|
||||
].map(resGroup => (
|
||||
<div key={resGroup[0]} className="space-y-2">
|
||||
{resGroup.map(res => (
|
||||
<Checkbox
|
||||
key={res}
|
||||
label={res.toString()}
|
||||
checked={dataset.resolution.includes(res)}
|
||||
onChange={value => {
|
||||
const resolutions = dataset.resolution.includes(res)
|
||||
? dataset.resolution.filter(r => r !== res)
|
||||
: [...dataset.resolution, res];
|
||||
setJobConfig(resolutions, `config.process[0].datasets[${i}].resolution`);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FormGroup>
|
||||
{!isAudioModel && (
|
||||
<FormGroup label="Flipping" docKey={'datasets.flip'} className="mt-2">
|
||||
<Checkbox
|
||||
label={
|
||||
<>
|
||||
Flip X <FlipHorizontal2 className="inline-block w-4 h-4 ml-1" />
|
||||
</>
|
||||
}
|
||||
checked={dataset.flip_x || false}
|
||||
onChange={value => setJobConfig(value, `config.process[0].datasets[${i}].flip_x`)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<>
|
||||
Flip Y <FlipVertical2 className="inline-block w-4 h-4 ml-1" />
|
||||
</>
|
||||
}
|
||||
checked={dataset.flip_y || false}
|
||||
onChange={value => setJobConfig(value, `config.process[0].datasets[${i}].flip_y`)}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
{!isAudioModel && (
|
||||
<div>
|
||||
<FormGroup label="Resolutions" className="pt-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
[256, 512, 768, 1024],
|
||||
[1280, 1328, 1536],
|
||||
].map(resGroup => (
|
||||
<div key={resGroup[0]} className="space-y-2">
|
||||
{resGroup.map(res => (
|
||||
<Checkbox
|
||||
key={res}
|
||||
label={res.toString()}
|
||||
checked={dataset.resolution.includes(res)}
|
||||
onChange={value => {
|
||||
const resolutions = dataset.resolution.includes(res)
|
||||
? dataset.resolution.filter(r => r !== res)
|
||||
: [...dataset.resolution, res];
|
||||
setJobConfig(resolutions, `config.process[0].datasets[${i}].resolution`);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FormGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1039,13 +1110,7 @@ export default function SimpleJob({
|
||||
</div>
|
||||
<div>
|
||||
<Card title="Sample">
|
||||
<div
|
||||
className={
|
||||
isVideoModel
|
||||
? 'grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6'
|
||||
: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6'
|
||||
}
|
||||
>
|
||||
<div className={sampleTopStyleClass}>
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Sample Every"
|
||||
@@ -1084,47 +1149,50 @@ export default function SimpleJob({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Width"
|
||||
value={jobConfig.config.process[0].sample.width}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.width')}
|
||||
placeholder="eg. 1024"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={jobConfig.config.process[0].sample.height}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.height')}
|
||||
placeholder="eg. 1024"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
{isVideoModel && (
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Num Frames"
|
||||
value={jobConfig.config.process[0].sample.num_frames}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.num_frames')}
|
||||
placeholder="eg. 0"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="FPS"
|
||||
value={jobConfig.config.process[0].sample.fps}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.fps')}
|
||||
placeholder="eg. 0"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAudioModel && (
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Width"
|
||||
value={jobConfig.config.process[0].sample.width}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.width')}
|
||||
placeholder="eg. 1024"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={jobConfig.config.process[0].sample.height}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.height')}
|
||||
placeholder="eg. 1024"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
{isVideoModel && (
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Num Frames"
|
||||
value={jobConfig.config.process[0].sample.num_frames}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.num_frames')}
|
||||
placeholder="eg. 0"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="FPS"
|
||||
value={jobConfig.config.process[0].sample.fps}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.fps')}
|
||||
placeholder="eg. 0"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
@@ -1199,68 +1267,143 @@ export default function SimpleJob({
|
||||
<div className="flex-1">
|
||||
<div className="flex">
|
||||
<div className="flex-1">
|
||||
<TextInput
|
||||
label={`Prompt`}
|
||||
value={sample.prompt}
|
||||
onChange={value => setJobConfig(value, `config.process[0].sample.samples[${i}].prompt`)}
|
||||
placeholder="Enter prompt"
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-2">
|
||||
<TextInput
|
||||
label={`Width`}
|
||||
value={sample.width ? `${sample.width}` : ''}
|
||||
onChange={value => {
|
||||
// remove any non-numeric characters
|
||||
value = value.replace(/\D/g, '');
|
||||
if (value === '') {
|
||||
// remove the key from the config if empty
|
||||
let newConfig = objectCopy(jobConfig);
|
||||
if (newConfig.config.process[0].sample.samples[i]) {
|
||||
delete newConfig.config.process[0].sample.samples[i].width;
|
||||
setJobConfig(
|
||||
newConfig.config.process[0].sample.samples,
|
||||
'config.process[0].sample.samples',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const intValue = parseInt(value);
|
||||
if (!isNaN(intValue)) {
|
||||
setJobConfig(intValue, `config.process[0].sample.samples[${i}].width`);
|
||||
{modelArch?.sampleTags && taggedSampleArr && modelArchTagSections ? (
|
||||
<>
|
||||
{modelArchTagSections.map((sampleTagSection, sti) => (
|
||||
<div key={sti} className="grid w-full lg:grid-flow-col lg:auto-cols-fr gap-4 mt-2">
|
||||
{Object.entries(sampleTagSection).map(([tagKey, tag]) => (
|
||||
<div key={tagKey} className="mb-2">
|
||||
{tag.type === 'text' && (
|
||||
<TextInput
|
||||
label={tag.title}
|
||||
value={taggedSampleArr[i][tagKey] ?? ''}
|
||||
onChange={value => {
|
||||
let taggedSample = { ...taggedSampleArr[i] };
|
||||
taggedSample[tagKey] = value;
|
||||
setJobConfig(
|
||||
objToTags(taggedSample),
|
||||
`config.process[0].sample.samples[${i}].prompt`,
|
||||
);
|
||||
}}
|
||||
placeholder={`Enter ${tag.title.toLowerCase()}`}
|
||||
/>
|
||||
)}
|
||||
{tag.type === 'multiline' && (
|
||||
<TextAreaInput
|
||||
label={tag.title}
|
||||
value={taggedSampleArr[i][tagKey] ?? ''}
|
||||
onChange={value => {
|
||||
let taggedSample = { ...taggedSampleArr[i] };
|
||||
taggedSample[tagKey] = value;
|
||||
setJobConfig(
|
||||
objToTags(taggedSample),
|
||||
`config.process[0].sample.samples[${i}].prompt`,
|
||||
);
|
||||
}}
|
||||
placeholder={`Enter ${tag.title.toLowerCase()}`}
|
||||
/>
|
||||
)}
|
||||
{tag.type === 'number' && (
|
||||
<NumberInput
|
||||
label={tag.title}
|
||||
value={taggedSampleArr[i][tagKey] ?? ''}
|
||||
onChange={value => {
|
||||
let taggedSample = { ...taggedSampleArr[i] };
|
||||
taggedSample[tagKey] = value;
|
||||
setJobConfig(
|
||||
objToTags(taggedSample),
|
||||
`config.process[0].sample.samples[${i}].prompt`,
|
||||
);
|
||||
}}
|
||||
placeholder={`Enter ${tag.title.toLowerCase()}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{modelArch?.hasMultiLinePrompts ? (
|
||||
<TextAreaInput
|
||||
label={`Prompt`}
|
||||
value={sample.prompt}
|
||||
onChange={value => setJobConfig(value, `config.process[0].sample.samples[${i}].prompt`)}
|
||||
placeholder="Enter prompt"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label={`Prompt`}
|
||||
value={sample.prompt}
|
||||
onChange={value => setJobConfig(value, `config.process[0].sample.samples[${i}].prompt`)}
|
||||
placeholder="Enter prompt"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid w-full lg:grid-flow-col lg:auto-cols-fr gap-4 mt-2">
|
||||
{!isAudioModel && (
|
||||
<TextInput
|
||||
label={`Width`}
|
||||
value={sample.width ? `${sample.width}` : ''}
|
||||
onChange={value => {
|
||||
// remove any non-numeric characters
|
||||
value = value.replace(/\D/g, '');
|
||||
if (value === '') {
|
||||
// remove the key from the config if empty
|
||||
let newConfig = objectCopy(jobConfig);
|
||||
if (newConfig.config.process[0].sample.samples[i]) {
|
||||
delete newConfig.config.process[0].sample.samples[i].width;
|
||||
setJobConfig(
|
||||
newConfig.config.process[0].sample.samples,
|
||||
'config.process[0].sample.samples',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid width value:', value);
|
||||
const intValue = parseInt(value);
|
||||
if (!isNaN(intValue)) {
|
||||
setJobConfig(intValue, `config.process[0].sample.samples[${i}].width`);
|
||||
} else {
|
||||
console.warn('Invalid width value:', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={`${jobConfig.config.process[0].sample.width} (default)`}
|
||||
/>
|
||||
<TextInput
|
||||
label={`Height`}
|
||||
value={sample.height ? `${sample.height}` : ''}
|
||||
onChange={value => {
|
||||
// remove any non-numeric characters
|
||||
value = value.replace(/\D/g, '');
|
||||
if (value === '') {
|
||||
// remove the key from the config if empty
|
||||
let newConfig = objectCopy(jobConfig);
|
||||
if (newConfig.config.process[0].sample.samples[i]) {
|
||||
delete newConfig.config.process[0].sample.samples[i].height;
|
||||
setJobConfig(
|
||||
newConfig.config.process[0].sample.samples,
|
||||
'config.process[0].sample.samples',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const intValue = parseInt(value);
|
||||
if (!isNaN(intValue)) {
|
||||
setJobConfig(intValue, `config.process[0].sample.samples[${i}].height`);
|
||||
}}
|
||||
placeholder={`${jobConfig.config.process[0].sample.width} (default)`}
|
||||
/>
|
||||
)}
|
||||
{!isAudioModel && (
|
||||
<TextInput
|
||||
label={`Height`}
|
||||
value={sample.height ? `${sample.height}` : ''}
|
||||
onChange={value => {
|
||||
// remove any non-numeric characters
|
||||
value = value.replace(/\D/g, '');
|
||||
if (value === '') {
|
||||
// remove the key from the config if empty
|
||||
let newConfig = objectCopy(jobConfig);
|
||||
if (newConfig.config.process[0].sample.samples[i]) {
|
||||
delete newConfig.config.process[0].sample.samples[i].height;
|
||||
setJobConfig(
|
||||
newConfig.config.process[0].sample.samples,
|
||||
'config.process[0].sample.samples',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid height value:', value);
|
||||
const intValue = parseInt(value);
|
||||
if (!isNaN(intValue)) {
|
||||
setJobConfig(intValue, `config.process[0].sample.samples[${i}].height`);
|
||||
} else {
|
||||
console.warn('Invalid height value:', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={`${jobConfig.config.process[0].sample.height} (default)`}
|
||||
/>
|
||||
}}
|
||||
placeholder={`${jobConfig.config.process[0].sample.height} (default)`}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={`Seed`}
|
||||
value={sample.seed ? `${sample.seed}` : ''}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { isMac } from '@/helpers/basic';
|
||||
import { JobConfig, DatasetConfig, SliderConfig } from '@/types';
|
||||
import { defaultSampleConfig } from '@/helpers/defaultSamples';
|
||||
import { JobConfig, SampleConfig, DatasetConfig, SliderConfig } from '@/types';
|
||||
|
||||
export const defaultDatasetConfig: DatasetConfig = {
|
||||
folder_path: '/path/to/images/folder',
|
||||
@@ -108,54 +109,7 @@ export const defaultJobConfig: JobConfig = {
|
||||
low_vram: false,
|
||||
model_kwargs: {},
|
||||
},
|
||||
sample: {
|
||||
sampler: 'flowmatch',
|
||||
sample_every: 250,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
samples: [
|
||||
{
|
||||
prompt: 'woman with red hair, playing chess at the park, bomb going off in the background',
|
||||
},
|
||||
{
|
||||
prompt: 'a woman holding a coffee cup, in a beanie, sitting at a cafe',
|
||||
},
|
||||
{
|
||||
prompt: 'a horse is a DJ at a night club, fish eye lens, smoke machine, lazer lights, holding a martini',
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'a man showing off his cool new t shirt at the beach, a shark is jumping out of the water in the background',
|
||||
},
|
||||
{
|
||||
prompt: 'a bear building a log cabin in the snow covered mountains',
|
||||
},
|
||||
{
|
||||
prompt: 'woman playing the guitar, on stage, singing a song, laser lights, punk rocker',
|
||||
},
|
||||
{
|
||||
prompt: 'hipster man with a beard, building a chair, in a wood shop',
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'photo of a man, white background, medium shot, modeling clothing, studio lighting, white backdrop',
|
||||
},
|
||||
{
|
||||
prompt: "a man holding a sign that says, 'this is a sign'",
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'a bulldog, in a post apocalyptic world, with a shotgun, in a leather jacket, in a desert, with a motorcycle',
|
||||
},
|
||||
],
|
||||
neg: '',
|
||||
seed: 42,
|
||||
walk_seed: true,
|
||||
guidance_scale: 4,
|
||||
sample_steps: 25,
|
||||
num_frames: 1,
|
||||
fps: 1,
|
||||
},
|
||||
sample: defaultSampleConfig,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GroupedSelectOption, SelectOption, JobConfig } from '@/types';
|
||||
import { defaultSliderConfig } from './jobConfig';
|
||||
import { defaultAudioSampleConfig, defaultSampleConfig } from '@/helpers/defaultSamples';
|
||||
|
||||
type Control = 'depth' | 'line' | 'pose' | 'inpaint';
|
||||
|
||||
@@ -31,7 +32,17 @@ type AdditionalSections =
|
||||
| 'model.qie.match_target_res'
|
||||
| 'model.assistant_lora_path';
|
||||
|
||||
type ModelGroup = 'image' | 'instruction' | 'video' | 'experimental';
|
||||
type ModelGroup = 'image' | 'instruction' | 'video' | 'experimental' | 'audio';
|
||||
|
||||
export type SampleTag = {
|
||||
title: string;
|
||||
type: 'text' | 'multiline' | 'number'
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
export interface SampleTags {
|
||||
[key: string]: SampleTag;
|
||||
}
|
||||
|
||||
export interface ModelArch {
|
||||
name: string;
|
||||
@@ -39,10 +50,12 @@ export interface ModelArch {
|
||||
group: ModelGroup;
|
||||
controls?: Control[];
|
||||
isVideoModel?: boolean;
|
||||
hasMultiLinePrompts?: boolean;
|
||||
defaults?: { [key: string]: any };
|
||||
disableSections?: DisableableSections[];
|
||||
additionalSections?: AdditionalSections[];
|
||||
accuracyRecoveryAdapters?: { [key: string]: string };
|
||||
sampleTags?: SampleTags;
|
||||
}
|
||||
|
||||
const defaultNameOrPath = '';
|
||||
@@ -760,6 +773,116 @@ export const modelArchs: ModelArch[] = [
|
||||
'model.qie.match_target_res',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ace_step_15_xl',
|
||||
label: 'ACE-Step 1.5 XL',
|
||||
group: 'audio',
|
||||
defaults: {
|
||||
// default updates when [selected, unselected] in the UI
|
||||
'config.process[0].model.name_or_path': ['ostris/ace_step_1.5_ComfyUI_files/ace_step_1.5_xl_base_aio.safetensors', defaultNameOrPath],
|
||||
'config.process[0].model.quantize': [true, false],
|
||||
'config.process[0].model.quantize_te': [true, false],
|
||||
'config.process[0].model.low_vram': [true, false],
|
||||
'config.process[0].train.unload_text_encoder': [false, false],
|
||||
'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
|
||||
'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
|
||||
'config.process[0].model.qtype': ['qfloat8', 'qfloat8'],
|
||||
'config.process[0].sample': [defaultAudioSampleConfig, defaultSampleConfig],
|
||||
},
|
||||
sampleTags: {
|
||||
"CAPTION": {
|
||||
title: "Audio Prompt",
|
||||
type: "text",
|
||||
full: true,
|
||||
},
|
||||
"LYRICS": {
|
||||
title: "Lyrics",
|
||||
type: "multiline",
|
||||
full: true,
|
||||
},
|
||||
"BPM": {
|
||||
title: "BPM",
|
||||
type: "number",
|
||||
},
|
||||
"KEYSCALE": {
|
||||
title: "Key Scale",
|
||||
type: "text",
|
||||
},
|
||||
"TIMESIGNATURE": {
|
||||
title: "Time Signature",
|
||||
type: "text",
|
||||
},
|
||||
"DURATION": {
|
||||
title: "Duration (sec)",
|
||||
type: "number",
|
||||
},
|
||||
"LANGUAGE": {
|
||||
title: "Language",
|
||||
type: "text",
|
||||
},
|
||||
},
|
||||
disableSections: ['network.conv'],
|
||||
additionalSections: [
|
||||
'sample.multi_ctrl_imgs',
|
||||
'model.low_vram',
|
||||
'model.layer_offloading',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ace_step_15',
|
||||
label: 'ACE-Step 1.5',
|
||||
group: 'audio',
|
||||
defaults: {
|
||||
// default updates when [selected, unselected] in the UI
|
||||
'config.process[0].model.name_or_path': ['ostris/ace_step_1.5_ComfyUI_files/ace_step_1.5_base_aio.safetensors', defaultNameOrPath],
|
||||
'config.process[0].model.quantize': [true, false],
|
||||
'config.process[0].model.quantize_te': [true, false],
|
||||
'config.process[0].model.low_vram': [true, false],
|
||||
'config.process[0].train.unload_text_encoder': [false, false],
|
||||
'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
|
||||
'config.process[0].train.timestep_type': ['weighted', 'sigmoid'],
|
||||
'config.process[0].model.qtype': ['qfloat8', 'qfloat8'],
|
||||
'config.process[0].sample': [defaultAudioSampleConfig, defaultSampleConfig],
|
||||
},
|
||||
sampleTags: {
|
||||
"CAPTION": {
|
||||
title: "Audio Prompt",
|
||||
type: "text",
|
||||
full: true,
|
||||
},
|
||||
"LYRICS": {
|
||||
title: "Lyrics",
|
||||
type: "multiline",
|
||||
full: true,
|
||||
},
|
||||
"BPM": {
|
||||
title: "BPM",
|
||||
type: "number",
|
||||
},
|
||||
"KEYSCALE": {
|
||||
title: "Key Scale",
|
||||
type: "text",
|
||||
},
|
||||
"TIMESIGNATURE": {
|
||||
title: "Time Signature",
|
||||
type: "text",
|
||||
},
|
||||
"DURATION": {
|
||||
title: "Duration (sec)",
|
||||
type: "number",
|
||||
},
|
||||
"LANGUAGE": {
|
||||
title: "Language",
|
||||
type: "text",
|
||||
},
|
||||
},
|
||||
disableSections: ['network.conv'],
|
||||
additionalSections: [
|
||||
'sample.multi_ctrl_imgs',
|
||||
'model.low_vram',
|
||||
'model.layer_offloading',
|
||||
],
|
||||
},
|
||||
].sort((a, b) => {
|
||||
// Sort by label, case-insensitive
|
||||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function Dashboard() {
|
||||
<>
|
||||
<TopBar>
|
||||
<div>
|
||||
<h1 className="text-lg">Training Queue</h1>
|
||||
<h1 className="text-lg">Queue</h1>
|
||||
</div>
|
||||
<div className="flex-1"></div>
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { apiClient } from '@/utils/api';
|
||||
|
||||
type AudioPlayerProps = {
|
||||
src: string;
|
||||
@@ -38,214 +39,17 @@ function broadcastExclusivePlay(token: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* ID3 helpers (v2.2/v2.3/v2.4):
|
||||
* - robust album art extraction: APIC (v2.3/2.4) + PIC (v2.2)
|
||||
* - basic text frames: title/artist/album
|
||||
* - handles tag-level unsynchronisation
|
||||
*
|
||||
* Requires fetch() byte access; if CORS blocks, it will fall back gracefully.
|
||||
* Build the server-side album-art URL from the audio src.
|
||||
* The audio src is `/api/img/{encodedPath}` — we extract the path
|
||||
* and point to `/api/audio/art/{encodedPath}` instead.
|
||||
*/
|
||||
type Id3Meta = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
pictureUrl?: string; // object URL
|
||||
};
|
||||
|
||||
function synchsafeToInt(b0: number, b1: number, b2: number, b3: number) {
|
||||
return ((b0 & 0x7f) << 21) | ((b1 & 0x7f) << 14) | ((b2 & 0x7f) << 7) | (b3 & 0x7f);
|
||||
}
|
||||
|
||||
function deUnsync(bytes: Uint8Array) {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const cur = bytes[i];
|
||||
out.push(cur);
|
||||
if (cur === 0xff && i + 1 < bytes.length && bytes[i + 1] === 0x00) i += 1;
|
||||
function albumArtUrlFromSrc(src: string): string {
|
||||
const prefix = '/api/img/';
|
||||
if (src.startsWith(prefix)) {
|
||||
return `/api/audio/art/${src.slice(prefix.length)}`;
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function decodeText(encoding: number, bytes: Uint8Array) {
|
||||
let end = bytes.length;
|
||||
while (end > 0 && bytes[end - 1] === 0) end--;
|
||||
const b = bytes.slice(0, end);
|
||||
|
||||
try {
|
||||
if (encoding === 0) return new TextDecoder('latin1').decode(b);
|
||||
if (encoding === 1) return new TextDecoder('utf-16').decode(b);
|
||||
if (encoding === 2) return new TextDecoder('utf-16be').decode(b);
|
||||
if (encoding === 3) return new TextDecoder('utf-8').decode(b);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return new TextDecoder('latin1').decode(b);
|
||||
}
|
||||
|
||||
function readNullTerminated(bytes: Uint8Array, start: number, encoding: number) {
|
||||
if (encoding === 1 || encoding === 2) {
|
||||
let i = start;
|
||||
while (i + 1 < bytes.length && !(bytes[i] === 0 && bytes[i + 1] === 0)) i += 2;
|
||||
const textBytes = bytes.slice(start, i);
|
||||
return { text: decodeText(encoding, textBytes), next: i + 2 };
|
||||
} else {
|
||||
let i = start;
|
||||
while (i < bytes.length && bytes[i] !== 0) i++;
|
||||
const textBytes = bytes.slice(start, i);
|
||||
return { text: decodeText(encoding, textBytes), next: i + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBytes(src: string, start: number, endInclusive: number) {
|
||||
const wantLen = endInclusive - start + 1;
|
||||
|
||||
try {
|
||||
const r = await fetch(src, { headers: { Range: `bytes=${start}-${endInclusive}` } });
|
||||
if (!r.ok) throw new Error('range not ok');
|
||||
const buf = await r.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
} catch {
|
||||
const r = await fetch(src);
|
||||
if (!r.ok) throw new Error('fetch not ok');
|
||||
const buf = await r.arrayBuffer();
|
||||
const u8 = new Uint8Array(buf);
|
||||
if (start === 0 && u8.length >= wantLen) return u8.slice(0, wantLen);
|
||||
return u8.slice(start, Math.min(u8.length, endInclusive + 1));
|
||||
}
|
||||
}
|
||||
|
||||
async function extractId3MetaAndArt(src: string, maxTagBytes = 4_000_000): Promise<Id3Meta> {
|
||||
const head = await fetchBytes(src, 0, 64 * 1024 - 1).catch(() => null);
|
||||
if (!head || head.length < 10) return {};
|
||||
if (head[0] !== 0x49 || head[1] !== 0x44 || head[2] !== 0x33) return {};
|
||||
|
||||
const verMajor = head[3]; // 2,3,4
|
||||
const flags = head[5];
|
||||
const tagSize = synchsafeToInt(head[6], head[7], head[8], head[9]);
|
||||
const tagEnd = 10 + tagSize;
|
||||
|
||||
const need = Math.min(tagEnd, maxTagBytes);
|
||||
let tagBytes = head;
|
||||
if (head.length < need) {
|
||||
const more = await fetchBytes(src, 0, need - 1).catch(() => null);
|
||||
if (!more) return {};
|
||||
tagBytes = more;
|
||||
} else {
|
||||
tagBytes = head.slice(0, need);
|
||||
}
|
||||
|
||||
const tagUnsync = (flags & 0x80) !== 0;
|
||||
const tagDataRaw = tagBytes.slice(10, Math.min(tagBytes.length, tagEnd));
|
||||
const tagData = tagUnsync ? deUnsync(tagDataRaw) : tagDataRaw;
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// Extended header (v2.3/v2.4)
|
||||
if (verMajor === 3 || verMajor === 4) {
|
||||
const hasExt = (flags & 0x40) !== 0;
|
||||
if (hasExt && tagData.length >= 4) {
|
||||
let extSize = 0;
|
||||
if (verMajor === 4) extSize = synchsafeToInt(tagData[0], tagData[1], tagData[2], tagData[3]);
|
||||
else extSize = (tagData[0] << 24) | (tagData[1] << 16) | (tagData[2] << 8) | tagData[3];
|
||||
offset += 4 + Math.max(0, extSize);
|
||||
}
|
||||
}
|
||||
|
||||
const meta: Id3Meta = {};
|
||||
const setIfEmpty = (k: keyof Id3Meta, v?: string) => {
|
||||
if (!v) return;
|
||||
if (!meta[k]) meta[k] = v;
|
||||
};
|
||||
|
||||
while (offset < tagData.length) {
|
||||
if (tagData[offset] === 0x00) break;
|
||||
|
||||
if (verMajor === 2) {
|
||||
if (offset + 6 > tagData.length) break;
|
||||
const id = new TextDecoder('latin1').decode(tagData.slice(offset, offset + 3));
|
||||
const size = (tagData[offset + 3] << 16) | (tagData[offset + 4] << 8) | tagData[offset + 5];
|
||||
offset += 6;
|
||||
if (!id.trim() || size <= 0 || offset + size > tagData.length) break;
|
||||
|
||||
const frame = tagData.slice(offset, offset + size);
|
||||
|
||||
if (id === 'TT2' || id === 'TP1' || id === 'TAL') {
|
||||
const enc = frame[0];
|
||||
const txt = decodeText(enc, frame.slice(1));
|
||||
if (id === 'TT2') setIfEmpty('title', txt);
|
||||
if (id === 'TP1') setIfEmpty('artist', txt);
|
||||
if (id === 'TAL') setIfEmpty('album', txt);
|
||||
}
|
||||
|
||||
if (id === 'PIC' && frame.length > 6) {
|
||||
const enc = frame[0];
|
||||
const fmt = new TextDecoder('latin1').decode(frame.slice(1, 4)).toLowerCase();
|
||||
const imgType = fmt === 'png' ? 'image/png' : 'image/jpeg';
|
||||
let p = 5;
|
||||
const desc = readNullTerminated(frame, p, enc);
|
||||
p = desc.next;
|
||||
if (p < frame.length) {
|
||||
const img = frame.slice(p);
|
||||
if (img.length > 64) {
|
||||
const blob = new Blob([img], { type: imgType });
|
||||
meta.pictureUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += size;
|
||||
} else {
|
||||
if (offset + 10 > tagData.length) break;
|
||||
|
||||
const id = new TextDecoder('latin1').decode(tagData.slice(offset, offset + 4));
|
||||
let size = 0;
|
||||
if (verMajor === 4)
|
||||
size = synchsafeToInt(tagData[offset + 4], tagData[offset + 5], tagData[offset + 6], tagData[offset + 7]);
|
||||
else
|
||||
size =
|
||||
(tagData[offset + 4] << 24) | (tagData[offset + 5] << 16) | (tagData[offset + 6] << 8) | tagData[offset + 7];
|
||||
|
||||
const flag2 = tagData[offset + 9];
|
||||
offset += 10;
|
||||
|
||||
if (!id.trim() || size <= 0 || offset + size > tagData.length) break;
|
||||
|
||||
let frame = tagData.slice(offset, offset + size);
|
||||
|
||||
const frameUnsync = verMajor === 4 && (flag2 & 0x02) !== 0;
|
||||
if (frameUnsync) frame = deUnsync(frame);
|
||||
|
||||
if (id === 'TIT2' || id === 'TPE1' || id === 'TALB') {
|
||||
const enc = frame[0];
|
||||
const txt = decodeText(enc, frame.slice(1));
|
||||
if (id === 'TIT2') setIfEmpty('title', txt);
|
||||
if (id === 'TPE1') setIfEmpty('artist', txt);
|
||||
if (id === 'TALB') setIfEmpty('album', txt);
|
||||
}
|
||||
|
||||
if (id === 'APIC' && frame.length > 10) {
|
||||
const enc = frame[0];
|
||||
const mimeZ = readNullTerminated(frame, 1, 0);
|
||||
const mime = mimeZ.text || 'image/jpeg';
|
||||
let p = mimeZ.next;
|
||||
if (p < frame.length) p += 1; // pictureType
|
||||
const desc = readNullTerminated(frame, p, enc);
|
||||
p = desc.next;
|
||||
|
||||
if (p < frame.length) {
|
||||
const img = frame.slice(p);
|
||||
if (img.length > 64) {
|
||||
const blob = new Blob([img], { type: mime });
|
||||
if (!meta.pictureUrl) meta.pictureUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += size;
|
||||
}
|
||||
}
|
||||
|
||||
return meta;
|
||||
// Fallback: assume src is already an encoded path
|
||||
return `/api/audio/art/${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({
|
||||
@@ -278,12 +82,8 @@ export default function AudioPlayer({
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragValue, setDragValue] = useState(0);
|
||||
|
||||
// Meta + artwork
|
||||
const [metaTitle, setMetaTitle] = useState<string | null>(null);
|
||||
const [metaArtist, setMetaArtist] = useState<string | null>(null);
|
||||
const [metaAlbum, setMetaAlbum] = useState<string | null>(null);
|
||||
// Album art: served by /api/audio/art endpoint (fast, server-side extraction)
|
||||
const [albumArtUrl, setAlbumArtUrl] = useState<string | null>(null);
|
||||
const albumArtBlobUrlRef = useRef<string | null>(null);
|
||||
|
||||
// WebAudio analyser
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
@@ -318,57 +118,23 @@ export default function AudioPlayer({
|
||||
c.height = Math.max(1, Math.floor(cssH * dpr));
|
||||
}, [size]);
|
||||
|
||||
// Extract meta + art
|
||||
// Set album art URL from server endpoint
|
||||
useEffect(() => {
|
||||
const artUrl = albumArtUrlFromSrc(src);
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
if (albumArtBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(albumArtBlobUrlRef.current);
|
||||
albumArtBlobUrlRef.current = null;
|
||||
}
|
||||
setAlbumArtUrl(null);
|
||||
setMetaTitle(null);
|
||||
setMetaArtist(null);
|
||||
setMetaAlbum(null);
|
||||
|
||||
try {
|
||||
const meta = await extractId3MetaAndArt(src);
|
||||
if (cancelled) return;
|
||||
|
||||
if (meta.title) setMetaTitle(meta.title);
|
||||
if (meta.artist) setMetaArtist(meta.artist);
|
||||
if (meta.album) setMetaAlbum(meta.album);
|
||||
|
||||
if (meta.pictureUrl) {
|
||||
albumArtBlobUrlRef.current = meta.pictureUrl;
|
||||
setAlbumArtUrl(meta.pictureUrl);
|
||||
} else if (defaultAlbumArtUrl) {
|
||||
setAlbumArtUrl(defaultAlbumArtUrl);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled && defaultAlbumArtUrl) setAlbumArtUrl(defaultAlbumArtUrl);
|
||||
}
|
||||
})();
|
||||
|
||||
apiClient
|
||||
.head(artUrl)
|
||||
.then(() => {
|
||||
if (!cancelled) setAlbumArtUrl(artUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAlbumArtUrl(defaultAlbumArtUrl ?? null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, defaultAlbumArtUrl]);
|
||||
|
||||
// Cleanup artwork blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (albumArtBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(albumArtBlobUrlRef.current);
|
||||
albumArtBlobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const effectiveTitle = metaTitle || title;
|
||||
const effectiveSubtitle = metaArtist || subtitle || metaAlbum || '';
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const cur = dragging ? dragValue : t;
|
||||
if (!duration) return 0;
|
||||
@@ -677,11 +443,11 @@ export default function AudioPlayer({
|
||||
className="truncate text-gray-200"
|
||||
style={{ fontSize: titleSize, lineHeight: 1.1, letterSpacing: '0.01em' }}
|
||||
>
|
||||
{effectiveTitle}
|
||||
{title}
|
||||
</div>
|
||||
{effectiveSubtitle ? (
|
||||
{subtitle ? (
|
||||
<div className="mt-1 truncate text-gray-400" style={{ fontSize: subSize, lineHeight: 1.15 }}>
|
||||
{effectiveSubtitle}
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
{err ? (
|
||||
|
||||
39
ui/src/components/AutoCaptionButton.tsx
Normal file
39
ui/src/components/AutoCaptionButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, {use, useEffect} from 'react';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { CaptionDatasetModal, openCaptionDatasetModal } from '@/components/CaptionDatasetModal';
|
||||
import useJobByRef from '@/hooks/useJobByRef';
|
||||
import Link from 'next/link';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type AutoCaptionButtonProps = {
|
||||
datasetPath: string;
|
||||
setIsAutoCaptioning?: (isAutoCaptioning: boolean) => void;
|
||||
};
|
||||
|
||||
export default function AutoCaptionButton({ datasetPath, setIsAutoCaptioning }: AutoCaptionButtonProps) {
|
||||
const { job, status, refreshJob } = useJobByRef(datasetPath, 5000);
|
||||
useEffect(() => {
|
||||
if (setIsAutoCaptioning) {
|
||||
setIsAutoCaptioning(!!(job && (job.status === 'running')));
|
||||
}
|
||||
}, [job, setIsAutoCaptioning]);
|
||||
|
||||
if (job && (job.status === 'running' || job.status === 'queued')) {
|
||||
return (
|
||||
<Link href={`/jobs/${job.id}`} className="text-white bg-gray-400 px-3 py-1 rounded-md mr-2 inline-flex items-center gap-1.5">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Auto Captioning...
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className="text-white bg-blue-600 px-3 py-1 rounded-md mr-2"
|
||||
onClick={() => openCaptionDatasetModal(datasetPath, () => {
|
||||
refreshJob();
|
||||
})}
|
||||
>
|
||||
Auto Caption
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
273
ui/src/components/CaptionDatasetModal.tsx
Normal file
273
ui/src/components/CaptionDatasetModal.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal } from '@/components/Modal';
|
||||
import { createGlobalState } from 'react-global-hooks';
|
||||
import { useFromNull } from '@/hooks/useFromNull';
|
||||
import {
|
||||
Checkbox,
|
||||
CreatableSelectInput,
|
||||
FormGroup,
|
||||
SelectInput,
|
||||
TextAreaInput,
|
||||
TextInput,
|
||||
} from '@/components/formInputs';
|
||||
import { CaptionJobConfig } from '@/types';
|
||||
import { defaultCaptionJobConfig, handleCaptionerTypeChange } from '@/helpers/captionJobConfig';
|
||||
import { objectCopy } from '@/utils/basic';
|
||||
import { useNestedState } from '@/utils/hooks';
|
||||
import {
|
||||
captionerTypes,
|
||||
defaultQtype,
|
||||
groupedCaptionerTypes,
|
||||
maxNewTokensOptions,
|
||||
maxResOptions,
|
||||
quantizationOptions,
|
||||
} from '@/helpers/captionOptions';
|
||||
import { isMac } from '@/helpers/basic';
|
||||
import useGPUInfo from '@/hooks/useGPUInfo';
|
||||
import { apiClient } from '@/utils/api';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { startJob } from '@/utils/jobs';
|
||||
import { startQueue } from '@/utils/queue';
|
||||
|
||||
export interface CaptionDatasetModalState {
|
||||
datasetPath: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const captionDatasetModalState = createGlobalState<CaptionDatasetModalState | null>(null);
|
||||
|
||||
export const openCaptionDatasetModal = (datasetPath: string, onClose?: () => void) => {
|
||||
captionDatasetModalState.set({ datasetPath, onClose });
|
||||
};
|
||||
|
||||
export const CaptionDatasetModal: React.FC = () => {
|
||||
const [modalInfo, setModalInfo] = captionDatasetModalState.use();
|
||||
const [jobConfig, setJobConfig] = useNestedState<CaptionJobConfig>(objectCopy(defaultCaptionJobConfig));
|
||||
const [gpuIDs, setGpuIDs] = useState<string | null>(null);
|
||||
const { gpuList, isGPUInfoLoaded } = useGPUInfo();
|
||||
const open = modalInfo !== null;
|
||||
const isSavingRef = useRef(false);
|
||||
const showGPUSelect = !isMac();
|
||||
|
||||
useFromNull(() => {
|
||||
// reset the state
|
||||
setJobConfig(objectCopy(defaultCaptionJobConfig));
|
||||
// set the path_to_caption
|
||||
if (modalInfo?.datasetPath) {
|
||||
setJobConfig(modalInfo.datasetPath, 'config.process[0].caption.path_to_caption');
|
||||
}
|
||||
}, [modalInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGPUInfoLoaded) {
|
||||
if (gpuIDs === null && gpuList.length > 0) {
|
||||
setGpuIDs(`${gpuList[0].index}`);
|
||||
}
|
||||
}
|
||||
}, [gpuList, isGPUInfoLoaded]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (modalInfo?.onClose) {
|
||||
modalInfo.onClose();
|
||||
}
|
||||
setModalInfo(null);
|
||||
};
|
||||
|
||||
const selectedCaptionOption = captionerTypes.find(option => option.name === jobConfig.config.process[0].type);
|
||||
|
||||
const saveJob = async () => {
|
||||
if (isSavingRef.current) return;
|
||||
if (!modalInfo?.datasetPath) {
|
||||
alert('Dataset path is missing. Please try again.');
|
||||
return;
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
|
||||
apiClient
|
||||
.post('/api/jobs', {
|
||||
id: null,
|
||||
name: uuidv4(),
|
||||
gpu_ids: gpuIDs,
|
||||
job_config: jobConfig,
|
||||
job_type: 'caption',
|
||||
job_ref: modalInfo.datasetPath,
|
||||
})
|
||||
.then(async res => {
|
||||
const jobId = res.data.id;
|
||||
await startJob(jobId);
|
||||
// start the queue as well
|
||||
await startQueue(gpuIDs || '');
|
||||
isSavingRef.current = false;
|
||||
handleClose();
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.response?.status === 409) {
|
||||
alert('A caption job for this dataset already exists. Please check your jobs list.');
|
||||
} else {
|
||||
alert('Failed to save job. Please try again.');
|
||||
}
|
||||
console.log('Error saving training:', error);
|
||||
isSavingRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={handleClose} title="Caption Dataset" size="lg">
|
||||
<div className="space-y-4 text-gray-200">
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
saveJob();
|
||||
}}
|
||||
>
|
||||
<div className="text-sm text-gray-400">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<SelectInput
|
||||
label="Captioner Type"
|
||||
value={jobConfig.config.process[0].type}
|
||||
onChange={value => {
|
||||
handleCaptionerTypeChange(jobConfig.config.process[0].type, value, jobConfig, setJobConfig);
|
||||
}}
|
||||
options={groupedCaptionerTypes}
|
||||
/>
|
||||
</div>
|
||||
{showGPUSelect && (
|
||||
<div>
|
||||
<SelectInput
|
||||
label="GPU ID"
|
||||
value={`${gpuIDs}`}
|
||||
onChange={value => setGpuIDs(value)}
|
||||
options={gpuList.map((gpu: any) => ({ value: `${gpu.index}`, label: `GPU #${gpu.index}` }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<CreatableSelectInput
|
||||
label="Name or Path"
|
||||
value={jobConfig.config.process[0].caption.model_name_or_path}
|
||||
docKey="config.process[0].caption.model_name_or_path"
|
||||
onChange={(value: string | null) => {
|
||||
if (value?.trim() === '') {
|
||||
value = null;
|
||||
}
|
||||
setJobConfig(value, 'config.process[0].caption.model_name_or_path');
|
||||
}}
|
||||
placeholder=""
|
||||
options={selectedCaptionOption?.name_or_path_options || []}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{selectedCaptionOption?.additionalSections?.includes('caption.model_name_or_path2') && (
|
||||
<div className="mt-4">
|
||||
<CreatableSelectInput
|
||||
label="Name or Path 2"
|
||||
value={jobConfig.config.process[0].caption.model_name_or_path2 || ''}
|
||||
onChange={(value: string | null) => {
|
||||
if (value?.trim() === '') {
|
||||
value = null;
|
||||
}
|
||||
setJobConfig(value, 'config.process[0].caption.model_name_or_path2');
|
||||
}}
|
||||
placeholder=""
|
||||
options={selectedCaptionOption?.name_or_path2_options || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<SelectInput
|
||||
label="Quantize"
|
||||
value={jobConfig.config.process[0].caption.quantize ? jobConfig.config.process[0].caption.qtype : ''}
|
||||
onChange={value => {
|
||||
if (value === '') {
|
||||
setJobConfig(false, 'config.process[0].caption.quantize');
|
||||
value = defaultQtype;
|
||||
} else {
|
||||
setJobConfig(true, 'config.process[0].caption.quantize');
|
||||
}
|
||||
setJobConfig(value, 'config.process[0].caption.qtype');
|
||||
}}
|
||||
options={quantizationOptions}
|
||||
/>
|
||||
{selectedCaptionOption?.additionalSections?.includes('caption.max_res') && (
|
||||
<div className="mt-4">
|
||||
<SelectInput
|
||||
label="Max Resolution"
|
||||
value={`${jobConfig.config.process[0].caption.max_res || ''}`}
|
||||
onChange={value => {
|
||||
const intVal = parseInt(value);
|
||||
if (!isNaN(intVal)) {
|
||||
setJobConfig(intVal, 'config.process[0].caption.max_res');
|
||||
}
|
||||
}}
|
||||
options={maxResOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedCaptionOption?.additionalSections?.includes('caption.max_new_tokens') && (
|
||||
<div className="mt-4">
|
||||
<SelectInput
|
||||
label="Max New Tokens"
|
||||
value={`${jobConfig.config.process[0].caption.max_new_tokens || ''}`}
|
||||
onChange={value => {
|
||||
const intVal = parseInt(value);
|
||||
if (!isNaN(intVal)) {
|
||||
setJobConfig(intVal, 'config.process[0].caption.max_new_tokens');
|
||||
}
|
||||
}}
|
||||
options={maxNewTokensOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<FormGroup label="Options">
|
||||
<Checkbox
|
||||
label="Low VRAM"
|
||||
checked={jobConfig.config.process[0].caption.low_vram}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].caption.low_vram')}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Recaption"
|
||||
checked={jobConfig.config.process[0].caption.recaption}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].caption.recaption')}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
{selectedCaptionOption?.additionalSections?.includes('caption.caption_prompt') && (
|
||||
<div className="mt-4">
|
||||
<TextAreaInput
|
||||
label="Caption Prompt"
|
||||
value={jobConfig.config.process[0].caption.caption_prompt || ''}
|
||||
onChange={value => {
|
||||
setJobConfig(value, 'config.process[0].caption.caption_prompt');
|
||||
}}
|
||||
placeholder="Enter caption prompt"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-gray-700 px-4 py-2 text-gray-200 hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
Add to Queue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { isVideo, isAudio } from '@/utils/basic';
|
||||
interface DatasetImageCardProps {
|
||||
imageUrl: string;
|
||||
alt: string;
|
||||
isAutoCaptioning: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onDelete?: () => void;
|
||||
@@ -17,6 +18,7 @@ interface DatasetImageCardProps {
|
||||
const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
imageUrl,
|
||||
alt,
|
||||
isAutoCaptioning,
|
||||
children,
|
||||
className = '',
|
||||
onDelete = () => {},
|
||||
@@ -80,6 +82,16 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
}
|
||||
}, [inViewport, isVisible]);
|
||||
|
||||
// Poll for caption updates every 5 seconds while auto-captioning
|
||||
useEffect(() => {
|
||||
if (!isAutoCaptioning || !inViewport || !isVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
// Reset so fetchCaption will re-fetch
|
||||
setIsCaptionLoaded(false);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isAutoCaptioning, inViewport, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
// Create intersection observer to check viewport visibility
|
||||
const observer = new IntersectionObserver(
|
||||
@@ -129,6 +141,8 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
|
||||
const isCaptionCurrent = caption.trim() === savedCaption;
|
||||
|
||||
const [showAudioPlayer, setShowAudioPlayer] = useState(true);
|
||||
|
||||
const isItAVideo = isVideo(imageUrl);
|
||||
const isItAudio = isAudio(imageUrl);
|
||||
const isItImage = !isItAVideo && !isItAudio;
|
||||
@@ -154,7 +168,22 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
controls
|
||||
/>
|
||||
)}
|
||||
{isItAudio && (
|
||||
{isItAudio && !showAudioPlayer && (
|
||||
<div
|
||||
className="w-full h-full cursor-pointer flex items-center justify-center bg-gray-900"
|
||||
onClick={() => setShowAudioPlayer(true)}
|
||||
>
|
||||
<img
|
||||
src={`/api/audio/art/${encodeURIComponent(imageUrl)}`}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain"
|
||||
onError={e => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isItAudio && showAudioPlayer && (
|
||||
<AudioPlayer
|
||||
src={`/api/img/${encodeURIComponent(imageUrl)}`}
|
||||
title={imageUrl.replace(/^.*[\\/]/, '')}
|
||||
@@ -221,9 +250,12 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
onBlur={saveCaption}
|
||||
>
|
||||
<textarea
|
||||
className="w-full bg-transparent resize-none outline-none focus:ring-0 focus:outline-none"
|
||||
className={classNames("w-full bg-transparent resize-none outline-none focus:ring-0 focus:outline-none", {
|
||||
'opacity-50 cursor-not-allowed': isAutoCaptioning,
|
||||
})}
|
||||
value={caption}
|
||||
rows={3}
|
||||
readOnly={isAutoCaptioning}
|
||||
onChange={e => setCaption(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
@@ -123,11 +123,16 @@ export default function JobActionBar({
|
||||
<Cog />
|
||||
</MenuButton>
|
||||
<MenuItems anchor="bottom" className="bg-gray-900 border border-gray-700 rounded shadow-lg w-48 px-2 py-2 mt-4">
|
||||
<MenuItem>
|
||||
<Link href={`/jobs/new?cloneId=${job.id}`} className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block">
|
||||
Clone Job
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{job.job_type === 'train' && (
|
||||
<MenuItem>
|
||||
<Link
|
||||
href={`/jobs/new?cloneId=${job.id}`}
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block"
|
||||
>
|
||||
Clone Job
|
||||
</Link>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
<div
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded"
|
||||
|
||||
@@ -21,11 +21,23 @@ const yamlConfig: YAML.DocumentOptions &
|
||||
directives: true,
|
||||
};
|
||||
|
||||
function toYaml(obj: any): string {
|
||||
const doc = new YAML.Document(obj, yamlConfig);
|
||||
YAML.visit(doc, {
|
||||
Scalar(_key, node) {
|
||||
if (typeof node.value === 'string' && node.value.includes('\n')) {
|
||||
node.type = YAML.Scalar.BLOCK_LITERAL;
|
||||
}
|
||||
},
|
||||
});
|
||||
return doc.toString(yamlConfig);
|
||||
}
|
||||
|
||||
export default function JobConfigViewer({ job }: Props) {
|
||||
const [editorValue, setEditorValue] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (job?.job_config) {
|
||||
const yamlContent = YAML.stringify(JSON.parse(job.job_config), yamlConfig);
|
||||
const yamlContent = toYaml(JSON.parse(job.job_config));
|
||||
setEditorValue(yamlContent);
|
||||
}
|
||||
}, [job]);
|
||||
|
||||
@@ -82,6 +82,8 @@ export default function JobOverview({ job }: JobOverviewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const jobType = job?.job_type || 'unknown';
|
||||
|
||||
let status = job.status;
|
||||
if (isStopping) {
|
||||
status = 'stopping';
|
||||
@@ -100,17 +102,19 @@ export default function JobOverview({ job }: JobOverviewProps) {
|
||||
|
||||
<div className="p-4 space-y-6 flex flex-col flex-grow">
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Progress</span>
|
||||
<span className="text-gray-200">
|
||||
Step {job.step} of {totalSteps}
|
||||
</span>
|
||||
{job.job_type === 'train' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-400">Progress</span>
|
||||
<span className="text-gray-200">
|
||||
Step {job.step} of {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div className="h-2 rounded-full bg-blue-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div className="h-2 rounded-full bg-blue-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job Info Grid */}
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
||||
@@ -164,9 +168,11 @@ export default function JobOverview({ job }: JobOverviewProps) {
|
||||
<div className="col-span-1">
|
||||
<div>{isCPUInfoLoaded && cpuInfo && <CPUWidget cpu={cpuInfo} />}</div>
|
||||
<div className="mt-4">{isGPUInfoLoaded && gpuList.length > 0 && <GPUWidget gpu={gpuList[0]} />}</div>
|
||||
<div className="mt-4">
|
||||
<FilesWidget jobID={job.id} />
|
||||
</div>
|
||||
{jobType === 'train' && (
|
||||
<div className="mt-4">
|
||||
<FilesWidget jobID={job.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,10 +14,11 @@ import useGPUInfo from '@/hooks/useGPUInfo';
|
||||
interface JobsTableProps {
|
||||
autoStartQueue?: boolean;
|
||||
onlyActive?: boolean;
|
||||
job_type?: string | null;
|
||||
}
|
||||
|
||||
export default function JobsTable({ onlyActive = false }: JobsTableProps) {
|
||||
const { jobs, status, refreshJobs } = useJobsList(onlyActive, 5000);
|
||||
export default function JobsTable({ onlyActive = false, job_type = null }: JobsTableProps) {
|
||||
const { jobs, status, refreshJobs } = useJobsList({ onlyActive, reloadInterval: 5000, job_type });
|
||||
const { queues, status: queueStatus, refreshQueues } = useQueueList();
|
||||
const { gpuList, isGPUInfoLoaded } = useGPUInfo();
|
||||
|
||||
@@ -30,21 +31,37 @@ export default function JobsTable({ onlyActive = false }: JobsTableProps) {
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
render: row => (
|
||||
<Link href={`/jobs/${row.id}`} className="font-medium whitespace-nowrap">
|
||||
{['running', 'stopping'].includes(row.status) ? (
|
||||
<CgSpinner className="inline animate-spin mr-2 text-blue-400" />
|
||||
) : null}
|
||||
{row.name}
|
||||
</Link>
|
||||
),
|
||||
render: row => {
|
||||
let title = row.name;
|
||||
// if (row.job_type === 'train') title = `Train: ${title}`;
|
||||
if (row.job_type === 'caption') {
|
||||
let splits = row.job_ref.split(/[/\\]/);
|
||||
const datasetPath = `${splits[splits.length - 1]}`;
|
||||
title = (
|
||||
<>
|
||||
<small className="opacity-50">CAPTION: </small> {datasetPath}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={`/jobs/${row.id}`} className="font-medium whitespace-nowrap">
|
||||
{['running', 'stopping'].includes(row.status) ? (
|
||||
<CgSpinner className="inline animate-spin mr-2 text-blue-400" />
|
||||
) : null}
|
||||
{title}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Steps',
|
||||
key: 'steps',
|
||||
render: row => {
|
||||
const jobConfig: JobConfig = JSON.parse(row.job_config);
|
||||
const totalSteps = jobConfig.config.process[0].train.steps;
|
||||
if (row.job_type !== 'train') {
|
||||
return <></>;
|
||||
}
|
||||
const totalSteps = jobConfig.config.process[0].train?.steps;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useState, ReactNode } from 'react';
|
||||
import { isVideo } from '@/utils/basic';
|
||||
import { isVideo, isAudio } from '@/utils/basic';
|
||||
|
||||
interface SampleImageCardProps {
|
||||
imageUrl: string;
|
||||
@@ -63,7 +63,18 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
<div ref={cardRef} className="relative w-full cursor-pointer" style={{ paddingBottom: '100%' }} onClick={onClick}>
|
||||
<div className="absolute inset-0 rounded-t-lg shadow-md">
|
||||
{isVisible ? (
|
||||
isVideo(imageUrl) ? (
|
||||
isAudio(imageUrl) ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
||||
<img
|
||||
src={`/api/audio/art/${encodeURIComponent(imageUrl)}`}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={e => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isVideo(imageUrl) ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={`/api/img/${encodeURIComponent(imageUrl)}`}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Cog } from 'lucide-react';
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { openConfirm } from './ConfirmModal';
|
||||
import { apiClient } from '@/utils/api';
|
||||
import { isVideo } from '@/utils/basic';
|
||||
import { isVideo, isAudio } from '@/utils/basic';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
|
||||
interface Props {
|
||||
imgPath: string | null; // current image path
|
||||
@@ -214,7 +215,15 @@ export default function SampleImageViewer({
|
||||
>
|
||||
<div className="overflow-hidden flex items-center justify-center">
|
||||
{displayedImgPath &&
|
||||
(isVideo(displayedImgPath) ? (
|
||||
(isAudio(displayedImgPath) ? (
|
||||
<div className="w-[500px] h-[500px] max-w-[95vw] max-h-[82vh]">
|
||||
<AudioPlayer
|
||||
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
|
||||
title={displayedImgPath.replace(/^.*[\\/]/, '')}
|
||||
autoPlay
|
||||
/>
|
||||
</div>
|
||||
) : isVideo(displayedImgPath) ? (
|
||||
<video
|
||||
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
|
||||
className="w-auto h-auto max-w-[95vw] max-h-[82vh] object-contain"
|
||||
@@ -282,7 +291,7 @@ export default function SampleImageViewer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 bg-gray-900 rounded-full p-1 leading-[0px] opacity-50 hover:opacity-100">
|
||||
<div className="absolute top-2 right-2 bg-gray-900 rounded-full p-1 leading-[0px] opacity-50 hover:opacity-100 z-20">
|
||||
<Menu>
|
||||
<MenuButton>
|
||||
<Cog />
|
||||
|
||||
@@ -8,7 +8,7 @@ const Sidebar = () => {
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||
{ name: 'New Job', href: '/jobs/new', icon: Plus },
|
||||
{ name: 'Training Queue', href: '/jobs', icon: BrainCircuit },
|
||||
{ name: 'Queue', href: '/jobs', icon: BrainCircuit },
|
||||
{ name: 'Datasets', href: '/datasets', icon: Images },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -67,6 +67,49 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>((props: Te
|
||||
// 👇 Helpful for debugging
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
export interface TextAreaInputProps extends InputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const TextAreaInput = forwardRef<HTMLTextAreaElement, TextAreaInputProps>((props: TextAreaInputProps, ref) => {
|
||||
const { label, value, onChange, placeholder, required, disabled, rows = 4, className, docKey = null } = props;
|
||||
let { doc } = props;
|
||||
if (!doc && docKey) {
|
||||
doc = getDoc(docKey);
|
||||
}
|
||||
return (
|
||||
<div className={classNames(className)}>
|
||||
{label && (
|
||||
<label className={labelClasses}>
|
||||
{label}{' '}
|
||||
{doc && (
|
||||
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
||||
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
if (!disabled) onChange(e.target.value);
|
||||
}}
|
||||
className={`${inputClasses} ${disabled ? 'opacity-30 cursor-not-allowed' : ''}`}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextAreaInput.displayName = 'TextAreaInput';
|
||||
|
||||
export interface NumberInputProps extends InputProps {
|
||||
value: number | null;
|
||||
onChange: (value: number | null) => void;
|
||||
@@ -200,6 +243,115 @@ export const SelectInput = (props: SelectInputProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export interface CreatableSelectInputProps extends InputProps {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
options: GroupedSelectOption[] | SelectOption[];
|
||||
}
|
||||
|
||||
const CUSTOM_SELECT_VALUE = '__custom__';
|
||||
|
||||
export const CreatableSelectInput = (props: CreatableSelectInputProps) => {
|
||||
const { label, value, onChange, options, docKey = null } = props;
|
||||
let { doc } = props;
|
||||
if (!doc && docKey) {
|
||||
doc = getDoc(docKey);
|
||||
}
|
||||
|
||||
// Check if current value matches any predefined option
|
||||
let isInOptions = false;
|
||||
if (options && options.length > 0) {
|
||||
if ('options' in options[0]) {
|
||||
isInOptions = (options as GroupedSelectOption[]).flatMap(g => g.options).some(opt => opt.value === value);
|
||||
} else {
|
||||
isInOptions = (options as SelectOption[]).some(opt => opt.value === value);
|
||||
}
|
||||
}
|
||||
|
||||
const [isCustom, setIsCustom] = React.useState(!isInOptions && !!value);
|
||||
|
||||
// Build select options with "Custom" at the top
|
||||
const customOption: SelectOption = { value: CUSTOM_SELECT_VALUE, label: 'Custom' };
|
||||
const selectOptions = React.useMemo(() => {
|
||||
if (options && options.length > 0 && 'options' in options[0]) {
|
||||
return [{ label: '', options: [customOption] }, ...(options as GroupedSelectOption[])];
|
||||
}
|
||||
return [customOption, ...(options as SelectOption[])];
|
||||
}, [options]);
|
||||
|
||||
const selectedOption = isCustom
|
||||
? customOption
|
||||
: (() => {
|
||||
if (!options || options.length === 0) return undefined;
|
||||
if ('options' in options[0]) {
|
||||
return (options as GroupedSelectOption[]).flatMap(g => g.options).find(opt => opt.value === value);
|
||||
}
|
||||
return (options as SelectOption[]).find(opt => opt.value === value);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(props.className, {
|
||||
'opacity-30 cursor-not-allowed': props.disabled,
|
||||
})}
|
||||
>
|
||||
{label && (
|
||||
<label className={labelClasses}>
|
||||
{label}{' '}
|
||||
{doc && (
|
||||
<div className="inline-block ml-1 text-xs text-gray-500 cursor-pointer" onClick={() => openDoc(doc)}>
|
||||
<CircleHelp className="inline-block w-4 h-4 cursor-pointer" />
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className={isCustom ? 'w-1/3' : 'w-full'}>
|
||||
<Select
|
||||
value={selectedOption}
|
||||
options={selectOptions}
|
||||
isDisabled={props.disabled}
|
||||
className="aitk-react-select-container"
|
||||
classNamePrefix="aitk-react-select"
|
||||
formatOptionLabel={(option: unknown) => {
|
||||
const opt = option as SelectOption;
|
||||
return opt.value === CUSTOM_SELECT_VALUE ? (
|
||||
<span className="opacity-50 italic">~ Custom ~</span>
|
||||
) : (
|
||||
opt.label
|
||||
);
|
||||
}}
|
||||
onChange={selected => {
|
||||
if (selected) {
|
||||
const val = (selected as { value: string }).value;
|
||||
if (val === CUSTOM_SELECT_VALUE) {
|
||||
setIsCustom(true);
|
||||
onChange('');
|
||||
} else {
|
||||
setIsCustom(false);
|
||||
onChange(val);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={`${inputClasses} w-2/3`}
|
||||
placeholder={props.placeholder ?? 'Enter custom value'}
|
||||
disabled={props.disabled}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CheckboxProps {
|
||||
label?: string | React.ReactNode;
|
||||
checked: boolean;
|
||||
|
||||
74
ui/src/helpers/captionJobConfig.ts
Normal file
74
ui/src/helpers/captionJobConfig.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { CaptionJobConfig } from "@/types";
|
||||
import { captionerTypes } from "./captionOptions";
|
||||
|
||||
|
||||
export const defaultCaptionJobConfig: CaptionJobConfig = {
|
||||
job: 'extension',
|
||||
config: {
|
||||
name: 'Caption Directory',
|
||||
process: [
|
||||
{
|
||||
type: 'AceStepCaptioner',
|
||||
sqlite_db_path: './aitk_db.db',
|
||||
device: 'cuda',
|
||||
caption: {
|
||||
model_name_or_path: "ACE-Step/acestep-transcriber",
|
||||
model_name_or_path2: "ACE-Step/acestep-captioner",
|
||||
dtype: 'bf16',
|
||||
quantize: true,
|
||||
qtype: 'float8',
|
||||
low_vram: true,
|
||||
extensions: ['mp3', 'wav'],
|
||||
path_to_caption: '',
|
||||
recaption: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const repairDefaults = (defaults: { [key: string]: any }) => {
|
||||
let newDefaults: { [key: string]: any } = {};
|
||||
// if the key doesnt start with config.process[0]., then add it
|
||||
for (const key in defaults) {
|
||||
if (!key.startsWith('config.process[0].')) {
|
||||
newDefaults[`config.process[0].${key}`] = defaults[key];
|
||||
} else {
|
||||
newDefaults[key] = defaults[key];
|
||||
}
|
||||
}
|
||||
return newDefaults;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const handleCaptionerTypeChange = (
|
||||
currentTypeName: string,
|
||||
newTypeName: string,
|
||||
jobConfig: CaptionJobConfig,
|
||||
setJobConfig: (value: any, key: string) => void,
|
||||
) => {
|
||||
const currentType = captionerTypes.find(a => a.name === currentTypeName);
|
||||
if (!currentType || currentType.name === newTypeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update the defaults when a model is selected
|
||||
const newType = captionerTypes.find(model => model.name === newTypeName);
|
||||
|
||||
let currentDefaults = repairDefaults(currentType.defaults || {});
|
||||
let newDefaults = repairDefaults(newType?.defaults || {});
|
||||
|
||||
// set new model
|
||||
setJobConfig(newTypeName, 'config.process[0].type');
|
||||
|
||||
// revert defaults from previous model
|
||||
for (const key in currentDefaults) {
|
||||
setJobConfig(currentDefaults[key][1], key);
|
||||
}
|
||||
|
||||
for (const key in newDefaults) {
|
||||
setJobConfig(newDefaults[key][0], key);
|
||||
}
|
||||
};
|
||||
114
ui/src/helpers/captionOptions.ts
Normal file
114
ui/src/helpers/captionOptions.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { GroupedSelectOption, SelectOption } from "@/types";
|
||||
|
||||
type CaptionGroup = 'image' | 'music';
|
||||
type AdditionalSections = 'caption.model_name_or_path2' | 'caption.caption_prompt' | 'caption.max_res' | 'caption.max_new_tokens';
|
||||
|
||||
export interface CaptionOption {
|
||||
name: string;
|
||||
label: string;
|
||||
group: CaptionGroup;
|
||||
hasMultiLinePrompts?: boolean;
|
||||
defaults?: { [key: string]: any };
|
||||
additionalSections?: AdditionalSections[];
|
||||
name_or_path_options?: SelectOption[];
|
||||
name_or_path2_options?: SelectOption[];
|
||||
}
|
||||
|
||||
const defaultNameOrPath = '';
|
||||
|
||||
const extensionsAudio = ['mp3', 'wav'];
|
||||
const extensionsImage = ['jpg', 'jpeg', 'png', 'bmp', 'webp'];
|
||||
|
||||
const defaultExtensions = [...extensionsImage];
|
||||
|
||||
const defaultImageCaptionPrompt = "Caption this image as if you were going to try to generate it with an image generator. Be thurough and describe everything in the image. Be decisive by stating things as they are. Do not say things like \"It appears that\" Or \"possibly\". Start out with things like \"A person on the beach\" or \"A black dragon\". No preamble. Just get to the point.";
|
||||
|
||||
export const captionerTypes: CaptionOption[] = [
|
||||
{
|
||||
name: 'AceStepCaptioner',
|
||||
label: 'Ace Step',
|
||||
group: 'music',
|
||||
defaults: {
|
||||
'config.process[0].caption.model_name_or_path': ['ACE-Step/acestep-transcriber', defaultNameOrPath],
|
||||
'config.process[0].caption.model_name_or_path2': ['ACE-Step/acestep-captioner', undefined],
|
||||
'config.process[0].caption.extensions': [extensionsAudio, defaultExtensions],
|
||||
},
|
||||
name_or_path_options: [
|
||||
{ value: 'ACE-Step/acestep-transcriber', label: 'ACE-Step/acestep-transcriber' },
|
||||
],
|
||||
name_or_path2_options: [
|
||||
{ value: 'ACE-Step/acestep-captioner', label: 'ACE-Step/acestep-captioner' },
|
||||
],
|
||||
additionalSections: [
|
||||
'caption.model_name_or_path2',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Qwen3VLCaptioner',
|
||||
label: 'Qwen3-VL',
|
||||
group: 'image',
|
||||
defaults: {
|
||||
'config.process[0].caption.model_name_or_path': ['Qwen/Qwen3-VL-8B-Instruct', defaultNameOrPath],
|
||||
'config.process[0].caption.extensions': [extensionsImage, defaultExtensions],
|
||||
'config.process[0].caption.caption_prompt': [defaultImageCaptionPrompt, undefined],
|
||||
'config.process[0].caption.max_res': [512, undefined],
|
||||
'config.process[0].caption.max_new_tokens': [128, undefined],
|
||||
|
||||
},
|
||||
name_or_path_options: [
|
||||
{ value: 'Qwen/Qwen3-VL-2B-Instruct', label: 'Qwen/Qwen3-VL-2B-Instruct' },
|
||||
{ value: 'Qwen/Qwen3-VL-4B-Instruct', label: 'Qwen/Qwen3-VL-4B-Instruct' },
|
||||
{ value: 'Qwen/Qwen3-VL-8B-Instruct', label: 'Qwen/Qwen3-VL-8B-Instruct' },
|
||||
{ value: 'Qwen/Qwen3-VL-30B-A3B-Instruct', label: 'Qwen/Qwen3-VL-30B-A3B-Instruct' },
|
||||
],
|
||||
additionalSections: [
|
||||
'caption.caption_prompt',
|
||||
'caption.max_res',
|
||||
'caption.max_new_tokens',
|
||||
],
|
||||
},
|
||||
|
||||
].sort((a, b) => {
|
||||
// Sort by label, case-insensitive
|
||||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
||||
}) as any;
|
||||
|
||||
export const groupedCaptionerTypes: GroupedSelectOption[] = captionerTypes.reduce((acc, arch) => {
|
||||
const group = acc.find(g => g.label === arch.group);
|
||||
if (group) {
|
||||
group.options.push({ value: arch.name, label: arch.label });
|
||||
} else {
|
||||
acc.push({
|
||||
label: arch.group,
|
||||
options: [{ value: arch.name, label: arch.label }],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [] as GroupedSelectOption[]);
|
||||
|
||||
export const quantizationOptions: SelectOption[] = [
|
||||
{ value: '', label: '- NONE -' },
|
||||
{ value: 'float8', label: 'float8 (default)' },
|
||||
{ value: 'uint7', label: '7 bit' },
|
||||
{ value: 'uint6', label: '6 bit' },
|
||||
{ value: 'uint5', label: '5 bit' },
|
||||
{ value: 'uint4', label: '4 bit' },
|
||||
{ value: 'uint3', label: '3 bit' },
|
||||
{ value: 'uint2', label: '2 bit' },
|
||||
];
|
||||
|
||||
export const maxResOptions: SelectOption[] = [
|
||||
{ value: '256', label: '256' },
|
||||
{ value: '512', label: '512 (default)' },
|
||||
{ value: '768', label: '768' },
|
||||
{ value: '1024', label: '1024' },
|
||||
];
|
||||
export const maxNewTokensOptions: SelectOption[] = [
|
||||
{ value: '64', label: '64' },
|
||||
{ value: '128', label: '128 (default)' },
|
||||
{ value: '256', label: '256' },
|
||||
{ value: '512', label: '512' },
|
||||
{ value: '1024', label: '1024' },
|
||||
];
|
||||
|
||||
export const defaultQtype = 'float8';
|
||||
403
ui/src/helpers/defaultSamples.ts
Normal file
403
ui/src/helpers/defaultSamples.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { SampleConfig } from "@/types";
|
||||
|
||||
export const defaultSampleConfig: SampleConfig = {
|
||||
sampler: 'flowmatch',
|
||||
sample_every: 250,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
samples: [
|
||||
{
|
||||
prompt: 'woman with red hair, playing chess at the park, bomb going off in the background',
|
||||
},
|
||||
{
|
||||
prompt: 'a woman holding a coffee cup, in a beanie, sitting at a cafe',
|
||||
},
|
||||
{
|
||||
prompt: 'a horse is a DJ at a night club, fish eye lens, smoke machine, lazer lights, holding a martini',
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'a man showing off his cool new t shirt at the beach, a shark is jumping out of the water in the background',
|
||||
},
|
||||
{
|
||||
prompt: 'a bear building a log cabin in the snow covered mountains',
|
||||
},
|
||||
{
|
||||
prompt: 'woman playing the guitar, on stage, singing a song, laser lights, punk rocker',
|
||||
},
|
||||
{
|
||||
prompt: 'hipster man with a beard, building a chair, in a wood shop',
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'photo of a man, white background, medium shot, modeling clothing, studio lighting, white backdrop',
|
||||
},
|
||||
{
|
||||
prompt: "a man holding a sign that says, 'this is a sign'",
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'a bulldog, in a post apocalyptic world, with a shotgun, in a leather jacket, in a desert, with a motorcycle',
|
||||
},
|
||||
],
|
||||
neg: '',
|
||||
seed: 42,
|
||||
walk_seed: true,
|
||||
guidance_scale: 4,
|
||||
sample_steps: 50,
|
||||
num_frames: 1,
|
||||
fps: 1,
|
||||
}
|
||||
|
||||
export const defaultAudioSampleConfig: SampleConfig = {
|
||||
sampler: 'flowmatch',
|
||||
sample_every: 250,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
samples: [
|
||||
{
|
||||
prompt: `
|
||||
<CAPTION>my style song</CAPTION>
|
||||
<LYRICS>
|
||||
[Intro choir]
|
||||
Laura
|
||||
Laura
|
||||
Laura
|
||||
Laura Training
|
||||
|
||||
[Verse 1]
|
||||
A new open model, she been training it nightly
|
||||
AI tool kit, she configures it tightly,
|
||||
Loss curves dropping down to the floor
|
||||
Wondering if she's done or she should train it some more.
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Verse 4]
|
||||
She's caching all the latents
|
||||
now she doesn't need a vay
|
||||
Training on some voices
|
||||
What will she make them say
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Guitar Solo]
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Outro]
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
</LYRICS>
|
||||
<BPM>112</BPM>
|
||||
<KEYSCALE>A minor</KEYSCALE>
|
||||
<TIMESIGNATURE>4</TIMESIGNATURE>
|
||||
<DURATION>180</DURATION>
|
||||
<LANGUAGE>en</LANGUAGE>
|
||||
`,
|
||||
}, {
|
||||
prompt: `
|
||||
<CAPTION>my style song</CAPTION>
|
||||
<LYRICS>
|
||||
[Intro choir]
|
||||
Laura
|
||||
Laura
|
||||
Laura
|
||||
Laura Training
|
||||
|
||||
[Verse 1]
|
||||
A new open model, she been training it nightly
|
||||
AI tool kit, she configures it tightly,
|
||||
Loss curves dropping down to the floor
|
||||
Wondering if she's done or she should train it some more.
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Verse 4]
|
||||
She's caching all the latents
|
||||
now she doesn't need a vay
|
||||
Training on some voices
|
||||
What will she make them say
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Guitar Solo]
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Outro]
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
</LYRICS>
|
||||
<BPM>112</BPM>
|
||||
<KEYSCALE>A minor</KEYSCALE>
|
||||
<TIMESIGNATURE>4</TIMESIGNATURE>
|
||||
<DURATION>180</DURATION>
|
||||
<LANGUAGE>en</LANGUAGE>
|
||||
`,
|
||||
}, {
|
||||
prompt: `
|
||||
<CAPTION>my style song</CAPTION>
|
||||
<LYRICS>
|
||||
[Intro choir]
|
||||
Laura
|
||||
Laura
|
||||
Laura
|
||||
Laura Training
|
||||
|
||||
[Verse 1]
|
||||
A new open model, she been training it nightly
|
||||
AI tool kit, she configures it tightly,
|
||||
Loss curves dropping down to the floor
|
||||
Wondering if she's done or she should train it some more.
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Verse 4]
|
||||
She's caching all the latents
|
||||
now she doesn't need a vay
|
||||
Training on some voices
|
||||
What will she make them say
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Guitar Solo]
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Outro]
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
</LYRICS>
|
||||
<BPM>112</BPM>
|
||||
<KEYSCALE>A minor</KEYSCALE>
|
||||
<TIMESIGNATURE>4</TIMESIGNATURE>
|
||||
<DURATION>180</DURATION>
|
||||
<LANGUAGE>en</LANGUAGE>
|
||||
`,
|
||||
}, {
|
||||
prompt: `
|
||||
<CAPTION>my style song</CAPTION>
|
||||
<LYRICS>
|
||||
[Intro choir]
|
||||
Laura
|
||||
Laura
|
||||
Laura
|
||||
Laura Training
|
||||
|
||||
[Verse 1]
|
||||
A new open model, she been training it nightly
|
||||
AI tool kit, she configures it tightly,
|
||||
Loss curves dropping down to the floor
|
||||
Wondering if she's done or she should train it some more.
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Verse 4]
|
||||
She's caching all the latents
|
||||
now she doesn't need a vay
|
||||
Training on some voices
|
||||
What will she make them say
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Guitar Solo]
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Chorus]
|
||||
Laura training
|
||||
She trains on what she pleases
|
||||
Laura training
|
||||
No paying corporate sleazes
|
||||
Laura training
|
||||
This could be her best one
|
||||
Why go outside, Laura training is too fun
|
||||
|
||||
[Instrumental Break]
|
||||
|
||||
[Outro]
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
Ah yeah!
|
||||
It's Converging!
|
||||
</LYRICS>
|
||||
<BPM>112</BPM>
|
||||
<KEYSCALE>A minor</KEYSCALE>
|
||||
<TIMESIGNATURE>4</TIMESIGNATURE>
|
||||
<DURATION>180</DURATION>
|
||||
<LANGUAGE>en</LANGUAGE>
|
||||
`,
|
||||
},
|
||||
],
|
||||
neg: '',
|
||||
seed: 42,
|
||||
walk_seed: true,
|
||||
guidance_scale: 4,
|
||||
sample_steps: 25,
|
||||
num_frames: 1,
|
||||
fps: 1,
|
||||
};
|
||||
42
ui/src/hooks/useJobByRef.tsx
Normal file
42
ui/src/hooks/useJobByRef.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Job } from '@prisma/client';
|
||||
import { apiClient } from '@/utils/api';
|
||||
|
||||
export default function useJobByRef(jobRef: string | null, reloadInterval: null | number = null) {
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const refreshJob = () => {
|
||||
setStatus('loading');
|
||||
apiClient
|
||||
.get(`/api/jobs?job_ref=${jobRef}`)
|
||||
.then(res => res.data)
|
||||
.then(data => {
|
||||
console.log('Job:', data);
|
||||
setJob(data);
|
||||
setStatus('success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching job:', error);
|
||||
setStatus('error');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshJob();
|
||||
|
||||
if (reloadInterval) {
|
||||
const interval = setInterval(() => {
|
||||
refreshJob();
|
||||
}, reloadInterval);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [jobRef, reloadInterval]);
|
||||
|
||||
return { job, setJob, status, refreshJob };
|
||||
}
|
||||
@@ -4,14 +4,20 @@ import { useEffect, useState } from 'react';
|
||||
import { Job } from '@prisma/client';
|
||||
import { apiClient } from '@/utils/api';
|
||||
|
||||
export default function useJobsList(onlyActive = false, reloadInterval: null | number = null) {
|
||||
type UseJobsListProps = {
|
||||
onlyActive?: boolean;
|
||||
reloadInterval?: number | null;
|
||||
job_type?: string | null;
|
||||
};
|
||||
|
||||
export default function useJobsList({ onlyActive = false, reloadInterval = null, job_type=null }: UseJobsListProps = {}) {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
const refreshJobs = () => {
|
||||
setStatus('loading');
|
||||
apiClient
|
||||
.get('/api/jobs')
|
||||
.get('/api/jobs', { params: job_type ? { job_type } : undefined })
|
||||
.then(res => res.data)
|
||||
.then(data => {
|
||||
console.log('Jobs:', data);
|
||||
|
||||
@@ -252,6 +252,36 @@ export interface JobConfig {
|
||||
meta: MetaConfig;
|
||||
}
|
||||
|
||||
export interface CaptionProcessConfig {
|
||||
type: string;
|
||||
sqlite_db_path?: string;
|
||||
device: string;
|
||||
caption: {
|
||||
model_name_or_path: string;
|
||||
model_name_or_path2?: string;
|
||||
dtype: string;
|
||||
quantize: boolean;
|
||||
qtype: string;
|
||||
low_vram: boolean;
|
||||
extensions: string[];
|
||||
path_to_caption: string;
|
||||
recaption: boolean;
|
||||
caption_prompt?: string;
|
||||
max_res?: number;
|
||||
max_new_tokens?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CaptionConfigObject {
|
||||
name: string;
|
||||
process: CaptionProcessConfig[];
|
||||
}
|
||||
|
||||
export interface CaptionJobConfig {
|
||||
job: string;
|
||||
config: CaptionConfigObject;
|
||||
}
|
||||
|
||||
export interface ConfigDoc {
|
||||
title: string | React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
|
||||
@@ -11,3 +11,43 @@ export const audioExtensions = ['.mp3', '.wav'];
|
||||
export const isVideo = (filePath: string) => videoExtensions.includes(filePath.toLowerCase().slice(-4));
|
||||
export const isImage = (filePath: string) => imgExtensions.includes(filePath.toLowerCase().slice(-4));
|
||||
export const isAudio = (filePath: string) => audioExtensions.includes(filePath.toLowerCase().slice(-4));
|
||||
|
||||
export const tagsToObj = (tagStr: string): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
const regex = /<([A-Z_][A-Z0-9_]*)>([\s\S]*?)<\/\1>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(tagStr)) !== null) {
|
||||
const value = match[2].trim();
|
||||
try {
|
||||
result[match[1]] = JSON.parse(value);
|
||||
} catch {
|
||||
result[match[1]] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const objToTags = (obj: Record<string, any>): string => {
|
||||
return Object.entries(obj)
|
||||
.map(([key, value]) => {
|
||||
const content = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return `<${key}>${content}</${key}>`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export const pathJoin = (...parts: string[]) => {
|
||||
const sep = parts.length > 0 && parts[0].includes('\\') ? '\\' : '/';
|
||||
const leadingTrailing = sep === '\\' ? /^\\+|\\+$/g : /^\/+|\/+$/g;
|
||||
const trailing = sep === '\\' ? /\\+$/ : /\/+$/;
|
||||
return parts
|
||||
.map((part, index) => {
|
||||
if (index === 0) {
|
||||
return part.replace(trailing, '');
|
||||
} else {
|
||||
return part.replace(leadingTrailing, '');
|
||||
}
|
||||
})
|
||||
.filter(part => part.length > 0)
|
||||
.join(sep);
|
||||
}
|
||||
@@ -74,14 +74,18 @@ export const getAvaliableJobActions = (job: Job) => {
|
||||
const jobConfig = getJobConfig(job);
|
||||
const isStopping = job.stop && job.status === 'running';
|
||||
const canDelete = ['queued', 'completed', 'stopped', 'error'].includes(job.status) && !isStopping;
|
||||
const canEdit = ['queued','completed', 'stopped', 'error'].includes(job.status) && !isStopping;
|
||||
let canEdit = ['queued', 'completed', 'stopped', 'error'].includes(job.status) && !isStopping;
|
||||
const canRemoveFromQueue = job.status === 'queued';
|
||||
const canStop = job.status === 'running' && !isStopping;
|
||||
let canStart = ['stopped', 'error'].includes(job.status) && !isStopping;
|
||||
// can resume if more steps were added
|
||||
if (job.status === 'completed' && jobConfig.config.process[0].train.steps > job.step && !isStopping) {
|
||||
if (job.status === 'completed' && (jobConfig.config.process[0].train?.steps || 0) > job.step && !isStopping) {
|
||||
canStart = true;
|
||||
}
|
||||
if (job.job_type !== 'train') {
|
||||
// for non-train jobs, allow editing unless it's currently running
|
||||
canEdit = false;
|
||||
}
|
||||
return { canDelete, canEdit, canStop, canStart, canRemoveFromQueue };
|
||||
};
|
||||
|
||||
@@ -92,5 +96,5 @@ export const getNumberOfSamples = (job: Job) => {
|
||||
|
||||
export const getTotalSteps = (job: Job) => {
|
||||
const jobConfig = getJobConfig(job);
|
||||
return jobConfig.config.process[0].train.steps;
|
||||
return jobConfig.config.process[0].train?.steps || 0;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user