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

+85
View File
@@ -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)); }
});
+151
View File
@@ -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; }
});