feat: add local image enhancement and lossless mod assets
CI / frontend (push) Failing after 26s
CI / backend (push) Failing after 1m37s

This commit is contained in:
mrleo1nid committed 2026-10-25 21:41:45 +03:00
1 parent cc71050362
commit f7f7f5b1b3
29 files changed
+1495 -10

No files matched your search

+5
View File
@@ -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>} />
+122
View File
@@ -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>;
}
+61
View File
@@ -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>;
}
+18
View File
@@ -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>;
}
+33
View File
@@ -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);
}
}
+57
View File
@@ -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 минут");
}
}
+11
View File
@@ -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);
}
}
+40
View File
@@ -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);
}
}
+25
View File
@@ -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();
}
+53
View File
@@ -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);
});
}
+46
View File
@@ -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("");
}
+15
View File
@@ -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; }
+70
View File
@@ -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(); }
}
+62
View File
@@ -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(); }
}
+58
View File
@@ -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]);
}
+101
View File
@@ -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();
+1
View File
@@ -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" && (
+1
View File
@@ -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>}
+29 -5
View File
@@ -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");
+11 -4
View File
@@ -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");
+2
View File
@@ -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;