feat(workspace): inspect and send positive and negative prompts
This commit is contained in:
+14
@@ -183,3 +183,17 @@ The compatibility runner reserves loopback ports 8193–8195; `PM_COMPAT_PORT`
|
||||
selects another consecutive unprivileged range. Never run automated tests against
|
||||
the maintainer's running ComfyUI. This JavaScript host extension needs a full
|
||||
browser refresh after deployment; its first slice adds no gallery send controls.
|
||||
|
||||
### Workspace prompt inspection and sending
|
||||
|
||||
`tests/test_api_prompt_inspector.py` covers rooted physical-file identity,
|
||||
execution/graph/parameter evidence, saved associations, ambiguity, empty negatives,
|
||||
read-only behavior, bounded PNG/video metadata and corrupt inputs. Run it with
|
||||
`python -m unittest discover -s tests -p test_api_prompt_inspector.py` in a disposable
|
||||
checkout. `tests/js/prompt-inspector.test.js` and `prompt-sender.test.js` cover
|
||||
request races, explicit destination selection, preview tickets and uncertain
|
||||
acknowledgements. Browser workflows exercise the same controls in Images and
|
||||
Viewer, including nested modal focus, mobile layouts and per-role sending.
|
||||
`tests/comfyui/prompt-sender.spec.mjs` sends through the actual workspace UI into
|
||||
real Classic and Nodes 2.0 widgets and verifies workflow serialization without
|
||||
loading a workflow or queueing generation.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Prompt inspection and sending
|
||||
|
||||
The opt-in workspace at `/prompt_manager/workspace#/images` shows full positive
|
||||
and negative prompts in both Image details and the full-image preview. Existing
|
||||
gallery and metadata tools remain available during the application rebuild.
|
||||
|
||||
Each prompt shows its evidence source: recorded execution, workflow inference,
|
||||
embedded generation parameters, or an exact saved-library association. Missing,
|
||||
ambiguous, and unreadable prompts remain visible as distinct states. An explicitly
|
||||
empty negative is a resolved value and can clear a destination's negative field.
|
||||
|
||||
Use the blue paper-plane action beside a prompt, or **Send prompt pair**. Keep a
|
||||
ComfyUI tab open under the same origin and deployment prefix. Choose a window and
|
||||
compatible Prompt Manager node, select **Preview changes**, review the complete
|
||||
before/after text and preserved prepend/append composition, then **Apply to
|
||||
ComfyUI**. The dialog reports success only after a matching acknowledgement.
|
||||
It does not load workflows or queue generation. If confirmation is lost, inspect
|
||||
ComfyUI and preview again before another attempt. Closing the dialog cannot undo
|
||||
an update already sent.
|
||||
|
||||
## Read-only API
|
||||
|
||||
`GET /prompt_manager/images/inspect/{relative_path}?root={root_index}` requires
|
||||
an explicit configured root, including when two roots contain the same filename.
|
||||
The response contains `image` identity and `prompts.positive` / `prompts.negative`.
|
||||
Each role has `status` (`resolved`, `absent`, `unresolved`, or `ambiguous`), `text`
|
||||
(string only when resolved, otherwise null), and `source`. Inspection does not
|
||||
index files, alter saved prompts, run scans, or decode image pixels. PNG text and
|
||||
video probe output are bounded; unsupported or malformed evidence remains
|
||||
unresolved rather than becoming sendable placeholder text.
|
||||
|
||||
Python route changes require restarting ComfyUI. Browser assets require a refresh.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -22,8 +22,18 @@ from .prompts import safe_error_message as _safe_error
|
||||
|
||||
try:
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_roles,
|
||||
read_inspection_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.parallel import map_parallel
|
||||
from utils.prompt_inspector import (
|
||||
MetadataReadError,
|
||||
inspect_prompt_roles,
|
||||
read_inspection_metadata,
|
||||
)
|
||||
|
||||
# Only these file types are ever served by the image routes, regardless of
|
||||
# what sits inside an allowed directory (defence in depth for the gallery).
|
||||
@@ -448,6 +458,10 @@ class ImageRoutesMixin:
|
||||
async def get_image_prompt_route(request):
|
||||
return await self.get_image_prompt(request)
|
||||
|
||||
@routes.get("/prompt_manager/images/inspect/{image_path:.*}")
|
||||
async def inspect_image_route(request):
|
||||
return await self.inspect_image(request)
|
||||
|
||||
@routes.delete("/prompt_manager/images/{image_id}")
|
||||
async def delete_image_route(request):
|
||||
return await self.delete_image(request)
|
||||
@@ -1628,6 +1642,65 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
async def inspect_image(self, request):
|
||||
"""Read prompt evidence for one explicitly rooted physical media file."""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
try:
|
||||
index = _workspace_root(request.query, roots)
|
||||
if index is None:
|
||||
raise ValueError("Gallery root is required")
|
||||
except ValueError as error:
|
||||
return bad_request(str(error))
|
||||
relative = request.match_info.get("image_path", "")
|
||||
if (
|
||||
_has_traversal(relative)
|
||||
or "\0" in relative
|
||||
or re.match(r"^[a-zA-Z]:", relative)
|
||||
):
|
||||
return _forbidden()
|
||||
path = Path(roots[index]) / relative
|
||||
denied = _media_access_error(path, [roots[index]])
|
||||
if denied is not None:
|
||||
return denied
|
||||
if not path.is_file():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
)
|
||||
try:
|
||||
|
||||
def inspect() -> dict:
|
||||
try:
|
||||
metadata = read_inspection_metadata(path)
|
||||
except MetadataReadError:
|
||||
return {
|
||||
role: {"status": "unresolved", "text": None, "source": None}
|
||||
for role in ("positive", "negative")
|
||||
}
|
||||
saved = (
|
||||
self.db.get_image_prompt_info(str(path), False)
|
||||
if not metadata
|
||||
else None
|
||||
)
|
||||
return inspect_prompt_roles(metadata, saved)
|
||||
|
||||
prompts = await self._run_in_executor(inspect)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"image": {
|
||||
"root_index": index,
|
||||
"path": Path(relative).as_posix(),
|
||||
"filename": path.name,
|
||||
},
|
||||
"prompts": prompts,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Prompt inspection failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Prompt inspection failed"}, status=500
|
||||
)
|
||||
|
||||
async def get_image_prompt(self, request):
|
||||
"""Get prompt information for a specific image path."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
|
||||
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
|
||||
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{prompt_inspector:true}})).toBeOK();
|
||||
page.promptErrors=[];page.on('pageerror',error=>page.promptErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.promptErrors).toEqual([]);});
|
||||
|
||||
async function fakeHost(context,page,{rejectApply=false}={}) {
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async rejectApply=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
|
||||
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after})),composition:{prepend:'trigger',append:'detail',prependConnected:false,appendConnected:false,before:`trigger ${window.hostValues.positive} detail`,after:`trigger ${roles.positive??window.hostValues.positive} detail`}},snapshot:roles}),
|
||||
apply:roles=>{if(rejectApply)throw new Error('Workflow changed. Preview again.');Object.assign(window.hostValues,roles);window.applied.push(roles);return{targetId:target.id,values:roles};},
|
||||
}});
|
||||
},rejectApply);
|
||||
return host;
|
||||
}
|
||||
async function chooseDestination(page) {
|
||||
const sender=dialog(page);await expect(sender).toBeVisible();
|
||||
const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(host.locator('option')).toContainText(['Test ComfyUI']);
|
||||
await host.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
||||
return sender;
|
||||
}
|
||||
|
||||
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
|
||||
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
|
||||
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
|
||||
});
|
||||
|
||||
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:empty-negative.png');
|
||||
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
|
||||
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
|
||||
let fail=true;let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/images/inspect/**',async route=>{
|
||||
const url=new URL(route.request().url());
|
||||
if(url.searchParams.get('root')==='0'&&url.pathname.endsWith('same.png')){
|
||||
if(fail){return route.fulfill({status:503,json:{success:false,error:'Temporarily unavailable'}});}
|
||||
await held;
|
||||
}
|
||||
await route.continue().catch(()=>{});
|
||||
});
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await expect(details(page)).toContainText('Could not read prompts from this file.');
|
||||
fail=false;await details(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
|
||||
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
|
||||
release();await expect(role(details(page),'Positive')).toContainText('Dog landscape');
|
||||
});
|
||||
|
||||
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([]);
|
||||
await page.screenshot({path:testInfo.outputPath('sender-preview.png')});
|
||||
await chosen.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(chosen).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:'Cat portrait',negative:'blurry, watermark'}]);
|
||||
await chosen.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
|
||||
const host=await fakeHost(context,page,{rejectApply:true});
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
|
||||
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
|
||||
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('empty negative send clears only the negative destination and preserves its positive',async({page,context})=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:empty-negative.png');
|
||||
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
|
||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
|
||||
await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Cat portrait',negative:'Existing negative'});
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('mobile inspector exposes full long prompts, truthful uncertainty and copy failure feedback',async({page})=>{
|
||||
const text=`A long detailed prompt ${'with readable full composition, '.repeat(150)}END OF FULL PROMPT`;
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
|
||||
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
|
||||
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
|
||||
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
|
||||
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
|
||||
});
|
||||
|
||||
test('filtering away a selected image aborts its pending metadata request',async({page})=>{
|
||||
await page.addInitScript(()=>{
|
||||
const fetch=window.fetch;window.inspectionAborts=0;
|
||||
window.fetch=function(input,options){
|
||||
if(String(input).includes('/images/inspect/')&&options?.signal)options.signal.addEventListener('abort',()=>window.inspectionAborts++,{once:true});
|
||||
return fetch.call(this,input,options);
|
||||
};
|
||||
});
|
||||
let started;const requested=new Promise(resolve=>{started=resolve;});let release;
|
||||
const hold=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/images/inspect/**',async route=>{started();await hold;await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
|
||||
const baseline=await page.evaluate(()=>window.inspectionAborts);
|
||||
await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
|
||||
await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
|
||||
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
|
||||
} finally {release();}
|
||||
});
|
||||
+42
-2
@@ -78,7 +78,11 @@ def create_app(root: Path) -> web.Application:
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
|
||||
def seed(extra_images: int = 0, include_video: bool = False) -> None:
|
||||
def seed(
|
||||
extra_images: int = 0,
|
||||
include_video: bool = False,
|
||||
prompt_inspector: bool = False,
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_prompt_pairs",
|
||||
@@ -107,6 +111,25 @@ def create_app(root: Path) -> web.Application:
|
||||
)
|
||||
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
|
||||
image.save(image_path, pnginfo=info)
|
||||
if prompt_inspector:
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "blurry, watermark" if index == 0 else "low quality"
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
|
||||
image.save(image_path, pnginfo=info)
|
||||
stamp = time.time() - index * 10
|
||||
os.utime(image_path, (stamp, stamp))
|
||||
pid = api.db.save_prompt(
|
||||
@@ -120,6 +143,21 @@ def create_app(root: Path) -> web.Application:
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(image_path, extra)
|
||||
api.db.link_image_to_prompt(pid, str(extra))
|
||||
if prompt_inspector:
|
||||
for name, parameters in (
|
||||
(
|
||||
"empty-negative.png",
|
||||
"Explicit empty negative\nNegative prompt: \n"
|
||||
"Steps: 20, Sampler: Euler",
|
||||
),
|
||||
("uncertain.png", None),
|
||||
):
|
||||
info = PngInfo()
|
||||
if parameters:
|
||||
info.add_text("parameters", parameters)
|
||||
with Image.new("RGB", (64, 64), (100, 80, 120)) as image:
|
||||
image.save(root / "output" / name, pnginfo=info)
|
||||
os.utime(root / "output" / name, (time.time() - 100, time.time() - 100))
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
if include_video:
|
||||
# Seed after thumbnail generation to exercise a real unthumbnailed video.
|
||||
@@ -135,14 +173,16 @@ def create_app(root: Path) -> web.Application:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
extra = data.get("extra_images", 0)
|
||||
include_video = data.get("include_video", False)
|
||||
prompt_inspector = data.get("prompt_inspector", False)
|
||||
if (
|
||||
isinstance(extra, bool)
|
||||
or not isinstance(extra, int)
|
||||
or not 0 <= extra <= 200
|
||||
or not isinstance(include_video, bool)
|
||||
or not isinstance(prompt_inspector, bool)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
await api._run_in_executor(seed, extra, include_video)
|
||||
await api._run_in_executor(seed, extra, include_video, prompt_inspector)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {installHostHooks} from './host-fixture.mjs';
|
||||
|
||||
installHostHooks(test,['PromptManagerPair']);
|
||||
|
||||
test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{
|
||||
const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark';
|
||||
const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[
|
||||
'import io,sys',
|
||||
'from PIL import Image',
|
||||
'from PIL.PngImagePlugin import PngInfo',
|
||||
'info=PngInfo()',
|
||||
'info.add_text("parameters",sys.argv[1]+"\\nNegative prompt: "+sys.argv[2]+"\\nSteps: 20, Sampler: Euler")',
|
||||
'buffer=io.BytesIO()',
|
||||
'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)',
|
||||
'sys.stdout.buffer.write(buffer.getvalue())',
|
||||
].join('\n'),positive,negative]);
|
||||
const filename=`sender-${testInfo.project.name}.png`;
|
||||
const upload=await request.post('/upload/image',{multipart:{image:{name:filename,mimeType:'image/png',buffer:image},type:'output',subfolder:'prompt-sender',overwrite:'true'}});
|
||||
expect(upload.ok()).toBe(true);
|
||||
const id=await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
|
||||
node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
|
||||
return node.id;
|
||||
});
|
||||
const nodeRoot=page.locator('.pm-pair');
|
||||
await nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Before positive');
|
||||
await nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Before negative');
|
||||
await nodeRoot.getByText('Composition · prepend / append',{exact:true}).click();
|
||||
await nodeRoot.getByRole('textbox',{name:'Prepend',exact:true}).fill('LoRA trigger');
|
||||
await nodeRoot.getByRole('textbox',{name:'Append',exact:true}).fill('fine detail');
|
||||
await page.evaluate(async()=>{
|
||||
const {app}=await import('/scripts/app.js');window.senderEffects={queue:0,load:0};
|
||||
for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){
|
||||
const original=app[method];app[method]=function(...args){window.senderEffects[key]++;return original.apply(this,args);};
|
||||
}
|
||||
});
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/images');
|
||||
await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
|
||||
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
|
||||
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
|
||||
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
|
||||
await inspector.getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);
|
||||
await hosts.selectOption({index:1});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative);
|
||||
const state=await page.evaluate(async id=>{
|
||||
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
|
||||
return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,
|
||||
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.senderEffects};
|
||||
},id);
|
||||
for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
|
||||
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
|
||||
} finally {await workspace.close();}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { inspectPath, promptRoles, createInspectionLoader } from '../../web/js/workspace/prompt-inspector.mjs';
|
||||
|
||||
test('inspection keeps exact physical root and encodes every path component', () => {
|
||||
assert.equal(inspectPath({root_index:2,path:'a b/x#%.png'}), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2');
|
||||
});
|
||||
test('only confirmed strings can be sent, including explicit empty negatives', () => {
|
||||
const record = {positive:{status:'resolved',text:'full\npositive'},negative:{status:'resolved',text:''}};
|
||||
assert.deepEqual(promptRoles(record), {positive:'full\npositive',negative:''});
|
||||
for (const status of ['absent','ambiguous','unresolved','other']) {
|
||||
assert.deepEqual(promptRoles({positive:{status,text:'placeholder'},negative:{status:'resolved',text:null}}), {});
|
||||
}
|
||||
assert.deepEqual(promptRoles(null), {});
|
||||
});
|
||||
test('superseded requests and disposed loaders cannot replace current metadata', async () => {
|
||||
const pending = [], changes = [];
|
||||
const loader = createInspectionLoader({get:(url,{signal})=>new Promise(resolve=>pending.push({url,signal,resolve}))}, value=>changes.push(value));
|
||||
const first=loader.load({root_index:0,path:'one.png'});
|
||||
const second=loader.load({root_index:1,path:'one.png'});
|
||||
assert.equal(pending[0].signal.aborted,true);
|
||||
pending[1].resolve({prompts:{positive:{status:'resolved',text:'latest'}}}); await second;
|
||||
pending[0].resolve({prompts:{positive:{status:'resolved',text:'old'}}}); await first;
|
||||
assert.equal(changes.at(-1).prompts.positive.text,'latest');
|
||||
const third=loader.load({root_index:0,path:'third.png'}); loader.dispose();
|
||||
assert.equal(pending[2].signal.aborted,true);
|
||||
pending[2].resolve({prompts:{positive:{status:'resolved',text:'disposed'}}}); await third;
|
||||
assert.equal(changes.at(-1).loading,true);
|
||||
});
|
||||
test('failed reads report a retryable error and later reads recover', async () => {
|
||||
let fail=true; const changes=[];
|
||||
const loader=createInspectionLoader({get:async()=>{if(fail)throw Error('bad');return {prompts:{}};}},value=>changes.push(value));
|
||||
await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).error,true);
|
||||
fail=false; await loader.load({root_index:0,path:'one.png'}); assert.deepEqual(changes.at(-1).prompts,{});
|
||||
loader.dispose(); await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).loading,undefined);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/prompt-sender.mjs');
|
||||
const hosts = [{hostId:'host',label:'Workflow',targets:[{id:'one',label:'Positive',roles:['positive']},{id:'pair',label:'Pair',roles:['positive','negative']}]}];
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
|
||||
const calls = [];
|
||||
const client = {discover:async () => hosts, preview:async (...args) => {calls.push(['preview',...args]);return {ticket:'ticket',target:{id:args[1]},fields:[{role:'positive',before:'old',after:'new'}],composition:{before:'prefix old',after:'prefix new'}};},apply:async (...args) => {calls.push(['apply',...args]);return {targetId:'one',values:{positive:'new'}};},dispose:() => calls.push(['dispose']),...overrides};
|
||||
const states = []; const session = createSendSession({client,roles,onChange:state=>states.push(state)});
|
||||
return {client,calls,states,session};
|
||||
}
|
||||
test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
|
||||
const {createSendSession} = await source; const s=setup(createSendSession);
|
||||
await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
|
||||
await s.session.preview(); assert.equal(s.calls.length,0);
|
||||
s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
|
||||
await s.session.apply();assert.equal(s.calls.length,0);
|
||||
await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
|
||||
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
|
||||
});
|
||||
test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
|
||||
await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
|
||||
s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
|
||||
});
|
||||
test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
|
||||
const {createSendSession}=await source;let resolve;const s=setup(createSendSession,{preview:()=>new Promise(done=>resolve=done)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');const pending=s.session.preview();
|
||||
s.session.selectTarget('pair');resolve({ticket:'old'});await pending;assert.equal(s.session.state.preview,null);
|
||||
s.session.selectTarget('one');const pending2=s.session.preview();s.session.dispose();const count=s.states.length;resolve({ticket:'old'});await pending2;assert.equal(s.states.length,count);
|
||||
});
|
||||
test('unknown apply outcome never retries a ticket and requires a new preview',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>{throw Error('timed out');}});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();
|
||||
assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/may have applied/i);assert.equal(s.session.state.preview,null);
|
||||
await s.session.apply();assert.equal(s.session.state.phase,'error');await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
|
||||
});
|
||||
test('only matching explicit acknowledgements count as success',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
for(const ack of [undefined,{targetId:'other',values:{positive:'new'}},{targetId:'one',values:{positive:'wrong'}}]){
|
||||
const s=setup(createSendSession,{apply:async()=>ack});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');s.session.dispose();
|
||||
}
|
||||
});
|
||||
test('discovery, empty results and stale preview errors remain actionable',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
|
||||
const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
|
||||
const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
|
||||
});
|
||||
test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
|
||||
const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
|
||||
const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
|
||||
s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
|
||||
});
|
||||
test('pending apply explains closing cannot undo the sent update',async()=>{
|
||||
const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
|
||||
assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
|
||||
});
|
||||
function fakeDOM() {
|
||||
class Node extends EventTarget {
|
||||
constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
|
||||
append(...children){this.children.push(...children);}
|
||||
add(option){this.append(option);}
|
||||
replaceChildren(...children){this.children=children;}
|
||||
setAttribute(key,value){this.attributes[key]=value;}
|
||||
showModal(){this.open=true;}
|
||||
close(){this.open=false;this.dispatchEvent(new Event('close'));}
|
||||
remove(){this.isConnected=false;}
|
||||
focus(){this.focused=true;}
|
||||
}
|
||||
const previous={document:global.document,window:global.window,Option:global.Option};
|
||||
const body=new Node('body');
|
||||
global.document={body,createElement:tag=>new Node(tag),createElementNS:(namespace,tag)=>new Node(tag)};
|
||||
global.Option=class extends Node {constructor(text,value){super('option');this.textContent=text;this.value=value;}};
|
||||
global.window={location:new URL('https://example.test/prefix/prompt_manager/workspace'),ApiClient:{resolveUrl:()=>'/prefix/prompt_manager/'}};
|
||||
const find=(root,predicate)=>predicate(root)?root:root.children.map(child=>find(child,predicate)).find(Boolean);
|
||||
return {body,Node,find,restore(){Object.assign(global,previous);}};
|
||||
}
|
||||
test('native sender dialog uses prefixed namespace, full safe text, explicit choices and owned cleanup',async()=>{
|
||||
const {createPromptSender,createSendButton}=await source;
|
||||
const {createScope}=await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom=fakeDOM();const scope=createScope();const trigger=new dom.Node('button');let options,disposed=0;
|
||||
const client={discover:async()=>hosts,preview:async()=>({ticket:'t',target:{id:'one'},fields:[{role:'positive',before:'',after:'<unsafe>new</unsafe>'}],composition:{prepend:'prefix',append:'suffix',before:null,after:null,prependConnected:true}}),apply:async()=>({targetId:'one',values:{positive:'<unsafe>new</unsafe>'}}),dispose:()=>disposed++};
|
||||
try {
|
||||
const button=createSendButton('Send positive prompt');assert.equal(button.attributes['aria-label'],'Send positive prompt');assert.equal(button.children[0].attributes['aria-hidden'],'true');
|
||||
const sender=createPromptSender({scope,clientFactory:value=>{options=value;return client;}});sender.open({positive:'<unsafe>new</unsafe>'},trigger);await tick();
|
||||
assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
|
||||
const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
|
||||
const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
|
||||
assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
|
||||
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='<unsafe>new</unsafe>'));
|
||||
const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
|
||||
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to ComfyUI.'));scope.dispose();assert.equal(dialog.isConnected,false);assert.equal(trigger.focused,true);assert.equal(disposed,1);
|
||||
sender.open({positive:'new'},trigger);assert.equal(dom.body.children.length,1);
|
||||
} finally {scope.dispose();dom.restore();}
|
||||
});
|
||||
test('unsupported client or deployment errors are visible and close stays available',async()=>{
|
||||
const {createPromptSender}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');const dom=fakeDOM();
|
||||
try {
|
||||
for(const baseUrl of ['https://other.test/prompt_manager/','/prefix/not-prompt-manager/','/prefix/prompt_manager/']) {
|
||||
const scope=createScope();const sender=createPromptSender({scope,baseUrl,clientFactory:()=>{throw Error('BroadcastChannel unavailable');}});
|
||||
sender.open({positive:'new'});const dialog=dom.body.children.at(-1);const alert=dom.find(dialog,node=>node.attributes.role==='alert');assert.match(alert.textContent,/unavailable|Invalid/);
|
||||
dom.find(dialog,node=>node.textContent==='Close').dispatchEvent(new Event('click'));assert.equal(dialog.isConnected,false);scope.dispose();
|
||||
}
|
||||
} finally {dom.restore();}
|
||||
});
|
||||
test('approval requires an exact destination and complete unambiguous matching preview',async()=>{
|
||||
const {createSendSession}=await source;
|
||||
const valid={ticket:'ticket',target:{id:'one'},fields:[{role:'positive',before:'old',after:'new'}]};
|
||||
for(const preview of [null,{}, {...valid,ticket:''},{...valid,target:{id:'other'}},{...valid,fields:[]},{...valid,fields:new Array(1)},{...valid,fields:[null]},{...valid,fields:[...valid.fields,...valid.fields]},{...valid,fields:[{role:'negative',before:'old',after:'new'}]},{...valid,fields:[{role:'positive',before:null,after:'new'}]},{...valid,fields:[{role:'positive',before:'old',after:'unexpected'}]}]) {
|
||||
const s=setup(createSendSession,{preview:async()=>preview});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();
|
||||
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.preview,null);await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);s.session.dispose();
|
||||
}
|
||||
});
|
||||
test('acknowledgement cannot claim an extra role beyond approved changes',async()=>{
|
||||
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
|
||||
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
|
||||
});
|
||||
@@ -0,0 +1,581 @@
|
||||
"""Physical-file prompt inspection is bounded, conservative and read-only."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
from utils.pair_execution import EXECUTION_KEY, record_pair_execution
|
||||
from database.pair_ingestion import ingest_resolved_pair
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
|
||||
def graph(positive="portrait", negative="blurry"):
|
||||
return {
|
||||
"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]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def png(path, **metadata):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
for key, value in metadata.items():
|
||||
info.add_text(key, value if isinstance(value, str) else json.dumps(value))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
return path
|
||||
|
||||
|
||||
class TestPromptInspector(ImageAPITestCase):
|
||||
async def inspect(self, path="image.png", root="0"):
|
||||
params = {} if root is None else {"root": root}
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/images/inspect/{path}", params=params
|
||||
)
|
||||
return response, await response.json()
|
||||
|
||||
def side(self, text, source="graph-inferred"):
|
||||
return {"status": "resolved", "text": text, "source": source}
|
||||
|
||||
async def test_unindexed_graph_pair_is_read_without_indexing_or_decoding(self):
|
||||
path = png(self.output_dir / "image.png", prompt=graph(negative=""))
|
||||
before = path.read_bytes()
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
rows = connection.execute("SELECT total_changes()").fetchone()[0]
|
||||
with patch.object(Image.Image, "load", side_effect=AssertionError("decode")):
|
||||
response, result = await self.inspect()
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
result["image"],
|
||||
{"root_index": 0, "path": "image.png", "filename": "image.png"},
|
||||
)
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
|
||||
self.assertEqual(result["prompts"]["negative"], self.side(""))
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
self.assertEqual(
|
||||
connection.execute("SELECT total_changes()").fetchone()[0], rows
|
||||
)
|
||||
self.assertEqual(
|
||||
connection.execute("SELECT COUNT(*) FROM prompts").fetchone()[0], 0
|
||||
)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(result))
|
||||
|
||||
async def test_root_identity_collisions_are_not_filename_fallbacks(self):
|
||||
other = self.comfy_dir / "input"
|
||||
other.mkdir()
|
||||
first = png(self.output_dir / "image.png", prompt=graph("output"))
|
||||
png(other / "image.png", prompt=graph("input"))
|
||||
self._link_image(first, "Saved output")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
|
||||
_, result = await self.inspect(root="1")
|
||||
self.assertEqual(result["image"]["root_index"], 1)
|
||||
self.assertEqual(result["prompts"]["positive"]["text"], "input")
|
||||
make_png(other / "plain.png")
|
||||
_, result = await self.inspect("plain.png", root="1")
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
|
||||
|
||||
async def test_requires_valid_root_safe_relative_path_existing_media(self):
|
||||
make_png(self.output_dir / "image.png")
|
||||
for root in (None, "-1", "1", "x", "0.0"):
|
||||
response, result = await self.inspect(root=root)
|
||||
self.assertEqual(response.status, 400, root)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(result))
|
||||
for path in (
|
||||
"%2Fetc%2Fpasswd.png",
|
||||
"..%2Fsecret.png",
|
||||
"%5Csecret.png",
|
||||
"bad.txt",
|
||||
"null%00.png",
|
||||
"C:%5Csecret.png",
|
||||
):
|
||||
response, result = await self.inspect(path)
|
||||
self.assertEqual(response.status, 403, path)
|
||||
response, result = await self.inspect("missing.png")
|
||||
self.assertEqual(response.status, 404)
|
||||
outside = png(self.comfy_dir / "outside.png", prompt=graph("private"))
|
||||
self._make_symlink(outside, self.output_dir / "escape.png")
|
||||
response, result = await self.inspect("escape.png")
|
||||
self.assertEqual(response.status, 403)
|
||||
self.assertNotIn("private", json.dumps(result))
|
||||
|
||||
async def test_missing_negative_is_distinct_from_explicit_empty(self):
|
||||
source = graph()
|
||||
del source["3"]["inputs"]["negative"]
|
||||
png(self.output_dir / "image.png", prompt=source)
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
|
||||
self.assertEqual(result["prompts"]["negative"]["status"], "absent")
|
||||
self.assertIsNone(result["prompts"]["negative"]["text"])
|
||||
|
||||
async def test_graph_ambiguity_and_unknown_semantics_do_not_use_saved_fallback(
|
||||
self,
|
||||
):
|
||||
path = self.output_dir / "image.png"
|
||||
self._link_image(png(path), "Legacy saved")
|
||||
for expected in ("ambiguous", "unresolved"):
|
||||
source = graph()
|
||||
if expected == "ambiguous":
|
||||
source["4"] = source["3"]
|
||||
else:
|
||||
source["1"]["class_type"] = "UnknownEncoder"
|
||||
png(
|
||||
path,
|
||||
prompt=source,
|
||||
parameters=(
|
||||
"Fallback\nNegative prompt: noise\n" "Steps: 20, Sampler: Euler"
|
||||
),
|
||||
)
|
||||
_, result = await self.inspect()
|
||||
for side in result["prompts"].values():
|
||||
self.assertEqual(side["status"], expected)
|
||||
self.assertIsNone(side["text"])
|
||||
|
||||
async def test_parameters_and_partial_resolution_are_conservative(self):
|
||||
path = self.output_dir / "image.png"
|
||||
png(path, parameters="A mountain\nNegative prompt: \nSteps: 20, Sampler: Euler")
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
self.side("A mountain", "embedded-parameters"),
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["negative"], self.side("", "embedded-parameters")
|
||||
)
|
||||
source = graph()
|
||||
source["2"]["class_type"] = "UnknownEncoder"
|
||||
png(path, prompt=source)
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
|
||||
self.assertEqual(result["prompts"]["negative"]["status"], "unresolved")
|
||||
|
||||
async def test_execution_evidence_wins_over_graph_and_saved_recipe(self):
|
||||
source = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "portrait", "negative_text": "blurry"},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["1", 1]},
|
||||
},
|
||||
}
|
||||
evidence = {}
|
||||
record_pair_execution(
|
||||
evidence,
|
||||
"1",
|
||||
text="portrait",
|
||||
negative_text="blurry",
|
||||
positive_text="portrait LoRA trigger",
|
||||
negative_effective_text="blurry",
|
||||
)
|
||||
path = png(self.output_dir / "image.png", prompt=source, **evidence)
|
||||
self._link_image(path, "Saved recipe")
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
self.side("portrait LoRA trigger", "execution"),
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["negative"], self.side("blurry", "execution")
|
||||
)
|
||||
png(path, prompt=source, **{EXECUTION_KEY: "broken"})
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
|
||||
async def test_saved_association_fallback_is_exact_and_uses_unique_context(self):
|
||||
path = make_png(self.output_dir / "image.png")
|
||||
self._link_image(path, "Saved positive")
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
self.side("Saved positive", "saved-association"),
|
||||
)
|
||||
self.assertEqual(result["prompts"]["negative"]["status"], "absent")
|
||||
other = make_png(self.output_dir / "paired.png")
|
||||
metadata = {"prompt": graph("Effective positive", "Recorded negative")}
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
ingest_resolved_pair(self.api.db, str(other), metadata, resolution)
|
||||
_, result = await self.inspect("paired.png")
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
self.side("Effective positive", "saved-association"),
|
||||
)
|
||||
self.assertEqual(
|
||||
result["prompts"]["negative"],
|
||||
self.side("Recorded negative", "saved-association"),
|
||||
)
|
||||
|
||||
async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
|
||||
path = self.output_dir / "image.png"
|
||||
for content, expected in (
|
||||
(b"broken", "unresolved"),
|
||||
(None, "absent"),
|
||||
("bad-json", "unresolved"),
|
||||
):
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
else:
|
||||
png(path, **({"prompt": content} if content else {}))
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], expected)
|
||||
self.assertIsNone(result["prompts"]["positive"]["text"])
|
||||
|
||||
async def test_compressed_international_and_post_pixel_metadata(self):
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
path = self.output_dir / "image.png"
|
||||
for kind in (
|
||||
"compressed",
|
||||
"international",
|
||||
"compressed-international",
|
||||
"post-idat",
|
||||
):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
value = json.dumps(graph("猫"), ensure_ascii=False)
|
||||
if kind == "compressed":
|
||||
info.add_text("prompt", json.dumps(graph()), zip=True)
|
||||
elif kind in ("international", "compressed-international"):
|
||||
info.add_itxt(
|
||||
"prompt",
|
||||
value,
|
||||
lang="en",
|
||||
tkey="Prompt",
|
||||
zip=kind.startswith("compressed"),
|
||||
)
|
||||
else:
|
||||
payload = b"prompt\0" + json.dumps(graph()).encode()
|
||||
info.add_text("unrelated", "header text")
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
if kind == "post-idat":
|
||||
content = path.read_bytes()
|
||||
chunk = (
|
||||
struct.pack(">I", len(payload))
|
||||
+ b"tEXt"
|
||||
+ payload
|
||||
+ struct.pack(">I", zlib.crc32(b"tEXt" + payload))
|
||||
)
|
||||
path.write_bytes(content[:-12] + chunk + content[-12:])
|
||||
with patch.object(
|
||||
Image.Image, "load", side_effect=AssertionError("pixels")
|
||||
):
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"]["text"],
|
||||
"猫" if "international" in kind else "portrait",
|
||||
)
|
||||
|
||||
async def test_corrupt_truncated_duplicate_and_expanding_metadata_fail_closed(self):
|
||||
import struct
|
||||
import zlib
|
||||
from utils.prompt_inspector import MAX_METADATA_BYTES
|
||||
|
||||
path = self.output_dir / "image.png"
|
||||
base = png(path, prompt=graph()).read_bytes()
|
||||
cases = [base[:-1], base[:30]]
|
||||
index = base.index(b"tEXt")
|
||||
broken = bytearray(base)
|
||||
broken[index + 8] ^= 1
|
||||
cases.append(bytes(broken))
|
||||
for kind, payload in (
|
||||
(b"tEXt", b"prompt\0{}"),
|
||||
(b"zTXt", b"prompt\0\0" + zlib.compress(b"x" * (MAX_METADATA_BYTES + 1))),
|
||||
(b"tEXt", b"prompt\0" + b"x" * (MAX_METADATA_BYTES + 1)),
|
||||
(b"tEXt", b"bad-key-without-separator"),
|
||||
(b"zTXt", b"prompt\0\1bad-compression"),
|
||||
(b"iTXt", b"prompt\0\2\0\0\0text"),
|
||||
(b"iTXt", b"prompt\0\1\1\0\0text"),
|
||||
(b"iTXt", b"prompt\0\0\0\0\0\xff"),
|
||||
):
|
||||
chunk = (
|
||||
struct.pack(">I", len(payload))
|
||||
+ kind
|
||||
+ payload
|
||||
+ struct.pack(">I", zlib.crc32(kind + payload))
|
||||
)
|
||||
cases.append(base[:-12] + chunk + base[-12:])
|
||||
for index, content in enumerate(cases):
|
||||
path.write_bytes(content)
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"]["status"], "unresolved", index
|
||||
)
|
||||
self.assertIsNone(result["prompts"]["positive"]["text"])
|
||||
|
||||
async def test_jpeg_header_only_no_metadata_and_read_failure_are_safe(self):
|
||||
path = self.output_dir / "image.jpg"
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path)
|
||||
with patch.object(Image.Image, "load", side_effect=AssertionError("pixels")):
|
||||
response, result = await self.inspect("image.jpg")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
|
||||
with patch(
|
||||
"py.api.images.read_inspection_metadata",
|
||||
side_effect=RuntimeError(str(self.comfy_dir)),
|
||||
):
|
||||
response, result = await self.inspect("image.jpg")
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(result))
|
||||
|
||||
async def test_invalid_json_depth_and_saved_filename_collision_do_not_escape(self):
|
||||
path = png(self.output_dir / "image.png", prompt="[" * 2000)
|
||||
_, result = await self.inspect()
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
self._link_image(path, "Saved first")
|
||||
other = self.comfy_dir / "input"
|
||||
other.mkdir()
|
||||
make_png(other / "image.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
|
||||
with patch.object(
|
||||
self.api.db,
|
||||
"get_image_prompt_info",
|
||||
wraps=self.api.db.get_image_prompt_info,
|
||||
) as lookup:
|
||||
_, result = await self.inspect(root="1")
|
||||
lookup.assert_called_once_with(str(other / "image.png"), False)
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "absent")
|
||||
|
||||
async def test_video_inspection_uses_bounded_container_evidence(self):
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
from utils.prompt_inspector import MAX_METADATA_BYTES
|
||||
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"video fixture")
|
||||
metadata = {"prompt": graph("Video portrait", "")}
|
||||
payload = json.dumps(
|
||||
{"format": {"tags": {"comment": json.dumps(metadata)}}}
|
||||
).encode()
|
||||
process = MagicMock()
|
||||
process.stdout = io.BytesIO(payload)
|
||||
process.poll.return_value = 0
|
||||
process.returncode = 0
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch(
|
||||
"utils.prompt_inspector.subprocess.Popen", return_value=process
|
||||
) as spawn,
|
||||
):
|
||||
response, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(result["prompts"]["positive"], self.side("Video portrait"))
|
||||
self.assertEqual(result["prompts"]["negative"], self.side(""))
|
||||
self.assertTrue(process.stdout.closed)
|
||||
process.wait.assert_called()
|
||||
self.assertNotIn("shell", spawn.call_args.kwargs)
|
||||
self.assertIn(str(self.output_dir / "clip.mp4"), spawn.call_args.args[0])
|
||||
process.stdout = io.BytesIO(b"x" * (MAX_METADATA_BYTES + 1))
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
|
||||
):
|
||||
_, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertTrue(process.stdout.closed)
|
||||
|
||||
async def test_video_missing_probe_and_timeout_preserve_uncertainty(self):
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
(self.output_dir / "clip.webm").write_bytes(b"fixture")
|
||||
with patch("utils.prompt_inspector.ffprobe_path", return_value=None):
|
||||
_, result = await self.inspect("clip.webm")
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
process = MagicMock()
|
||||
process.stdout = io.BytesIO(b"")
|
||||
process.poll.return_value = None
|
||||
process.returncode = -9
|
||||
with (
|
||||
patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
|
||||
):
|
||||
_, result = await self.inspect("clip.webm")
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
process.kill.assert_called_once()
|
||||
process.wait.assert_called()
|
||||
self.assertTrue(process.stdout.closed)
|
||||
|
||||
async def test_video_description_execution_evidence_and_invalid_envelope(self):
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
path = self.output_dir / "clip.mp4"
|
||||
path.write_bytes(b"fixture")
|
||||
source = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "portrait", "negative_text": "bad"},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["1", 1]},
|
||||
},
|
||||
}
|
||||
evidence = {}
|
||||
record_pair_execution(
|
||||
evidence,
|
||||
"1",
|
||||
text="portrait",
|
||||
negative_text="bad",
|
||||
positive_text="portrait trigger",
|
||||
negative_effective_text="bad",
|
||||
)
|
||||
for value in (
|
||||
{
|
||||
"format": {
|
||||
"tags": {
|
||||
"comment": "not-json",
|
||||
"description": json.dumps({"prompt": source, **evidence}),
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
{"format": {"tags": []}},
|
||||
{"format": {"tags": {}}},
|
||||
{
|
||||
"format": {
|
||||
"tags": {
|
||||
"comment": json.dumps(
|
||||
{"prompt": source, EXECUTION_KEY: "bad-json"}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
):
|
||||
process = MagicMock()
|
||||
process.stdout = io.BytesIO(json.dumps(value).encode())
|
||||
process.poll.return_value = 0
|
||||
process.returncode = 0
|
||||
with (
|
||||
patch(
|
||||
"utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"
|
||||
),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
|
||||
):
|
||||
_, result = await self.inspect("clip.mp4")
|
||||
positive = result["prompts"]["positive"]
|
||||
if (
|
||||
isinstance(value, list)
|
||||
or not isinstance(value["format"]["tags"], dict)
|
||||
or "bad-json" in json.dumps(value)
|
||||
):
|
||||
self.assertEqual(positive["status"], "unresolved")
|
||||
elif not value["format"]["tags"]:
|
||||
self.assertEqual(positive["status"], "absent")
|
||||
else:
|
||||
self.assertEqual(positive, self.side("portrait trigger", "execution"))
|
||||
|
||||
async def test_large_sparse_pixels_are_skipped_and_chunk_count_is_bounded(self):
|
||||
import struct
|
||||
import zlib
|
||||
from utils.prompt_inspector import read_inspection_metadata, MetadataReadError
|
||||
|
||||
path = self.output_dir / "large.png"
|
||||
header = struct.pack(">IIBBBBB", 1024, 1024, 8, 2, 0, 0, 0)
|
||||
|
||||
def chunk(kind, data):
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ kind
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(kind + data))
|
||||
)
|
||||
|
||||
with path.open("wb") as stream:
|
||||
stream.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header))
|
||||
stream.write(struct.pack(">I4s", 64 * 1024 * 1024, b"IDAT"))
|
||||
stream.seek(64 * 1024 * 1024, 1)
|
||||
stream.write(b"\0\0\0\0")
|
||||
stream.write(chunk(b"tEXt", b"prompt\0" + json.dumps(graph()).encode()))
|
||||
stream.write(chunk(b"IEND", b""))
|
||||
original_open = type(path).open
|
||||
reads = []
|
||||
|
||||
def tracked_open(item, *args, **kwargs):
|
||||
stream = original_open(item, *args, **kwargs)
|
||||
original_read = stream.read
|
||||
|
||||
def read(size=-1):
|
||||
value = original_read(size)
|
||||
reads.append(len(value))
|
||||
return value
|
||||
|
||||
stream.read = read
|
||||
return stream
|
||||
|
||||
with patch.object(type(path), "open", tracked_open):
|
||||
metadata = read_inspection_metadata(path)
|
||||
self.assertEqual(json.loads(metadata["prompt"]), graph())
|
||||
self.assertLess(sum(reads), 4096)
|
||||
with patch("utils.prompt_inspector.MAX_PNG_CHUNKS", 1):
|
||||
with self.assertRaises(MetadataReadError):
|
||||
read_inspection_metadata(path)
|
||||
|
||||
async def test_large_jpeg_uses_bounded_header_without_rejecting_valid_file(self):
|
||||
from utils.prompt_inspector import MAX_HEADER_BYTES
|
||||
|
||||
path = self.output_dir / "large.jpg"
|
||||
with Image.new("RGB", (4, 4)) as image:
|
||||
image.save(path)
|
||||
with path.open("ab") as stream:
|
||||
stream.write(b"x" * (MAX_HEADER_BYTES + 1))
|
||||
self._link_image(path, "Saved large JPEG")
|
||||
with patch.object(Image.Image, "load", side_effect=AssertionError("pixels")):
|
||||
_, result = await self.inspect("large.jpg")
|
||||
self.assertEqual(
|
||||
result["prompts"]["positive"],
|
||||
self.side("Saved large JPEG", "saved-association"),
|
||||
)
|
||||
|
||||
async def test_invalid_or_execution_only_video_evidence_never_uses_saved_fallback(
|
||||
self,
|
||||
):
|
||||
import io
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
path = self.output_dir / "clip.mp4"
|
||||
path.write_bytes(b"fixture")
|
||||
self._link_image(path, "Saved recipe must not replace unknown evidence")
|
||||
for envelope in (
|
||||
{"prompt": None},
|
||||
{"parameters": None},
|
||||
{EXECUTION_KEY: {"version": 1, "nodes": {}}},
|
||||
):
|
||||
process = MagicMock()
|
||||
process.stdout = io.BytesIO(
|
||||
json.dumps(
|
||||
{
|
||||
"format": {
|
||||
"tags": {
|
||||
"comment": json.dumps(envelope),
|
||||
"description": json.dumps(
|
||||
{"prompt": graph("Other comment")}
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
process.poll.return_value = 0
|
||||
process.returncode = 0
|
||||
with (
|
||||
patch(
|
||||
"utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"
|
||||
),
|
||||
patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
|
||||
):
|
||||
_, result = await self.inspect("clip.mp4")
|
||||
self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
|
||||
self.assertIsNone(result["prompts"]["positive"]["text"])
|
||||
+12
@@ -190,5 +190,17 @@
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-inspector.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/prompt-sender.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 89,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import subprocess # nosec B404
|
||||
import threading
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .pair_execution import EXECUTION_KEY
|
||||
from .prompt_pair_resolver import resolve_prompt_pair
|
||||
from .video_metadata import ffprobe_path, is_video_path, metadata_from_tags
|
||||
|
||||
MAX_METADATA_BYTES = 2 * 1024 * 1024
|
||||
MAX_HEADER_BYTES = 8 * 1024 * 1024
|
||||
MAX_PNG_CHUNKS = 10_000
|
||||
_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
|
||||
_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
class MetadataReadError(ValueError):
|
||||
"""Prompt evidence is unreadable, incomplete, or exceeds inspection limits."""
|
||||
|
||||
|
||||
def _inflate(data: bytes) -> bytes:
|
||||
inflater = zlib.decompressobj()
|
||||
result = inflater.decompress(data, MAX_METADATA_BYTES + 1)
|
||||
if len(result) > MAX_METADATA_BYTES or not inflater.eof or inflater.unused_data:
|
||||
raise MetadataReadError("Compressed metadata is invalid or exceeds limits")
|
||||
return result
|
||||
|
||||
|
||||
def _png_text(kind: bytes, data: bytes) -> tuple[str, str]:
|
||||
keyword, separator, body = data.partition(b"\0")
|
||||
if not separator or not 1 <= len(keyword) <= 79:
|
||||
raise MetadataReadError("Malformed PNG text metadata")
|
||||
key = keyword.decode("latin-1")
|
||||
encoding = "latin-1"
|
||||
if kind == b"zTXt":
|
||||
if not body or body[0] != 0:
|
||||
raise MetadataReadError("Unsupported PNG text compression")
|
||||
body = _inflate(body[1:])
|
||||
elif kind == b"iTXt":
|
||||
if len(body) < 2 or body[0] not in (0, 1):
|
||||
raise MetadataReadError("Malformed international PNG metadata")
|
||||
flag, method = body[:2]
|
||||
fields = body[2:].split(b"\0", 2)
|
||||
if len(fields) != 3 or (flag and method != 0):
|
||||
raise MetadataReadError("Malformed international PNG metadata")
|
||||
fields[0].decode("ascii")
|
||||
fields[1].decode("utf-8")
|
||||
body = _inflate(fields[2]) if flag else fields[2]
|
||||
encoding = "utf-8"
|
||||
if b"\0" in body:
|
||||
raise MetadataReadError("Malformed PNG text metadata")
|
||||
return key, body.decode(encoding)
|
||||
|
||||
|
||||
def _png_metadata(stream: Any, size: int) -> dict:
|
||||
"""CRC-check text and structural chunks, seeking past compressed pixels.
|
||||
|
||||
PNG text chunks may follow IDAT. Pillow's ``.text`` loads pixels to find
|
||||
them; this bounded reader instead follows the W3C PNG chunk layout.
|
||||
IDAT checksums/pixel validity are deliberately outside metadata inspection.
|
||||
"""
|
||||
found, compressed_size, expanded_size = {}, 0, 0
|
||||
saw_header = saw_pixels = False
|
||||
for index in range(MAX_PNG_CHUNKS):
|
||||
header = stream.read(8)
|
||||
if len(header) != 8:
|
||||
raise MetadataReadError("Incomplete PNG metadata")
|
||||
length, kind = struct.unpack(">I4s", header)
|
||||
if length > 0x7FFFFFFF or stream.tell() + length + 4 > size:
|
||||
raise MetadataReadError("Incomplete PNG chunk")
|
||||
if index == 0 and (kind != b"IHDR" or length != 13):
|
||||
raise MetadataReadError("Missing PNG header")
|
||||
if kind in (b"tEXt", b"zTXt", b"iTXt", b"IHDR", b"IEND"):
|
||||
compressed_size += length
|
||||
if compressed_size > MAX_METADATA_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
data, checksum = stream.read(length), stream.read(4)
|
||||
if zlib.crc32(kind + data) != int.from_bytes(checksum, "big"):
|
||||
raise MetadataReadError("PNG metadata checksum mismatch")
|
||||
if kind == b"IHDR":
|
||||
if saw_header or not all(struct.unpack(">II", data[:8])):
|
||||
raise MetadataReadError("Malformed PNG header")
|
||||
saw_header = True
|
||||
elif kind == b"IEND":
|
||||
if length or not saw_pixels:
|
||||
raise MetadataReadError("Incomplete PNG image")
|
||||
return found
|
||||
else:
|
||||
key, value = _png_text(kind, data)
|
||||
expanded_size += len(value.encode("utf-8"))
|
||||
if expanded_size > MAX_METADATA_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
if key in _METADATA_KEYS:
|
||||
if key in found:
|
||||
raise MetadataReadError("Duplicate prompt metadata")
|
||||
found[key] = value
|
||||
else:
|
||||
saw_pixels |= kind == b"IDAT"
|
||||
stream.seek(length + 4, 1)
|
||||
raise MetadataReadError("PNG chunk count exceeds inspection limits")
|
||||
|
||||
|
||||
def _video_metadata(path: Path) -> dict:
|
||||
probe = ffprobe_path()
|
||||
if not probe:
|
||||
raise MetadataReadError("Video metadata reader is unavailable")
|
||||
command = [
|
||||
probe,
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_entries",
|
||||
"format_tags=comment,description",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
]
|
||||
process = subprocess.Popen( # nosec B603
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
def stop() -> None:
|
||||
if process.poll() is None:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
timer = threading.Timer(3, stop)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
try:
|
||||
raw = process.stdout.read(MAX_METADATA_BYTES + 1)
|
||||
if len(raw) > MAX_METADATA_BYTES:
|
||||
raise MetadataReadError("Video metadata exceeds inspection limits")
|
||||
process.wait()
|
||||
if process.returncode:
|
||||
raise MetadataReadError("Video metadata could not be read")
|
||||
finally:
|
||||
timer.cancel()
|
||||
stop()
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
probed = json.loads(raw)
|
||||
if not isinstance(probed, dict) or not isinstance(probed.get("format"), dict):
|
||||
raise MetadataReadError("Malformed video metadata")
|
||||
tags = probed["format"].get("tags", {})
|
||||
if not isinstance(tags, dict):
|
||||
raise MetadataReadError("Malformed video metadata")
|
||||
# The scan normalizer drops null fields and unknown keys. Inspection must
|
||||
# retain explicit prompt/execution markers so malformed evidence cannot
|
||||
# silently fall back to an unrelated saved recipe or another comment.
|
||||
for key in ("comment", "description"):
|
||||
raw = tags.get(key)
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
envelope = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(envelope, dict) or not _METADATA_KEYS.intersection(envelope):
|
||||
continue
|
||||
metadata = metadata_from_tags({key: raw})
|
||||
for field in _METADATA_KEYS.intersection(envelope):
|
||||
value = envelope[field]
|
||||
metadata[field] = (
|
||||
value if value is None or isinstance(value, str) else json.dumps(value)
|
||||
)
|
||||
return metadata
|
||||
return {}
|
||||
|
||||
|
||||
def read_inspection_metadata(path: Path) -> dict:
|
||||
"""Return bounded, header-only metadata or raise a response-safe error."""
|
||||
try:
|
||||
if is_video_path(path):
|
||||
metadata = _video_metadata(path)
|
||||
else:
|
||||
with path.open("rb") as stream:
|
||||
signature = stream.read(8)
|
||||
if signature == _SIGNATURE:
|
||||
return _png_metadata(stream, path.stat().st_size)
|
||||
stream.seek(0)
|
||||
header = stream.read(MAX_HEADER_BYTES)
|
||||
with Image.open(io.BytesIO(header)) as image:
|
||||
metadata = dict(image.info)
|
||||
result, size = {}, 0
|
||||
for key in _METADATA_KEYS:
|
||||
if key not in metadata:
|
||||
continue
|
||||
value = metadata[key]
|
||||
if not isinstance(value, str):
|
||||
raise MetadataReadError("Malformed prompt metadata")
|
||||
size += len(value.encode("utf-8"))
|
||||
if size > MAX_METADATA_BYTES:
|
||||
raise MetadataReadError("Metadata exceeds inspection limits")
|
||||
result[key] = value
|
||||
return result
|
||||
except MetadataReadError:
|
||||
raise
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
EOFError,
|
||||
SyntaxError,
|
||||
RecursionError,
|
||||
struct.error,
|
||||
zlib.error,
|
||||
):
|
||||
raise MetadataReadError("Media metadata could not be read") from None
|
||||
|
||||
|
||||
def _side(status: str, text: str | None = None, source: str | None = None) -> dict:
|
||||
return {"status": status, "text": text, "source": source}
|
||||
|
||||
|
||||
def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
|
||||
"""Keep uncertainty visible; a saved association never replaces file evidence."""
|
||||
if not metadata:
|
||||
context = saved.get("pair_context") if saved else None
|
||||
if isinstance(context, dict) and all(
|
||||
isinstance(context.get(f"{role}_text"), str)
|
||||
for role in ("positive", "negative")
|
||||
):
|
||||
return {
|
||||
role: _side("resolved", context[f"{role}_text"], "saved-association")
|
||||
for role in ("positive", "negative")
|
||||
}
|
||||
positive = saved.get("text") if saved else None
|
||||
return {
|
||||
"positive": (
|
||||
_side("resolved", positive, "saved-association")
|
||||
if isinstance(positive, str)
|
||||
else _side("absent")
|
||||
),
|
||||
"negative": _side("absent"),
|
||||
}
|
||||
canonical = dict(metadata)
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return {role: _side("unresolved") for role in ("positive", "negative")}
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return {role: _side("unresolved") for role in ("positive", "negative")}
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
state = resolution["state"]
|
||||
result = {}
|
||||
for role in ("positive", "negative"):
|
||||
side = resolution[role]
|
||||
if (
|
||||
state != "ambiguous"
|
||||
and isinstance(side, dict)
|
||||
and (role == "negative" or side["text"].strip())
|
||||
):
|
||||
result[role] = _side(
|
||||
"resolved", side["text"], side["provenance"]["text_source"]
|
||||
)
|
||||
else:
|
||||
result[role] = _side(state if state != "resolved" else "unresolved")
|
||||
return result
|
||||
@@ -57,3 +57,46 @@ main.images-main { max-inline-size: none; padding: 28px; }
|
||||
}
|
||||
.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
|
||||
.options-status { font-size: 12px; padding-inline: 4px; }
|
||||
|
||||
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
|
||||
.prompt-block { border: 1px solid var(--ca-line); border-inline-start: 3px solid var(--prompt-role); border-radius: 7px; padding: 14px; margin-block-end: 14px; min-inline-size: 0; }
|
||||
.prompt-positive { --prompt-role: #6bd6a4; }
|
||||
[data-ca-theme="light"] .prompt-positive { --prompt-role: #14784f; }
|
||||
.prompt-negative { --prompt-role: #ff7f7f; }
|
||||
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b51d2d; }
|
||||
.prompt-block h3 { font-size: 12px; margin: 0 0 8px; color: var(--prompt-role); }
|
||||
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 11px; }
|
||||
.prompt-text { font: inherit; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 12px; }
|
||||
.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.prompt-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.prompt-actions button { font-size: 11px; }
|
||||
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
|
||||
.prompt-copy-status:empty { display: none; }
|
||||
.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); }
|
||||
.image-preview-details { padding: 0 18px 18px; max-block-size: 75dvh; overflow: auto; min-inline-size: 0; }
|
||||
.image-preview-details .prompt-inspector { margin-block-start: 0; }
|
||||
.image-preview > header { border-block-end: 1px solid var(--ca-line); }
|
||||
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
|
||||
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
|
||||
.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; }
|
||||
.prompt-sender::backdrop { background: #000b; }
|
||||
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.prompt-sender-header h2 { font-size: 20px; margin: 0; }
|
||||
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
|
||||
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
|
||||
.prompt-sender-status { font-size: 13px; line-height: 1.6; }
|
||||
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
|
||||
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
|
||||
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
|
||||
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
|
||||
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
|
||||
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
|
||||
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
|
||||
@media (max-width: 780px) {
|
||||
.image-preview-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.image-preview-stage { block-size: 52dvh; }
|
||||
.image-preview-details { max-block-size: none; }
|
||||
.image-preview { max-block-size: calc(100dvh - 24px); overflow: auto; }
|
||||
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, imageKey } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages } from './image-layout.mjs';
|
||||
import { mountPromptInspector } from './prompt-inspector.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -19,6 +21,9 @@ export function mountImages(main, api, parentScope) {
|
||||
const scope = createScope();
|
||||
parentScope.cleanup(() => scope.dispose());
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
const sender = createPromptSender({scope});
|
||||
let detailInspector, previewInspector;
|
||||
scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); });
|
||||
const visit = lastVisit;
|
||||
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer;
|
||||
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest;
|
||||
@@ -107,11 +112,17 @@ export function mountImages(main, api, parentScope) {
|
||||
const close = createButton('Close preview');
|
||||
dialogHeader.append(previewTitle, close);
|
||||
const stage = el('div', undefined, 'image-preview-stage');
|
||||
dialog.append(dialogHeader, stage);
|
||||
const previewBody = el('div', undefined, 'image-preview-body');
|
||||
const previewDetails = el('aside', undefined, 'image-preview-details');
|
||||
previewDetails.setAttribute('aria-label', 'Preview prompt details');
|
||||
previewBody.append(stage, previewDetails);
|
||||
dialog.append(dialogHeader, previewBody);
|
||||
main.append(dialog);
|
||||
scope.listen(close, 'click', () => dialog.close());
|
||||
scope.listen(dialog, 'close', () => { destroyPreview(); scheduleLoad(); });
|
||||
function destroyPreview() {
|
||||
previewInspector?.dispose(); previewInspector = null;
|
||||
previewDetails.replaceChildren();
|
||||
viewer?.destroy(); viewer = null;
|
||||
for (const video of stage.querySelectorAll('video')) releaseVideo(video);
|
||||
stage.replaceChildren();
|
||||
@@ -130,6 +141,7 @@ export function mountImages(main, api, parentScope) {
|
||||
video.pause(); video.removeAttribute('src'); video.load();
|
||||
}
|
||||
function inspect(image) {
|
||||
detailInspector?.dispose();
|
||||
selection = imageKey(image);
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
const thumbnail = mediaElement(image);
|
||||
@@ -145,8 +157,10 @@ export function mountImages(main, api, parentScope) {
|
||||
preview.dataset.previewKey = selection;
|
||||
const download = el('a', 'Download original', 'workspace-link');
|
||||
download.href = resolveUrl(image.url); download.download = image.filename;
|
||||
const legacy = el('p', 'Open Current gallery for prompt details and tagging.', 'muted');
|
||||
details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, metadata, preview, download, legacy);
|
||||
const prompts = el('div');
|
||||
const legacy = el('p', 'Open Current gallery for tagging.', 'muted');
|
||||
details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, preview, download, prompts, metadata, legacy);
|
||||
detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope});
|
||||
for (const [key, card] of rendered) {
|
||||
card.classList.toggle('selected', key === selection);
|
||||
card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
|
||||
@@ -161,6 +175,7 @@ export function mountImages(main, api, parentScope) {
|
||||
if (selection !== imageKey(image)) inspect(image);
|
||||
destroyPreview();
|
||||
previewTitle.textContent = image.filename;
|
||||
previewInspector = mountPromptInspector(previewDetails, {api, image, sender, parentScope:scope});
|
||||
if (!dialog.open) dialog.showModal();
|
||||
if (image.is_video) {
|
||||
const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata';
|
||||
@@ -315,6 +330,7 @@ export function mountImages(main, api, parentScope) {
|
||||
observer.observe(continuation);
|
||||
}
|
||||
function reset() {
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
selection = null;
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createSendButton } from './prompt-sender.mjs';
|
||||
|
||||
export function inspectPath(image) {
|
||||
return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?root=${image.root_index}`;
|
||||
}
|
||||
|
||||
export function promptRoles(prompts) {
|
||||
return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text]));
|
||||
}
|
||||
|
||||
export function createInspectionLoader(api, onChange) {
|
||||
let request, disposed = false;
|
||||
return {
|
||||
async load(image) {
|
||||
if (disposed) return;
|
||||
request?.abort();
|
||||
const current = request = new AbortController();
|
||||
onChange({ loading: true });
|
||||
try {
|
||||
const result = await api.get(inspectPath(image), {signal:current.signal});
|
||||
if (!disposed && request === current) onChange({prompts:result.prompts});
|
||||
} catch {
|
||||
if (!disposed && request === current) onChange({error:true});
|
||||
}
|
||||
},
|
||||
dispose() { disposed = true; request?.abort(); },
|
||||
};
|
||||
}
|
||||
|
||||
const sources = {
|
||||
execution: 'Recorded at execution',
|
||||
'graph-inferred': 'Inferred from the saved workflow',
|
||||
'embedded-parameters': 'Embedded generation parameters',
|
||||
'saved-association': 'Saved library association',
|
||||
};
|
||||
const statuses = {
|
||||
absent: 'Not recorded in this file.',
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function mountPromptInspector(container, {api, image, sender, parentScope}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
container.classList.add('prompt-inspector');
|
||||
const loader = createInspectionLoader(api, render);
|
||||
scope.cleanup(() => loader.dispose());
|
||||
scope.listen(container, 'click', async event => {
|
||||
const send = event.target.closest('[data-send-role]');
|
||||
if (send) {
|
||||
const role = send.dataset.sendRole;
|
||||
sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send);
|
||||
}
|
||||
const copy = event.target.closest('[data-copy-role]');
|
||||
if (copy) {
|
||||
const role = copy.dataset.copyRole;
|
||||
const status = copy.closest('section').querySelector('.prompt-copy-status');
|
||||
try {
|
||||
await navigator.clipboard.writeText(roles[role]);
|
||||
if (!scope.signal.aborted) status.textContent = 'Copied.';
|
||||
} catch {
|
||||
if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.';
|
||||
}
|
||||
}
|
||||
if (event.target.closest('[data-retry-prompts]')) loader.load(image);
|
||||
});
|
||||
let roles = {};
|
||||
function render(state) {
|
||||
roles = {};
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted');
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status');
|
||||
container.append(status);
|
||||
if (state.error) {
|
||||
const retry = createButton('Retry prompt details');
|
||||
retry.dataset.retryPrompts = '';
|
||||
container.append(retry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
roles = promptRoles(state.prompts);
|
||||
for (const role of ['positive', 'negative']) {
|
||||
const title = `${role[0].toUpperCase() + role.slice(1)} prompt`;
|
||||
const record = state.prompts?.[role];
|
||||
const block = el('section', undefined, `prompt-block prompt-${role}`);
|
||||
block.setAttribute('aria-label', title);
|
||||
block.append(el('h3', title));
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
|
||||
else {
|
||||
const text = el('pre', roles[role], 'prompt-text');
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
}
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${role} prompt`);
|
||||
send.dataset.sendRole = role;
|
||||
const copy = createButton(`Copy ${role} prompt`);
|
||||
copy.dataset.copyRole = role;
|
||||
actions.append(send, copy);
|
||||
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
|
||||
block.append(actions, status);
|
||||
} else block.append(el('p', statuses[record?.status] || statuses.unresolved, 'muted'));
|
||||
container.append(block);
|
||||
}
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
container.append(pair);
|
||||
}
|
||||
}
|
||||
loader.load(image);
|
||||
return {dispose:() => scope.dispose()};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {createBridgeClient, validateRoles} from '../shared/toolkit/prompt-bridge.js';
|
||||
import {createButton, createField, createScope, createSelect} from '../shared/toolkit/core.js';
|
||||
|
||||
export function createSendSession({client, roles:rawRoles, onChange}) {
|
||||
const roles = Object.freeze(validateRoles(rawRoles));
|
||||
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,message:''};
|
||||
let operation, generation = 0, disposed = false;
|
||||
const emit = patch => { if (!disposed) { state = Object.freeze({...state,...patch}); onChange(state); } };
|
||||
function invalidate() { generation++; operation?.abort(); operation = null; }
|
||||
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,message:''}); return {signal:operation.signal,version:generation}; }
|
||||
const current = request => !disposed && generation === request.version;
|
||||
const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
|
||||
return {
|
||||
get state() { return state; },
|
||||
async discover() {
|
||||
if (disposed) return;
|
||||
const request = begin('discovering'); emit({hosts:[],hostId:'',targetId:'',message:'Looking for ComfyUI windows…'});
|
||||
try {
|
||||
const found = await client.discover({signal:request.signal});
|
||||
if (!current(request)) return;
|
||||
const hosts = found.map(host=>({...host,targets:host.targets.filter(target=>typeof target.id==='string' && Array.isArray(target.roles) && Object.keys(roles).every(role=>target.roles.includes(role)))})).filter(host=>host.targets.length);
|
||||
emit({phase:'select',hosts,message:hosts.length ? 'Choose a ComfyUI window and destination node.' : 'No compatible ComfyUI windows found. Open a workflow with a compatible Prompt Manager node, then find windows again.'});
|
||||
} catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
|
||||
},
|
||||
selectHost(hostId) { if (disposed) return; invalidate(); emit({phase:'select',hostId:state.hosts.some(host=>host.hostId===hostId)?hostId:'',targetId:'',preview:null,message:'Choose a destination node, then preview changes.'}); },
|
||||
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); emit({phase:'select',targetId:host?.targets.some(target=>target.id===targetId)?targetId:'',preview:null,message:'Preview changes before applying.'}); },
|
||||
async preview() {
|
||||
if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
|
||||
const request=begin('previewing');
|
||||
try {
|
||||
const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
|
||||
if (!current(request)) return;
|
||||
const fields=preview?.fields;
|
||||
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
|
||||
emit({phase:'ready',preview,message:'Review the complete changes, then apply.'});
|
||||
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
|
||||
},
|
||||
async apply() {
|
||||
if(disposed || state.phase!=='ready' || !state.preview) return;
|
||||
const {ticket}=state.preview; const targetId=state.targetId; const hostId=state.hostId;
|
||||
const request=begin('applying');
|
||||
emit({message:'Waiting for ComfyUI confirmation. Closing this dialog does not undo a sent update.'});
|
||||
try {
|
||||
const acknowledgement=await client.apply(hostId,ticket,{signal:request.signal});
|
||||
if(!current(request)) return;
|
||||
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
|
||||
emit({phase:'success',message:'Sent to ComfyUI.'});
|
||||
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:`No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}`}); }
|
||||
},
|
||||
dispose() { if(disposed) return; disposed=true; invalidate(); client.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
function element(tag,text,className) {
|
||||
const node=document.createElement(tag);
|
||||
if(text!==undefined) node.textContent=text;
|
||||
if(className) node.className=className;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createSendButton(label='Send to ComfyUI') {
|
||||
const button=createButton('', 'prompt-send-button');
|
||||
button.setAttribute('aria-label',label);
|
||||
const icon=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||
icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.setAttribute('fill','none');icon.setAttribute('stroke','currentColor');icon.setAttribute('stroke-width','1.8');
|
||||
const path=document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||
path.setAttribute('d','M21 3 9.5 14.5M21 3l-7 18-4.5-6.5L3 10z');icon.append(path);
|
||||
button.append(icon,element('span',label));
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/prompt_manager/'),clientFactory=createBridgeClient}) {
|
||||
let active;
|
||||
const close = () => active?.dispose();
|
||||
scope.cleanup(close);
|
||||
return {open(roles,trigger) {
|
||||
if(scope.signal.aborted) return;
|
||||
close();
|
||||
const local=createScope();
|
||||
const dialog=element('dialog',undefined,'prompt-sender');
|
||||
dialog.setAttribute('aria-label','Send to ComfyUI');
|
||||
const header=element('header',undefined,'prompt-sender-header');
|
||||
const title=element('h2','Send to ComfyUI');const dismiss=createButton('Close');
|
||||
header.append(title,dismiss);
|
||||
const description=element('p','Choose where to send this prompt. Nothing changes until you approve the preview.');
|
||||
const status=element('p','', 'prompt-sender-status');status.setAttribute('role','status');status.setAttribute('aria-live','polite');
|
||||
const host=createSelect([['','Choose a ComfyUI window']], '');
|
||||
const target=createSelect([['','Choose a destination node']], '');
|
||||
const controls=element('div',undefined,'prompt-sender-options');
|
||||
controls.append(createField('ComfyUI window',host,'prompt-sender-host'),createField('Destination node',target,'prompt-sender-target'));
|
||||
const preview=element('section',undefined,'prompt-sender-preview');preview.setAttribute('aria-label','Prompt changes');
|
||||
const find=createButton('Find ComfyUI windows');const inspect=createButton('Preview changes');const apply=createButton('Apply to ComfyUI','ca-primary');
|
||||
inspect.disabled=true;apply.disabled=true;
|
||||
const actions=element('footer',undefined,'prompt-sender-actions');actions.append(find,inspect,apply);
|
||||
dialog.append(header,description,controls,status,preview,actions);document.body.append(dialog);
|
||||
let session,closed=false;
|
||||
function dispose() {
|
||||
if(closed) return;closed=true;local.dispose();session?.dispose();
|
||||
if(dialog.open) dialog.close();dialog.remove();
|
||||
if(active?.dialog===dialog) active=null;
|
||||
if(trigger?.isConnected) trigger.focus();
|
||||
}
|
||||
active={dialog,dispose};
|
||||
local.listen(dismiss,'click',dispose);local.listen(dialog,'close',dispose);
|
||||
local.listen(host,'change',()=>session?.selectHost(host.value));
|
||||
local.listen(target,'change',()=>session?.selectTarget(target.value));
|
||||
local.listen(find,'click',()=>session?.discover());
|
||||
local.listen(inspect,'click',()=>session?.preview());
|
||||
local.listen(apply,'click',()=>session?.apply());
|
||||
function options(select,placeholder,entries,value) {
|
||||
select.replaceChildren(...[["",placeholder],...entries].map(([value,label])=>{const option=element('option',label);option.value=value;return option;}));select.value=value;
|
||||
}
|
||||
function textBlock(parent,label,value) {
|
||||
parent.append(element('h4',label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
|
||||
}
|
||||
function render(state) {
|
||||
if(closed) return;
|
||||
const busy=['discovering','previewing','applying'].includes(state.phase);
|
||||
status.textContent=state.message;status.dataset.phase=state.phase;
|
||||
status.setAttribute('role',state.phase==='error'?'alert':'status');status.setAttribute('aria-live',state.phase==='error'?'assertive':'polite');
|
||||
dialog.setAttribute('aria-busy',String(busy));
|
||||
options(host,'Choose a ComfyUI window',state.hosts.map(item=>[item.hostId,item.label]),state.hostId);
|
||||
const selected=state.hosts.find(item=>item.hostId===state.hostId);
|
||||
options(target,'Choose a destination node',(selected?.targets || []).map(item=>[item.id,item.label]),state.targetId);
|
||||
host.disabled=busy;target.disabled=busy || !selected;
|
||||
find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=state.phase!=='ready';
|
||||
preview.replaceChildren();
|
||||
if(state.preview) {
|
||||
preview.append(element('h3','Review changes'));
|
||||
for(const field of state.preview.fields || []) {
|
||||
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
|
||||
section.append(element('h3',field.role==='positive'?'Positive prompt':'Negative prompt'));
|
||||
textBlock(section,'Before',field.before);textBlock(section,'After',field.after);preview.append(section);
|
||||
}
|
||||
const composition=state.preview.composition;
|
||||
if(composition) {
|
||||
const section=element('section',undefined,'prompt-sender-composition');section.append(element('h3','Positive composition'));
|
||||
textBlock(section,'Prepend (preserved)',composition.prepend || '');textBlock(section,'Append (preserved)',composition.append || '');
|
||||
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.');
|
||||
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.');
|
||||
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
|
||||
preview.append(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.showModal();
|
||||
try {
|
||||
const url=new URL(baseUrl,window.location.href);
|
||||
if(url.origin!==window.location.origin || !url.pathname.endsWith('/prompt_manager/')) throw Error('Invalid PromptManager deployment origin or path');
|
||||
const client=clientFactory({namespace:url.pathname.slice(0,-'prompt_manager/'.length),origin:window.location.origin});
|
||||
try { session=createSendSession({client,roles,onChange:render}); } catch(error) {client.dispose();throw error;}
|
||||
session.discover();
|
||||
} catch(error) {status.textContent=`Prompt sending is unavailable. ${error.message}`;status.dataset.phase='error';status.setAttribute('role','alert');status.setAttribute('aria-live','assertive');find.disabled=true;host.disabled=true;target.disabled=true;}
|
||||
}};
|
||||
}
|
||||
Reference in New Issue
Block a user