Add support for training VLLMs, omni models, and LLMs
This commit is contained in:
1 parent
426ccb4ec4
commit
db8dbd68c2
21 files changed
+1794
-524
No files matched your search
@@ -28,11 +28,22 @@ export async function GET(request: NextRequest, { params }: { params: { jobID: s
|
||||
// find all img (png, jpg, jpeg) files in the samples folder. Thumbnails
|
||||
// live in the hidden .thumbs subfolder (and partial writes in .tmp) — the
|
||||
// isFile check keeps those directories out even if their names ever match.
|
||||
const samples = (await fs.promises.readdir(samplesFolder, { withFileTypes: true }))
|
||||
const names = (await fs.promises.readdir(samplesFolder, { withFileTypes: true }))
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => entry.name)
|
||||
.map(entry => entry.name);
|
||||
const isMedia = (file: string) =>
|
||||
file.endsWith('.png') ||
|
||||
file.endsWith('.jpg') ||
|
||||
file.endsWith('.jpeg') ||
|
||||
file.endsWith('.webp') ||
|
||||
file.endsWith('.mp4') ||
|
||||
file.endsWith('mp3');
|
||||
const mediaStems = new Set(names.filter(isMedia).map(file => file.slice(0, file.lastIndexOf('.'))));
|
||||
const samples = names
|
||||
.filter(file => {
|
||||
return file.endsWith('.png') || file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.webp') || file.endsWith('.mp4') || file.endsWith('mp3') || file.endsWith('wav') || file.endsWith('flac') || file.endsWith('ogg');
|
||||
if (isMedia(file)) return true;
|
||||
// text samples (LLM models); a .txt next to a media sample is its prompt sidecar, not a sample
|
||||
return file.endsWith('.txt') && !mediaStems.has(file.slice(0, -4));
|
||||
})
|
||||
.map(file => {
|
||||
return path.join(samplesFolder, file);
|
||||
|
||||
+305
-62
@@ -3,10 +3,30 @@
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Loader2, OctagonX, Play, Plus, Square, Sparkles, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
OctagonX,
|
||||
Play,
|
||||
Plus,
|
||||
Square,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { openConfirm } from '@/components/ConfirmModal';
|
||||
import { TopBar, MainContent } from '@/components/layout';
|
||||
import { Checkbox, CreatableSelectInput, NumberInput, SelectInput, SliderInput, TextAreaInput, TextInput } from '@/components/formInputs';
|
||||
import {
|
||||
Checkbox,
|
||||
CreatableSelectInput,
|
||||
NumberInput,
|
||||
SelectInput,
|
||||
SliderInput,
|
||||
TextAreaInput,
|
||||
TextInput,
|
||||
} from '@/components/formInputs';
|
||||
import { apiClient } from '@/utils/api';
|
||||
import { startJob, stopJob } from '@/utils/jobs';
|
||||
import { startQueue } from '@/utils/queue';
|
||||
@@ -20,7 +40,6 @@ import { modelArchs, getGenerateDefaults, GenerateDefaults } from '@/app/jobs/ne
|
||||
import GenerateFooter from '@/components/generate/GenerateFooter';
|
||||
import LoraBrowserModal, { LoraPick } from '@/components/generate/LoraBrowserModal';
|
||||
|
||||
|
||||
interface EngineStatus {
|
||||
running: boolean;
|
||||
engine: { jobId: string; jobName: string; gpuIds: string; status: string; ready: boolean } | null;
|
||||
@@ -90,7 +109,11 @@ function Card({
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800">
|
||||
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between gap-2 px-3 py-1.5 text-xs text-gray-200 font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-1.5 text-xs text-gray-200 font-semibold"
|
||||
>
|
||||
<span className="flex items-baseline gap-2 min-w-0">
|
||||
<span>{title}</span>
|
||||
{subtitle && <span className="text-[11px] font-normal text-gray-500 truncate">{subtitle}</span>}
|
||||
@@ -124,7 +147,11 @@ function GeneratePageInner() {
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
const engineJobId = preferredJob || engineStatus?.engine?.jobId || null;
|
||||
const proxy = useCallback((path: string) => `/api/inference/${path}${engineJobId ? `${path.includes('?') ? '&' : '?'}job=${engineJobId}` : ''}`, [engineJobId]);
|
||||
const proxy = useCallback(
|
||||
(path: string) =>
|
||||
`/api/inference/${path}${engineJobId ? `${path.includes('?') ? '&' : '?'}job=${engineJobId}` : ''}`,
|
||||
[engineJobId],
|
||||
);
|
||||
|
||||
usePollLoop(
|
||||
() =>
|
||||
@@ -139,7 +166,10 @@ function GeneratePageInner() {
|
||||
setStartingJobId(null);
|
||||
} else if (!mine) {
|
||||
// no longer queued/running: the launch failed (or was stopped)
|
||||
const job = await apiClient.get('/api/jobs', { params: { id: startingJobId } }).then(r => r.data).catch(() => null);
|
||||
const job = await apiClient
|
||||
.get('/api/jobs', { params: { id: startingJobId } })
|
||||
.then(r => r.data)
|
||||
.catch(() => null);
|
||||
if (!job || ['error', 'stopped', 'completed'].includes(job.status)) {
|
||||
setStartingJobId(null);
|
||||
alert(`The engine did not start${job?.info ? `: ${job.info}` : ''}. Check the job log in the Queue.`);
|
||||
@@ -172,7 +202,9 @@ function GeneratePageInner() {
|
||||
try {
|
||||
const gpu = isMac() ? 'mps' : gpuId;
|
||||
const name = `inference_engine_gpu${gpu}`;
|
||||
const existing = await apiClient.get('/api/jobs', { params: { job_type: 'inference' } }).then(r => r.data.jobs || []);
|
||||
const existing = await apiClient
|
||||
.get('/api/jobs', { params: { job_type: 'inference' } })
|
||||
.then(r => r.data.jobs || []);
|
||||
const match = existing.find((j: any) => j.name === name);
|
||||
const res = await apiClient.post('/api/jobs', {
|
||||
id: match ? match.id : null,
|
||||
@@ -206,7 +238,10 @@ function GeneratePageInner() {
|
||||
let alive = true;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 750));
|
||||
const st = await apiClient.get(`/api/inference/status?job=${id}`).then(r => r.data).catch(() => null);
|
||||
const st = await apiClient
|
||||
.get(`/api/inference/status?job=${id}`)
|
||||
.then(r => r.data)
|
||||
.catch(() => null);
|
||||
alive = !!st?.engines?.some((e: any) => e.jobId === id && (e.ready || e.status === 'stopping'));
|
||||
if (!alive) break;
|
||||
}
|
||||
@@ -222,7 +257,8 @@ function GeneratePageInner() {
|
||||
|
||||
// a row that is stopped but whose process is still alive shows as 'stopping' from the status route
|
||||
const isStopping = !!stoppingJobId || engineStatus?.engine?.status === 'stopping';
|
||||
const isStarting = !isStopping && (engineBusy || !!startingJobId || (!!engineStatus?.engine && !engineStatus.running));
|
||||
const isStarting =
|
||||
!isStopping && (engineBusy || !!startingJobId || (!!engineStatus?.engine && !engineStatus.running));
|
||||
const stoppingForMs = stoppingSince ? now - stoppingSince : isStopping ? 999_999 : 0;
|
||||
// cancel a start that is not getting anywhere: a queued job (no process)
|
||||
// is just marked stopped; a launched one gets the normal stop
|
||||
@@ -259,12 +295,18 @@ function GeneratePageInner() {
|
||||
};
|
||||
|
||||
// ---- models: the same arch list the training UI uses (jobs/new/options.tsx) ----
|
||||
const archs: GenerateDefaults[] = useMemo(() => modelArchs.map(getGenerateDefaults), []);
|
||||
// text-generating archs have no Generate page path yet
|
||||
const archs: GenerateDefaults[] = useMemo(
|
||||
() => modelArchs.filter(a => a.group !== 'llm').map(getGenerateDefaults),
|
||||
[],
|
||||
);
|
||||
const ready = !!engineStatus?.running;
|
||||
|
||||
const [arch, setArch] = useState<string>(persisted.arch || 'zimage');
|
||||
const [model, setModel] = useState<{ [key: string]: any }>(persisted.model || {});
|
||||
const [sample, setSample] = useState<{ [key: string]: any }>(persisted.sample || { prompt: '', negative_prompt: '', seed: -1 });
|
||||
const [sample, setSample] = useState<{ [key: string]: any }>(
|
||||
persisted.sample || { prompt: '', negative_prompt: '', seed: -1 },
|
||||
);
|
||||
const entry = useMemo(() => archs.find(a => a.arch === arch) || null, [archs, arch]);
|
||||
|
||||
const applyArch = (name: string) => {
|
||||
@@ -272,7 +314,14 @@ function GeneratePageInner() {
|
||||
const e = archs.find(a => a.arch === name);
|
||||
if (!e) return;
|
||||
setModel({ ...e.model });
|
||||
setSample(s => ({ ...s, ...e.sample, prompt: s.prompt, negative_prompt: s.negative_prompt, seed: s.seed ?? -1, ctrl_img: undefined }));
|
||||
setSample(s => ({
|
||||
...s,
|
||||
...e.sample,
|
||||
prompt: s.prompt,
|
||||
negative_prompt: s.negative_prompt,
|
||||
seed: s.seed ?? -1,
|
||||
ctrl_img: undefined,
|
||||
}));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (archs.length && !Object.keys(model).length) applyArch(archs.some(a => a.arch === arch) ? arch : archs[0].arch);
|
||||
@@ -652,7 +701,9 @@ function GeneratePageInner() {
|
||||
: engineStatus?.engine
|
||||
? `Engine ${engineStatus.engine.status}`
|
||||
: 'Engine off';
|
||||
const vram = health?.vram ? `${(health.vram.used / 1e9).toFixed(1)} / ${(health.vram.total / 1e9).toFixed(0)} GB` : null;
|
||||
const vram = health?.vram
|
||||
? `${(health.vram.used / 1e9).toFixed(1)} / ${(health.vram.total / 1e9).toFixed(0)} GB`
|
||||
: null;
|
||||
const pool = health?.pool;
|
||||
|
||||
return (
|
||||
@@ -666,11 +717,18 @@ function GeneratePageInner() {
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Button disabled className="px-2 py-1 rounded-md bg-red-900 text-white/70 flex items-center gap-1 cursor-wait">
|
||||
<Button
|
||||
disabled
|
||||
className="px-2 py-1 rounded-md bg-red-900 text-white/70 flex items-center gap-1 cursor-wait"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Stopping engine…
|
||||
</Button>
|
||||
{stoppingForMs > 8000 && (
|
||||
<Button onClick={forceStop} className="px-2 py-1 rounded-md bg-red-700 hover:bg-red-600 text-white flex items-center gap-1" title="Kill the engine process">
|
||||
<Button
|
||||
onClick={forceStop}
|
||||
className="px-2 py-1 rounded-md bg-red-700 hover:bg-red-600 text-white flex items-center gap-1"
|
||||
title="Kill the engine process"
|
||||
>
|
||||
<OctagonX className="w-4 h-4" /> Force stop
|
||||
</Button>
|
||||
)}
|
||||
@@ -680,7 +738,11 @@ function GeneratePageInner() {
|
||||
<span className="text-green-400">Engine running</span>
|
||||
{health?.active?.arch && <span className="text-gray-400 hidden sm:inline">· {health.active.arch}</span>}
|
||||
{vram && <span className="text-gray-400 hidden md:inline">· VRAM {vram}</span>}
|
||||
<Button onClick={stopEngine} disabled={engineBusy} className="ml-2 px-2 py-1 rounded-md bg-red-700 hover:bg-red-600 disabled:opacity-60 text-white flex items-center gap-1">
|
||||
<Button
|
||||
onClick={stopEngine}
|
||||
disabled={engineBusy}
|
||||
className="ml-2 px-2 py-1 rounded-md bg-red-700 hover:bg-red-600 disabled:opacity-60 text-white flex items-center gap-1"
|
||||
>
|
||||
{engineBusy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Square className="w-4 h-4" />} Stop
|
||||
</Button>
|
||||
</>
|
||||
@@ -702,15 +764,28 @@ function GeneratePageInner() {
|
||||
</p>
|
||||
{isStarting ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button disabled className="w-full px-3 py-2 rounded-md bg-blue-900 text-white/70 flex items-center justify-center gap-2 cursor-wait">
|
||||
<Button
|
||||
disabled
|
||||
className="w-full px-3 py-2 rounded-md bg-blue-900 text-white/70 flex items-center justify-center gap-2 cursor-wait"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{engineStatus?.engine?.status === 'running' ? 'Engine loading…' : engineStatus?.engine ? `Engine ${engineStatus.engine.status}…` : 'Starting engine…'}
|
||||
{engineStatus?.engine?.status === 'running'
|
||||
? 'Engine loading…'
|
||||
: engineStatus?.engine
|
||||
? `Engine ${engineStatus.engine.status}…`
|
||||
: 'Starting engine…'}
|
||||
</Button>
|
||||
{engineStatus?.engine?.status === 'queued' && (
|
||||
<div className="text-[11px] text-gray-500">Waiting for the GPU queue to pick the job up. If the queue is stopped, start it from the Queue page or cancel here.</div>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
Waiting for the GPU queue to pick the job up. If the queue is stopped, start it from the Queue
|
||||
page or cancel here.
|
||||
</div>
|
||||
)}
|
||||
{startingSince !== null && now - startingSince > 5000 && (
|
||||
<Button onClick={cancelStart} className="w-full px-3 py-1.5 rounded-md bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm">
|
||||
<Button
|
||||
onClick={cancelStart}
|
||||
className="w-full px-3 py-1.5 rounded-md bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
@@ -718,7 +793,11 @@ function GeneratePageInner() {
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!isMac() && (
|
||||
<select value={gpuId} onChange={e => setGpuId(e.target.value)} className="w-full bg-gray-800 text-gray-200 rounded-md px-3 py-2 border border-gray-700">
|
||||
<select
|
||||
value={gpuId}
|
||||
onChange={e => setGpuId(e.target.value)}
|
||||
className="w-full bg-gray-800 text-gray-200 rounded-md px-3 py-2 border border-gray-700"
|
||||
>
|
||||
{gpuList.map(g => (
|
||||
<option key={g.index} value={`${g.index}`}>
|
||||
GPU #{g.index} {g.name}
|
||||
@@ -726,7 +805,10 @@ function GeneratePageInner() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={startEngine} className="w-full px-3 py-2 rounded-md bg-blue-700 hover:bg-blue-600 text-white flex items-center justify-center gap-2">
|
||||
<Button
|
||||
onClick={startEngine}
|
||||
className="w-full px-3 py-2 rounded-md bg-blue-700 hover:bg-blue-600 text-white flex items-center justify-center gap-2"
|
||||
>
|
||||
<Play className="w-4 h-4" /> Start engine
|
||||
</Button>
|
||||
</div>
|
||||
@@ -756,7 +838,10 @@ function GeneratePageInner() {
|
||||
) : null}
|
||||
<div className="flex-1" />
|
||||
{running && (
|
||||
<Button onClick={cancel} className="px-2 py-0.5 rounded bg-gray-800/80 hover:bg-gray-700 text-gray-200 text-xs">
|
||||
<Button
|
||||
onClick={cancel}
|
||||
className="px-2 py-0.5 rounded bg-gray-800/80 hover:bg-gray-700 text-gray-200 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
@@ -766,7 +851,10 @@ function GeneratePageInner() {
|
||||
Engine busy: {health.current.arch}
|
||||
{health.current.total_steps ? ` ${health.current.step}/${health.current.total_steps}` : ''}
|
||||
</span>
|
||||
<Button onClick={cancelBusy} className="px-2 py-0.5 rounded bg-gray-800/80 hover:bg-gray-700 text-gray-200 text-xs">
|
||||
<Button
|
||||
onClick={cancelBusy}
|
||||
className="px-2 py-0.5 rounded bg-gray-800/80 hover:bg-gray-700 text-gray-200 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
@@ -774,10 +862,17 @@ function GeneratePageInner() {
|
||||
</div>
|
||||
{progress?.total ? (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gray-800 z-10">
|
||||
<div className="h-0.5 bg-blue-500 transition-all" style={{ width: `${Math.min(100, (progress.step / progress.total) * 100)}%` }} />
|
||||
<div
|
||||
className="h-0.5 bg-blue-500 transition-all"
|
||||
style={{ width: `${Math.min(100, (progress.step / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error && <div className="absolute bottom-3 left-3 right-3 z-10 text-rose-300 text-sm bg-rose-950/70 rounded px-3 py-2 whitespace-pre-wrap">{error}</div>}
|
||||
{error && (
|
||||
<div className="absolute bottom-3 left-3 right-3 z-10 text-rose-300 text-sm bg-rose-950/70 rounded px-3 py-2 whitespace-pre-wrap">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* live latent preview: shown while generating (or when nothing is selected) */}
|
||||
<div className={`absolute inset-3 flex items-center justify-center ${showPreview ? '' : 'hidden'}`}>
|
||||
@@ -791,7 +886,14 @@ function GeneratePageInner() {
|
||||
{!showPreview && selected && (
|
||||
<div className="absolute inset-3 flex items-center justify-center">
|
||||
{selected.kind === 'video' ? (
|
||||
<video key={selected.path} src={resultUrl(selected)} controls autoPlay loop className="max-w-full max-h-full rounded" />
|
||||
<video
|
||||
key={selected.path}
|
||||
src={resultUrl(selected)}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
className="max-w-full max-h-full rounded"
|
||||
/>
|
||||
) : selected.kind === 'audio' ? (
|
||||
<div className="w-full max-w-xl bg-gray-900 rounded-xl p-6 text-center">
|
||||
<div className="text-gray-300 text-sm mb-4 truncate" title={selected.prompt}>
|
||||
@@ -800,9 +902,19 @@ function GeneratePageInner() {
|
||||
<audio key={selected.path} src={resultUrl(selected)} controls autoPlay className="w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<a href={resultUrl(selected)} target="_blank" rel="noreferrer" className="w-full h-full flex items-center justify-center">
|
||||
<a
|
||||
href={resultUrl(selected)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img key={selected.path} src={resultUrl(selected)} alt={selected.prompt} className="max-w-full max-h-full rounded object-contain" />
|
||||
<img
|
||||
key={selected.path}
|
||||
src={resultUrl(selected)}
|
||||
alt={selected.prompt}
|
||||
className="max-w-full max-h-full rounded object-contain"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
@@ -901,20 +1013,40 @@ function GeneratePageInner() {
|
||||
>
|
||||
{sidebarOpen ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<div className={`h-full flex flex-col border-l border-gray-800 bg-gray-900/60 ${sidebarOpen ? '' : 'hidden'}`}>
|
||||
<div
|
||||
className={`h-full flex flex-col border-l border-gray-800 bg-gray-900/60 ${sidebarOpen ? '' : 'hidden'}`}
|
||||
>
|
||||
<div className="compact-form flex-1 min-h-0 overflow-y-auto p-2 space-y-2">
|
||||
|
||||
<Card title="Model" subtitle={entry?.label || arch} open={cardOpen('model')} onToggle={() => toggleCard('model')}>
|
||||
<SelectInput label="Architecture" value={arch} onChange={v => applyArch(v as string)} options={archOptions} disabled={!ready} />
|
||||
<Card
|
||||
title="Model"
|
||||
subtitle={entry?.label || arch}
|
||||
open={cardOpen('model')}
|
||||
onToggle={() => toggleCard('model')}
|
||||
>
|
||||
<SelectInput
|
||||
label="Architecture"
|
||||
value={arch}
|
||||
onChange={v => applyArch(v as string)}
|
||||
options={archOptions}
|
||||
disabled={!ready}
|
||||
/>
|
||||
<CreatableSelectInput
|
||||
label="Name or Path"
|
||||
value={model.name_or_path || ''}
|
||||
onChange={(v: string | null) => setModel(m => ({ ...m, name_or_path: v || '' }))}
|
||||
options={entry?.model?.name_or_path ? [{ value: entry.model.name_or_path, label: entry.model.name_or_path }] : []}
|
||||
options={
|
||||
entry?.model?.name_or_path
|
||||
? [{ value: entry.model.name_or_path, label: entry.model.name_or_path }]
|
||||
: []
|
||||
}
|
||||
placeholder="hub repo, local folder, or .safetensors"
|
||||
/>
|
||||
{'extras_name_or_path' in (entry?.model || {}) && (
|
||||
<TextInput label="Extras Name or Path" value={model.extras_name_or_path || ''} onChange={v => setModel(m => ({ ...m, extras_name_or_path: v }))} />
|
||||
<TextInput
|
||||
label="Extras Name or Path"
|
||||
value={model.extras_name_or_path || ''}
|
||||
onChange={v => setModel(m => ({ ...m, extras_name_or_path: v }))}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SelectInput
|
||||
@@ -931,8 +1063,16 @@ function GeneratePageInner() {
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Checkbox label="Low VRAM" checked={!!model.low_vram} onChange={v => setModel(m => ({ ...m, low_vram: v }))} />
|
||||
<Checkbox label="Layer offloading" checked={!!model.layer_offloading} onChange={v => setModel(m => ({ ...m, layer_offloading: v }))} />
|
||||
<Checkbox
|
||||
label="Low VRAM"
|
||||
checked={!!model.low_vram}
|
||||
onChange={v => setModel(m => ({ ...m, low_vram: v }))}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Layer offloading"
|
||||
checked={!!model.layer_offloading}
|
||||
onChange={v => setModel(m => ({ ...m, layer_offloading: v }))}
|
||||
/>
|
||||
</div>
|
||||
{model.layer_offloading && (
|
||||
<div className="mt-2 space-y-1">
|
||||
@@ -955,11 +1095,19 @@ function GeneratePageInner() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="LoRAs" subtitle={loras.length ? `${activeLoras.length}/${loras.length} active` : 'none'} open={cardOpen('loras')} onToggle={() => toggleCard('loras')}>
|
||||
|
||||
<Card
|
||||
title="LoRAs"
|
||||
subtitle={loras.length ? `${activeLoras.length}/${loras.length} active` : 'none'}
|
||||
open={cardOpen('loras')}
|
||||
onToggle={() => toggleCard('loras')}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{loras.map((l, i) => (
|
||||
<div key={l.path} className={`bg-gray-950/60 border border-gray-800 rounded-md px-2 py-1.5 ${l.disabled ? 'opacity-50' : ''}`}>
|
||||
<div
|
||||
key={l.path}
|
||||
className={`bg-gray-950/60 border border-gray-800 rounded-md px-2 py-1.5 ${l.disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -977,7 +1125,10 @@ function GeneratePageInner() {
|
||||
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform ${l.disabled ? '' : 'translate-x-3'}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-xs truncate flex-1 ${l.disabled ? 'text-gray-500 line-through' : 'text-gray-200'}`} title={l.path}>
|
||||
<span
|
||||
className={`text-xs truncate flex-1 ${l.disabled ? 'text-gray-500 line-through' : 'text-gray-200'}`}
|
||||
title={l.path}
|
||||
>
|
||||
{l.name}
|
||||
</span>
|
||||
<input
|
||||
@@ -1022,13 +1173,15 @@ function GeneratePageInner() {
|
||||
description: (
|
||||
<>
|
||||
<p>
|
||||
<strong>Off</strong> (default): each LoRA is applied dynamically as an extra term on the layers it targets. Strength
|
||||
changes take effect on the next generation and adding or removing a LoRA never reloads the model.
|
||||
<strong>Off</strong> (default): each LoRA is applied dynamically as an extra term on the
|
||||
layers it targets. Strength changes take effect on the next generation and adding or
|
||||
removing a LoRA never reloads the model.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>On</strong>: the LoRA deltas are added into the model weights. Quantized weights are dequantized, merged, and
|
||||
re-quantized with stochastic rounding so small deltas are not rounded away. Merged weights cannot be un-merged, so
|
||||
changing the LoRA set or strengths reloads the affected components. Slightly faster per step than the dynamic path.
|
||||
<strong>On</strong>: the LoRA deltas are added into the model weights. Quantized weights
|
||||
are dequantized, merged, and re-quantized with stochastic rounding so small deltas are not
|
||||
rounded away. Merged weights cannot be un-merged, so changing the LoRA set or strengths
|
||||
reloads the affected components. Slightly faster per step than the dynamic path.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
@@ -1041,40 +1194,125 @@ function GeneratePageInner() {
|
||||
// tagged prompt archs (audio): one field per tag, stored as the tagged prompt string
|
||||
Object.entries(entry.sampleTags).map(([tagKey, tag]) => {
|
||||
const tags = tagsToObj(sample.prompt || '');
|
||||
const setTag = (v: any) => setSample(s => ({ ...s, prompt: objToTags({ ...tagsToObj(s.prompt || ''), [tagKey]: v }) }));
|
||||
const setTag = (v: any) =>
|
||||
setSample(s => ({ ...s, prompt: objToTags({ ...tagsToObj(s.prompt || ''), [tagKey]: v }) }));
|
||||
const value = tags[tagKey] ?? '';
|
||||
if (tag.type === 'multiline') return <TextAreaInput key={tagKey} label={tag.title} value={value} onChange={setTag} placeholder={`Enter ${tag.title.toLowerCase()}`} />;
|
||||
if (tag.type === 'number') return <NumberInput key={tagKey} label={tag.title} value={value} onChange={setTag} placeholder={`Enter ${tag.title.toLowerCase()}`} />;
|
||||
return <TextInput key={tagKey} label={tag.title} value={value} onChange={setTag} placeholder={`Enter ${tag.title.toLowerCase()}`} />;
|
||||
if (tag.type === 'multiline')
|
||||
return (
|
||||
<TextAreaInput
|
||||
key={tagKey}
|
||||
label={tag.title}
|
||||
value={value}
|
||||
onChange={setTag}
|
||||
placeholder={`Enter ${tag.title.toLowerCase()}`}
|
||||
/>
|
||||
);
|
||||
if (tag.type === 'number')
|
||||
return (
|
||||
<NumberInput
|
||||
key={tagKey}
|
||||
label={tag.title}
|
||||
value={value}
|
||||
onChange={setTag}
|
||||
placeholder={`Enter ${tag.title.toLowerCase()}`}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TextInput
|
||||
key={tagKey}
|
||||
label={tag.title}
|
||||
value={value}
|
||||
onChange={setTag}
|
||||
placeholder={`Enter ${tag.title.toLowerCase()}`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TextAreaInput label="Prompt" value={sample.prompt || ''} onChange={v => setSample(s => ({ ...s, prompt: v }))} placeholder={modality === 'audio' ? 'song description, lyrics, bpm…' : 'describe what to generate'} />
|
||||
<TextAreaInput
|
||||
label="Prompt"
|
||||
value={sample.prompt || ''}
|
||||
onChange={v => setSample(s => ({ ...s, prompt: v }))}
|
||||
placeholder={
|
||||
modality === 'audio' ? 'song description, lyrics, bpm…' : 'describe what to generate'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(sample.guidance_scale ?? 4) > 1 && modality !== 'audio' && (
|
||||
<TextAreaInput label="Negative prompt" value={sample.negative_prompt || ''} onChange={v => setSample(s => ({ ...s, negative_prompt: v }))} />
|
||||
<TextAreaInput
|
||||
label="Negative prompt"
|
||||
value={sample.negative_prompt || ''}
|
||||
onChange={v => setSample(s => ({ ...s, negative_prompt: v }))}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{modality !== 'audio' && (
|
||||
<>
|
||||
<NumberInput label="Width" value={sample.width ?? 1024} onChange={v => setSample(s => ({ ...s, width: v }))} min={64} max={4096} />
|
||||
<NumberInput label="Height" value={sample.height ?? 1024} onChange={v => setSample(s => ({ ...s, height: v }))} min={64} max={4096} />
|
||||
<NumberInput
|
||||
label="Width"
|
||||
value={sample.width ?? 1024}
|
||||
onChange={v => setSample(s => ({ ...s, width: v }))}
|
||||
min={64}
|
||||
max={4096}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Height"
|
||||
value={sample.height ?? 1024}
|
||||
onChange={v => setSample(s => ({ ...s, height: v }))}
|
||||
min={64}
|
||||
max={4096}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<NumberInput label="Steps" value={sample.num_inference_steps ?? 25} onChange={v => setSample(s => ({ ...s, num_inference_steps: v }))} min={1} max={200} />
|
||||
<NumberInput label="Guidance" value={sample.guidance_scale ?? 4} onChange={v => setSample(s => ({ ...s, guidance_scale: v }))} min={1} max={30} />
|
||||
<NumberInput label="Seed (-1 random)" value={sample.seed ?? -1} onChange={v => setSample(s => ({ ...s, seed: v }))} min={-1} max={4294967295} />
|
||||
<NumberInput
|
||||
label="Steps"
|
||||
value={sample.num_inference_steps ?? 25}
|
||||
onChange={v => setSample(s => ({ ...s, num_inference_steps: v }))}
|
||||
min={1}
|
||||
max={200}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Guidance"
|
||||
value={sample.guidance_scale ?? 4}
|
||||
onChange={v => setSample(s => ({ ...s, guidance_scale: v }))}
|
||||
min={1}
|
||||
max={30}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Seed (-1 random)"
|
||||
value={sample.seed ?? -1}
|
||||
onChange={v => setSample(s => ({ ...s, seed: v }))}
|
||||
min={-1}
|
||||
max={4294967295}
|
||||
/>
|
||||
{modality === 'audio' && sample.duration !== undefined && (
|
||||
<NumberInput label="Duration (s)" value={sample.duration} onChange={v => setSample(s => ({ ...s, duration: v }))} min={1} max={600} />
|
||||
<NumberInput
|
||||
label="Duration (s)"
|
||||
value={sample.duration}
|
||||
onChange={v => setSample(s => ({ ...s, duration: v }))}
|
||||
min={1}
|
||||
max={600}
|
||||
/>
|
||||
)}
|
||||
{modality === 'video' && (
|
||||
<>
|
||||
<NumberInput label="Frames" value={sample.num_frames ?? 33} onChange={v => setSample(s => ({ ...s, num_frames: v }))} min={1} max={1000} />
|
||||
<NumberInput label="FPS" value={sample.fps ?? 16} onChange={v => setSample(s => ({ ...s, fps: v }))} min={1} max={60} />
|
||||
<NumberInput
|
||||
label="Frames"
|
||||
value={sample.num_frames ?? 33}
|
||||
onChange={v => setSample(s => ({ ...s, num_frames: v }))}
|
||||
min={1}
|
||||
max={1000}
|
||||
/>
|
||||
<NumberInput
|
||||
label="FPS"
|
||||
value={sample.fps ?? 16}
|
||||
onChange={v => setSample(s => ({ ...s, fps: v }))}
|
||||
min={1}
|
||||
max={60}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
{/* always-visible action bar */}
|
||||
<div className="shrink-0 p-2 border-t border-gray-800 bg-gray-900 flex gap-2">
|
||||
@@ -1096,7 +1334,12 @@ function GeneratePageInner() {
|
||||
</div>
|
||||
</div>
|
||||
</MainContent>
|
||||
<GenerateFooter jobId={engineJobId} status={footerStatus} busy={running || isStarting} progress={running ? progress : null} />
|
||||
<GenerateFooter
|
||||
jobId={engineJobId}
|
||||
status={footerStatus}
|
||||
busy={running || isStarting}
|
||||
progress={running ? progress : null}
|
||||
/>
|
||||
<LoraBrowserModal isOpen={loraModalOpen} onClose={() => setLoraModalOpen(false)} onPick={addLora} />
|
||||
</>
|
||||
);
|
||||
|
||||
+407
-366
@@ -82,6 +82,8 @@ export default function SimpleJob({
|
||||
|
||||
const isVideoModel = !!(modelArch?.group === 'video');
|
||||
const isAudioModel = !!(modelArch?.group === 'audio');
|
||||
// text-generating models: samples are media in, text out (no size)
|
||||
const isLlmModel = !!(modelArch?.group === 'llm');
|
||||
|
||||
const taggedSampleArr: Record<string, any>[] | null = useMemo(() => {
|
||||
if (!modelArch) return null;
|
||||
@@ -213,6 +215,9 @@ export default function SimpleJob({
|
||||
numDatasetCols -= 1;
|
||||
numSampleTopCols -= 1;
|
||||
}
|
||||
if (isLlmModel) {
|
||||
numSampleTopCols -= 1;
|
||||
}
|
||||
if (numDatasetCols == 3) {
|
||||
datasetStyleClass = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6';
|
||||
}
|
||||
@@ -296,6 +301,18 @@ export default function SimpleJob({
|
||||
placeholder=""
|
||||
required
|
||||
/>
|
||||
{modelArch?.additionalSections?.includes('model.model_kwargs.instruction') && (
|
||||
<TextAreaInput
|
||||
label="LLM Prompt"
|
||||
className="pt-2"
|
||||
rows={2}
|
||||
docKey="model.model_kwargs.instruction"
|
||||
value={jobConfig.config.process[0].model.model_kwargs?.instruction ?? ''}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].model.model_kwargs.instruction')}
|
||||
placeholder="Describe this in detail."
|
||||
required
|
||||
/>
|
||||
)}
|
||||
{modelArch?.additionalSections?.includes('model.assistant_lora_path') && (
|
||||
<TextInput
|
||||
label="Training Adapter Path"
|
||||
@@ -489,7 +506,7 @@ export default function SimpleJob({
|
||||
}}
|
||||
options={transformerQuantizationOptions}
|
||||
/>
|
||||
{!disableSections.includes('model.quantize_te') && (
|
||||
{!disableSections.includes('model.quantize_te') && !isLlmModel && (
|
||||
<SelectInput
|
||||
label="Text Encoder"
|
||||
value={
|
||||
@@ -507,25 +524,29 @@ export default function SimpleJob({
|
||||
options={quantizationOptions}
|
||||
/>
|
||||
)}
|
||||
<FormGroup label="Compile Options">
|
||||
<></>
|
||||
</FormGroup>
|
||||
<Checkbox
|
||||
label="Compile Model"
|
||||
checked={jobConfig.config.process[0].model.compile || false}
|
||||
onChange={value => {
|
||||
setJobConfig(value, 'config.process[0].model.compile');
|
||||
if (value) {
|
||||
for (const key in defaultCompileOptions) {
|
||||
setJobConfig((defaultCompileOptions as any)[key], `config.process[0].model.${key}`);
|
||||
}
|
||||
} else {
|
||||
for (const key in defaultCompileOptions) {
|
||||
setJobConfig(undefined, `config.process[0].model.${key}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!isLlmModel && (
|
||||
<>
|
||||
<FormGroup label="Compile Options">
|
||||
<></>
|
||||
</FormGroup>
|
||||
<Checkbox
|
||||
label="Compile Model"
|
||||
checked={jobConfig.config.process[0].model.compile || false}
|
||||
onChange={value => {
|
||||
setJobConfig(value, 'config.process[0].model.compile');
|
||||
if (value) {
|
||||
for (const key in defaultCompileOptions) {
|
||||
setJobConfig((defaultCompileOptions as any)[key], `config.process[0].model.${key}`);
|
||||
}
|
||||
} else {
|
||||
for (const key in defaultCompileOptions) {
|
||||
setJobConfig(undefined, `config.process[0].model.${key}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{modelArch?.additionalSections?.includes('model.multistage') && (
|
||||
@@ -737,56 +758,58 @@ export default function SimpleJob({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{disableSections.includes('train.timestep_type') ? null : (
|
||||
{!isLlmModel && (
|
||||
<div>
|
||||
{disableSections.includes('train.timestep_type') ? null : (
|
||||
<SelectInput
|
||||
label="Timestep Type"
|
||||
value={jobConfig.config.process[0].train.timestep_type}
|
||||
disabled={disableSections.includes('train.timestep_type') || false}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.timestep_type')}
|
||||
options={[
|
||||
{ value: 'sigmoid', label: 'Sigmoid' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'shift', label: 'Shift' },
|
||||
{ value: 'weighted', label: 'Weighted' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<SelectInput
|
||||
label="Timestep Type"
|
||||
value={jobConfig.config.process[0].train.timestep_type}
|
||||
disabled={disableSections.includes('train.timestep_type') || false}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.timestep_type')}
|
||||
label="Timestep Bias"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].train.content_or_style}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.content_or_style')}
|
||||
options={[
|
||||
{ value: 'sigmoid', label: 'Sigmoid' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
{ value: 'shift', label: 'Shift' },
|
||||
{ value: 'weighted', label: 'Weighted' },
|
||||
{ value: 'balanced', label: 'Balanced' },
|
||||
{ value: 'content', label: 'High Noise' },
|
||||
{ value: 'style', label: 'Low Noise' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<SelectInput
|
||||
label="Timestep Bias"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].train.content_or_style}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.content_or_style')}
|
||||
options={[
|
||||
{ value: 'balanced', label: 'Balanced' },
|
||||
{ value: 'content', label: 'High Noise' },
|
||||
{ value: 'style', label: 'Low Noise' },
|
||||
]}
|
||||
/>
|
||||
<SelectInput
|
||||
label="Loss Type"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].train.loss_type}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.loss_type')}
|
||||
options={[
|
||||
{ value: 'mse', label: 'Mean Squared Error' },
|
||||
{ value: 'mae', label: 'Mean Absolute Error' },
|
||||
{ value: 'wavelet', label: 'Wavelet' },
|
||||
{ value: 'stepped', label: 'Stepped Recovery' },
|
||||
]}
|
||||
/>
|
||||
{modelArch?.additionalSections?.includes('train.audio_loss_multiplier') && (
|
||||
<NumberInput
|
||||
label="Audio Loss Multiplier"
|
||||
<SelectInput
|
||||
label="Loss Type"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].train.audio_loss_multiplier ?? 1.0}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.audio_loss_multiplier')}
|
||||
placeholder="eg. 1.0"
|
||||
docKey={'train.audio_loss_multiplier'}
|
||||
min={0}
|
||||
value={jobConfig.config.process[0].train.loss_type}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.loss_type')}
|
||||
options={[
|
||||
{ value: 'mse', label: 'Mean Squared Error' },
|
||||
{ value: 'mae', label: 'Mean Absolute Error' },
|
||||
{ value: 'wavelet', label: 'Wavelet' },
|
||||
{ value: 'stepped', label: 'Stepped Recovery' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{modelArch?.additionalSections?.includes('train.audio_loss_multiplier') && (
|
||||
<NumberInput
|
||||
label="Audio Loss Multiplier"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].train.audio_loss_multiplier ?? 1.0}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.audio_loss_multiplier')}
|
||||
placeholder="eg. 1.0"
|
||||
docKey={'train.audio_loss_multiplier'}
|
||||
min={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<FormGroup label="EMA (Exponential Moving Average)">
|
||||
<Checkbox
|
||||
@@ -807,32 +830,34 @@ export default function SimpleJob({
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormGroup label="Text Encoder Optimizations" className="pt-2">
|
||||
{!disableSections.includes('train.unload_text_encoder') && (
|
||||
{!isLlmModel && (
|
||||
<FormGroup label="Text Encoder Optimizations" className="pt-2">
|
||||
{!disableSections.includes('train.unload_text_encoder') && (
|
||||
<Checkbox
|
||||
label="Unload TE"
|
||||
checked={jobConfig.config.process[0].train.unload_text_encoder || false}
|
||||
docKey={'train.unload_text_encoder'}
|
||||
onChange={value => {
|
||||
setJobConfig(value, 'config.process[0].train.unload_text_encoder');
|
||||
if (value) {
|
||||
setJobConfig(false, 'config.process[0].train.cache_text_embeddings');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label="Unload TE"
|
||||
checked={jobConfig.config.process[0].train.unload_text_encoder || false}
|
||||
docKey={'train.unload_text_encoder'}
|
||||
label="Cache Text Embeddings"
|
||||
checked={jobConfig.config.process[0].train.cache_text_embeddings || false}
|
||||
docKey={'train.cache_text_embeddings'}
|
||||
onChange={value => {
|
||||
setJobConfig(value, 'config.process[0].train.unload_text_encoder');
|
||||
setJobConfig(value, 'config.process[0].train.cache_text_embeddings');
|
||||
if (value) {
|
||||
setJobConfig(false, 'config.process[0].train.cache_text_embeddings');
|
||||
setJobConfig(false, 'config.process[0].train.unload_text_encoder');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label="Cache Text Embeddings"
|
||||
checked={jobConfig.config.process[0].train.cache_text_embeddings || false}
|
||||
docKey={'train.cache_text_embeddings'}
|
||||
onChange={value => {
|
||||
setJobConfig(value, 'config.process[0].train.cache_text_embeddings');
|
||||
if (value) {
|
||||
setJobConfig(false, 'config.process[0].train.unload_text_encoder');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{disableSections.includes('train.diff_output_preservation') ||
|
||||
@@ -914,212 +939,218 @@ export default function SimpleJob({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FormGroup label="Other" className="pt-2">
|
||||
<>
|
||||
<Checkbox
|
||||
label="Contrastive Guidance Loss"
|
||||
docKey={'train.do_guidance_loss'}
|
||||
className="pt-1"
|
||||
checked={jobConfig.config.process[0].train.do_guidance_loss || false}
|
||||
onChange={value => {
|
||||
if (value) {
|
||||
setJobConfig(true, 'config.process[0].train.do_guidance_loss');
|
||||
if (!jobConfig.config.process[0].train.guidance_loss_target) {
|
||||
setJobConfig(4.0, 'config.process[0].train.guidance_loss_target');
|
||||
{!isLlmModel && (
|
||||
<FormGroup label="Other" className="pt-2">
|
||||
<>
|
||||
<Checkbox
|
||||
label="Contrastive Guidance Loss"
|
||||
docKey={'train.do_guidance_loss'}
|
||||
className="pt-1"
|
||||
checked={jobConfig.config.process[0].train.do_guidance_loss || false}
|
||||
onChange={value => {
|
||||
if (value) {
|
||||
setJobConfig(true, 'config.process[0].train.do_guidance_loss');
|
||||
if (!jobConfig.config.process[0].train.guidance_loss_target) {
|
||||
setJobConfig(4.0, 'config.process[0].train.guidance_loss_target');
|
||||
}
|
||||
} else {
|
||||
setJobConfig(undefined, 'config.process[0].train.do_guidance_loss');
|
||||
setJobConfig(undefined, 'config.process[0].train.guidance_loss_target');
|
||||
}
|
||||
} else {
|
||||
setJobConfig(undefined, 'config.process[0].train.do_guidance_loss');
|
||||
setJobConfig(undefined, 'config.process[0].train.guidance_loss_target');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{jobConfig.config.process[0].train.do_guidance_loss && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Guidance Loss Target"
|
||||
docKey={'train.guidance_loss_target'}
|
||||
value={(jobConfig.config.process[0].train.guidance_loss_target as number) || 4.0}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.guidance_loss_target')}
|
||||
placeholder="eg. 3.0"
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<Card
|
||||
title="Validation"
|
||||
toggled={!!validationConfig}
|
||||
onToggle={value => {
|
||||
if (value) {
|
||||
setJobConfig(
|
||||
{
|
||||
validation_items: [{ image_path: '', prompt: '' }],
|
||||
resolution: 1024,
|
||||
validate_every_n_steps: 1,
|
||||
validation_sigmas: [0.5],
|
||||
},
|
||||
'config.process[0].train.validation_config',
|
||||
);
|
||||
} else {
|
||||
setJobConfig(undefined, 'config.process[0].train.validation_config');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{validationConfig && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Validation runs a stable loss check on a fixed set of images. Each image is encoded once at startup
|
||||
and predicted at the selected sigmas with fixed seeds, so the result is always deterministic and
|
||||
comparable across the run. The average loss is logged as val/loss every time validation runs. The
|
||||
images need to match the concept of your dataset, but{' '}
|
||||
<span className="font-bold text-gray-300">do not include the validation images in the dataset</span>.
|
||||
They must be images containing the concept you want to train, but not an image trained on.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<NumberInput
|
||||
label="Validate Every"
|
||||
value={validationConfig.validate_every_n_steps}
|
||||
onChange={value =>
|
||||
setJobConfig(value, 'config.process[0].train.validation_config.validate_every_n_steps')
|
||||
}
|
||||
placeholder="eg. 10"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Validation Resolution"
|
||||
value={validationConfig.resolution}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.validation_config.resolution')}
|
||||
placeholder="eg. 512"
|
||||
min={64}
|
||||
required
|
||||
/>
|
||||
<SelectInput
|
||||
label="Validation Sigmas"
|
||||
value={(validationConfig.validation_sigmas ?? [1.0, 0.75, 0.5, 0.25]).join(', ')}
|
||||
onChange={value =>
|
||||
setJobConfig(
|
||||
value.split(',').map((v: string) => parseFloat(v)),
|
||||
'config.process[0].train.validation_config.validation_sigmas',
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ value: '0.5', label: '0.5' },
|
||||
{ value: '1, 0.5', label: '1.0, 0.5' },
|
||||
{ value: '1, 0.66, 0.33', label: '1.0, 0.66, 0.33' },
|
||||
{ value: '1, 0.75, 0.5, 0.25', label: '1.0, 0.75, 0.5, 0.25' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-gray-300 mb-2">
|
||||
Validation Images ({validationConfig.validation_items.length})
|
||||
</label>
|
||||
{validationConfig.validation_items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg pl-4 pr-1 py-3 mb-4 bg-gray-950">
|
||||
<div className="flex items-center space-x-4">
|
||||
<SampleControlImage
|
||||
instruction="Add Image"
|
||||
src={item.image_path === '' ? null : item.image_path}
|
||||
onNewImageSelected={imagePath => {
|
||||
setJobConfig(
|
||||
imagePath ?? '',
|
||||
`config.process[0].train.validation_config.validation_items[${i}].image_path`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<TextInput
|
||||
label="Prompt"
|
||||
value={item.prompt}
|
||||
onChange={value =>
|
||||
setJobConfig(
|
||||
value,
|
||||
`config.process[0].train.validation_config.validation_items[${i}].prompt`,
|
||||
)
|
||||
}
|
||||
placeholder="Enter prompt"
|
||||
}}
|
||||
/>
|
||||
{jobConfig.config.process[0].train.do_guidance_loss && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Guidance Loss Target"
|
||||
docKey={'train.guidance_loss_target'}
|
||||
value={(jobConfig.config.process[0].train.guidance_loss_target as number) || 4.0}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.guidance_loss_target')}
|
||||
placeholder="eg. 3.0"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setJobConfig(
|
||||
validationConfig.validation_items.filter((_, index) => index !== i),
|
||||
'config.process[0].train.validation_config.validation_items',
|
||||
)
|
||||
}
|
||||
className="rounded-full p-1 text-sm"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setJobConfig(
|
||||
[...validationConfig.validation_items, { image_path: '', prompt: '' }],
|
||||
'config.process[0].train.validation_config.validation_items',
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Add Validation Image
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
<Card title="Advanced" collapsible>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Do Differential Guidance"
|
||||
docKey={'train.do_differential_guidance'}
|
||||
className="pt-1"
|
||||
checked={jobConfig.config.process[0].train.do_differential_guidance || false}
|
||||
onChange={value => {
|
||||
let newValue = value == false ? undefined : value;
|
||||
setJobConfig(newValue, 'config.process[0].train.do_differential_guidance');
|
||||
if (!newValue) {
|
||||
setJobConfig(undefined, 'config.process[0].train.differential_guidance_scale');
|
||||
} else if (
|
||||
jobConfig.config.process[0].train.differential_guidance_scale === undefined ||
|
||||
jobConfig.config.process[0].train.differential_guidance_scale === null
|
||||
) {
|
||||
// set default differential guidance scale to 3.0
|
||||
setJobConfig(3.0, 'config.process[0].train.differential_guidance_scale');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{jobConfig.config.process[0].train.differential_guidance_scale && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Differential Guidance Scale"
|
||||
className="pt-2"
|
||||
value={(jobConfig.config.process[0].train.differential_guidance_scale as number) || 3.0}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.differential_guidance_scale')}
|
||||
placeholder="eg. 3.0"
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</FormGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div>
|
||||
{!isLlmModel && (
|
||||
<Card
|
||||
title="Validation"
|
||||
toggled={!!validationConfig}
|
||||
onToggle={value => {
|
||||
if (value) {
|
||||
setJobConfig(
|
||||
{
|
||||
validation_items: [{ image_path: '', prompt: '' }],
|
||||
resolution: 1024,
|
||||
validate_every_n_steps: 1,
|
||||
validation_sigmas: [0.5],
|
||||
},
|
||||
'config.process[0].train.validation_config',
|
||||
);
|
||||
} else {
|
||||
setJobConfig(undefined, 'config.process[0].train.validation_config');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{validationConfig && (
|
||||
<>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Validation runs a stable loss check on a fixed set of images. Each image is encoded once at startup
|
||||
and predicted at the selected sigmas with fixed seeds, so the result is always deterministic and
|
||||
comparable across the run. The average loss is logged as val/loss every time validation runs. The
|
||||
images need to match the concept of your dataset, but{' '}
|
||||
<span className="font-bold text-gray-300">do not include the validation images in the dataset</span>
|
||||
. They must be images containing the concept you want to train, but not an image trained on.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<NumberInput
|
||||
label="Validate Every"
|
||||
value={validationConfig.validate_every_n_steps}
|
||||
onChange={value =>
|
||||
setJobConfig(value, 'config.process[0].train.validation_config.validate_every_n_steps')
|
||||
}
|
||||
placeholder="eg. 10"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Validation Resolution"
|
||||
value={validationConfig.resolution}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.validation_config.resolution')}
|
||||
placeholder="eg. 512"
|
||||
min={64}
|
||||
required
|
||||
/>
|
||||
<SelectInput
|
||||
label="Validation Sigmas"
|
||||
value={(validationConfig.validation_sigmas ?? [1.0, 0.75, 0.5, 0.25]).join(', ')}
|
||||
onChange={value =>
|
||||
setJobConfig(
|
||||
value.split(',').map((v: string) => parseFloat(v)),
|
||||
'config.process[0].train.validation_config.validation_sigmas',
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{ value: '0.5', label: '0.5' },
|
||||
{ value: '1, 0.5', label: '1.0, 0.5' },
|
||||
{ value: '1, 0.66, 0.33', label: '1.0, 0.66, 0.33' },
|
||||
{ value: '1, 0.75, 0.5, 0.25', label: '1.0, 0.75, 0.5, 0.25' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-gray-300 mb-2">
|
||||
Validation Images ({validationConfig.validation_items.length})
|
||||
</label>
|
||||
{validationConfig.validation_items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg pl-4 pr-1 py-3 mb-4 bg-gray-950">
|
||||
<div className="flex items-center space-x-4">
|
||||
<SampleControlImage
|
||||
instruction="Add Image"
|
||||
src={item.image_path === '' ? null : item.image_path}
|
||||
onNewImageSelected={imagePath => {
|
||||
setJobConfig(
|
||||
imagePath ?? '',
|
||||
`config.process[0].train.validation_config.validation_items[${i}].image_path`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<TextInput
|
||||
label="Prompt"
|
||||
value={item.prompt}
|
||||
onChange={value =>
|
||||
setJobConfig(
|
||||
value,
|
||||
`config.process[0].train.validation_config.validation_items[${i}].prompt`,
|
||||
)
|
||||
}
|
||||
placeholder="Enter prompt"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setJobConfig(
|
||||
validationConfig.validation_items.filter((_, index) => index !== i),
|
||||
'config.process[0].train.validation_config.validation_items',
|
||||
)
|
||||
}
|
||||
className="rounded-full p-1 text-sm"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setJobConfig(
|
||||
[...validationConfig.validation_items, { image_path: '', prompt: '' }],
|
||||
'config.process[0].train.validation_config.validation_items',
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Add Validation Image
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{!isLlmModel && (
|
||||
<Card title="Advanced" collapsible>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<Checkbox
|
||||
label="Do Differential Guidance"
|
||||
docKey={'train.do_differential_guidance'}
|
||||
className="pt-1"
|
||||
checked={jobConfig.config.process[0].train.do_differential_guidance || false}
|
||||
onChange={value => {
|
||||
let newValue = value == false ? undefined : value;
|
||||
setJobConfig(newValue, 'config.process[0].train.do_differential_guidance');
|
||||
if (!newValue) {
|
||||
setJobConfig(undefined, 'config.process[0].train.differential_guidance_scale');
|
||||
} else if (
|
||||
jobConfig.config.process[0].train.differential_guidance_scale === undefined ||
|
||||
jobConfig.config.process[0].train.differential_guidance_scale === null
|
||||
) {
|
||||
// set default differential guidance scale to 3.0
|
||||
setJobConfig(3.0, 'config.process[0].train.differential_guidance_scale');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{jobConfig.config.process[0].train.differential_guidance_scale && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Differential Guidance Scale"
|
||||
className="pt-2"
|
||||
value={(jobConfig.config.process[0].train.differential_guidance_scale as number) || 3.0}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].train.differential_guidance_scale')}
|
||||
placeholder="eg. 3.0"
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Card title="Datasets">
|
||||
<>
|
||||
@@ -1359,7 +1390,7 @@ export default function SimpleJob({
|
||||
/>
|
||||
)}
|
||||
</FormGroup>
|
||||
{!isAudioModel && (
|
||||
{!isAudioModel && !isLlmModel && (
|
||||
<FormGroup label="Flipping" docKey={'datasets.flip'} className="mt-2">
|
||||
<Checkbox
|
||||
label={
|
||||
@@ -1458,37 +1489,41 @@ export default function SimpleJob({
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<SelectInput
|
||||
label="Sampler"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].sample.sampler}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.sampler')}
|
||||
options={[
|
||||
{ value: 'flowmatch', label: 'FlowMatch' },
|
||||
{ value: 'ddpm', label: 'DDPM' },
|
||||
]}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Guidance Scale"
|
||||
value={jobConfig.config.process[0].sample.guidance_scale}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.guidance_scale')}
|
||||
placeholder="eg. 1.0"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Sample Steps"
|
||||
value={jobConfig.config.process[0].sample.sample_steps}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.sample_steps')}
|
||||
placeholder="eg. 1"
|
||||
className="pt-2"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
{!isLlmModel && (
|
||||
<>
|
||||
<SelectInput
|
||||
label="Sampler"
|
||||
className="pt-2"
|
||||
value={jobConfig.config.process[0].sample.sampler}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.sampler')}
|
||||
options={[
|
||||
{ value: 'flowmatch', label: 'FlowMatch' },
|
||||
{ value: 'ddpm', label: 'DDPM' },
|
||||
]}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Guidance Scale"
|
||||
value={jobConfig.config.process[0].sample.guidance_scale}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.guidance_scale')}
|
||||
placeholder="eg. 1.0"
|
||||
className="pt-2"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Sample Steps"
|
||||
value={jobConfig.config.process[0].sample.sample_steps}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.sample_steps')}
|
||||
placeholder="eg. 1"
|
||||
className="pt-2"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAudioModel && (
|
||||
{!isAudioModel && !isLlmModel && (
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Width"
|
||||
@@ -1532,33 +1567,35 @@ export default function SimpleJob({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Seed"
|
||||
value={jobConfig.config.process[0].sample.seed}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.seed')}
|
||||
placeholder="eg. 0"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
{modelArch?.additionalSections?.includes('sample.duration') && (
|
||||
{!isLlmModel && (
|
||||
<div>
|
||||
<NumberInput
|
||||
label="Duration (seconds)"
|
||||
value={jobConfig.config.process[0].sample.duration ?? 120}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.duration')}
|
||||
placeholder="eg. 120"
|
||||
className="pt-2"
|
||||
min={1}
|
||||
label="Seed"
|
||||
value={jobConfig.config.process[0].sample.seed}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.seed')}
|
||||
placeholder="eg. 0"
|
||||
min={0}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label="Walk Seed"
|
||||
className="pt-4 pl-2"
|
||||
checked={jobConfig.config.process[0].sample.walk_seed}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.walk_seed')}
|
||||
/>
|
||||
</div>
|
||||
{modelArch?.additionalSections?.includes('sample.duration') && (
|
||||
<NumberInput
|
||||
label="Duration (seconds)"
|
||||
value={jobConfig.config.process[0].sample.duration ?? 120}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.duration')}
|
||||
placeholder="eg. 120"
|
||||
className="pt-2"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
label="Walk Seed"
|
||||
className="pt-4 pl-2"
|
||||
checked={jobConfig.config.process[0].sample.walk_seed}
|
||||
onChange={value => setJobConfig(value, 'config.process[0].sample.walk_seed')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<FormGroup label="Advanced Sampling" className="pt-2">
|
||||
<div>
|
||||
@@ -1713,8 +1750,8 @@ export default function SimpleJob({
|
||||
label={`Prompt`}
|
||||
value={sample.prompt}
|
||||
onChange={value => setJobConfig(value, `config.process[0].sample.samples[${i}].prompt`)}
|
||||
placeholder="Enter prompt"
|
||||
required
|
||||
placeholder={isLlmModel ? 'blank = LLM Prompt' : 'Enter prompt'}
|
||||
required={!isLlmModel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -1746,7 +1783,7 @@ export default function SimpleJob({
|
||||
)}
|
||||
|
||||
<div className="grid w-full lg:grid-flow-col lg:auto-cols-fr gap-4 mt-2">
|
||||
{!isAudioModel && (
|
||||
{!isAudioModel && !isLlmModel && (
|
||||
<TextInput
|
||||
label={`Width`}
|
||||
value={sample.width ? `${sample.width}` : ''}
|
||||
@@ -1775,7 +1812,7 @@ export default function SimpleJob({
|
||||
placeholder={`${jobConfig.config.process[0].sample.width} (default)`}
|
||||
/>
|
||||
)}
|
||||
{!isAudioModel && (
|
||||
{!isAudioModel && !isLlmModel && (
|
||||
<TextInput
|
||||
label={`Height`}
|
||||
value={sample.height ? `${sample.height}` : ''}
|
||||
@@ -1804,33 +1841,35 @@ export default function SimpleJob({
|
||||
placeholder={`${jobConfig.config.process[0].sample.height} (default)`}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={`Seed`}
|
||||
value={sample.seed ? `${sample.seed}` : ''}
|
||||
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].seed;
|
||||
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}].seed`);
|
||||
{!isLlmModel && (
|
||||
<TextInput
|
||||
label={`Seed`}
|
||||
value={sample.seed ? `${sample.seed}` : ''}
|
||||
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].seed;
|
||||
setJobConfig(
|
||||
newConfig.config.process[0].sample.samples,
|
||||
'config.process[0].sample.samples',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid seed value:', value);
|
||||
const intValue = parseInt(value);
|
||||
if (!isNaN(intValue)) {
|
||||
setJobConfig(intValue, `config.process[0].sample.samples[${i}].seed`);
|
||||
} else {
|
||||
console.warn('Invalid seed value:', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={`${jobConfig.config.process[0].sample.walk_seed ? jobConfig.config.process[0].sample.seed + i : jobConfig.config.process[0].sample.seed} (default)`}
|
||||
/>
|
||||
}}
|
||||
placeholder={`${jobConfig.config.process[0].sample.walk_seed ? jobConfig.config.process[0].sample.seed + i : jobConfig.config.process[0].sample.seed} (default)`}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label={`LoRA Scale`}
|
||||
value={sample.network_multiplier ? `${sample.network_multiplier}` : ''}
|
||||
@@ -1883,6 +1922,8 @@ export default function SimpleJob({
|
||||
{modelArch?.additionalSections?.includes('sample.ctrl_img') && (
|
||||
<SampleControlImage
|
||||
className="mt-6 ml-4"
|
||||
instruction={isLlmModel ? 'Add Media' : undefined}
|
||||
allowAudio={isLlmModel}
|
||||
src={sample.ctrl_img}
|
||||
onNewImageSelected={imagePath => {
|
||||
if (!imagePath) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
defaultSampleConfig,
|
||||
defaultIdeogramSamplesConfig,
|
||||
defaultYue2SampleConfig,
|
||||
defaultQwen25OmniSampleConfig,
|
||||
} from '@/helpers/defaultSamples';
|
||||
|
||||
type Control = 'depth' | 'line' | 'pose' | 'inpaint';
|
||||
@@ -42,9 +43,10 @@ type AdditionalSections =
|
||||
| 'model.assistant_lora_path'
|
||||
| 'model.unconditional_lora_path'
|
||||
| 'model.model_kwargs.kv_cache'
|
||||
| 'model.model_kwargs.instruction'
|
||||
| 'ideogram_4_prompt';
|
||||
|
||||
type ModelGroup = 'image' | 'instruction' | 'video' | 'experimental' | 'audio';
|
||||
type ModelGroup = 'image' | 'instruction' | 'video' | 'experimental' | 'audio' | 'llm';
|
||||
|
||||
export interface CustomModelSelectOption {
|
||||
label: string;
|
||||
@@ -1378,6 +1380,62 @@ export const modelArchs: ModelArch[] = [
|
||||
disableSections: ['network.conv'],
|
||||
additionalSections: ['sample.multi_ctrl_imgs', 'model.low_vram', 'model.layer_offloading'],
|
||||
},
|
||||
{
|
||||
name: 'qwen25_omni',
|
||||
label: 'Qwen2.5-Omni',
|
||||
group: 'llm',
|
||||
defaults: {
|
||||
// default updates when [selected, unselected] in the UI
|
||||
'config.process[0].model.name_or_path': [
|
||||
'ostris/qwen2_5_omni/text_encoders/qwen2_5_omni_7b_convrot8.safetensors',
|
||||
defaultNameOrPath,
|
||||
],
|
||||
'config.process[0].model.quantize': [true, false],
|
||||
'config.process[0].model.quantize_te': [false, false],
|
||||
'config.process[0].model.low_vram': [false, false],
|
||||
// the single-file thinker ships convrot8 layers; requesting convrot8 keeps them as-is
|
||||
'config.process[0].model.qtype': ['convrot8', 'qfloat8'],
|
||||
'config.process[0].train.unload_text_encoder': [false, false],
|
||||
'config.process[0].train.noise_scheduler': ['flowmatch', 'flowmatch'],
|
||||
'config.process[0].train.batch_size': [1, 1],
|
||||
'config.process[0].sample': [defaultQwen25OmniSampleConfig, defaultSampleConfig],
|
||||
// media is encoded on the GPU per step; the cache is optional and large (~54 MB per 300 s of audio)
|
||||
'config.process[0].datasets[x].cache_latents_to_disk': [false, true],
|
||||
'config.process[0].datasets[x].resolution': [[512], [512, 768, 1024]],
|
||||
// the caption is the training target; a blank one trains nothing
|
||||
'config.process[0].datasets[x].caption_dropout_rate': [0, 0.05],
|
||||
'config.process[0].model.model_kwargs': [{ instruction: 'Describe this in detail.' }, {}],
|
||||
},
|
||||
disableSections: [
|
||||
'network.conv',
|
||||
'trigger_word',
|
||||
'train.diff_output_preservation',
|
||||
'train.blank_prompt_preservation',
|
||||
'train.unload_text_encoder',
|
||||
'slider',
|
||||
],
|
||||
additionalSections: ['model.model_kwargs.instruction', 'sample.ctrl_img', 'datasets.num_frames'],
|
||||
modelNotes: (
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
Qwen2.5-Omni 7B thinker as a text-generating model: audio, image or video in, text out. Each dataset item is a
|
||||
media file (mp3, wav, flac, ogg, jpg, png, webp, mp4, ...) with a caption file next to it; the caption is the
|
||||
text the model learns to produce for that media. One dataset can mix all three kinds. Video files are used
|
||||
when Num Frames is above 1 and are seen as frames only (no audio track).
|
||||
</p>
|
||||
<p>
|
||||
The instruction in <code>model_kwargs.instruction</code> is the user turn for every training item; use the
|
||||
same wording when captioning with the trained LoRA. Samples take a media file per prompt and write the
|
||||
generated text as a .txt file.
|
||||
</p>
|
||||
<p>
|
||||
Media is encoded by the frozen audio and vision towers on the GPU each step, so Cache Latents to Disk can stay
|
||||
off (the cache is about 54 MB per 300 seconds of audio). Batch size is 1. The LoRA trains the text stack only.
|
||||
Watch <code>loss/ce</code>: next-token cross-entropy on the caption.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'yue2',
|
||||
label: 'YuE2',
|
||||
@@ -1402,7 +1460,10 @@ export const modelArchs: ModelArch[] = [
|
||||
'config.process[0].datasets[x].resolution': [[512], [512, 768, 1024]],
|
||||
// blank captions break lyric following; the AR must always see the prefix
|
||||
'config.process[0].datasets[x].caption_dropout_rate': [0, 0.05],
|
||||
'config.process[0].model.model_kwargs': [{ cot: 'full', abc_dropout: 0.5, sample_ar_repetition_penalty: 1.2, ar_kl_weight: 0.2 }, {}],
|
||||
'config.process[0].model.model_kwargs': [
|
||||
{ cot: 'full', abc_dropout: 0.5, sample_ar_repetition_penalty: 1.2, ar_kl_weight: 0.2 },
|
||||
{},
|
||||
],
|
||||
},
|
||||
// native YuE2 prompt: style text, a [Lyrics] line, the lyrics
|
||||
hasMultiLinePrompts: true,
|
||||
|
||||
@@ -9,13 +9,17 @@ import { encodeFilePathForUrl } from '@/utils/basic';
|
||||
import type { AxiosProgressEvent } from 'axios';
|
||||
|
||||
const VIDEO_EXTS = ['.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v', '.wmv', '.flv'];
|
||||
const AUDIO_EXTS = ['.mp3', '.wav', '.flac', '.ogg'];
|
||||
const isVideoPath = (p: string) => VIDEO_EXTS.some(ext => p.toLowerCase().endsWith(ext));
|
||||
const isAudioPath = (p: string) => AUDIO_EXTS.some(ext => p.toLowerCase().endsWith(ext));
|
||||
|
||||
interface Props {
|
||||
src: string | null | undefined;
|
||||
className?: string;
|
||||
instruction?: string;
|
||||
onNewImageSelected: (imagePath: string | null) => void;
|
||||
/** also accept audio files (text-generating models take any media) */
|
||||
allowAudio?: boolean;
|
||||
}
|
||||
|
||||
export default function SampleControlImage({
|
||||
@@ -23,6 +27,7 @@ export default function SampleControlImage({
|
||||
className,
|
||||
instruction = 'Add Control Image',
|
||||
onNewImageSelected,
|
||||
allowAudio = false,
|
||||
}: Props) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
@@ -31,7 +36,8 @@ export default function SampleControlImage({
|
||||
|
||||
const backgroundUrl = useMemo(() => {
|
||||
if (localPreview) return localPreview;
|
||||
// videos preview as a server-generated thumbnail
|
||||
// videos preview as a server-generated thumbnail, audio as its waveform art
|
||||
if (src && isAudioPath(src)) return `/api/audio/art/${encodeURIComponent(src)}`;
|
||||
if (src) return `/api/img/${encodeFilePathForUrl(src)}${isVideoPath(src) ? '?thumb=1' : ''}`;
|
||||
return null;
|
||||
}, [src, localPreview]);
|
||||
@@ -104,6 +110,7 @@ export default function SampleControlImage({
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
|
||||
'video/*': VIDEO_EXTS,
|
||||
...(allowAudio ? { 'audio/*': AUDIO_EXTS } : {}),
|
||||
},
|
||||
multiple: false,
|
||||
noClick: true,
|
||||
@@ -138,7 +145,7 @@ export default function SampleControlImage({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
accept={allowAudio ? 'image/*,video/*,audio/*' : 'image/*,video/*'}
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useState, ReactNode } from 'react';
|
||||
import { isVideo, isAudio, encodeFilePathForUrl } from '@/utils/basic';
|
||||
import { isVideo, isAudio, isText, encodeFilePathForUrl } from '@/utils/basic';
|
||||
|
||||
interface SampleImageCardProps {
|
||||
imageUrl: string;
|
||||
@@ -39,6 +39,28 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
|
||||
const isItAudio = isAudio(imageUrl);
|
||||
const isItVideo = isVideo(imageUrl);
|
||||
const isItText = isText(imageUrl);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
|
||||
// text samples: fetch the file body and render it in the card
|
||||
useEffect(() => {
|
||||
if (!isItText || !isVisible) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`/api/img/${encodeFilePathForUrl(imageUrl)}`, { signal: controller.signal })
|
||||
.then(r => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then(t => {
|
||||
setText(t);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err?.name !== 'AbortError') console.error('Sample text fetch failed:', err);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
setText(null);
|
||||
setLoaded(false);
|
||||
};
|
||||
}, [isItText, isVisible, imageUrl]);
|
||||
|
||||
// Observe both enter and exit
|
||||
useEffect(() => {
|
||||
@@ -69,7 +91,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
// the element unmounts). A short debounce skips requests entirely during fast
|
||||
// scrolls where the card is only briefly visible.
|
||||
useEffect(() => {
|
||||
if (isItAudio) return;
|
||||
if (isItAudio || isItText) return;
|
||||
if (!isVisible) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -114,7 +136,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
setLoaded(false);
|
||||
setVideoFallback(false);
|
||||
};
|
||||
}, [isVisible, isItAudio, isItVideo, imageUrl]);
|
||||
}, [isVisible, isItAudio, isItText, isItVideo, imageUrl]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
@@ -127,14 +149,23 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={`absolute rounded-t-lg shadow-md bg-gray-900 overflow-hidden transition-all duration-200 ${
|
||||
className={`absolute rounded-t-lg shadow-md bg-gray-900 overflow-hidden transition-all duration-200 [container-type:inline-size] ${
|
||||
selected ? 'inset-2' : 'inset-0'
|
||||
} ${
|
||||
isVisible && !isItAudio && !loaded ? 'animate-pulse' : ''
|
||||
}`}
|
||||
} ${isVisible && !isItAudio && !loaded ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
{isVisible ? (
|
||||
isItAudio ? (
|
||||
isItText ? (
|
||||
// font size scales with the card and the text length: a square of side W holds
|
||||
// ~W^2 / (0.6 f^2) characters at size f, so f ~ 120/sqrt(n) cqw fills the box
|
||||
<div
|
||||
className="w-full h-full overflow-hidden p-[3cqw] leading-snug text-gray-200 whitespace-pre-wrap break-words text-left bg-gray-900"
|
||||
style={{
|
||||
fontSize: `clamp(2.5cqw, ${(120 / Math.sqrt(Math.max((text ?? '').length, 1))).toFixed(2)}cqw, 12cqw)`,
|
||||
}}
|
||||
>
|
||||
{text ?? ''}
|
||||
</div>
|
||||
) : isItAudio ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
||||
<img
|
||||
src={`/api/audio/art/${encodeURIComponent(imageUrl)}`}
|
||||
|
||||
@@ -8,7 +8,7 @@ import classNames from 'classnames';
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { openConfirm } from './ConfirmModal';
|
||||
import { apiClient } from '@/utils/api';
|
||||
import { isVideo, isAudio, encodeFilePathForUrl } from '@/utils/basic';
|
||||
import { isVideo, isAudio, isText, encodeFilePathForUrl } from '@/utils/basic';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
|
||||
import BoundingBoxOverlay, { parseBoundingBoxes } from './BoundingBoxOverlay';
|
||||
@@ -199,6 +199,27 @@ export default function SampleImageViewer({
|
||||
return imgPath;
|
||||
}, [showingControlIdx, controlImages, imgPath]);
|
||||
|
||||
// text samples (LLM models): the file body is the sample
|
||||
const [sampleText, setSampleText] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!displayedImgPath || !isText(displayedImgPath)) {
|
||||
setSampleText(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
fetch(`/api/img/${encodeFilePathForUrl(displayedImgPath)}`, { signal: controller.signal })
|
||||
.then(r => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then(setSampleText)
|
||||
.catch(err => {
|
||||
if (err?.name !== 'AbortError') console.error('Sample text fetch failed:', err);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [displayedImgPath]);
|
||||
|
||||
// control thumbnails: audio has no image thumb, it gets its waveform art
|
||||
const thumbSrc = (p: string) =>
|
||||
isAudio(p) ? `/api/audio/art/${encodeURIComponent(p)}` : `/api/img/${encodeFilePathForUrl(p)}?thumb=1`;
|
||||
|
||||
// The sample's prompt is what generated it; if it's an Ideogram bbox-JSON we can
|
||||
// overlay the boxes on the generated image. Only on the main image (not controls).
|
||||
const boundingBoxes = useMemo(
|
||||
@@ -311,7 +332,11 @@ export default function SampleImageViewer({
|
||||
>
|
||||
<div className="overflow-hidden flex items-center justify-center">
|
||||
{displayedImgPath &&
|
||||
(isAudio(displayedImgPath) ? (
|
||||
(isText(displayedImgPath) ? (
|
||||
<div className="w-[640px] max-w-full sm:max-w-[95vw] max-h-[82vh] overflow-y-auto p-6 text-sm text-gray-100 whitespace-pre-wrap break-words text-left">
|
||||
{sampleText ?? 'Loading…'}
|
||||
</div>
|
||||
) : isAudio(displayedImgPath) ? (
|
||||
<div className="w-[500px] h-[500px] max-w-full sm:max-w-[95vw] max-h-[82vh]">
|
||||
<AudioPlayer
|
||||
src={`/api/img/${encodeFilePathForUrl(displayedImgPath)}`}
|
||||
@@ -370,19 +395,28 @@ export default function SampleImageViewer({
|
||||
</div>
|
||||
{controlImages.length > 0 && (
|
||||
<div key={imgPath} className="flex space-x-2 mr-4">
|
||||
{showingControlIdx !== null && (
|
||||
<img
|
||||
src={`/api/img/${encodeFilePathForUrl(imgPath!)}?thumb=1`}
|
||||
alt="Main"
|
||||
className="max-h-12 max-w-12 object-contain bg-black border-2 border-gray-700 hover:border-gray-500 rounded cursor-pointer"
|
||||
onClick={() => setShowingControlIdx(null)}
|
||||
title="Main image"
|
||||
/>
|
||||
)}
|
||||
{showingControlIdx !== null &&
|
||||
(isText(imgPath!) ? (
|
||||
<div
|
||||
className="h-12 w-12 flex items-center justify-center text-xs bg-black border-2 border-gray-700 hover:border-gray-500 rounded cursor-pointer"
|
||||
onClick={() => setShowingControlIdx(null)}
|
||||
title="Generated text"
|
||||
>
|
||||
Txt
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbSrc(imgPath!)}
|
||||
alt="Main"
|
||||
className="max-h-12 max-w-12 object-contain bg-black border-2 border-gray-700 hover:border-gray-500 rounded cursor-pointer"
|
||||
onClick={() => setShowingControlIdx(null)}
|
||||
title="Main image"
|
||||
/>
|
||||
))}
|
||||
{controlImages.map((ci, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={`/api/img/${encodeFilePathForUrl(ci)}?thumb=1`}
|
||||
src={thumbSrc(ci)}
|
||||
alt={`Control ${idx + 1}`}
|
||||
className={`max-h-12 max-w-12 object-contain bg-black border-2 rounded cursor-pointer ${
|
||||
showingControlIdx === idx ? 'border-blue-500' : 'border-gray-700 hover:border-gray-500'
|
||||
@@ -429,7 +463,7 @@ export default function SampleImageViewer({
|
||||
anchor="bottom end"
|
||||
className="bg-gray-900 border border-gray-700 rounded shadow-lg w-48 px-2 py-2 mt-1 z-50"
|
||||
>
|
||||
{imgPath && isAudio(imgPath) && (
|
||||
{imgPath && (isAudio(imgPath) || isText(imgPath)) && (
|
||||
<MenuItem>
|
||||
<a
|
||||
className="cursor-pointer px-4 py-1 hover:bg-gray-800 rounded block"
|
||||
|
||||
+21
-15
@@ -340,24 +340,30 @@ const docs: { [key: string]: ConfigDoc } = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
'model.model_kwargs.instruction': {
|
||||
title: 'LLM Prompt',
|
||||
description: (
|
||||
<>
|
||||
The instruction the model is asked for every training item, as the user turn of the chat. The item's caption
|
||||
file is the answer it learns to give. Use the same wording when running the trained LoRA, and it is also what
|
||||
samples use when their own prompt is left blank.
|
||||
</>
|
||||
),
|
||||
},
|
||||
'model.model_kwargs.kv_cache': {
|
||||
title: 'KV Cache',
|
||||
description: (
|
||||
<>
|
||||
This will enable KV Cache for control images in a model that supports it. LoRAs trained with this on
|
||||
need to also be inferenced with it, and vice versa. This does not speed up or slow down training, but on inference,
|
||||
the control images only need to be processed once for the entire generation, vs being processed for every step.
|
||||
This will enable KV Cache for control images in a model that supports it. LoRAs trained with this on need to
|
||||
also be inferenced with it, and vice versa. This does not speed up or slow down training, but on inference, the
|
||||
control images only need to be processed once for the entire generation, vs being processed for every step.
|
||||
Which leads to a significant speedup on inference.
|
||||
</>
|
||||
),
|
||||
},
|
||||
'train.guidance_loss_target': {
|
||||
title: 'Guidance Loss Target',
|
||||
description: (
|
||||
<>
|
||||
For contrastive guidance loss, this is the target CGF to amplify predictions to.
|
||||
</>
|
||||
),
|
||||
description: <>For contrastive guidance loss, this is the target CGF to amplify predictions to.</>,
|
||||
},
|
||||
'datasets.caption_dropout_rate': {
|
||||
title: 'Caption Dropout Rate',
|
||||
@@ -365,15 +371,15 @@ const docs: { [key: string]: ConfigDoc } = {
|
||||
<>
|
||||
Caption dropout rate is the probability that the caption for an image will be dropped (replaced with a blank
|
||||
caption) for any given training step. For example, a value of 0.05 will drop the caption around 5% of the time.
|
||||
Dropping captions helps the model learn the concept being trained without relying entirely on the caption,
|
||||
and helps preserve the model's ability to generate without a prompt. If a trigger word is set, the trigger
|
||||
word is still used when the caption is dropped, so the model still associates the dropped samples with your
|
||||
trigger word. Regularization images, or images without a trigger word, drop to a fully blank caption.
|
||||
Dropping captions helps the model learn the concept being trained without relying entirely on the caption, and
|
||||
helps preserve the model's ability to generate without a prompt. If a trigger word is set, the trigger word
|
||||
is still used when the caption is dropped, so the model still associates the dropped samples with your trigger
|
||||
word. Regularization images, or images without a trigger word, drop to a fully blank caption.
|
||||
<br />
|
||||
<br />
|
||||
Caption dropout also works when caching text embeddings. An additional embedding for the dropout caption
|
||||
(blank, or the trigger word alone) is cached to disk alongside the normal one, and it is randomly swapped in
|
||||
at train time at this rate.
|
||||
Caption dropout also works when caching text embeddings. An additional embedding for the dropout caption (blank,
|
||||
or the trigger word alone) is cached to disk alongside the normal one, and it is randomly swapped in at train
|
||||
time at this rate.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -985,6 +985,16 @@ const tagValue = (prompt: string, tag: string) => {
|
||||
return m ? m[1].trim() : '';
|
||||
};
|
||||
|
||||
// text-generating models: the prompt is the instruction, ctrl_img the media; output is a .txt
|
||||
export const defaultQwen25OmniSampleConfig: SampleConfig = {
|
||||
...defaultSampleConfig,
|
||||
width: 512,
|
||||
height: 512,
|
||||
guidance_scale: 1,
|
||||
sample_steps: 1,
|
||||
samples: [{ prompt: 'Describe this in detail.' }, { prompt: 'Describe this in detail.' }],
|
||||
};
|
||||
|
||||
export const defaultYue2SampleConfig: SampleConfig = {
|
||||
...defaultAudioSampleConfig,
|
||||
samples: defaultAudioSampleConfig.samples.map(s => ({
|
||||
|
||||
Reference in new issue
Block a user