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:
Jaret Burkett
2026-04-09 15:02:03 -06:00
committed by GitHub
parent 9ca58e9aa2
commit 78cf049c29
54 changed files with 5589 additions and 536 deletions

View 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 });
}
}

View File

@@ -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);

View File

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

View File

@@ -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>

View File

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

View File

@@ -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}` : ''}

View File

@@ -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,
},
],
},

View File

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

View File

@@ -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>

View File

@@ -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 ? (

View 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>
);
}

View 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>
);
};

View File

@@ -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}
/>

View File

@@ -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"

View File

@@ -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]);

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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)}`}

View File

@@ -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 />

View File

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

View File

@@ -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;

View 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);
}
};

View 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';

View 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,
};

View 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 };
}

View File

@@ -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);

View File

@@ -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;

View File

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

View File

@@ -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;
};