Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f473b1600c | ||
|
|
e110b987fe |
@@ -4,7 +4,6 @@ Database operations for KikoTextEncode prompt storage and retrieval.
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import math
|
||||
import datetime
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
@@ -27,7 +26,6 @@ from .health import HealthOperations, iter_consistency_issues
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
from ..utils.json_strict import dumps_strict
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
@@ -35,7 +33,6 @@ except ImportError:
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.json_strict import dumps_strict
|
||||
|
||||
|
||||
# Subquery to fetch tags from junction table, embedded in SELECT statements
|
||||
@@ -1700,9 +1697,9 @@ class PromptDatabase(
|
||||
width,
|
||||
height,
|
||||
image_format,
|
||||
dumps_strict(metadata.get("workflow", {})),
|
||||
dumps_strict(metadata.get("prompt", {})),
|
||||
dumps_strict(metadata.get("parameters", {})),
|
||||
json.dumps(metadata.get("workflow", {})),
|
||||
json.dumps(metadata.get("prompt", {})),
|
||||
json.dumps(metadata.get("parameters", {})),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -1907,13 +1904,13 @@ class PromptDatabase(
|
||||
obj: The object to clean (dict, list, or scalar)
|
||||
|
||||
Returns:
|
||||
Cleaned object with NaN and Infinity values replaced by None
|
||||
Cleaned object with NaN values replaced by None
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {key: self._clean_nan_values(value) for key, value in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [self._clean_nan_values(item) for item in obj]
|
||||
elif isinstance(obj, float) and not math.isfinite(obj):
|
||||
elif isinstance(obj, float) and str(obj) == "nan":
|
||||
return None
|
||||
else:
|
||||
return obj
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -8,10 +9,8 @@ from .operations import _coerce_file_info
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
from ..utils.json_strict import dumps_strict
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.json_strict import dumps_strict
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
@@ -25,7 +24,9 @@ def ingest_resolved_pair(
|
||||
negative_text = negative.get("storage_text", negative["text"])
|
||||
normalized = normalize_image_path(image_path)
|
||||
provenance = {"resolver_version": 1, "positive": positive, "negative": negative}
|
||||
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
|
||||
encoded = json.dumps(
|
||||
provenance, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
with db._pair_transaction() as conn:
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
@@ -66,9 +67,8 @@ def ingest_resolved_pair(
|
||||
).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
# ComfyUI's NaN/Infinity are stored as null: the snapshot is strict JSON.
|
||||
snapshots = {
|
||||
column: dumps_strict(metadata[key], sort_keys=True)
|
||||
column: json.dumps(metadata[key], sort_keys=True, allow_nan=False)
|
||||
for column, key in (
|
||||
("workflow_data", "workflow"),
|
||||
("prompt_metadata", "prompt"),
|
||||
|
||||
+2
-3
@@ -16,7 +16,6 @@ import datetime
|
||||
import functools
|
||||
import gzip as gzip_module
|
||||
import json
|
||||
import math
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
@@ -610,12 +609,12 @@ class PromptManagerAPI(
|
||||
return prompts
|
||||
|
||||
def _clean_nan_recursive(self, obj):
|
||||
"""Recursively replace NaN and Infinity in nested data with None."""
|
||||
"""Recursively clean NaN values from nested data structures."""
|
||||
if isinstance(obj, dict):
|
||||
return {key: self._clean_nan_recursive(value) for key, value in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [self._clean_nan_recursive(item) for item in obj]
|
||||
elif isinstance(obj, float) and not math.isfinite(obj):
|
||||
elif isinstance(obj, float) and str(obj) == "nan":
|
||||
return None
|
||||
else:
|
||||
return obj
|
||||
|
||||
@@ -804,9 +804,6 @@ class AdminRoutesMixin:
|
||||
result = await self._run_in_executor(
|
||||
self._delete_duplicate_images_sync, image_paths, output_dir
|
||||
)
|
||||
if result["deleted_count"]:
|
||||
# The cached listing would keep (and count) the deleted files.
|
||||
self.invalidate_gallery_cache()
|
||||
|
||||
response_data = {
|
||||
"success": True,
|
||||
|
||||
+21
-9
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
@@ -38,7 +37,6 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.json_strict import dumps_strict
|
||||
from ...utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
@@ -58,7 +56,6 @@ except ImportError:
|
||||
from database.models import normalize_image_path
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.parallel import map_parallel
|
||||
from utils.json_strict import dumps_strict
|
||||
from utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
@@ -985,12 +982,27 @@ class ImageRoutesMixin:
|
||||
# Add url and thumbnail_url so the frontend can serve them
|
||||
cleaned_images = self._present_images(cleaned_images)
|
||||
|
||||
# Rows an older version stored may still hold NaN/Infinity: answer
|
||||
# strict JSON, and leave prompt text that spells "NaN" untouched.
|
||||
return web.json_response(
|
||||
{"success": True, "images": cleaned_images},
|
||||
dumps=functools.partial(dumps_strict, default=str),
|
||||
)
|
||||
# Additional fallback: convert to JSON string and clean NaN values manually
|
||||
try:
|
||||
response_data = {"success": True, "images": cleaned_images}
|
||||
# Convert to JSON string
|
||||
json_str = json.dumps(response_data, default=str)
|
||||
|
||||
# Clean any remaining NaN values with regex
|
||||
json_str = re.sub(r":\s*NaN", ": null", json_str)
|
||||
json_str = re.sub(r"\[\s*NaN\s*\]", "[null]", json_str)
|
||||
json_str = re.sub(r",\s*NaN\s*,", ", null,", json_str)
|
||||
json_str = re.sub(r",\s*NaN\s*\]", ", null]", json_str)
|
||||
json_str = re.sub(r"\[\s*NaN\s*,", "[null,", json_str)
|
||||
|
||||
# Parse back to verify it's valid JSON
|
||||
cleaned_data = json.loads(json_str)
|
||||
|
||||
return web.json_response(cleaned_data)
|
||||
except Exception as json_error:
|
||||
self.logger.error(f"JSON cleaning error: {json_error}")
|
||||
# Fallback to original response
|
||||
return web.json_response({"success": True, "images": cleaned_images})
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get prompt images error: {e}")
|
||||
|
||||
@@ -354,48 +354,39 @@ test('prompt deletion requires confirmation and can retry after a network failur
|
||||
}
|
||||
});
|
||||
|
||||
// The duplicate scan reads the output root only, but the fixture's input root holds a different
|
||||
// shared/same.png. A bare /images/serve/<path> URL tries every gallery root in turn, so once the
|
||||
// output copy is deleted it answers 200 with the input copy: check each file in its own root.
|
||||
const serveIn = (root, path) => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}?root=${root}`;
|
||||
const rootOf = url => new URL(url, 'http://gallery.test').searchParams.get('root');
|
||||
|
||||
// Which copy the scan keeps depends on file times: the fixture stamps shared/same.png with the
|
||||
// wall clock, and the kernel can stamp its bulk copies a fraction of a millisecond earlier.
|
||||
// listOrder rewrites the finished scan's groups so a test can pick the order it needs.
|
||||
async function openDuplicateScan(page, request, listOrder = null) {
|
||||
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
|
||||
if (listOrder) {
|
||||
await page.route('**/prompt_manager/scan_duplicates/stream', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = (await response.text()).replace(/^data: (.*)$/gm, (line, json) => {
|
||||
const event = JSON.parse(json);
|
||||
if (event.type !== 'complete') return line;
|
||||
for (const group of event.duplicates) group.images = listOrder(group.images);
|
||||
return `data: ${JSON.stringify(event)}`;
|
||||
});
|
||||
await route.fulfill({ response, body });
|
||||
});
|
||||
}
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
|
||||
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
|
||||
const output = rootOf(originals.find(url => url.includes('/bulk/')));
|
||||
const input = rootOf(originals.find(url => url.includes('/shared/same.png') && rootOf(url) !== output));
|
||||
expect(output).not.toBeNull();
|
||||
expect(input).not.toBeNull();
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.locator('#scanDuplicatesBtn').click();
|
||||
await expect(page.locator('#duplicatesCount')).toHaveText('1');
|
||||
const candidates = page.locator('.duplicate-checkbox');
|
||||
await expect(candidates).toHaveCount(2);
|
||||
return { originals, output, input, candidates };
|
||||
}
|
||||
|
||||
async function expectOnlySelectedGone(page, request, { originals, output, input }, selected, retained) {
|
||||
expect((await request.get(serveIn(output, selected))).status()).toBe(404);
|
||||
await expect(await request.get(serveIn(output, retained))).toBeOK();
|
||||
await expect(await request.get(serveIn(input, 'shared/same.png'))).toBeOK();
|
||||
const selected = await candidates.first().getAttribute('data-image-path');
|
||||
const retained = await candidates.last().getAttribute('data-image-path');
|
||||
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||
await candidates.first().check();
|
||||
let deletes = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
|
||||
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect(deletes).toBe(0);
|
||||
await expect(await request.get(serve(selected))).toBeOK();
|
||||
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
|
||||
await expect(await request.get(serve(selected))).toBeOK();
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
|
||||
await expect(page.locator('#duplicatesModal')).toBeHidden();
|
||||
expect((await request.get(serve(selected))).status()).toBe(404);
|
||||
await expect(await request.get(serve(retained))).toBeOK();
|
||||
await page.reload();
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
|
||||
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
|
||||
@@ -404,47 +395,4 @@ async function expectOnlySelectedGone(page, request, { originals, output, input
|
||||
expect(originals).toContain(url);
|
||||
await expect(await request.get(url)).toBeOK();
|
||||
}
|
||||
}
|
||||
|
||||
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
|
||||
const scan = await openDuplicateScan(page, request);
|
||||
const { output, candidates } = scan;
|
||||
const selected = await candidates.first().getAttribute('data-image-path');
|
||||
const retained = await candidates.last().getAttribute('data-image-path');
|
||||
await candidates.first().check();
|
||||
let deletes = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
|
||||
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect(deletes).toBe(0);
|
||||
await expect(await request.get(serveIn(output, selected))).toBeOK();
|
||||
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
|
||||
await expect(await request.get(serveIn(output, selected))).toBeOK();
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
|
||||
await expect(page.locator('#duplicatesModal')).toBeHidden();
|
||||
await expectOnlySelectedGone(page, request, scan, selected, retained);
|
||||
});
|
||||
|
||||
test('deleting a duplicate whose path another gallery root also holds removes only the scanned copy', async ({ page, request }) => {
|
||||
// The order CI saw: a bulk copy stamped first, so shared/same.png is the first Delete candidate.
|
||||
const ciOrder = ['bulk/0000.png', 'shared/same.png', 'bulk/0001.png'];
|
||||
const rank = image => ciOrder.indexOf(image.path.replace(/\\/g, '/'));
|
||||
const scan = await openDuplicateScan(page, request, images => [...images].sort((a, b) => rank(a) - rank(b)));
|
||||
const selected = await scan.candidates.first().getAttribute('data-image-path');
|
||||
const retained = await scan.candidates.last().getAttribute('data-image-path');
|
||||
expect(selected.replace(/\\/g, '/')).toBe('shared/same.png');
|
||||
await scan.candidates.first().check();
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
|
||||
await expect(page.locator('#duplicatesModal')).toBeHidden();
|
||||
await expectOnlySelectedGone(page, request, scan, selected, retained);
|
||||
});
|
||||
|
||||
@@ -724,42 +724,6 @@ test('a held legacy job shows no Cancel in the list or its view', async ({ page,
|
||||
await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']);
|
||||
});
|
||||
|
||||
// Since #289 an item dropped its Cancel job only when it was already in the page, so the first render of jobs the
|
||||
// store already knew (Activity opened from another page) kept a Cancel job on a job that had ended, and moved a
|
||||
// running job's progress after its actions. The read Activity starts on opening is held, so that first render is
|
||||
// what the page shows (the read's own render would otherwise repair it a moment later).
|
||||
test('jobs that ended before Activity opened show no Cancel job, a running one keeps its progress first', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const finished = await startJob(request, { title: 'Finished scan' });
|
||||
await finish(request, finished.id);
|
||||
const cancelled = await startJob(request, { title: 'Cancelled scan' });
|
||||
await finish(request, cancelled.id, 'cancelled');
|
||||
await startJob(request, { title: 'Running scan' });
|
||||
// The page's first list read has all three jobs once the running one shows in the pill.
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(pill(page)).toBeVisible();
|
||||
let hold;
|
||||
const held = new Promise(resolve => { hold = resolve; });
|
||||
// Only the first read is held; any later one goes through.
|
||||
await page.route(url => url.pathname === '/prompt_manager/jobs', route => {
|
||||
if (!hold) return route.continue();
|
||||
hold(route);
|
||||
hold = null;
|
||||
});
|
||||
await activityButton(page).click();
|
||||
const route = await held;
|
||||
await expect(items(page)).toHaveCount(3);
|
||||
const item = title => items(page).filter({ has: page.getByRole('link', { name: new RegExp(`${title}$`) }) });
|
||||
for (const ended of ['Finished scan', 'Cancelled scan']) {
|
||||
await expect(item(ended).locator('progress, .row-actions')).toHaveCount(0);
|
||||
await expect(item(ended).getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
|
||||
}
|
||||
await expect(item('Running scan').getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(1);
|
||||
expect(await item('Running scan').evaluate(node => [...node.children].map(child => child.localName + (child.className ? `.${child.className}` : ''))))
|
||||
.toEqual(['div.panel-heading', 'p', 'progress', 'div.row-actions']);
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
test('busy start names the running job', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
// The page's timers are Playwright's, so the toast's lifetime below is run out rather than waited for.
|
||||
|
||||
@@ -463,50 +463,6 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret
|
||||
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('collection viewer leaving an image Viewer.js has not built yet releases it at once',async({page})=>{
|
||||
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
|
||||
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/images/serve/shared/same.png?*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
|
||||
await route.continue().catch(()=>{});
|
||||
});
|
||||
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
|
||||
const preview=page.locator('dialog#viewer-dialog');
|
||||
await page.evaluate(()=>{window.unbuiltImage=document.querySelector('#viewer-stage > img');});
|
||||
await preview.getByRole('button',{name:'Next media',exact:true}).click();
|
||||
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
// The left image is off the stage. Viewer.js has not built it, so the viewer is destroyed and the src released at
|
||||
// once: its late load must never build Viewer.js into an image without a parent (getComputedStyle(null)).
|
||||
release();
|
||||
await expect.poll(()=>page.evaluate(()=>[window.unbuiltImage.isConnected,window.unbuiltImage.hasAttribute('src'),!!window.unbuiltImage.viewer])).toEqual([false,false,false]);
|
||||
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('collection viewer closed while its image finishes loading never builds Viewer.js afterwards',async({page})=>{
|
||||
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
|
||||
let held;const waiting=new Promise(resolve=>{held=resolve;});
|
||||
// The image is held for the whole test: its late load is delivered below, at the moment Viewer.js cannot cancel.
|
||||
await page.route('**/images/serve/shared/same.png?*',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();return;}
|
||||
await route.continue().catch(()=>{});
|
||||
});
|
||||
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
|
||||
const preview=page.locator('dialog#viewer-dialog');
|
||||
// Viewer.js's own cancel removes its image listeners only while `image.complete` is false. An image that has
|
||||
// finished downloading but whose load event is still queued is already complete: close in that state, then deliver it.
|
||||
await page.evaluate(()=>{window.finishingImage=document.querySelector('#viewer-stage > img');Object.defineProperty(window.finishingImage,'complete',{get:()=>true});});
|
||||
await page.keyboard.press('Escape');await expect(preview).toHaveCount(0);
|
||||
const after=await page.evaluate(async()=>{
|
||||
const image=window.finishingImage;image.dispatchEvent(new Event('load'));
|
||||
// Viewer.js builds an inline viewer in a zero-delay timeout after its image loads; this runs after it.
|
||||
await new Promise(resolve=>setTimeout(resolve));
|
||||
return {src:image.hasAttribute('src'),built:!!image.parentNode?.querySelector('.viewer-container')};
|
||||
});
|
||||
expect(after).toEqual({src:false,built:false});
|
||||
});
|
||||
|
||||
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
|
||||
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
|
||||
|
||||
@@ -124,51 +124,18 @@ test('real inspection navigation preserves slideshow across two advances and def
|
||||
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
|
||||
});
|
||||
|
||||
test('Viewer.js is built only on a loaded image and an unbuilt viewer is released at once', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
const instances = [];
|
||||
// On a loaded image Viewer.js schedules its inline build; until it runs `ready` is false and destroy() cancels it.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = false; this.viewed = false; instances.push(this); }
|
||||
destroy() { this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
const stageImage = () => f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
view.open({items,selectedKey:'d'.repeat(64)});
|
||||
// Viewer.js's own cancel misses an image whose load event is still queued, so none exists before the load.
|
||||
const unloaded = stageImage(), dropped = [];unloaded.removeAttribute = key => dropped.push(key);
|
||||
assert.equal(instances.length, 0, 'no Viewer.js before the image loads');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Previous media')[0].fire('click');
|
||||
assert.deepEqual(dropped, ['src'], 'an image left before it loads is released at once');
|
||||
unloaded.fire('load');
|
||||
assert.equal(instances.length, 0, 'its late load builds no Viewer.js');
|
||||
stageImage().fire('load');
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
// Next drops the image whose build is pending; a deferred destroy would let that build run there.
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
stageImage().fire('load');assert.equal(instances.length, 2, 'the next image builds its own Viewer.js once loaded');
|
||||
assert.equal(loading.destroyed, 1, 'an unbuilt viewer is destroyed at once');
|
||||
assert.deepEqual(removed, ['src'], 'and its image is released at once');
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'never destroyed twice');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
|
||||
const instances = [];
|
||||
// A built (`ready`) inline Viewer.js's destroy() leaves an in-flight view running; its callback then needs a live instance.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewed = false; instances.push(this); }
|
||||
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
|
||||
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
|
||||
destroy() { this.destroyed++; } resize() {} };
|
||||
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
|
||||
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
@@ -181,7 +148,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
|
||||
loading.options.viewed.call(loading);
|
||||
assert.equal(loading.destroyed, 1, 'never destroyed twice');
|
||||
|
||||
view.open({items,selectedKey:'c'.repeat(64)});f.walk(f.container,n => n.tagName === 'img')[0].fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
|
||||
@@ -269,7 +236,7 @@ test('viewer uses adapter.mediaUrl for image and video', async () => {
|
||||
const img = f.walk(f.container,n => n.tagName === 'img')[0];
|
||||
assert.equal(img.src, image.url, 'the blob URL is used exactly');
|
||||
assert.doesNotMatch(img.src, /\?/);
|
||||
img.fire('load');assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
|
||||
assert.equal(instances[0].node, img, 'Viewer.js shows the blob image');
|
||||
const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0];
|
||||
assert.equal(download.href, image.url);assert.equal(download.download, 'a.png');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
|
||||
@@ -868,23 +868,6 @@ class TestDeleteDuplicatesEndpoint(AdminAPITestCase):
|
||||
self.assertFalse(target.exists())
|
||||
self.assertTrue(thumb_dir.exists())
|
||||
|
||||
async def test_deleted_files_leave_the_cached_gallery_listing(self):
|
||||
# The listing is cached for 30 s; a delete must not leave the gallery
|
||||
# listing (and counting) a file that is gone.
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
for name in ("keep.png", "gone.png"):
|
||||
(self.output_dir / name).write_bytes(b"same bytes")
|
||||
|
||||
async def listed():
|
||||
resp = await self.client.get("/prompt_manager/images/output")
|
||||
data = await resp.json()
|
||||
return data["total"], sorted(i["filename"] for i in data["images"])
|
||||
|
||||
self.assertEqual(await listed(), (2, ["gone.png", "keep.png"]))
|
||||
resp = await self._post({"image_paths": ["gone.png"]})
|
||||
self.assertEqual((await resp.json())["deleted_count"], 1)
|
||||
self.assertEqual(await listed(), (1, ["keep.png"]))
|
||||
|
||||
def test_unexpected_error_per_file_is_reported_without_path(self):
|
||||
self.api._delete_one_duplicate = lambda *a: (_ for _ in ()).throw(
|
||||
PermissionError(13, "Permission denied", os.path.join(self.tmpdir, "x.png"))
|
||||
|
||||
@@ -24,9 +24,6 @@ from database.operations import PromptDatabase
|
||||
from tests.open_handles import assert_closed
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
# A bound on thread hand-offs that only a hung test reaches (never a pacing timer).
|
||||
THREAD_WAIT_SECONDS = 120
|
||||
|
||||
|
||||
def _count_prompts(path):
|
||||
"""Row count read through a brand-new connection (closed before return)."""
|
||||
@@ -353,13 +350,7 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
def worker():
|
||||
self.db.search_prompts(text="old") # opens this thread's connection
|
||||
ready.set()
|
||||
# Wait for the restore itself, not for a timer: a stalled Windows
|
||||
# runner once took longer than 5 s to restore, and the worker then
|
||||
# read the old database before the restore had run.
|
||||
if not proceed.wait(THREAD_WAIT_SECONDS):
|
||||
results["error"] = "the restore did not finish"
|
||||
self.db.close()
|
||||
return
|
||||
proceed.wait(5)
|
||||
try:
|
||||
results["texts"] = [p["text"] for p in self.db.search_prompts()]
|
||||
except Exception as exc: # noqa: BLE001 - reported through results
|
||||
@@ -369,15 +360,12 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
self.assertTrue(ready.wait(THREAD_WAIT_SECONDS))
|
||||
ready.wait(5)
|
||||
|
||||
try:
|
||||
self.db.model.restore_from_file(src)
|
||||
finally:
|
||||
proceed.set()
|
||||
thread.join(THREAD_WAIT_SECONDS)
|
||||
self.db.model.restore_from_file(src)
|
||||
proceed.set()
|
||||
thread.join(5)
|
||||
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertNotIn("error", results)
|
||||
self.assertEqual(results["texts"], ["restored"])
|
||||
self.assertEqual([p["text"] for p in self.db.search_prompts()], ["restored"])
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
"""ComfyUI's ``NaN``/``Infinity`` metadata ingests, and is stored as strict JSON.
|
||||
|
||||
ComfyUI serializes the prompt with Python's ``json.dumps``, so a node whose
|
||||
``IS_CHANGED`` returns ``float("nan")`` (the "always re-run" idiom) writes
|
||||
``"is_changed": [NaN]`` into every saved PNG; widgets can hold ``Infinity``.
|
||||
The legacy pages replaced ``NaN`` with ``null`` before ``JSON.parse``, so such
|
||||
files must ingest. Whatever is stored or answered as JSON stays strict (the
|
||||
non-finite numbers become ``null``) and the prompt text and hash never change.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from tests.history_waits import drain_writer
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
# The words "NaN" and "Infinity" inside the prompt are text, never numbers.
|
||||
POSITIVE = " A cat named NaN beside an Infinity pool, ratio: NaN, [NaN] "
|
||||
NEGATIVE = "blurry"
|
||||
NAN, INF = float("nan"), float("inf")
|
||||
|
||||
|
||||
def graph(positive=POSITIVE, negative=NEGATIVE):
|
||||
"""An API prompt graph with non-finite numbers where ComfyUI writes them."""
|
||||
g = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "model": ["4", 0]},
|
||||
},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"5": {
|
||||
"class_type": "Image Filter",
|
||||
"inputs": {"strength": INF, "floor": -INF},
|
||||
"_meta": {"title": "Image Filter"},
|
||||
"is_changed": [NAN],
|
||||
},
|
||||
}
|
||||
return g
|
||||
|
||||
|
||||
WORKFLOW = {
|
||||
"nodes": [
|
||||
{"id": 5, "type": "Image Filter", "widgets_values": [NAN, INF, -INF]},
|
||||
],
|
||||
"links": [],
|
||||
}
|
||||
|
||||
|
||||
def comfy_text(value):
|
||||
"""The record text ComfyUI writes: Python's default, non-strict JSON."""
|
||||
text = json.dumps(value)
|
||||
assert "NaN" in text and "Infinity" in text
|
||||
return text
|
||||
|
||||
|
||||
def write_png(path, prompt_graph=None, workflow=WORKFLOW):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", comfy_text(prompt_graph or graph()))
|
||||
if workflow is not None:
|
||||
info.add_text("workflow", comfy_text(workflow))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
return path
|
||||
|
||||
|
||||
def _reject(name):
|
||||
raise ValueError(f"non-strict JSON constant {name}")
|
||||
|
||||
|
||||
def strict_loads(text):
|
||||
return json.loads(text, parse_constant=_reject)
|
||||
|
||||
|
||||
class StrictRows:
|
||||
"""Assertions over the rows a write left behind."""
|
||||
|
||||
def image_rows(self, db):
|
||||
with db.model.get_connection() as conn:
|
||||
return [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT prompt_id, workflow_data, prompt_metadata, parameters "
|
||||
"FROM generated_images ORDER BY id"
|
||||
)
|
||||
]
|
||||
|
||||
def assert_strict_snapshot(self, row, *, workflow=True):
|
||||
prompt = strict_loads(row["prompt_metadata"])
|
||||
self.assertEqual(prompt["5"]["is_changed"], [None])
|
||||
self.assertEqual(prompt["5"]["inputs"], {"strength": None, "floor": None})
|
||||
self.assertEqual(prompt["1"]["inputs"]["text"], POSITIVE)
|
||||
stored_workflow = strict_loads(row["workflow_data"])
|
||||
if workflow:
|
||||
self.assertEqual(
|
||||
stored_workflow["nodes"][0]["widgets_values"], [None, None, None]
|
||||
)
|
||||
if row["parameters"] is not None:
|
||||
strict_loads(row["parameters"])
|
||||
|
||||
def assert_prompt_text(self, db, prompt_id):
|
||||
with db.model.get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT text, hash FROM prompts WHERE id=?", (prompt_id,)
|
||||
).fetchone()
|
||||
self.assertEqual(row["text"], POSITIVE.strip())
|
||||
self.assertEqual(row["hash"], generate_prompt_hash(POSITIVE))
|
||||
|
||||
|
||||
def dumps_strict(value, **kwargs):
|
||||
from utils.json_strict import dumps_strict as dumps
|
||||
|
||||
return dumps(value, **kwargs)
|
||||
|
||||
|
||||
class TestDumpsStrict(unittest.TestCase):
|
||||
def test_finite_values_serialize_byte_identically(self):
|
||||
value = {"b": [1, 2.5, "NaN", None, True], "a": {"x": "Infinity"}}
|
||||
for kwargs in ({}, {"sort_keys": True, "separators": (",", ":")}):
|
||||
with self.subTest(kwargs=kwargs):
|
||||
self.assertEqual(
|
||||
dumps_strict(value, **kwargs),
|
||||
json.dumps(value, allow_nan=False, **kwargs),
|
||||
)
|
||||
|
||||
def test_non_finite_numbers_become_null_and_strings_stay(self):
|
||||
value = {"n": NAN, "i": [INF, -INF], "s": "NaN Infinity", "t": (NAN, 1)}
|
||||
encoded = dumps_strict(value, sort_keys=True)
|
||||
self.assertEqual(
|
||||
strict_loads(encoded),
|
||||
{"n": None, "i": [None, None], "s": "NaN Infinity", "t": [None, 1]},
|
||||
)
|
||||
|
||||
def test_other_errors_still_raise(self):
|
||||
cycle = []
|
||||
cycle.append(cycle)
|
||||
for value in (cycle, [NAN, cycle]):
|
||||
with self.assertRaisesRegex(ValueError, "Circular"):
|
||||
dumps_strict(value)
|
||||
with self.assertRaises(TypeError):
|
||||
dumps_strict({"x": object()})
|
||||
self.assertEqual(
|
||||
dumps_strict({"x": object()}, default=lambda _: NAN), '{"x": null}'
|
||||
)
|
||||
|
||||
|
||||
class TestScanIngestsNonFiniteMetadata(StrictRows, AdminAPITestCase):
|
||||
def ingest(self, name, prompt_graph=None):
|
||||
path = write_png(self.output_dir / name, prompt_graph)
|
||||
extracted = self.api._extract_comfyui_metadata(str(path))
|
||||
return self.api._ingest_scan_batch_sync([(path, extracted)])
|
||||
|
||||
def test_resolved_pair_with_nan_is_added_with_strict_snapshots(self):
|
||||
counts = self.ingest("nan.png")
|
||||
self.assertEqual(counts["failed"], 0)
|
||||
self.assertEqual(counts["added"], 1)
|
||||
rows = self.image_rows(self.api.db)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assert_strict_snapshot(rows[0])
|
||||
self.assert_prompt_text(self.api.db, rows[0]["prompt_id"])
|
||||
pairs = self.api.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(
|
||||
[(p["positive_text"], p["negative_text"]) for p in pairs],
|
||||
[(POSITIVE.strip(), NEGATIVE)],
|
||||
)
|
||||
# A repeated scan compares the same strict snapshot: unchanged.
|
||||
self.assertEqual(self.ingest("nan.png")["unchanged"], 1)
|
||||
|
||||
async def test_scan_route_reports_no_failures(self):
|
||||
from py.config import GalleryConfig
|
||||
|
||||
write_png(self.output_dir / "nan.png")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
resp = await self.client.request("POST", "/prompt_manager/scan")
|
||||
body = await resp.text()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertIn('"type": "complete"', body)
|
||||
self.assertIn('"added": 1', body)
|
||||
self.assertIn('"failed": 0', body)
|
||||
|
||||
|
||||
class TestMonitorIngestsNonFiniteMetadata(StrictRows, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "db.sqlite"))
|
||||
self.pid = self.db.save_prompt(
|
||||
POSITIVE.strip(), prompt_hash=generate_prompt_hash(POSITIVE)
|
||||
)
|
||||
self.tracker = SimpleNamespace(
|
||||
pop_next_prompt=lambda: None,
|
||||
get_current_prompt=lambda: None,
|
||||
extend_prompt_timeout=lambda *args: None,
|
||||
)
|
||||
self.handler = ImageGenerationHandler(self.db, self.tracker)
|
||||
self.handler.logger = logging.getLogger("test.nonfinite.monitor")
|
||||
self.handler.logger.disabled = True
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_new_image_with_nan_links_its_pair(self):
|
||||
# The monitor links only images a PromptManager node produced.
|
||||
g = graph()
|
||||
g["1"]["class_type"] = "PromptManager"
|
||||
path = write_png(Path(self.temp.name) / "out" / "nan.png", g)
|
||||
self.handler.process_new_image(str(path))
|
||||
rows = self.image_rows(self.db)
|
||||
self.assertEqual([row["prompt_id"] for row in rows], [self.pid])
|
||||
self.assert_strict_snapshot(rows[0])
|
||||
self.assert_prompt_text(self.db, self.pid)
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(len(pairs), 1)
|
||||
self.assertEqual(pairs[0]["image_count"], 1)
|
||||
|
||||
def test_legacy_link_stores_strict_json(self):
|
||||
# The plain prompt link (no exact pair) must not write NaN either.
|
||||
path = str(Path(self.temp.name) / "plain.png")
|
||||
metadata = {"prompt": graph(), "workflow": WORKFLOW, "parameters": {"x": NAN}}
|
||||
self.assertTrue(self.db.link_image_to_prompt(self.pid, path, metadata))
|
||||
row = self.image_rows(self.db)[0]
|
||||
self.assert_strict_snapshot(row)
|
||||
self.assertEqual(strict_loads(row["parameters"]), {"x": None})
|
||||
|
||||
def test_stored_images_read_back_without_non_finite_numbers(self):
|
||||
path = str(Path(self.temp.name) / "legacy.png")
|
||||
self.db.link_image_to_prompt(self.pid, path, {"prompt": graph()})
|
||||
with self.db.model.get_connection() as conn:
|
||||
# A row an older version wrote with Python's default json.dumps.
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET prompt_metadata=?", (comfy_text(graph()),)
|
||||
)
|
||||
conn.commit()
|
||||
images = self.db.get_prompt_images(str(self.pid))
|
||||
strict_loads(json.dumps(images, default=str))
|
||||
|
||||
|
||||
class TestInspectorAnswersStrictJSON(ImageAPITestCase):
|
||||
async def test_inspect_route_resolves_and_answers_strict_json(self):
|
||||
write_png(self.output_dir / "nan.png")
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/inspect/nan.png", params={"root": "0"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
result = strict_loads(await response.text())
|
||||
self.assertEqual(result["resolution"]["state"], "resolved")
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
{"status": "resolved", "text": POSITIVE, "source": "graph-inferred"},
|
||||
)
|
||||
|
||||
async def test_prompt_images_route_answers_strict_json(self):
|
||||
pid = self.api.db.save_prompt(
|
||||
POSITIVE.strip(), prompt_hash=generate_prompt_hash(POSITIVE)
|
||||
)
|
||||
path = str(write_png(self.output_dir / "linked.png"))
|
||||
self.api.db.link_image_to_prompt(pid, path, {"prompt": graph()})
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE generated_images SET prompt_metadata=?", (comfy_text(graph()),)
|
||||
)
|
||||
conn.commit()
|
||||
response = await self.client.get(f"/prompt_manager/prompts/{pid}/images")
|
||||
self.assertEqual(response.status, 200)
|
||||
result = strict_loads(await response.text())
|
||||
self.assertEqual(len(result["images"]), 1)
|
||||
stored = result["images"][0]["prompt_metadata"]
|
||||
self.assertEqual(stored["1"]["inputs"]["text"], POSITIVE)
|
||||
self.assertEqual(stored["5"]["is_changed"], [None])
|
||||
|
||||
|
||||
class TestHistoryCapturesNonFiniteGraphs(unittest.TestCase):
|
||||
"""The queued graph ComfyUI hands the hook may carry float('nan')."""
|
||||
|
||||
JOB = "a0c26da3-eb26-405c-b143-c662a84c3112"
|
||||
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.db = PromptDatabase(os.path.join(self.directory.name, "history.db"))
|
||||
self.addCleanup(self.db.model.reset_schema_cache)
|
||||
self.addCleanup(self.db.model.close_all)
|
||||
|
||||
def test_queued_graph_with_nan_records_its_run_and_contexts(self):
|
||||
from tests.test_execution_history_hooks import HostQueue, status
|
||||
from utils.execution_context_capture import record_runtime_context
|
||||
from utils.execution_history import register_history_hook
|
||||
|
||||
queue = HostQueue()
|
||||
registration = register_history_hook(
|
||||
SimpleNamespace(prompt_queue=queue), lambda: self.db
|
||||
)
|
||||
self.addCleanup(lambda: registration.dispose(3))
|
||||
self.addCleanup(registration.writer.invalidate)
|
||||
deadline = time.monotonic() + 3
|
||||
while not registration.writer.status().accepting:
|
||||
self.assertLess(time.monotonic(), deadline, "writer never accepted")
|
||||
time.sleep(0.005)
|
||||
g = {
|
||||
"p": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": POSITIVE, "negative_text": NEGATIVE},
|
||||
},
|
||||
"s": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["p", 0], "negative": ["p", 1], "cfg": INF},
|
||||
"is_changed": [NAN],
|
||||
},
|
||||
"5": graph()["5"],
|
||||
}
|
||||
extra = {"extra_pnginfo": {"workflow": WORKFLOW}}
|
||||
queue.results.append(((0, self.JOB, g, extra, ["s"]), 1))
|
||||
queue.get()
|
||||
host = types.ModuleType("comfy_execution.utils")
|
||||
host.get_executing_context = lambda: SimpleNamespace(
|
||||
prompt_id=self.JOB, node_id="p", list_index=0
|
||||
)
|
||||
with patch.dict(sys.modules, {"comfy_execution.utils": host}):
|
||||
for role, text in (("positive", POSITIVE), ("negative", NEGATIVE)):
|
||||
record_runtime_context(
|
||||
"PromptManagerPair", text, "", "", text, role=role
|
||||
)
|
||||
queue.task_done(1, {}, status("execution_success", job=self.JOB))
|
||||
drain_writer(registration.writer, clock=time.monotonic)
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
runs = [dict(r) for r in conn.execute("SELECT * FROM execution_runs")]
|
||||
self.assertEqual([run["outcome"] for run in runs], ["succeeded"])
|
||||
contexts = self.db.get_execution_contexts(runs[0]["run_id"])
|
||||
self.assertEqual(contexts["state"], "complete")
|
||||
self.assertEqual(contexts["contexts"][0]["positive"]["raw"], POSITIVE)
|
||||
strict_loads(json.dumps(contexts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Strict JSON output for values that may carry ComfyUI's non-finite numbers.
|
||||
|
||||
ComfyUI writes its metadata with Python's default ``json.dumps``, so a graph can
|
||||
hold ``NaN``, ``Infinity`` and ``-Infinity`` (an ``IS_CHANGED`` that returns
|
||||
``float("nan")`` puts ``"is_changed": [NaN]`` into every saved prompt). Those
|
||||
literals are not JSON: browsers' ``JSON.parse`` and ``allow_nan=False`` reject
|
||||
them. Reading follows the legacy pages (``parse_page_json`` where the browser
|
||||
parser is mirrored; Python's ``json.loads`` elsewhere). Whatever PromptManager
|
||||
stores or answers as JSON goes through ``dumps_strict``, which writes each
|
||||
non-finite number as ``null`` (the legacy pages' own replacement for NaN) and
|
||||
leaves strings, including the words ``NaN`` or ``Infinity`` inside prompt
|
||||
text, untouched.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["dumps_strict"]
|
||||
|
||||
|
||||
def _null(_constant: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def dumps_strict(value: Any, **kwargs: Any) -> str:
|
||||
"""``json.dumps(value, allow_nan=False, **kwargs)`` with non-finite numbers null.
|
||||
|
||||
A value without non-finite numbers serializes byte for byte as the plain
|
||||
call does. Any other error (a cycle, an unserializable object) still raises.
|
||||
"""
|
||||
kwargs.pop("allow_nan", None)
|
||||
try:
|
||||
return json.dumps(value, allow_nan=False, **kwargs)
|
||||
except ValueError as error:
|
||||
if "Out of range float" not in str(error):
|
||||
raise
|
||||
# The non-strict text spells each non-finite number as a bare literal, never
|
||||
# inside a string, so parsing it back maps exactly those numbers to null.
|
||||
lenient = json.dumps(value, allow_nan=True, default=kwargs.get("default"))
|
||||
return json.dumps(
|
||||
json.loads(lenient, parse_constant=_null), allow_nan=False, **kwargs
|
||||
)
|
||||
@@ -148,20 +148,18 @@ function itemFor(job, scope) {
|
||||
chip.textContent = state;
|
||||
detail.textContent = jobDetail(next);
|
||||
const running = next.state === 'running';
|
||||
// parentNode, not isConnected: on the first render the item is not in the page yet, so a job that had already
|
||||
// ended when Activity opened kept its Cancel job (and a running one's progress moved after its actions).
|
||||
if (running) {
|
||||
progress.value = next.progress;
|
||||
progress.textContent = `${next.progress}%`;
|
||||
progress.setAttribute('aria-label', `${next.title} progress`);
|
||||
if (!progress.parentNode) item.insertBefore(progress, actions.parentNode ? actions : null);
|
||||
if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null);
|
||||
} else {
|
||||
progress.remove();
|
||||
}
|
||||
// Truth rule: a job that cannot be cancelled (a held legacy-page job) shows no Cancel job.
|
||||
if (running && next.cancellable) {
|
||||
if (!actions.parentNode) item.append(actions);
|
||||
} else if (actions.parentNode) {
|
||||
if (!actions.isConnected) item.append(actions);
|
||||
} else if (actions.isConnected) {
|
||||
// A focused Cancel job that goes away hands its focus to the item's title link, never to the page body.
|
||||
if (actions.contains(document.activeElement)) link.focus();
|
||||
actions.remove();
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
if (scope.signal.aborted) return;
|
||||
active?.close(false);
|
||||
const {items,selectedKey,trigger = document.activeElement} = options;
|
||||
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
|
||||
let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false;
|
||||
let originalDownload = null, inspectionState = inspectionController.state;
|
||||
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
|
||||
if (!mounted) { initial = [state,selected]; return; }
|
||||
@@ -115,9 +115,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
mediaScope?.dispose();mediaScope = createScope();viewer = null;
|
||||
for (const child of [...stage.children]) if (child !== failure) child.remove();
|
||||
failure.hidden = true;
|
||||
const mediaLocal = mediaScope, key = session.state.key, ViewerJs = window.Viewer;
|
||||
// Transforms follow the Viewer.js this media was built with, which builds once the image has loaded.
|
||||
withoutViewer = !ViewerJs;
|
||||
const mediaLocal = mediaScope, key = session.state.key;
|
||||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||||
// An adapter's own media URL (a session-owned blob:) is used exactly, so a retry reuses it; a server
|
||||
// original gets a fresh cache-buster on every build.
|
||||
@@ -144,23 +142,15 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
},{once:true});
|
||||
} else {
|
||||
node.alt = selected.filename;node.decoding = 'async';
|
||||
node.hidden = !!ViewerJs;
|
||||
node.hidden = !!window.Viewer;
|
||||
// With Viewer.js the src is released after the viewer (see release below).
|
||||
if (!ViewerJs) {
|
||||
if (!window.Viewer) {
|
||||
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
|
||||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
}
|
||||
node.src = src;stage.append(node);
|
||||
if (!selected.is_video && ViewerJs) {
|
||||
// Viewer.js is built on an image that has loaded. Its own cancel removes its image listeners only
|
||||
// while `image.complete` is false, so an image left with its load event still queued would build a
|
||||
// viewer into the released image after destroy() (#293). Until then no viewer exists to cancel.
|
||||
let mountedViewer = false;
|
||||
mediaLocal.listen(node,'load',() => { if (current()) { mountedViewer = true;mountViewer(); } },{once:true});
|
||||
mediaLocal.cleanup(() => { if (!mountedViewer) node.removeAttribute('src'); });
|
||||
}
|
||||
function mountViewer() {
|
||||
if (!selected.is_video && window.Viewer) {
|
||||
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
@@ -176,7 +166,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
fitWidth = width;fitHeight = height;instance.resize();
|
||||
});
|
||||
};
|
||||
const instance = new ViewerJs(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
|
||||
const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
|
||||
keyboard:false,fullscreen:false,transition:false,viewed() {
|
||||
viewSettled = true;
|
||||
if (mediaLocal.signal.aborted) { release(); return; }
|
||||
@@ -186,12 +176,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
|
||||
viewer = instance;mediaLocal.cleanup(() => {
|
||||
observer.disconnect();cancelAnimationFrame(fitFrame);
|
||||
// Before Viewer.js has built (its zero-delay build is pending), destroy() cancels that build;
|
||||
// deferring it would let the build run into the image the stage has just dropped.
|
||||
// Once built, inline Viewer.js keeps viewing a still-loading image after destroy(), and that
|
||||
// view rebuilds its list from images that still have a src; keep both until the view settles,
|
||||
// fails or times out.
|
||||
if (viewSettled || !instance.ready) { release(); return; }
|
||||
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
|
||||
// rebuilds its list from images that still have a src; keep both until the view
|
||||
// settles, fails or times out.
|
||||
if (viewSettled) { release(); return; }
|
||||
node.addEventListener('error',release,{once:true});
|
||||
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
|
||||
});
|
||||
@@ -200,7 +188,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
}
|
||||
function updateControls(state) {
|
||||
const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ?
|
||||
'Image transforms are unavailable for video.' : withoutViewer ?
|
||||
'Image transforms are unavailable for video.' : !viewer ?
|
||||
'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : '';
|
||||
reason.textContent = unavailable;reason.hidden = !unavailable;
|
||||
for (const node of transforms) {
|
||||
|
||||
Reference in New Issue
Block a user