initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user