feat: add local image enhancement and lossless mod assets
This commit is contained in:
1 parent
cc71050362
commit
f7f7f5b1b3
29 files changed
+1495
-10
No files matched your search
@@ -12,6 +12,8 @@ import { Roles } from "./pages/Roles";
|
||||
import { Saves } from "./pages/Saves";
|
||||
import { TranslationEditor } from "./pages/TranslationEditor";
|
||||
import { TranslatorSetup } from "./translator/TranslatorSetup";
|
||||
import { ImagerSetup } from "./imager/ImagerSetup";
|
||||
import { ImageEnhance } from "./imager/ImageEnhance";
|
||||
import { Users } from "./pages/Users";
|
||||
import { useTheme } from "./theme";
|
||||
import { Icon } from "./ui/Icon";
|
||||
@@ -64,6 +66,7 @@ function UserMenu() {
|
||||
</div>
|
||||
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
|
||||
<NavLink to="/saves" className="menu-item" role="menuitem"><Icon name="download" size={18} />Сохранения</NavLink>
|
||||
<NavLink to="/imager" className="menu-item" role="menuitem"><Icon name="edit" size={18} />Улучшение графики</NavLink>
|
||||
{can("translations.edit") && (
|
||||
<NavLink to="/translator" className="menu-item" role="menuitem"><Icon name="globe" size={18} />Локальный переводчик</NavLink>
|
||||
)}
|
||||
@@ -122,6 +125,8 @@ export function App() {
|
||||
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
|
||||
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
|
||||
<Route path="/translator" element={<RequirePerm perm="translations.edit"><TranslatorSetup /></RequirePerm>} />
|
||||
<Route path="/imager" element={<RequirePerm perm="games.play"><ImagerSetup /></RequirePerm>} />
|
||||
<Route path="/versions/:versionId/enhance" element={<RequirePerm perm="games.play"><ImageEnhance /></RequirePerm>} />
|
||||
<Route path="/versions/:versionId/edit" element={<RequirePerm perm="games.play"><ModEditor /></RequirePerm>} />
|
||||
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
|
||||
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, uploadRaw, type Version } from "../api";
|
||||
import { ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
||||
import { imageStore, type CachedImage } from "./cache";
|
||||
import { dataUrl } from "./images";
|
||||
import { cacheKey, loadPresets, presetHash } from "./presets";
|
||||
import { imageQueue } from "./queue";
|
||||
import { QueuePanel } from "./QueuePanel";
|
||||
import "./imager.css";
|
||||
|
||||
interface Editor {
|
||||
revision: string; editable: boolean; baseRevision?: string;
|
||||
files: Record<string, { hash: string; size: number }>;
|
||||
document: { game: { assetScale?: Record<string, number> } };
|
||||
}
|
||||
function useBlobUrl(blob?: Blob) {
|
||||
const [url, setUrl] = useState("");
|
||||
useEffect(() => { if (!blob) { setUrl(""); return; } const next = URL.createObjectURL(blob); setUrl(next); return () => URL.revokeObjectURL(next); }, [blob]);
|
||||
return url;
|
||||
}
|
||||
function Compare({ source, result }: { source: string; result: CachedImage }) {
|
||||
const url = useBlobUrl(result.blob);
|
||||
const [position, setPosition] = useState(50);
|
||||
return <section className="card"><h2>До / после</h2><div className="imager-compare"><img src={source} alt="Оригинал" /><img src={url} alt="Улучшенное изображение" style={{ clipPath: `inset(0 0 0 ${position}%)` }} /></div>
|
||||
<label>Сравнить оригинал слева и результат справа<input type="range" min="0" max="100" value={position} onChange={(e) => setPosition(Number(e.target.value))} /></label>
|
||||
<p>{result.width} × {result.height}, масштаб ×{result.scale.toFixed(3)}.{Math.max(result.width, result.height) > 16383 && " Больше лимита WebP: сохранится как PNG."}</p>
|
||||
</section>;
|
||||
}
|
||||
export function ImageEnhance() {
|
||||
const { versionId = "" } = useParams(); const id = Number(versionId);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [version, setVersion] = useState<Version | null>(null);
|
||||
const [error, setError] = useState(""); const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [presets] = useState(loadPresets); const [preset, setPreset] = useState(0);
|
||||
const [hash, setHash] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState(""); const [minSize, setMinSize] = useState(0); const [only, setOnly] = useState("all");
|
||||
const [preview, setPreview] = useState<{ path: string; result: CachedImage } | null>(null);
|
||||
const [batch, setBatch] = useState(false);
|
||||
const tasks = useSyncExternalStore(imageQueue.subscribe, imageQueue.snapshot);
|
||||
const load = async () => {
|
||||
const [e, v] = await Promise.all([api.get<Editor>(`/api/versions/${id}/editor`), api.get<Version>(`/api/versions/${id}`)]);
|
||||
setEditor(e); setVersion(v);
|
||||
};
|
||||
useEffect(() => { setEditor(null); setSelected(new Set()); setPreview(null); void load().catch((e) => setError(e.message)); }, [id]);
|
||||
useEffect(() => {
|
||||
let cancelled = false; setHash(""); setPreview(null);
|
||||
void presetHash(presets[preset]).then((h) => { if (!cancelled) setHash(h); }).catch((e) => setError(e.message));
|
||||
return () => { cancelled = true; };
|
||||
}, [preset, presets]);
|
||||
const run = async (fn: () => Promise<void>) => { setBusy(true); setError(""); setMessage(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } };
|
||||
if (!editor || !version) return <div className="page">{error ? <ErrorNote>{error}</ErrorNote> : <Spinner />}</div>;
|
||||
const source = (path: string) => `/api/versions/${id}/files/${editor.files[path].hash}`;
|
||||
const results = new Map(tasks.filter((t) => t.version === id && t.status === "done" && t.key === cacheKey(id, t.path, editor.files[t.path]?.hash ?? "", hash)).map((t) => [t.path, t]));
|
||||
const resultTask = (path: string) => results.get(path);
|
||||
const paths = Object.keys(editor.files).filter((path) => /\.(png|webp|jpe?g|avif|bmp)$/i.test(path));
|
||||
const visible = paths.filter((path) => path.toLowerCase().includes(filter.toLowerCase()) && editor.files[path].size >= minSize * 1024 && (only === "all" || (only === "done" ? !!resultTask(path) : !resultTask(path))));
|
||||
const ready = [...selected].filter((path) => resultTask(path));
|
||||
const apply = async () => {
|
||||
let current = editor;
|
||||
const applyGroup = async (group: string[]) => {
|
||||
const items = [];
|
||||
for (const path of group) {
|
||||
const t = resultTask(path); if (!t) continue;
|
||||
const image = await imageStore.get(t.key); if (!image) throw new Error(`Результат ${path} удалён из кэша. Повторите обработку`);
|
||||
items.push({ path, image, scale: (current.document.game.assetScale?.[path] ?? 1) * image.scale });
|
||||
}
|
||||
let reply: Pick<Editor, "revision" | "files">;
|
||||
if (batch) {
|
||||
const assets = [];
|
||||
for (const item of items) assets.push({ path: item.path, filename: "enhanced.png", scale: item.scale, data: (await dataUrl(item.image.blob)).split(",")[1] });
|
||||
reply = await api.post(`/api/versions/${id}/assets`, { revision: current.revision, assets });
|
||||
} else {
|
||||
const item = items[0]; if (!item) return;
|
||||
reply = await uploadRaw("PUT", `/api/versions/${id}/asset?` + new URLSearchParams({ path: item.path, revision: current.revision, filename: "enhanced.png", lossless: "1", scale: String(item.scale) }), item.image.blob, () => {});
|
||||
}
|
||||
current = { ...current, ...reply }; setEditor(current);
|
||||
};
|
||||
// Bound the JSON batch in both count and encoded bytes. Keep results in IndexedDB
|
||||
// if a later group conflicts, so the user can reload and inspect before retrying.
|
||||
let group: string[] = [], bytes = 0;
|
||||
for (const path of ready) {
|
||||
const t = resultTask(path)!; const image = await imageStore.get(t.key);
|
||||
if (!image) throw new Error(`Нет результата ${path}`);
|
||||
if (batch && image.blob.size > 80 * 1024 * 1024) throw new Error("Для файлов больше 80 МБ выключите пакетную загрузку");
|
||||
if (group.length && (!batch || group.length >= 32 || bytes + image.blob.size > 80 * 1024 * 1024)) { await applyGroup(group); group = []; bytes = 0; }
|
||||
group.push(path); bytes += image.blob.size;
|
||||
}
|
||||
if (group.length) await applyGroup(group);
|
||||
await load(); setPreview(null); setMessage(`Применено изображений: ${ready.length}. Откат доступен в истории мода.`);
|
||||
};
|
||||
return <div className="page imager"><PageHeader title="Улучшение графики" subtitle={`${version.name} · ревизия ${editor.revision.slice(0, 8)}`} actions={<><Link className="btn" to="/imager">Настройка генератора</Link><Link className="btn" to={`/versions/${id}/edit`}>Редактор и история</Link></>} />
|
||||
{error && <ErrorNote>{error}</ErrorNote>}{message && <p role="status">{message}</p>}
|
||||
{!editor.editable || version.kind !== "mod" ? <p>Для применения создайте свой черновик мода кнопкой «Форк» в карточке игры.</p> : null}
|
||||
<section className="card"><h2>Изображения версии</h2><p>Сначала обработайте один файл и проверьте результат. «Обработать» сохраняет превью локально; «Применить» заменяет выбранные файлы в моде. Анимации пропускаются с пояснением.</p>
|
||||
<div className="imager-fields"><label>Путь / папка<input value={filter} onChange={(e) => setFilter(e.target.value)} /></label>
|
||||
<label>Размер от (КБ)<input type="number" min="0" value={minSize} onChange={(e) => setMinSize(Number(e.target.value))} /></label>
|
||||
<label>Результат<select value={only} onChange={(e) => setOnly(e.target.value)}><option value="all">Все</option><option value="done">Обработанные</option><option value="pending">Без результата</option></select></label>
|
||||
<label>Пресет<select value={preset} onChange={(e) => setPreset(Number(e.target.value))}>{presets.map((p, i) => <option key={i} value={i}>{p.name}</option>)}</select></label></div>
|
||||
<div className="actions"><button className="btn" disabled={busy} onClick={() => setSelected(new Set(visible))}>Выбрать видимые ({visible.length})</button><button className="btn" onClick={() => setSelected(new Set())}>Снять выбор</button>
|
||||
<button className="btn" disabled={busy || !selected.size} onClick={() => void run(async () => { for (const path of selected) await imageQueue.enqueue(id, path, editor.files[path].hash, source(path), presets[preset]); setMessage("Задания добавлены в общую очередь"); })}>Обработать ({selected.size})</button>
|
||||
<button className="btn btn-primary" disabled={busy || !editor.editable || !ready.length} onClick={() => void run(apply)}>Применить ({ready.length})</button></div>
|
||||
<button className="btn" disabled={busy || !editor.editable || !editor.baseRevision || !selected.size} onClick={() => void run(async () => {
|
||||
let current = editor;
|
||||
for (const path of selected) { const reply = await api.post<Pick<Editor, "revision" | "files">>(`/api/versions/${id}/asset/restore`, { revision: current.revision, path }); current = { ...current, ...reply }; setEditor(current); }
|
||||
await load(); setPreview(null); setMessage("Выбранные изображения возвращены из базовой ревизии форка. Предыдущее состояние сохранено в истории.");
|
||||
})}>Вернуть выбранные из базы форка</button>
|
||||
<label className="imager-check"><input type="checkbox" checked={batch} onChange={(e) => setBatch(e.target.checked)} />Пакетная загрузка (до 32 картинок и 80 МБ на одну ревизию)</label>
|
||||
<button className="btn btn-sm" disabled={busy} onClick={() => void run(load)}>Обновить ревизию после внешних изменений</button>
|
||||
</section>
|
||||
{preview && editor.files[preview.path] && <Compare source={source(preview.path)} result={preview.result} />}
|
||||
<div className="imager-grid">{visible.map((path) => <article className="imager-tile" key={path}><img src={source(path)} loading="lazy" alt={path} />
|
||||
<label className="imager-check"><input type="checkbox" checked={selected.has(path)} onChange={(e) => setSelected((old) => { const next = new Set(old); if (e.target.checked) next.add(path); else next.delete(path); return next; })} />{path}</label>
|
||||
<p className="muted">{Math.round(editor.files[path].size / 1024)} КБ {resultTask(path) ? "· результат готов" : ""}</p>
|
||||
<button className="btn btn-sm" disabled={busy} onClick={() => void run(async () => { await imageQueue.enqueue(id, path, editor.files[path].hash, source(path), presets[preset]); })}>Обработать</button>
|
||||
{resultTask(path) && <button className="btn btn-sm" onClick={() => void run(async () => { const t = resultTask(path)!; const image = await imageStore.get(t.key); if (!image) throw new Error("Результат удалён из кэша"); setPreview({ path, result: image }); })}>До / после</button>}
|
||||
</article>)}</div>
|
||||
<QueuePanel version={id} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader, ErrorNote } from "../ui/Bits";
|
||||
import { backend } from "./backends";
|
||||
import { DEFAULT_PRESET, loadPresets, savePresets, validatePreset, type Preset } from "./presets";
|
||||
import { QueuePanel } from "./QueuePanel";
|
||||
import { imageQueue } from "./queue";
|
||||
import "./imager.css";
|
||||
|
||||
export function ImagerSetup() {
|
||||
const [presets, setPresets] = useState(loadPresets);
|
||||
const [p, setP] = useState(presets[0]);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [upscalers, setUpscalers] = useState<string[]>([]);
|
||||
const [version, setVersion] = useState("");
|
||||
const change = <K extends keyof Preset>(key: K, value: Preset[K]) => setP({ ...p, [key]: value });
|
||||
const run = async (fn: () => Promise<void>) => { setBusy(true); setError(""); setMessage(""); try { await fn(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } };
|
||||
const save = () => { const next = [validatePreset(p), ...presets.filter((x) => x.name !== p.name)]; savePresets(next); setPresets(next); setMessage("Пресет сохранён и выбран для плеера"); };
|
||||
return <div className="page imager"><PageHeader title="Локальное улучшение графики" subtitle="Браузер обращается к генератору на вашем компьютере. По ходу игры результаты хранятся только в этом браузере." />
|
||||
{error && <ErrorNote>{error}</ErrorNote>}{message && <p role="status">{message}</p>}
|
||||
<section className="card"><h2>Подключение и пресеты</h2>
|
||||
<form onSubmit={(e) => { e.preventDefault(); void run(async () => save()); }}><fieldset disabled={busy}>
|
||||
<label>Сохранённые пресеты<select value="" onChange={(e) => { const chosen = presets[Number(e.target.value)]; if (chosen) { setP(chosen); setModels([]); setUpscalers([]); } }}><option value="" disabled>Выбрать…</option>{presets.map((x, i) => <option value={i} key={i}>{x.name}</option>)}</select></label>
|
||||
<div className="imager-fields"><label>Название<input required value={p.name} onChange={(e) => change("name", e.target.value)} /></label>
|
||||
<label>Генератор<select value={p.backend} onChange={(e) => { const kind = e.target.value as Preset["backend"]; setP({ ...p, backend: kind, url: `http://127.0.0.1:${kind === "swarm" ? 7801 : kind === "comfy" ? 8188 : 7860}`, model: "", upscaler: kind === "a1111" ? "Lanczos" : "pixel-lanczos" }); setModels([]); setUpscalers([]); }}><option value="swarm">SwarmUI</option><option value="comfy">ComfyUI</option><option value="a1111">AUTOMATIC1111</option></select></label>
|
||||
<label>Адрес<input required value={p.url} onChange={(e) => change("url", e.target.value)} /></label></div>
|
||||
<button className="btn" type="button" onClick={() => void run(async () => {
|
||||
const client = backend(validatePreset(p)); const signal = AbortSignal.timeout(10_000);
|
||||
try { await client.health(signal); const list = await client.models(signal); setModels(list.models); setUpscalers(list.upscalers); setMessage(`Связь установлена. Моделей: ${list.models.length}, апскейлеров: ${list.upscalers.length}`); }
|
||||
catch (e) { throw new Error(`Не удалось прочитать API. Проверьте запуск, адрес и CORS для ${location.origin}. ${(e as Error).message}`); }
|
||||
})}>{busy ? "Проверка…" : "Проверить связь и модели"}</button>
|
||||
<div className="imager-fields"><label>Режим<select value={p.mode} onChange={(e) => change("mode", e.target.value as Preset["mode"])}><option value="upscale">Только апскейлер (сохраняет альфу)</option><option value="img2img">Img2img (непрозрачные изображения)</option></select></label>
|
||||
<label>Модель<input list="imager-models" value={p.model} onChange={(e) => change("model", e.target.value)} /><datalist id="imager-models">{models.map((m) => <option key={m} value={m} />)}</datalist></label>
|
||||
<label>Апскейлер<input list="imager-upscalers" value={p.upscaler} onChange={(e) => change("upscaler", e.target.value)} /><datalist id="imager-upscalers">{upscalers.map((m) => <option key={m} value={m} />)}</datalist></label>
|
||||
{([['scale', 'Множитель', 1, 8, .25], ['creativity', 'Creativity', 0, 1, .05], ['steps', 'Шаги', 1, 150, 1], ['cfg', 'CFG', 0, 30, .5], ['seed', 'Seed', 0, 2147483647, 1]] as const).map(([key, label, min, max, step]) => <label key={key}>{label}<input type="number" min={min} max={max} step={step} value={p[key]} onChange={(e) => change(key, Number(e.target.value))} /></label>)}
|
||||
</div><label>Prompt<textarea value={p.prompt} onChange={(e) => change("prompt", e.target.value)} /></label><label>Negative prompt<textarea value={p.negative} onChange={(e) => change("negative", e.target.value)} /></label>
|
||||
<div className="actions"><button className="btn btn-primary" type="submit">Сохранить и использовать</button>
|
||||
<button className="btn" type="button" onClick={() => { setP({ ...DEFAULT_PRESET, name: "Новый пресет" }); }}>Новый пресет</button>
|
||||
<button className="btn" type="button" onClick={() => void run(async () => {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(validatePreset(p), null, 2)], { type: "application/json" }));
|
||||
const a = document.createElement("a"); a.href = url; a.download = "image-preset.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
})}>Экспорт JSON</button>
|
||||
<label>Импорт JSON<input type="file" accept=".json,application/json" onChange={(e) => { const file = e.target.files?.[0]; e.target.value = ""; if (file) void run(async () => setP(validatePreset(JSON.parse(await file.text())))); }} /></label></div>
|
||||
</fieldset></form>
|
||||
</section>
|
||||
<section className="card"><h2>Установка и доступ из браузера</h2>
|
||||
<p>Установите <a href="https://github.com/mcmonkeyprojects/SwarmUI" target="_blank" rel="noreferrer">SwarmUI</a>, загрузите модель и апскейлер. В Server → Server Configuration → Network задайте <code>AccessControlAllowOrigin</code>: <code>{location.origin}</code>, сохраните и перезапустите. Проверка выше делает настоящий запрос из этого браузера.</p>
|
||||
<p>ComfyUI: запустите с <code>--enable-cors-header {location.origin}</code>. A1111: <code>--api --cors-allow-origins {location.origin}</code>. Оставьте сервис доступным только локально.</p>
|
||||
<p>Если браузер запрашивает доступ к локальной сети, разрешите его для сайта. При блокировке HTTP из HTTPS используйте совместимую настройку браузера или HTTPS для локального генератора. Успешная проверка API ещё не проверяет скачивание результата: сначала обработайте одну картинку в редакторе.</p>
|
||||
<p>В плеере: Настройки → Графика. Обработка по ходу игры включается отдельно; по умолчанию выключена. Для замены исходников откройте свой черновик мода → <Link to="/">Библиотека</Link>.</p>
|
||||
<p>SwarmUI использует выбранную модель даже в режиме апскейлера; для ESRGAN выберите модельный апскейлер из списка. Animated WebP, GIF и видео исключены.</p>
|
||||
</section>
|
||||
<QueuePanel />
|
||||
<section className="card"><h2>Очистка локального кэша</h2><label>ID версии<input type="number" min="1" value={version} onChange={(e) => setVersion(e.target.value)} /></label><button className="btn" disabled={busy || !version} onClick={() => void run(async () => {
|
||||
await imageQueue.clear(Number(version)); setMessage("Кэш версии удалён. Перезапустите открытый плеер, чтобы освободить изображения из памяти.");
|
||||
})}>Удалить кэш версии</button></section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||
import { imageQueue } from "./queue";
|
||||
const labels = { queued: "В очереди", running: "Обработка", done: "Готово", failed: "Ошибка", cancelled: "Отменено" };
|
||||
export function QueuePanel({ version }: { version?: number }) {
|
||||
const tasks = useSyncExternalStore(imageQueue.subscribe, imageQueue.snapshot);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => { void imageQueue.restore().catch((e) => setError(e.message)); }, []);
|
||||
const visible = tasks.filter((t) => version === undefined || t.version === version);
|
||||
return <section className="card"><h2>Очередь обработки</h2>
|
||||
<p>{visible.filter((t) => t.status === "done").length} / {visible.length} готово. Одновременно обрабатывается одно изображение.</p>
|
||||
<p className="muted">При отмене результат активного задания отбрасывается; следующее начнётся, когда генератор освободится. После перезагрузки нажмите «Повторить».</p>
|
||||
{(error || imageQueue.storageError) && <p role="alert">{error || imageQueue.storageError}</p>}
|
||||
<div className="imager-jobs">{visible.map((t) => <div key={t.key} className="imager-job"><span>{t.path} · {labels[t.status]} {t.error}</span>
|
||||
{["queued", "running"].includes(t.status) && <button className="btn btn-sm" onClick={() => imageQueue.cancel(t.key)}>Отменить</button>}
|
||||
{["failed", "cancelled"].includes(t.status) && <button className="btn btn-sm" onClick={() => void imageQueue.retry(t.key).catch((e) => setError(e.message))}>Повторить</button>}
|
||||
</div>)}</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { dataUrl } from "../images";
|
||||
import type { Preset } from "../presets";
|
||||
import { json, output, type ImageBackend } from "./types";
|
||||
export class A1111Backend implements ImageBackend {
|
||||
constructor(private base: string) {}
|
||||
async health(signal?: AbortSignal) { await json(this.base + "/sdapi/v1/options", undefined, signal); }
|
||||
async models(signal?: AbortSignal) {
|
||||
const [models, upscalers] = await Promise.all([
|
||||
json(this.base + "/sdapi/v1/sd-models", undefined, signal), json(this.base + "/sdapi/v1/upscalers", undefined, signal),
|
||||
]);
|
||||
return { models: models.map((m: any) => m.title), upscalers: upscalers.map((m: any) => m.name) };
|
||||
}
|
||||
async process(png: Blob, p: Preset, signal?: AbortSignal) {
|
||||
const image = await dataUrl(png);
|
||||
let result: string;
|
||||
if (p.mode === "upscale") {
|
||||
result = (await json(this.base + "/sdapi/v1/extra-single-image", { image, upscaling_resize: p.scale,
|
||||
upscaler_1: p.upscaler, resize_mode: 0, gfpgan_visibility: 0, codeformer_visibility: 0 }, signal)).image;
|
||||
} else {
|
||||
const bitmap = await createImageBitmap(png);
|
||||
const width = Math.round(bitmap.width * p.scale), height = Math.round(bitmap.height * p.scale); bitmap.close();
|
||||
if (!p.model) throw new Error("Выберите модель A1111");
|
||||
result = (await json(this.base + "/sdapi/v1/img2img", { init_images: [image], width, height,
|
||||
prompt: p.prompt, negative_prompt: p.negative, steps: p.steps, cfg_scale: p.cfg,
|
||||
denoising_strength: p.creativity, seed: p.seed, batch_size: 1, n_iter: 1,
|
||||
override_settings: { sd_model_checkpoint: p.model }, override_settings_restore_afterwards: true,
|
||||
save_images: false, send_images: true,
|
||||
}, signal)).images?.[0];
|
||||
}
|
||||
if (!result) throw new Error("A1111 не вернул изображение");
|
||||
return output(result.startsWith("data:") ? result : "data:image/png;base64," + result, this.base, signal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Preset } from "../presets";
|
||||
import { randomId } from "../hash";
|
||||
import { json, output, type ImageBackend } from "./types";
|
||||
|
||||
export function workflow(image: string, p: Preset, width: number, height: number) {
|
||||
const graph: Record<string, { class_type: string; inputs: Record<string, any> }> = {};
|
||||
const node = (id: string, type: string, inputs: Record<string, any>) => { graph[id] = { class_type: type, inputs }; return [id, 0]; };
|
||||
let pixels = node("1", "LoadImage", { image });
|
||||
if (p.mode === "upscale" && !p.upscaler.startsWith("pixel-")) {
|
||||
const model = node("2", "UpscaleModelLoader", { model_name: p.upscaler });
|
||||
pixels = node("3", "ImageUpscaleWithModel", { upscale_model: model, image: pixels });
|
||||
}
|
||||
pixels = node("4", "ImageScale", { image: pixels, upscale_method: "lanczos", width: Math.round(width * p.scale), height: Math.round(height * p.scale), crop: "disabled" });
|
||||
if (p.mode === "img2img") {
|
||||
if (!p.model) throw new Error("Выберите checkpoint ComfyUI");
|
||||
node("5", "CheckpointLoaderSimple", { ckpt_name: p.model });
|
||||
const positive = node("6", "CLIPTextEncode", { clip: ["5", 1], text: p.prompt });
|
||||
const negative = node("7", "CLIPTextEncode", { clip: ["5", 1], text: p.negative });
|
||||
const latent = node("8", "VAEEncode", { pixels, vae: ["5", 2] });
|
||||
const samples = node("9", "KSampler", { model: ["5", 0], positive, negative, latent_image: latent,
|
||||
seed: p.seed, steps: p.steps, cfg: p.cfg, sampler_name: "euler", scheduler: "normal", denoise: p.creativity });
|
||||
pixels = node("10", "VAEDecode", { samples, vae: ["5", 2] });
|
||||
}
|
||||
node("11", "PreviewImage", { images: pixels });
|
||||
return graph;
|
||||
}
|
||||
export class ComfyBackend implements ImageBackend {
|
||||
constructor(private base: string) {}
|
||||
async health(signal?: AbortSignal) { await json(this.base + "/system_stats", undefined, signal); }
|
||||
async models(signal?: AbortSignal) {
|
||||
const info = await json(this.base + "/object_info", undefined, signal);
|
||||
return { models: info.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [],
|
||||
upscalers: ["pixel-lanczos", ...(info.UpscaleModelLoader?.input?.required?.model_name?.[0] ?? [])] };
|
||||
}
|
||||
async process(png: Blob, p: Preset, signal?: AbortSignal) {
|
||||
const form = new FormData(); form.append("image", png, randomId() + ".png"); form.append("type", "temp");
|
||||
const upload = await fetch(this.base + "/upload/image", { method: "POST", body: form, signal });
|
||||
if (!upload.ok) throw new Error(`ComfyUI upload: HTTP ${upload.status}`);
|
||||
const file = await upload.json();
|
||||
const bitmap = await createImageBitmap(png);
|
||||
const width = bitmap.width, height = bitmap.height; bitmap.close();
|
||||
const name = (file.subfolder ? file.subfolder + "/" : "") + file.name + (file.type === "temp" ? " [temp]" : "");
|
||||
const queued = await json(this.base + "/prompt", { prompt: workflow(name, p, width, height), client_id: randomId() }, signal);
|
||||
if (!queued.prompt_id || Object.keys(queued.node_errors ?? {}).length) throw new Error("ComfyUI отклонил workflow: " + JSON.stringify(queued.node_errors));
|
||||
const deadline = Date.now() + 30 * 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
const history = await json(this.base + "/history/" + encodeURIComponent(queued.prompt_id), undefined, signal);
|
||||
const job = history[queued.prompt_id];
|
||||
if (job?.status?.status_str === "error") throw new Error("Ошибка ComfyUI: " + JSON.stringify(job.status.messages));
|
||||
const image = job?.outputs?.["11"]?.images?.[0];
|
||||
if (image) return output("view?" + new URLSearchParams({ filename: image.filename, subfolder: image.subfolder, type: image.type }), this.base, signal);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new Error("ComfyUI не завершил задание за 30 минут");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Preset } from "../presets";
|
||||
import { SwarmBackend } from "./swarm";
|
||||
import { A1111Backend } from "./a1111";
|
||||
import { ComfyBackend } from "./comfy";
|
||||
export function backend(p: Preset) {
|
||||
switch (p.backend) {
|
||||
case "swarm": return new SwarmBackend(p.url);
|
||||
case "a1111": return new A1111Backend(p.url);
|
||||
case "comfy": return new ComfyBackend(p.url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { dataUrl } from "../images";
|
||||
import type { Preset } from "../presets";
|
||||
import { GeneratorError, json, output, type ImageBackend } from "./types";
|
||||
|
||||
export class SwarmBackend implements ImageBackend {
|
||||
private session = "";
|
||||
constructor(private base: string) {}
|
||||
private async call(route: string, body: object = {}, signal?: AbortSignal): Promise<any> {
|
||||
if (!this.session) this.session = (await json(this.base + "/API/GetNewSession", {}, signal)).session_id;
|
||||
const send = () => json(this.base + "/API/" + route, { ...body, session_id: this.session }, signal);
|
||||
try { return await send(); } catch (e) {
|
||||
if (!(e instanceof GeneratorError) || e.id !== "invalid_session_id") throw e;
|
||||
this.session = (await json(this.base + "/API/GetNewSession", {}, signal)).session_id;
|
||||
return send();
|
||||
}
|
||||
}
|
||||
async health(signal?: AbortSignal) { await this.call("ListT2IParams", {}, signal); }
|
||||
async models(signal?: AbortSignal) {
|
||||
const r = await this.call("ListT2IParams", {}, signal);
|
||||
const values = (id: string): string[] => (r.list?.find((p: any) => p.id === id)?.values ?? []).map((s: string) => s.split("///")[0]);
|
||||
const modelList = r.models?.["Stable-Diffusion"];
|
||||
return { models: Array.isArray(modelList) ? modelList.map((m: any) => typeof m === "string" ? m : Array.isArray(m) ? m[0] : m.name) : values("model"), upscalers: values("refinerupscalemethod") };
|
||||
}
|
||||
async process(png: Blob, p: Preset, signal?: AbortSignal) {
|
||||
const bitmap = await createImageBitmap(png);
|
||||
const width = bitmap.width, height = bitmap.height; bitmap.close();
|
||||
if (!p.model) throw new Error("Выберите модель SwarmUI в настройках");
|
||||
if (p.mode === "upscale" && !/^(pixel-|model-)/.test(p.upscaler)) throw new Error("Для режима без диффузии выберите pixel- или model-апскейлер из списка SwarmUI");
|
||||
const r = await this.call("GenerateText2Image", { images: 1, initimage: await dataUrl(png),
|
||||
model: p.model, width, height, prompt: p.prompt, negativeprompt: p.negative, seed: p.seed,
|
||||
steps: p.steps, cfgscale: p.cfg, initimagecreativity: p.mode === "upscale" ? 0 : p.creativity,
|
||||
refinermethod: "PostApply", refinerupscale: p.scale, refinerupscalemethod: p.upscaler, refinercontrolpercentage: 0,
|
||||
imageformat: "PNG", donotsave: true, outputintermediateimages: false,
|
||||
}, signal);
|
||||
const image = r.images?.at(-1);
|
||||
const url = typeof image === "string" ? image : image?.image;
|
||||
if (!url) throw new Error("SwarmUI не вернул изображение");
|
||||
return output(url, this.base, signal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Preset } from "../presets";
|
||||
export interface ImageBackend {
|
||||
health(signal?: AbortSignal): Promise<void>;
|
||||
models(signal?: AbortSignal): Promise<{ models: string[]; upscalers: string[] }>;
|
||||
process(png: Blob, preset: Preset, signal?: AbortSignal): Promise<Blob>;
|
||||
}
|
||||
export class GeneratorError extends Error {
|
||||
constructor(message: string, public id?: string) { super(message); }
|
||||
}
|
||||
export async function json(url: string, body?: unknown, signal?: AbortSignal): Promise<any> {
|
||||
const response = await fetch(url, { method: body === undefined ? "GET" : "POST", signal,
|
||||
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
if (!response.ok) throw new Error(`Генератор: HTTP ${response.status}`);
|
||||
const value = await response.json();
|
||||
if (value.error || value.error_id) throw new GeneratorError(value.error || value.error_id, value.error_id);
|
||||
return value;
|
||||
}
|
||||
export async function output(url: string, base: string, signal?: AbortSignal): Promise<Blob> {
|
||||
const target = new URL(url, base + "/");
|
||||
if (target.protocol !== "data:" && target.origin !== new URL(base).origin) throw new Error("Генератор вернул изображение с другого адреса");
|
||||
const r = await fetch(target, { signal });
|
||||
if (!r.ok) throw new Error(`Не удалось скачать результат: HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Preset } from "./presets";
|
||||
|
||||
export interface CachedImage { key: string; version: number; path: string; blob: Blob; scale: number; width: number; height: number }
|
||||
export interface Task {
|
||||
key: string; version: number; path: string; source: string; originalHash: string; preset: Preset;
|
||||
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
}
|
||||
export interface ImageStore {
|
||||
get(key: string): Promise<CachedImage | undefined>;
|
||||
put(image: CachedImage): Promise<void>;
|
||||
tasks(): Promise<Task[]>;
|
||||
save(task: Task): Promise<void>;
|
||||
}
|
||||
let opened: Promise<IDBDatabase> | undefined;
|
||||
function db(): Promise<IDBDatabase> {
|
||||
return opened ??= new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("pg.image-enhance", 1);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore("images", { keyPath: "key" }).createIndex("version", "version");
|
||||
request.result.createObjectStore("tasks", { keyPath: "key" }).createIndex("version", "version");
|
||||
};
|
||||
request.onsuccess = () => { request.result.onversionchange = () => { request.result.close(); opened = undefined; }; resolve(request.result); };
|
||||
request.onerror = () => { opened = undefined; reject(request.error); };
|
||||
});
|
||||
}
|
||||
async function transaction<T>(store: string, mode: IDBTransactionMode, action: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
const database = await db();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = database.transaction(store, mode);
|
||||
const request = action(tx.objectStore(store));
|
||||
tx.oncomplete = () => resolve(request.result);
|
||||
tx.onabort = tx.onerror = () => reject(tx.error ?? new Error("Не удалось сохранить кэш изображений"));
|
||||
});
|
||||
}
|
||||
export const imageStore: ImageStore = {
|
||||
get: (key) => transaction("images", "readonly", (s) => s.get(key)),
|
||||
put: async (value) => { await transaction("images", "readwrite", (s) => s.put(value)); },
|
||||
tasks: () => transaction("tasks", "readonly", (s) => s.getAll()),
|
||||
save: async (value) => { await transaction("tasks", "readwrite", (s) => s.put(value)); },
|
||||
};
|
||||
export async function clearVersion(version: number): Promise<void> {
|
||||
const database = await db();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = database.transaction(["images", "tasks"], "readwrite");
|
||||
for (const name of ["images", "tasks"]) {
|
||||
const request = tx.objectStore(name).index("version").openCursor(IDBKeyRange.only(version));
|
||||
request.onsuccess = () => { const c = request.result; if (c) { c.delete(); c.continue(); } };
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onabort = tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// SubtleCrypto is unavailable on a home server opened over plain HTTP. Keep cache
|
||||
// identities identical on those origins without requiring a crypto dependency.
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
const rotate = (n: number, bits: number) => (n >>> bits) | (n << (32 - bits));
|
||||
export function sha256Fallback(input: Uint8Array): string {
|
||||
const padded = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64);
|
||||
padded.set(input); padded[input.length] = 0x80;
|
||||
const view = new DataView(padded.buffer);
|
||||
view.setUint32(padded.length - 8, Math.floor(input.length / 0x20000000));
|
||||
view.setUint32(padded.length - 4, input.length * 8);
|
||||
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
||||
const w = new Uint32Array(64);
|
||||
for (let offset = 0; offset < padded.length; offset += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const a = w[i - 15], b = w[i - 2];
|
||||
w[i] = w[i - 16] + (rotate(a, 7) ^ rotate(a, 18) ^ (a >>> 3)) + w[i - 7] + (rotate(b, 17) ^ rotate(b, 19) ^ (b >>> 10));
|
||||
}
|
||||
let [a, b, c, d, e, f, g, z] = h;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const t1 = (z + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
||||
const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
||||
z = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
const state = [a, b, c, d, e, f, g, z];
|
||||
for (let i = 0; i < 8; i++) h[i] += state[i];
|
||||
}
|
||||
return [...h].map((n) => n.toString(16).padStart(8, "0")).join("");
|
||||
}
|
||||
export async function sha256(text: string): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
if (!globalThis.crypto?.subtle) return sha256Fallback(bytes);
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
export function randomId(): string {
|
||||
return [...crypto.getRandomValues(new Uint8Array(16))].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.imager { max-width: 1200px; }
|
||||
.imager .card { padding: 24px; margin-bottom: 20px; }
|
||||
.imager h2 { margin: 0 0 16px; }
|
||||
.imager fieldset { border: 0; padding: 0; min-width: 0; }
|
||||
.imager label { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
|
||||
.imager-fields { display: grid; grid-template-columns: repeat(auto-fit,minmax(190px,1fr)); gap: 16px; align-items: end; }
|
||||
.imager-jobs { max-height: 350px; overflow: auto; }
|
||||
.imager-job { display: flex; align-items: center; gap: 12px; padding: 8px 0; overflow-wrap: anywhere; }
|
||||
.imager-job span { flex: 1; }
|
||||
.imager-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(210px,1fr)); gap: 16px; margin: 20px 0; }
|
||||
.imager-tile { border: 1px solid var(--border); border-radius: 12px; padding: 12px; overflow-wrap: anywhere; }
|
||||
.imager-tile img { width: 100%; height: 140px; object-fit: contain; background: repeating-conic-gradient(#8882 0% 25%,transparent 0% 50%) 0 / 16px 16px; }
|
||||
.imager .imager-check { display: flex; flex-direction: row; align-items: center; }
|
||||
.imager-compare { position: relative; height: min(55vh,500px); background: repeating-conic-gradient(#8882 0% 25%,transparent 0% 50%) 0 / 16px 16px; }
|
||||
.imager-compare img { position: absolute; width: 100%; height: 100%; object-fit: contain; }
|
||||
@@ -0,0 +1,70 @@
|
||||
export function dataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error); r.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
export function canvas(width: number, height: number): HTMLCanvasElement {
|
||||
if (width < 1 || height < 1 || width > 32768 || height > 32768 || width * height > 100_000_000) throw new Error("Изображение слишком большое для обработки в браузере");
|
||||
const c = document.createElement("canvas"); c.width = width; c.height = height; return c;
|
||||
}
|
||||
export function png(c: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => c.toBlob((b) => b ? resolve(b) : reject(new Error("Браузер не смог сохранить PNG")), "image/png"));
|
||||
}
|
||||
/** Reject animation rather than silently replacing a moving image with its first frame. */
|
||||
export async function assertStatic(blob: Blob) {
|
||||
const b = new Uint8Array(await blob.arrayBuffer());
|
||||
const text = (at: number, length: number) => String.fromCharCode(...b.subarray(at, at + length));
|
||||
let animated = text(0, 3) === "GIF";
|
||||
const view = new DataView(b.buffer);
|
||||
if (text(0, 4) === "RIFF" && text(8, 4) === "WEBP") {
|
||||
for (let at = 12; at + 8 <= b.length;) {
|
||||
if (text(at, 4) === "ANIM") { animated = true; break; }
|
||||
const size = view.getUint32(at + 4, true); at += 8 + size + (size % 2);
|
||||
}
|
||||
} else if (b[0] === 137 && text(1, 3) === "PNG") {
|
||||
for (let at = 8; at + 12 <= b.length;) {
|
||||
if (text(at + 4, 4) === "acTL") { animated = true; break; }
|
||||
at += 12 + view.getUint32(at);
|
||||
}
|
||||
}
|
||||
if (animated) throw new Error("Анимированные изображения не поддерживаются");
|
||||
}
|
||||
export async function prepareImage(source: Blob, mode: "upscale" | "img2img") {
|
||||
await assertStatic(source);
|
||||
const bitmap = await createImageBitmap(source);
|
||||
try {
|
||||
const c = canvas(bitmap.width, bitmap.height); const ctx = c.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const pixels = ctx.getImageData(0, 0, c.width, c.height);
|
||||
let transparent = false;
|
||||
for (let i = 3; i < pixels.data.length; i += 4) if (pixels.data[i] !== 255) { transparent = true; break; }
|
||||
if (transparent && mode === "img2img") throw new Error("У картинки есть прозрачность. Выберите «Только апскейлер» для сохранения альфа-канала");
|
||||
// Keep straight RGB separate from alpha; never send an opaque white composite to the upscaler.
|
||||
if (transparent) {
|
||||
for (let i = 3; i < pixels.data.length; i += 4) pixels.data[i] = 255;
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
return { blob: await png(c), width: c.width, height: c.height,
|
||||
alpha: transparent ? source : null };
|
||||
} finally { bitmap.close(); }
|
||||
}
|
||||
export async function finishImage(result: Blob, original: { width: number; height: number; alpha: Blob | null }) {
|
||||
const bitmap = await createImageBitmap(result);
|
||||
try {
|
||||
const scale = bitmap.width / original.width;
|
||||
if (Math.abs(bitmap.height - original.height * scale) > 1) throw new Error("Генератор изменил пропорции изображения");
|
||||
const c = canvas(bitmap.width, bitmap.height); const ctx = c.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
if (original.alpha) {
|
||||
const alpha = await createImageBitmap(original.alpha);
|
||||
try {
|
||||
const mask = canvas(c.width, c.height); const mc = mask.getContext("2d")!;
|
||||
mc.drawImage(alpha, 0, 0, c.width, c.height);
|
||||
const a = mc.getImageData(0, 0, c.width, c.height); const rgb = ctx.getImageData(0, 0, c.width, c.height);
|
||||
for (let i = 3; i < rgb.data.length; i += 4) rgb.data[i] = a.data[i];
|
||||
ctx.putImageData(rgb, 0, 0);
|
||||
} finally { alpha.close(); }
|
||||
}
|
||||
return { blob: await png(c), scale, width: c.width, height: c.height };
|
||||
} finally { bitmap.close(); }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { imageStore, type CachedImage } from "./cache";
|
||||
import { activePreset, cacheKey, presetHash, type Preset } from "./presets";
|
||||
import { imageQueue } from "./queue";
|
||||
|
||||
/** Synchronous URL lookup for Engine.fileUrl; IndexedDB work stays off the render path. */
|
||||
export class LiveImages {
|
||||
enabled = false;
|
||||
automatic = false;
|
||||
private results = new Map<string, { url: string; image: CachedImage }>();
|
||||
private pending = new Set<string>();
|
||||
private dead = false;
|
||||
private preset: Preset;
|
||||
private hash: Promise<string>;
|
||||
private reported = false;
|
||||
private unsubscribe: () => void;
|
||||
constructor(private version: number, private report: (error: string) => void = () => {}) {
|
||||
this.preset = activePreset(); this.hash = presetHash(this.preset);
|
||||
// Avoid an unhandled rejection in contexts with unavailable SubtleCrypto.
|
||||
void this.hash.catch(() => {});
|
||||
try { const prefs = JSON.parse(localStorage.getItem(`pg.imager.live.${version}`) || "{}"); this.enabled = prefs.enabled === true; this.automatic = prefs.automatic === true; } catch { /* default off */ }
|
||||
this.unsubscribe = imageQueue.subscribe(() => {
|
||||
for (const task of imageQueue.snapshot()) {
|
||||
if (task.version !== version || task.status !== "done" || this.results.has(task.key) || this.pending.has(task.key)) continue;
|
||||
this.pending.add(task.key);
|
||||
void this.hash.then(async (hash) => {
|
||||
if (task.key !== cacheKey(version, task.path, task.originalHash, hash)) return;
|
||||
const result = await imageStore.get(task.key);
|
||||
if (result && !this.dead && !this.results.has(task.key)) this.results.set(task.key, { url: URL.createObjectURL(result.blob), image: result });
|
||||
}).catch(() => {}).finally(() => this.pending.delete(task.key));
|
||||
}
|
||||
});
|
||||
}
|
||||
save() { try { localStorage.setItem(`pg.imager.live.${this.version}`, JSON.stringify({ enabled: this.enabled, automatic: this.automatic })); } catch { /* private mode */ } }
|
||||
private keys = new Map<string, string>();
|
||||
url(path: string, originalHash: string, source: string): string | null {
|
||||
if (!this.enabled || !/\.(png|webp|jpe?g|avif|bmp)$/i.test(path)) return null;
|
||||
const identity = JSON.stringify([path, originalHash]);
|
||||
const known = this.keys.get(identity);
|
||||
if (known && this.results.has(known)) return this.results.get(known)!.url;
|
||||
// Mark the identity before the asynchronous hash to avoid duplicate lookups in one frame.
|
||||
if (!this.pending.has(identity)) {
|
||||
this.pending.add(identity);
|
||||
void this.hash.then(async (hash) => {
|
||||
const key = cacheKey(this.version, path, originalHash, hash); this.keys.set(identity, key);
|
||||
if (this.dead || this.results.has(key)) return;
|
||||
const image = await imageStore.get(key);
|
||||
if (this.dead) return;
|
||||
if (image && !this.results.has(key)) this.results.set(key, { url: URL.createObjectURL(image.blob), image });
|
||||
else if (this.automatic) {
|
||||
await imageQueue.enqueue(this.version, path, originalHash, source, this.preset);
|
||||
// Allow the next showing to read a completed result, without polling.
|
||||
}
|
||||
}).catch((e) => { if (!this.dead && !this.reported) { this.reported = true; this.report((e as Error).message); } }).finally(() => this.pending.delete(identity));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
scale(url: string) {
|
||||
for (const result of this.results.values()) if (result.url === url) return result.image.scale;
|
||||
return 1;
|
||||
}
|
||||
destroy() { this.dead = true; this.unsubscribe(); for (const r of this.results.values()) URL.revokeObjectURL(r.url); this.results.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { sha256 } from "./hash";
|
||||
export type BackendKind = "swarm" | "comfy" | "a1111";
|
||||
export interface Preset {
|
||||
name: string;
|
||||
backend: BackendKind;
|
||||
url: string;
|
||||
mode: "upscale" | "img2img";
|
||||
model: string;
|
||||
upscaler: string;
|
||||
scale: number;
|
||||
creativity: number;
|
||||
prompt: string;
|
||||
negative: string;
|
||||
steps: number;
|
||||
cfg: number;
|
||||
seed: number;
|
||||
}
|
||||
export const DEFAULT_PRESET: Preset = {
|
||||
name: "Бережный апскейл ×2", backend: "swarm", url: "http://127.0.0.1:7801",
|
||||
mode: "upscale", model: "", upscaler: "pixel-lanczos", scale: 2,
|
||||
creativity: 0.2, prompt: "", negative: "", steps: 20, cfg: 7, seed: 0,
|
||||
};
|
||||
export function normalizeUrl(raw: string): string {
|
||||
const url = new URL(/^https?:\/\//i.test(raw.trim()) ? raw.trim() : `http://${raw.trim()}`);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("Нужен HTTP-адрес генератора без пароля и параметров");
|
||||
return url.href.replace(/\/+$/, "");
|
||||
}
|
||||
export function validatePreset(value: unknown): Preset {
|
||||
if (!value || typeof value !== "object") throw new Error("Некорректный пресет");
|
||||
const p = value as Preset;
|
||||
if (!["swarm", "comfy", "a1111"].includes(p.backend) || !["upscale", "img2img"].includes(p.mode)) throw new Error("Неизвестный режим или генератор");
|
||||
for (const k of ["name", "model", "upscaler", "prompt", "negative"] as const) if (typeof p[k] !== "string") throw new Error(`Некорректное поле ${k}`);
|
||||
for (const [k, min, max] of [["scale", 1, 8], ["creativity", 0, 1], ["steps", 1, 150], ["cfg", 0, 30], ["seed", 0, 2147483647]] as const) {
|
||||
if (typeof p[k] !== "number" || !Number.isFinite(p[k]) || p[k] < min || p[k] > max) throw new Error(`Некорректное поле ${k}`);
|
||||
}
|
||||
if (!Number.isInteger(p.steps) || !Number.isInteger(p.seed)) throw new Error("Шаги и seed должны быть целыми");
|
||||
return { ...p, url: normalizeUrl(p.url) };
|
||||
}
|
||||
export function loadPresets(): Preset[] {
|
||||
try { const list = JSON.parse(localStorage.getItem("pg.imager.presets") || "null");
|
||||
if (Array.isArray(list) && list.length) return list.map(validatePreset);
|
||||
} catch { /* unavailable or old storage */ }
|
||||
return [{ ...DEFAULT_PRESET }];
|
||||
}
|
||||
export function savePresets(presets: Preset[]) {
|
||||
localStorage.setItem("pg.imager.presets", JSON.stringify(presets.map(validatePreset)));
|
||||
}
|
||||
export function activePreset(): Preset { return loadPresets()[0]; }
|
||||
/** Stable cryptographic digest; names do not change the actual generation. */
|
||||
export async function presetHash(preset: Preset): Promise<string> {
|
||||
const p = validatePreset(preset);
|
||||
const { name: _name, ...settings } = p;
|
||||
const canonical = JSON.stringify(Object.fromEntries(Object.entries(settings).sort(([a], [b]) => a.localeCompare(b))));
|
||||
return sha256(canonical);
|
||||
}
|
||||
export function cacheKey(version: number, path: string, originalHash: string, hash: string): string {
|
||||
return JSON.stringify([version, path, originalHash, hash]);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { clearVersion, imageStore, type ImageStore, type Task } from "./cache";
|
||||
import { backend } from "./backends";
|
||||
import { finishImage, prepareImage } from "./images";
|
||||
import { cacheKey, presetHash, validatePreset, type Preset } from "./presets";
|
||||
|
||||
export async function processTask(t: Task) {
|
||||
const signal = AbortSignal.timeout(30 * 60_000);
|
||||
const r = await fetch(t.source, { credentials: "same-origin", signal });
|
||||
if (!r.ok) throw new Error(`Не удалось загрузить оригинал: HTTP ${r.status}`);
|
||||
const original = await prepareImage(await r.blob(), t.preset.mode);
|
||||
return { ...await finishImage(await backend(t.preset).process(original.blob, t.preset, signal), original),
|
||||
key: t.key, version: t.version, path: t.path };
|
||||
}
|
||||
export class ImageQueue {
|
||||
private items: Task[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
private running = false;
|
||||
private restored?: Promise<void>;
|
||||
storageError = "";
|
||||
constructor(private store: ImageStore = imageStore, private process = processTask) {}
|
||||
snapshot = () => this.items;
|
||||
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
|
||||
private changed() { this.items = [...this.items]; this.listeners.forEach((f) => f()); }
|
||||
restore() {
|
||||
return this.restored ??= this.store.tasks().then((items) => {
|
||||
// No GPU work on page load. Resume is explicit, and interrupted tasks are retryable.
|
||||
this.items = items.map((t) => t.status === "running" || t.status === "queued" ? { ...t, status: "cancelled", error: "Прервано перезагрузкой. Можно повторить" } : t);
|
||||
this.changed();
|
||||
});
|
||||
}
|
||||
async enqueue(version: number, path: string, originalHash: string, source: string, preset: Preset) {
|
||||
await this.restore();
|
||||
const p = validatePreset(preset);
|
||||
const key = cacheKey(version, path, originalHash, await presetHash(p));
|
||||
const existing = this.items.find((t) => t.key === key);
|
||||
if (existing) {
|
||||
if (existing.status === "done" && !await this.store.get(key)) {
|
||||
existing.status = "queued"; await this.store.save(existing); this.changed(); void this.pump();
|
||||
}
|
||||
return key;
|
||||
}
|
||||
const task: Task = { key, version, path, originalHash, source, preset: p, status: "queued" };
|
||||
await this.store.save(task);
|
||||
if (this.items.some((t) => t.key === key)) return key;
|
||||
this.items.push(task); this.changed(); void this.pump(); return key;
|
||||
}
|
||||
async retry(key: string) {
|
||||
const task = this.items.find((t) => t.key === key);
|
||||
if (!task || !["failed", "cancelled"].includes(task.status) || this.current === key) return;
|
||||
task.status = "queued"; task.error = undefined;
|
||||
await this.store.save(task); this.changed(); void this.pump();
|
||||
}
|
||||
cancel(key: string) {
|
||||
const task = this.items.find((t) => t.key === key);
|
||||
if (!task || !["running", "queued"].includes(task.status)) return;
|
||||
task.status = "cancelled";
|
||||
// Keep the GPU lane occupied until the in-flight request finishes. Do not interrupt
|
||||
// another user's generation through a backend-global interrupt endpoint.
|
||||
void this.store.save(task).catch((e) => { this.storageError = String(e); this.changed(); }); this.changed();
|
||||
}
|
||||
private current: string | null = null;
|
||||
async clear(version: number) {
|
||||
await this.restore();
|
||||
if (this.items.some((t) => t.version === version && (t.status === "queued" || t.key === this.current))) throw new Error("Сначала дождитесь окончания обработки этой версии");
|
||||
await clearVersion(version);
|
||||
this.items = this.items.filter((t) => t.version !== version); this.changed();
|
||||
}
|
||||
private async pump() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
let task: Task | undefined;
|
||||
while ((task = this.items.find((t) => t.status === "queued"))) {
|
||||
const t = task; this.current = t.key;
|
||||
t.status = "running"; this.changed();
|
||||
try {
|
||||
await this.store.save(t);
|
||||
const process = async () => {
|
||||
if (this.cancelled(t)) return;
|
||||
const cached = await this.store.get(t.key);
|
||||
const image = cached ?? await this.process(t);
|
||||
if (this.cancelled(t)) return;
|
||||
if (!cached) await this.store.put(image);
|
||||
t.status = "done";
|
||||
};
|
||||
// Editor and player can be open in different tabs. A shared browser lock
|
||||
// keeps the single GPU lane across both, with cache rechecked inside it.
|
||||
if (typeof navigator !== "undefined" && navigator.locks) await navigator.locks.request("pg.imager.gpu", process);
|
||||
else await process();
|
||||
} catch (e) {
|
||||
if (!this.cancelled(t)) { t.status = "failed"; t.error = (e as Error).message; }
|
||||
} finally {
|
||||
try { await this.store.save(t); } catch (e) { this.storageError = String(e); }
|
||||
this.current = null; this.changed();
|
||||
}
|
||||
}
|
||||
} finally { this.running = false; }
|
||||
}
|
||||
private cancelled(t: Task) { return t.status === "cancelled"; }
|
||||
}
|
||||
export const imageQueue = new ImageQueue();
|
||||
@@ -278,6 +278,7 @@ function VersionRow(props: {
|
||||
{v.status === "ready" && <LanguageMenu title="Языки версии" entries={versionLanguages(v)} onPick={(e) => props.onPlayLang(e.code)} />}
|
||||
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
|
||||
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
|
||||
{v.status === "ready" && v.kind === "mod" && props.canFork && <Link className="btn btn-sm" to={`/versions/${v.id}/enhance`}>Улучшение графики</Link>}
|
||||
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
|
||||
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
|
||||
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
|
||||
|
||||
@@ -160,6 +160,7 @@ export function ModEditor() {
|
||||
return <div className="page mod-editor"><div className="section-title"><h1>{version.name}</h1>
|
||||
<Link className="btn" to={`/games/${version.game_id}`} onClick={(e) => { if (dirty && !window.confirm("Уйти без сохранения?")) e.preventDefault(); }}>К игре</Link>
|
||||
<Link className="btn" target="_blank" to={`/play/${versionId}`}>Предпросмотр сохранённой версии</Link>
|
||||
<Link className="btn" to={`/versions/${versionId}/enhance`}>Улучшение графики</Link>
|
||||
</div>{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<p className="muted">{dirty ? "Есть несохранённые изменения" : "Изменения сохранены"}. Ревизия {data.revision.slice(0, 8)}. {data.baseRevision ? `База ${data.baseRevision.slice(0, 8)}` : "Историческая база неизвестна"}.</p>
|
||||
{!data.editable && <p>Для редактирования создайте свой черновик мода через кнопку «Форк».</p>}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { AudioSystem } from "./audio";
|
||||
import { VolumeStore } from "./volume-store";
|
||||
import { LiveImages } from "../imager/live";
|
||||
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||
import { Renderer, type ResolvedImage } from "./renderer";
|
||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||
@@ -24,6 +25,8 @@ interface LabelRef {
|
||||
}
|
||||
|
||||
export class Engine {
|
||||
private liveImages?: LiveImages;
|
||||
private imageScales = new Map<string, number>();
|
||||
private labels = new Map<string, LabelRef>();
|
||||
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
|
||||
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
|
||||
@@ -136,6 +139,7 @@ export class Engine {
|
||||
root.append(this.container);
|
||||
this.renderer = new Renderer(this.container, w, h, {
|
||||
url: (f) => this.fileUrl(f),
|
||||
assetScale: (url) => this.imageScales.get(url) ?? 1,
|
||||
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
|
||||
evalCond: (c) => truthy(this.safeEval(c)),
|
||||
transform: (name) => game.transforms[name] ?? null,
|
||||
@@ -153,6 +157,7 @@ export class Engine {
|
||||
});
|
||||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
||||
this.buildUI();
|
||||
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
|
||||
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
|
||||
this.resizeObserver.observe(this.container);
|
||||
this.renderer.fit(this.container);
|
||||
@@ -161,6 +166,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.liveImages?.destroy();
|
||||
this.screens.destroy();
|
||||
this.gen++;
|
||||
this.wakeAll();
|
||||
@@ -184,7 +190,13 @@ export class Engine {
|
||||
}
|
||||
}
|
||||
const entry = logical ? this.manifest.files[logical] : undefined;
|
||||
return entry ? `/api/versions/${this.manifest.versionId}/files/${entry.hash}` : null;
|
||||
if (!entry || !logical) return null;
|
||||
const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`;
|
||||
const improved = this.liveImages?.url(logical, entry.hash, source);
|
||||
const url = improved || source;
|
||||
const baseScale = this.game.assetScale?.[logical] ?? 1;
|
||||
this.imageScales.set(url, baseScale * (improved ? this.liveImages!.scale(improved) : 1));
|
||||
return url;
|
||||
}
|
||||
|
||||
private resolveImage(name: string[]): ResolvedImage | null {
|
||||
@@ -698,10 +710,7 @@ export class Engine {
|
||||
if (resolved) return this.renderer.displayable(resolved.def);
|
||||
const url = this.fileUrl(value);
|
||||
if (!url) return null;
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.draggable = false;
|
||||
return img;
|
||||
return this.renderer.displayable({ file: value });
|
||||
}
|
||||
|
||||
private screenFont(file: string): string | null {
|
||||
@@ -2008,6 +2017,21 @@ export class Engine {
|
||||
row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v), "reading");
|
||||
row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v), "reading");
|
||||
const display = section("Игра", "Изменения сохраняются автоматически в этом браузере.");
|
||||
const graphics = section("Графика", "Локальный генератор настраивается в меню профиля → Улучшение графики. Результат подхватывается при следующем показе картинки.");
|
||||
const imageToggle = (label: string, checked: boolean, apply: (value: boolean) => void) => {
|
||||
const row = el("label", "pg-check"); const box = el("input");
|
||||
Object.assign(box, { type: "checkbox", checked });
|
||||
box.onchange = () => apply(box.checked); row.append(box, el("span", "", label)); graphics.append(row);
|
||||
};
|
||||
imageToggle("Показывать улучшенные изображения", this.liveImages?.enabled ?? false, (value) => {
|
||||
if (!this.liveImages) return;
|
||||
this.liveImages.enabled = value;
|
||||
this.liveImages.save();
|
||||
// Refresh shown scene for immediate original/result comparison.
|
||||
const shown = this.renderer.snapshot();
|
||||
for (const image of shown) { const resolved = this.resolveImage(image.name); if (resolved) this.renderer.show(resolved, image.tag, image.at, { zorder: image.zorder }); }
|
||||
});
|
||||
imageToggle("Улучшать по ходу игры", this.liveImages?.automatic ?? false, (value) => { if (this.liveImages) { this.liveImages.automatic = value; this.liveImages.save(); } });
|
||||
const langs = this.languages();
|
||||
if (langs.length > 1) {
|
||||
const r = el("label", "pg-setting");
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ResolvedImage {
|
||||
|
||||
export interface RendererHost {
|
||||
url(file: string): string | null;
|
||||
assetScale?(url: string): number;
|
||||
resolveRef(name: string): ImageDef | null;
|
||||
evalCond(e: any): boolean;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
@@ -204,12 +205,18 @@ export class Renderer {
|
||||
img.draggable = false;
|
||||
img.decoding = "async";
|
||||
const src = this.host.url(def.file);
|
||||
const scale = src ? this.host.assetScale?.(src) ?? 1 : 1;
|
||||
if (Number.isFinite(scale) && scale > 0 && scale !== 1) {
|
||||
img.addEventListener("load", () => {
|
||||
// HTML width supplies a natural-size fallback. Explicit CSS size/ATL still wins,
|
||||
// and the unspecified side continues to follow the picture's aspect ratio.
|
||||
if (!img.style.width && !img.style.height) img.width = Math.max(1, Math.round(img.naturalWidth / scale));
|
||||
}, { once: true });
|
||||
}
|
||||
const size = def.props?.size ?? def.size;
|
||||
if (size) { img.style.width = `${size[0]}px`; img.style.height = `${size[1]}px`; }
|
||||
if (src) img.src = src;
|
||||
else console.warn("[pgvn] нет файла", def.file);
|
||||
if (def.props?.size) {
|
||||
img.style.width = `${def.props.size[0]}px`;
|
||||
img.style.height = `${def.props.size[1]}px`;
|
||||
}
|
||||
return img;
|
||||
}
|
||||
return document.createElement("div");
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface ScriptModule {
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
/** Natural image dimensions are divided by this factor after an asset replacement. */
|
||||
assetScale?: Record<string, number>;
|
||||
runtime?: string;
|
||||
tomie?: Omit<import("./tomie-player").TomieContent, "scripts" | "resources">;
|
||||
engine?: string;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Real browser checks for IndexedDB, Canvas alpha and actual layout; opt-in like tomie-browser.
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
test("image enhancement preserves alpha, natural layout, persistent cache and local-only live URLs in Chromium", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({ stdin: { contents: `export * from './images'; export * from './cache'; export * from './presets'; export * from './live'; export * from '../player/renderer';
|
||||
import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { ImagerSetup } from './ImagerSetup'; import { ImageEnhance } from './ImageEnhance';
|
||||
export function showPage(kind) { createRoot(document.querySelector('main')).render(createElement(MemoryRouter, {initialEntries: ['/versions/7/enhance']}, createElement(Routes, null, createElement(Route, {path: '/versions/:versionId/enhance', element: createElement(kind === 'setup' ? ImagerSetup : ImageEnhance)})))); }`,
|
||||
resolveDir: fileURLToPath(new URL("../src/imager/", import.meta.url)), loader: "ts" }, bundle: true, write: false, format: "iife", globalName: "Imager", platform: "browser", loader: { '.css': 'empty' }, logLevel: "silent" });
|
||||
let writes = 0;
|
||||
const server = createServer((req, res) => {
|
||||
if (req.method === 'PUT' || req.method === 'POST') writes++;
|
||||
if (req.url === '/api/versions/7/editor') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ revision: 'revision', editable: true, baseRevision: 'base', files: { 'images/sprite.webp': { hash: 'sourcehash', size: 1000 } }, document: { game: {} } })); }
|
||||
else if (req.url === '/api/versions/7') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ id: 7, name: 'Тестовый мод', kind: 'mod' })); }
|
||||
else if (req.url === '/api/versions/7/files/sourcehash') { res.setHeader('Content-Type', 'image/png'); res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jCTkAAAAASUVORK5CYII=', 'base64')); }
|
||||
else if (req.url === '/bundle.js') { res.setHeader('Content-Type', 'application/javascript'); res.end(compiled.outputFiles[0].text); }
|
||||
else { res.setHeader('Content-Type', 'text/html'); res.end('<!doctype html><main></main><script src="/bundle.js"></script>'); }
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
||||
const result = await page.evaluate(async () => {
|
||||
const c = Imager.canvas(4, 2), ctx = c.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(255,0,0,0.5)'; ctx.fillRect(0, 0, 2, 2);
|
||||
const original = await Imager.png(c);
|
||||
const prepared = await Imager.prepareImage(original, 'upscale');
|
||||
let rejected = false;
|
||||
try { await Imager.prepareImage(original, 'img2img'); } catch { rejected = true; }
|
||||
const large = Imager.canvas(8, 4); const gc = large.getContext('2d');
|
||||
gc.fillStyle = 'blue'; gc.fillRect(0, 0, 8, 4);
|
||||
const image = await Imager.finishImage(await Imager.png(large), prepared);
|
||||
const bitmap = await createImageBitmap(image.blob); gc.clearRect(0, 0, 8, 4); gc.drawImage(bitmap, 0, 0); bitmap.close();
|
||||
const a = gc.getImageData(0, 0, 8, 4).data;
|
||||
const hash = await Imager.presetHash(Imager.DEFAULT_PRESET);
|
||||
const key = Imager.cacheKey(7, 'images/sprite.webp', 'sourcehash', hash);
|
||||
await Imager.imageStore.put({ ...image, key, version: 7, path: 'images/sprite.webp' });
|
||||
window.key = key;
|
||||
const live = new Imager.LiveImages(7); live.enabled = true;
|
||||
const first = live.url('images/sprite.webp', 'sourcehash', '/original');
|
||||
let improved;
|
||||
for (let i = 0; i < 100 && !improved; i++) { await new Promise((r) => setTimeout(r, 5)); improved = live.url('images/sprite.webp', 'sourcehash', '/original'); }
|
||||
const renderer = new Imager.Renderer(document.querySelector('main'), 100, 100, { url: () => improved, assetScale: () => 2 });
|
||||
const img = renderer.displayable({ file: 'sprite.webp' }); document.querySelector('main').append(img); await img.decode();
|
||||
const natural = { width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height };
|
||||
const sized = renderer.displayable({ file: 'sprite.webp', size: [20, 10] }); document.querySelector('main').append(sized); await sized.decode();
|
||||
const explicit = { width: sized.getBoundingClientRect().width, height: sized.getBoundingClientRect().height };
|
||||
let revoked = false; const revoke = URL.revokeObjectURL; URL.revokeObjectURL = (url) => { if (url === improved) revoked = true; revoke(url); };
|
||||
live.destroy();
|
||||
return { rejected, alpha: [a[3], a[(7 * 4) + 3]], scale: image.scale, first, improved, natural, explicit, revoked };
|
||||
});
|
||||
assert.equal(result.rejected, true); assert.deepEqual(result.alpha, [128, 0]); assert.equal(result.scale, 2);
|
||||
assert.equal(result.first, null); assert.match(result.improved, /^blob:/); assert.equal(result.revoked, true);
|
||||
assert.deepEqual(result.natural, { width: 4, height: 2 }); assert.deepEqual(result.explicit, { width: 20, height: 10 });
|
||||
assert.equal(writes, 0, 'on-the-fly does not upload anything to the library');
|
||||
const key = await page.evaluate(() => window.key);
|
||||
await page.reload();
|
||||
assert.equal(await page.evaluate(async (key) => (await Imager.imageStore.get(key)).scale, key), 2);
|
||||
await page.evaluate(async () => Imager.clearVersion(7));
|
||||
assert.equal(await page.evaluate(async (key) => !!await Imager.imageStore.get(key), key), false);
|
||||
const css = ['../src/styles.css', '../src/imager/imager.css'].map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n');
|
||||
await page.addStyleTag({ content: css }); await page.evaluate(() => Imager.showPage('setup'));
|
||||
await page.getByRole('heading', { name: 'Локальное улучшение графики', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Сохранить и использовать' }).click();
|
||||
await page.getByRole('status').filter({ hasText: 'Пресет сохранён' }).waitFor();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'setup fits a mobile viewport');
|
||||
if (process.env.PGAMES_IMAGER_SCREENSHOT) await page.screenshot({ path: process.env.PGAMES_IMAGER_SCREENSHOT, fullPage: true });
|
||||
await page.reload(); await page.addStyleTag({ content: css }); await page.evaluate(() => Imager.showPage('editor'));
|
||||
await page.getByRole('heading', { name: 'Улучшение графики', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Выбрать видимые (1)' }).click();
|
||||
await page.getByRole('button', { name: 'Обработать (1)', exact: true }).waitFor();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'editor fits a mobile viewport');
|
||||
} finally { await browser?.close(); await new Promise((r) => server.close(r)); }
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const out = await build({ stdin: { contents: `export * from './hash'; export * from './presets'; export * from './queue'; export * from './backends/swarm'; export * from './backends/a1111'; export * from './backends/comfy'; export * from '../player/renderer';`,
|
||||
resolveDir: fileURLToPath(new URL("../src/imager/", import.meta.url)), loader: "ts" }, bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||
const { DEFAULT_PRESET: preset, validatePreset, presetHash, cacheKey, ImageQueue, SwarmBackend, A1111Backend, ComfyBackend, workflow, Renderer, sha256Fallback } = await import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString("base64")}`);
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
async function until(check) { for (let i = 0; i < 200; i++) { if (check()) return; await tick(); } assert.fail("timed out"); }
|
||||
function store(tasks = []) {
|
||||
const journal = new Map(tasks.map((t) => [t.key, structuredClone(t)])), images = new Map();
|
||||
return { journal, images, get: async (k) => images.get(k), put: async (i) => { images.set(i.key, i); },
|
||||
tasks: async () => [...journal.values()].map((t) => structuredClone(t)), save: async (t) => { journal.set(t.key, structuredClone(t)); } };
|
||||
}
|
||||
const image = (t) => ({ key: t.key, version: t.version, path: t.path, blob: new Blob(['png']), scale: 2, width: 20, height: 10 });
|
||||
|
||||
test("plain HTTP hash fallback matches SHA-256 for empty, Unicode and multiple blocks", () => {
|
||||
for (const text of ['', 'abc', 'Апскейл 🎨', 'x'.repeat(55), 'x'.repeat(56), 'x'.repeat(64), 'x'.repeat(2000)]) {
|
||||
assert.equal(sha256Fallback(new TextEncoder().encode(text)), createHash('sha256').update(text).digest('hex'));
|
||||
}
|
||||
});
|
||||
|
||||
test("preset validation and stable cache identity include source, version, backend and actual parameters", async () => {
|
||||
const hash = await presetHash(preset);
|
||||
assert.equal(hash, await presetHash({ ...preset, name: "Renamed" }));
|
||||
for (const changed of [{ scale: 4 }, { url: "http://127.0.0.1:8000" }, { seed: 1 }, { creativity: .3 }, { backend: "a1111" }]) assert.notEqual(hash, await presetHash({ ...preset, ...changed }));
|
||||
assert.notEqual(cacheKey(1, 'a|b', 'c', hash), cacheKey(1, 'a', 'b|c', hash));
|
||||
assert.notEqual(cacheKey(1, 'a', 'old', hash), cacheKey(1, 'a', 'new', hash));
|
||||
for (const bad of [{ scale: 0 }, { scale: NaN }, { creativity: 2 }, { steps: 1.5 }, { seed: -1 }, { url: 'javascript:alert(1)' }, { backend: "unknown" }]) assert.throws(() => validatePreset({ ...preset, ...bad }));
|
||||
});
|
||||
|
||||
test("one GPU task, concurrent deduplication and cancelling does not overlap remote work", async () => {
|
||||
const s = store(); const releases = []; let active = 0, maximum = 0;
|
||||
const q = new ImageQueue(s, async (t) => { maximum = Math.max(maximum, ++active); await new Promise((r) => releases.push(r)); active--; return image(t); });
|
||||
const [key, duplicate] = await Promise.all([q.enqueue(1, 'a.png', 'a', '/a', preset), q.enqueue(1, 'a.png', 'a', '/a', preset)]);
|
||||
assert.equal(key, duplicate); assert.equal(q.snapshot().length, 1);
|
||||
await until(() => releases.length === 1);
|
||||
const second = await q.enqueue(1, 'b.png', 'b', '/b', preset);
|
||||
q.cancel(key); await tick(); assert.equal(releases.length, 1);
|
||||
releases.shift()(); await until(() => releases.length === 1);
|
||||
releases.shift()(); await until(() => q.snapshot().find((t) => t.key === second).status === 'done');
|
||||
assert.equal(maximum, 1); assert.equal(s.images.has(key), false); assert.equal(s.images.has(second), true);
|
||||
assert.equal(s.journal.get(key).status, 'cancelled');
|
||||
});
|
||||
|
||||
test("failed generation and storage quota are retryable; reload never starts the GPU automatically", async () => {
|
||||
const s = store(); let fail = true, calls = 0;
|
||||
const q = new ImageQueue(s, async (t) => { calls++; if (fail) throw new Error('GPU offline'); return image(t); });
|
||||
const key = await q.enqueue(1, 'a.png', 'a', '/a', preset);
|
||||
await until(() => q.snapshot()[0].status === 'failed'); assert.match(q.snapshot()[0].error, /offline/);
|
||||
fail = false; await q.retry(key); await until(() => q.snapshot()[0].status === 'done'); assert.equal(calls, 2);
|
||||
const task = { ...q.snapshot()[0], status: 'running' };
|
||||
const recovered = new ImageQueue(store([task]), async () => assert.fail('must resume explicitly'));
|
||||
await recovered.restore(); assert.equal(recovered.snapshot()[0].status, 'cancelled');
|
||||
const quota = store(); quota.put = async () => { throw new Error('QuotaExceededError'); };
|
||||
const noSpace = new ImageQueue(quota, async (t) => image(t));
|
||||
await noSpace.enqueue(1, 'x.png', 'x', '/x', preset); await until(() => noSpace.snapshot()[0].status === 'failed');
|
||||
assert.match(noSpace.snapshot()[0].error, /Quota/);
|
||||
});
|
||||
|
||||
test("cached task bypasses GPU and cache loss permits regeneration", async () => {
|
||||
const s = store(); let calls = 0;
|
||||
const q = new ImageQueue(s, async (t) => { calls++; return image(t); });
|
||||
const key = await q.enqueue(1, 'a.png', 'a', '/a', preset); await until(() => q.snapshot()[0].status === 'done');
|
||||
await q.enqueue(1, 'a.png', 'a', '/a', preset); assert.equal(calls, 1);
|
||||
s.images.clear(); await q.enqueue(1, 'a.png', 'a', '/a', preset); await until(() => q.snapshot()[0].status === 'done'); assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("Swarm creates a session, refreshes invalid session once and understands current model tuples", async () => {
|
||||
const original = globalThis.fetch; let sessions = 0, lists = 0;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
const body = JSON.parse(options.body);
|
||||
if (url.endsWith('GetNewSession')) { sessions++; return Response.json({ session_id: 'session' + sessions }); }
|
||||
assert.ok(body.session_id);
|
||||
if (++lists === 1) return Response.json({ error: 'Session expired', error_id: 'invalid_session_id' });
|
||||
return Response.json({ list: [{ id: 'refinerupscalemethod', values: ['pixel-lanczos///Lanczos', 'model-4x.pth'] }], models: { 'Stable-Diffusion': [['sdxl.safetensors', 'sdxl']] } });
|
||||
};
|
||||
try { const b = new SwarmBackend('http://127.0.0.1:7801'); const models = await b.models(); assert.equal(sessions, 2); assert.deepEqual(models.models, ['sdxl.safetensors']); assert.equal(models.upscalers[0], 'pixel-lanczos'); }
|
||||
finally { globalThis.fetch = original; }
|
||||
});
|
||||
|
||||
test("Comfy upscale is a pixel-only graph; img2img adds the sampling graph", () => {
|
||||
const upscale = workflow('input.png', { ...preset, upscaler: '4x.pth' }, 100, 50);
|
||||
assert.ok(Object.values(upscale).some((n) => n.class_type === 'ImageUpscaleWithModel'));
|
||||
assert.ok(!Object.values(upscale).some((n) => n.class_type === 'KSampler' || n.class_type === 'VAEEncode'));
|
||||
assert.equal(upscale['4'].inputs.width, 200);
|
||||
const diffusion = workflow('input.png', { ...preset, mode: 'img2img', model: 'sdxl' }, 100, 50);
|
||||
assert.equal(diffusion['9'].inputs.denoise, preset.creativity);
|
||||
assert.deepEqual(diffusion['11'].inputs.images, ['10', 0]);
|
||||
});
|
||||
|
||||
test("backend wire protocols send the source and return downloadable results", async () => {
|
||||
const original = { fetch: globalThis.fetch, FileReader: globalThis.FileReader, createImageBitmap: globalThis.createImageBitmap };
|
||||
globalThis.FileReader = class {
|
||||
readAsDataURL(blob) { blob.arrayBuffer().then((buffer) => { this.result = 'data:image/png;base64,' + Buffer.from(buffer).toString('base64'); this.onload(); }); }
|
||||
};
|
||||
globalThis.createImageBitmap = async () => ({ width: 64, height: 128, close() {} });
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
const path = new URL(url).pathname;
|
||||
const body = typeof options?.body === 'string' ? JSON.parse(options.body) : options?.body;
|
||||
calls.push({ path, body });
|
||||
if (String(url).startsWith('data:')) return original.fetch(url);
|
||||
if (path.endsWith('/GetNewSession')) return Response.json({ session_id: 's' });
|
||||
if (path.endsWith('/GenerateText2Image')) return Response.json({ images: ['View/generated.png'] });
|
||||
if (path.endsWith('/extra-single-image')) return Response.json({ image: 'AAAA' });
|
||||
if (path.endsWith('/img2img')) return Response.json({ images: ['AAAA'] });
|
||||
if (path.endsWith('/upload/image')) return Response.json({ name: 'source.png', subfolder: '', type: 'temp' });
|
||||
if (path.endsWith('/prompt')) return Response.json({ prompt_id: 'job', node_errors: {} });
|
||||
if (path.endsWith('/history/job')) return Response.json({ job: { outputs: { '11': { images: [{ filename: 'output.png', subfolder: '', type: 'temp' }] } } } });
|
||||
return new Response(new Blob(['result'], { type: 'image/png' }));
|
||||
};
|
||||
try {
|
||||
const source = new Blob(['original'], { type: 'image/png' });
|
||||
const swarm = new SwarmBackend('http://127.0.0.1:7801');
|
||||
assert.equal(await (await swarm.process(source, { ...preset, model: 'sdxl' })).text(), 'result');
|
||||
const swarmBody = calls.find((c) => c.path.endsWith('/GenerateText2Image')).body;
|
||||
assert.equal(swarmBody.refinermethod, 'PostApply'); assert.equal(swarmBody.refinercontrolpercentage, 0); assert.equal(swarmBody.initimagecreativity, 0);
|
||||
assert.equal(swarmBody.width, 64); assert.equal(swarmBody.height, 128); assert.equal(swarmBody.refinerupscale, 2);
|
||||
assert.match(swarmBody.initimage, /^data:image\/png;base64,/); assert.equal(swarmBody.donotsave, true);
|
||||
const a = new A1111Backend('http://127.0.0.1:7860');
|
||||
await a.process(source, { ...preset, upscaler: 'ESRGAN' });
|
||||
assert.equal(calls.find((c) => c.path.endsWith('/extra-single-image')).body.upscaler_1, 'ESRGAN');
|
||||
await a.process(source, { ...preset, mode: 'img2img', model: 'sdxl' });
|
||||
const aBody = calls.find((c) => c.path.endsWith('/img2img')).body;
|
||||
assert.equal(aBody.width, 128); assert.equal(aBody.height, 256); assert.equal(aBody.denoising_strength, preset.creativity); assert.equal(aBody.override_settings.sd_model_checkpoint, 'sdxl');
|
||||
const comfy = new ComfyBackend('http://127.0.0.1:8188');
|
||||
await comfy.process(source, preset);
|
||||
const graph = calls.find((c) => c.path === '/prompt').body.prompt;
|
||||
assert.equal(graph['1'].inputs.image, 'source.png [temp]'); assert.equal(graph['4'].inputs.width, 128);
|
||||
assert.ok(calls.some((c) => c.path === '/view'));
|
||||
} finally { Object.assign(globalThis, original); }
|
||||
});
|
||||
|
||||
test("renderer compensates natural size but leaves explicit and ATL dimensions intact", () => {
|
||||
const { document } = parseHTML('<html><body><main></main></body></html>');
|
||||
const previous = globalThis.document; globalThis.document = document;
|
||||
try {
|
||||
const renderer = new Renderer(document.querySelector('main'), 1920, 1080, { url: () => 'blob:improved', assetScale: () => 2 });
|
||||
const img = renderer.displayable({ file: 'a.png' });
|
||||
Object.defineProperty(img, 'naturalWidth', { value: 800 }); img.dispatchEvent(new document.defaultView.Event('load'));
|
||||
assert.equal(img.width, 400);
|
||||
const sized = renderer.displayable({ file: 'a.png', size: [80, 40] });
|
||||
sized.dispatchEvent(new document.defaultView.Event('load')); assert.equal(sized.style.width, '80px');
|
||||
const atl = renderer.displayable({ file: 'a.png' }); atl.style.width = '100px';
|
||||
atl.dispatchEvent(new document.defaultView.Event('load')); assert.equal(atl.style.width, '100px'); assert.equal(atl.hasAttribute('width'), false);
|
||||
} finally { globalThis.document = previous; }
|
||||
});
|
||||
Reference in new issue
Block a user