initial commit

This commit is contained in:
Aggtaa
2026-09-23 21:56:40 +03:00
commit 77da3ec94e
39 changed files with 9667 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
import { Link } from 'react-router';
import type { ImageBatch } from '../../shared/api.ts';
import { hasProject } from '../../shared/api.ts';
import { thumbUrl } from '../lib/api.ts';
import { previewPrompt } from './Text2ImgForm.tsx';
/** Текст состояния батча: «черновик», «в очереди: позиция 2 · 5 с», «генерация… 12 с», «ошибка: …». */
export function batchStateText(b: ImageBatch): { text: string; error: boolean } | null {
if (b.state === 'draft') return { text: 'черновик', error: false };
if (b.state === 'error') return { text: 'ошибка: ' + (b.error ?? ''), error: true };
if (b.state !== 'pending') return null;
const sec = Math.max(0, Math.round((Date.now() - b.updated) / 1000));
if (b.live?.state === 'queued') return { text: `в очереди: позиция ${b.live.position} · ${sec} с`, error: false };
if (b.live?.state === 'running') return { text: `генерация… ${sec} с`, error: false };
return { text: `ожидание… ${sec} с`, error: false };
}
/** «1 картинка», «3 картинки», «6 картинок». */
function pictures(n: number): string {
const d = n % 10, dd = n % 100;
const word = d === 1 && dd !== 11 ? 'картинка' : d >= 2 && d <= 4 && (dd < 12 || dd > 14) ? 'картинки' : 'картинок';
return `${n} ${word}`;
}
/**
* Карточка батча (вся — ссылка на его страницу): картинки батча, под ними строки «форма» и «цвета» (text)
* или «Uploaded» (upload). У старых прогонов без формы и цветов (промпт без шаблона) — промпт целиком.
* Картинки, из которых созданы проекты, и батч с хотя бы одной такой картинкой — с обводкой.
* Показываются только прикреплённые картинки; у запущенного батча недостающие до запрошенного количества —
* пустые блоки с «…»; у черновика — один блок «черновик, N картинок».
*/
export function BatchCard({ batch: b }: { batch: ImageBatch }) {
const lines = b.sourceType === 'upload' ? ['Uploaded']
: [b.source.input.shape, b.source.input.colors].filter(Boolean);
if (b.sourceType === 'text' && !lines.length) lines.push(b.prompt || previewPrompt(b.source.input));
const draft = b.sourceType === 'text' && b.state === 'draft';
// запущенный text-батч: недостающие до запрошенного количества картинки — пустые блоки с «…»
const missing = b.sourceType === 'text' && !draft ? Math.max(0, b.source.input.batch - b.images.length) : 0;
// подсказка: дата и (для text) промпт и негатив целиком
const tooltip = [
new Date(b.created).toLocaleString(),
...(b.sourceType === 'text' ? [
b.prompt || previewPrompt(b.source.input),
...(b.source.input.negative.trim() ? ['Негатив: ' + b.source.input.negative] : []),
] : []),
].join('\n');
return (
<Link to={'/batch/' + b.id} className={'run run-link' + (hasProject(b) ? ' in-project' : '')} title={tooltip}>
<div className="strip">
{b.images.map(i => (
<div key={i.id} className={'th' + (i.projectId != null ? ' in-project' : '')}>
<img src={thumbUrl(i)} alt={i.filename} loading="lazy" />
</div>
))}
{Array.from({ length: missing }, (_, n) => <div key={n} className="th">…</div>)}
{draft && <div className="th draft">черновик<br />{pictures(b.source.input.batch)}</div>}
</div>
{lines.map((line, n) => <div key={n} className="prompt">{line}</div>)}
</Link>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { FileRef } from '../../shared/api.ts';
import { fileUrl } from '../lib/api.ts';
// <model-viewer> (https://modelviewer.dev) — из node_modules, в сборку попадает отдельным чанком, интернет не нужен.
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'model-viewer': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
src?: string; alt?: string; loading?: string; 'camera-controls'?: string; 'camera-orbit'?: string;
'shadow-intensity'?: string; 'environment-image'?: string; exposure?: string;
};
}
}
}
let loading: Promise<unknown> | null = null;
function loadModelViewer() {
loading ??= import('@google/model-viewer');
return loading;
}
export type View = 'img' | '3d';
/** Переключатель из нескольких кнопок. */
function Seg<T extends string>({ options, value, onChange }: {
options: readonly (readonly [T, string])[]; value: T; onChange: (v: T) => void;
}) {
return (
<span className="seg" onClick={e => e.stopPropagation()}>
{options.map(([v, text]) => (
<button key={v} type="button" className={v === value ? 'on' : ''} onClick={() => onChange(v)}>{text}</button>
))}
</span>
);
}
/** Переключатель «картинка / 3D». */
export function ViewToggle({ value, onChange }: { value: View; onChange: (v: View) => void }) {
return <Seg options={[['img', 'картинка'], ['3d', '3D']] as const} value={value} onChange={onChange} />;
}
/**
* Готовая модель: интерактивный GLB или PNG-превью.
* Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще
* (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer.
*/
export function ModelView({ glb, preview, view, onOpen }: {
glb?: FileRef | null; preview?: FileRef | null; view: View; onOpen: (src: string, caption: string) => void;
}) {
const [ready, setReady] = useState(false); // скрипт model-viewer загружен
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB
const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани)
const ref = useRef<HTMLElement>(null);
const src = !glb ? '' : smooth ? fileUrl(glb)
: '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder });
useEffect(() => {
if (view !== '3d' || !src) return;
let alive = true;
loadModelViewer().then(() => { if (alive) setReady(true); }, () => { if (alive) setState('error'); });
return () => { alive = false; };
}, [view, src]);
useEffect(() => { setState('loading'); }, [src]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onLoad = () => setState('loaded');
const onError = () => setState('error');
el.addEventListener('load', onLoad);
el.addEventListener('error', onError);
return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); };
}, [ready, src, view]);
const previewImg = preview && (
<img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} />
);
if (view === '3d' && glb) {
return (
<div className="mv-wrap">
{ready && (
<model-viewer ref={ref} src={src} alt={glb.filename} loading="eager" camera-controls="" camera-orbit="35deg 80deg auto"
shadow-intensity="1" environment-image="neutral" exposure="0.8"
style={{ visibility: state === 'loaded' ? 'visible' : 'hidden' }} />
)}
{state !== 'loaded' && (previewImg || <span className="mv-note">загрузка 3D…</span>)}
{state === 'error' && <span className="mv-note err">3D не загрузилась</span>}
<span className="mv-seg">
<Seg options={[['smooth', 'сглаживание'], ['flat', 'без']]} value={smooth ? 'smooth' : 'flat'}
onChange={v => setSmooth(v === 'smooth')} />
</span>
</div>
);
}
return previewImg || <span>превью нет</span>;
}
/** Ссылки «GLB» и «PNG» для скачивания; before — ещё кнопки слева от них. */
export function ModelLinks({ glb, preview, before }: { glb?: FileRef | null; preview?: FileRef | null; before?: ReactNode }) {
const items = [[glb, 'GLB', 'main'], [preview, 'PNG', '']] as const;
return (
<div className="links">
{before}
{items.map(([i, text, cls]) => i && (
<a key={text} href={fileUrl(i)} download={i.filename} className={cls} title={'скачать ' + i.filename}
onClick={e => e.stopPropagation()}>{text}</a>
))}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router';
import type { QueueInfo, QueueItem } from '../../shared/api.ts';
import { api } from '../lib/api.ts';
const LINKS = [
['/', 'Исходники'],
['/projects', 'Проекты'],
] as const;
export function Nav() {
return (
<nav className="nav">
{LINKS.map(([to, text]) => <NavLink key={to} to={to} end>{text}</NavLink>)}
<QueueIndicator />
</nav>
);
}
function counts(list: QueueItem[]): string {
const c = new Map<string, number>();
list.forEach(x => c.set(x.kind, (c.get(x.kind) ?? 0) + 1));
return [...c].map(([k, n]) => k + (n > 1 ? ' ×' + n : '')).join(', ');
}
/** Состояние очереди ComfyUI: свободен / чем занят и сколько ждёт / недоступен. Обновляется каждые 2 с. */
function QueueIndicator() {
const [q, setQ] = useState<QueueInfo | null>(null);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
const update = async () => {
if (document.hidden) return;
try {
const info = await api<QueueInfo>('/api/queue');
if (alive) { setQ(info); setError(''); }
} catch (e) {
if (alive) setError((e as Error).message);
}
};
void update();
const t = setInterval(update, 2000);
document.addEventListener('visibilitychange', update);
return () => { alive = false; clearInterval(t); document.removeEventListener('visibilitychange', update); };
}, []);
if (error) return <span className="queue down" title={error}><span className="dot" />ComfyUI недоступен</span>;
if (!q) return <span className="queue"><span className="dot" />ComfyUI…</span>;
if (!q.running.length && !q.pending.length) return <span className="queue idle"><span className="dot" />ComfyUI свободен</span>;
return (
<span className="queue busy" title={q.pending.length ? 'Ждут: ' + counts(q.pending) : ''}>
<span className="dot" />ComfyUI: <b>{counts(q.running) || '—'}</b>
{q.pending.length > 0 && <> · в очереди <b>{q.pending.length}</b></>}
</span>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { CSSProperties, FormEvent, ReactNode } from 'react';
import type { Text2ImgInput, Text2ImgSettings } from '../../shared/api.ts';
import { DEFAULT_TEMPLATE } from '../../shared/api.ts';
export type Values = Text2ImgSettings;
export type SetValue = <K extends keyof Values>(k: K, v: Values[K]) => void;
/** Значения полей для новой генерации, если сохранённых ещё нет. */
export const DEFAULT_VALUES: Values = {
shape: 'A heavily armored fantasy knight',
colors: 'Deep navy plated armor with gold trim, red cape, glowing red eyes through the visor',
template: '',
negative: '(sprite sheet, character sheet, multiple views, front and back view, turnaround:1.5), (multiple characters, duplicate:1.4), '
+ '(cropped, feet out of frame:1.3), head out of frame, (weapon, sword, holding weapon:1.4), shield, busy background, scenery, '
+ 'cast shadow, text, watermark, signature, blurry, deformed, extra limbs',
batch: '4',
seed: '0',
promptsOpen: false,
};
export const toInput = (v: Values): Text2ImgInput => ({
shape: v.shape, colors: v.colors, template: v.template, negative: v.negative, batch: Number(v.batch), seed: v.seed,
});
export const toValues = (i: Text2ImgInput, promptsOpen: boolean): Values => ({ ...i, batch: String(i.batch), promptsOpen });
/** Итоговый промпт, как его соберёт сервер (форма и цвета — без точек в конце). */
export function previewPrompt(v: Pick<Values, 'shape' | 'colors' | 'template'>) {
const clean = (s: string) => s.trim().replace(/\.+$/, '');
const values: Record<string, string> = { '{a}': clean(v.shape), '{b}': clean(v.colors) };
return (v.template.trim() || DEFAULT_TEMPLATE).split(/(\{[ab]\})/).map(p => values[p] ?? p).join('');
}
/**
* Поля text2img. readOnly — поля недоступны (батч уже запущен), кнопка действия при этом работает.
* action — кнопка отправки формы («Сгенерировать» или «Дублировать»), extra — дополнительные кнопки рядом.
*/
export function Text2ImgForm({ values: v, set, readOnly, action, actionDisabled, onAction, extra }: {
values: Values; set: SetValue; readOnly: boolean;
action: string; actionDisabled: boolean; onAction: () => void; extra?: ReactNode;
}) {
return (
<form onSubmit={(e: FormEvent) => { e.preventDefault(); onAction(); }}>
<fieldset disabled={readOnly}>
<label>Форма персонажа {'{a}'}
<textarea rows={2} value={v.shape} onChange={e => set('shape', e.target.value)} />
</label>
<label><span>Цвета и декали {'{b}'} <small>— без точки в конце</small></span>
<textarea rows={2} value={v.colors} onChange={e => set('colors', e.target.value)} />
</label>
</fieldset>
<details open={v.promptsOpen} onToggle={e => set('promptsOpen', (e.target as HTMLDetailsElement).open)}>
<summary>Промпты</summary>
<fieldset disabled={readOnly}>
<label className="sub-field">
<span>Шаблон позитивного промпта <small>— {'{a}'} и {'{b}'} подставляются из полей выше; пусто = шаблон по умолчанию</small></span>
<textarea rows={3} placeholder={DEFAULT_TEMPLATE} value={v.template} onChange={e => set('template', e.target.value)} />
</label>
<label className="sub-field">Негативный промпт
<textarea rows={4} value={v.negative} onChange={e => set('negative', e.target.value)} />
</label>
</fieldset>
</details>
<div className="row" style={{ '--cols': 2 } as CSSProperties}>
<label>Картинок в батче
<select disabled={readOnly} value={v.batch} onChange={e => set('batch', e.target.value)}>
{['1', '2', '3', '4', '6', '8'].map(n => <option key={n}>{n}</option>)}
</select>
</label>
<label><span>Seed <small>— пусто = случайный</small></span>
<input disabled={readOnly} type="number" min={0} step={1} placeholder="случайный" value={v.seed}
onChange={e => set('seed', e.target.value)} />
</label>
<div className="actions">
{extra}
<button type="submit" disabled={actionDisabled}>{action}</button>
</div>
</div>
<div className="preview">Промпт: {previewPrompt(v)}</div>
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useCallback, useEffect, useState } from 'react';
/** Полноэкранный просмотр картинки: open(src, подпись), закрытие кликом или Esc. */
export function useViewer() {
const [shown, setShown] = useState<{ src: string; caption: string } | null>(null);
const open = useCallback((src: string, caption: string) => setShown({ src, caption }), []);
useEffect(() => {
if (!shown) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShown(null); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [shown]);
const element = shown && (
<div className="viewer" onClick={() => setShown(null)}>
<img src={shown.src} alt="" />
<div className="cap">{shown.caption}</div>
</div>
);
return { open, element };
}