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>