Extract and display copyable CivitAI ComfyUI workflow metadata in model previews

- Preserve existing image metadata when re-normalizing CivitAI model images
- Add extractComfyWorkflow helper to recover pasteable editor workflows from image metadata, including nested/double-encoded JSON and non-finite number tolerance
- Surface recovered workflows as a copyable button in the model info image preview under Other metadata
- Add focused tests for workflow extraction, metadata preservation, and edge cases
This commit is contained in:
Dariusz L
2026-07-26 20:40:41 +02:00
parent 58e1aeff29
commit 2d6be246b4
6 changed files with 254 additions and 1 deletions
+3
View File
@@ -794,6 +794,9 @@ def normalize_model_image(image_data: Dict[str, Any], default_civitai_url: str =
meta = image_data.get("meta")
if not isinstance(meta, dict):
meta = {}
normalized_metadata = image_data.get("metadata")
if isinstance(normalized_metadata, dict):
meta = {**normalized_metadata, **meta}
url = image_data.get("url") or image_data.get("imageUrl") or image_data.get("src") or ""
@@ -4,6 +4,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { html, normalizePathIdentity } from '../web/resolver/utils/html_utils.js';
import { getModelCardUrl } from '../web/resolver/utils/url_utils.js';
import { extractComfyWorkflow } from '../web/resolver/utils/workflow_metadata.js';
import {
buildModelResolverNodeMenu,
getImmediateModelsForNode,
@@ -63,6 +64,70 @@ const renderFormatMethodsSource = fs.readFileSync(
'utf8'
);
test('CivitAI Comfy workflow metadata is extracted as pasteable JSON', () => {
const workflow = {
last_node_id: 2,
nodes: [
{ id: 1, type: 'CheckpointLoaderSimple' },
{ id: 2, type: 'KSampler' },
],
links: [],
};
const result = extractComfyWorkflow({
metadata: {
comfy: JSON.stringify(workflow),
},
});
assert.equal(result.nodeCount, 2);
assert.deepEqual(JSON.parse(result.text), workflow);
});
test('CivitAI Comfy workflow metadata supports nested and double-encoded JSON', () => {
const workflow = {
nodes: [{ id: 7, type: 'SaveImage' }],
links: [],
};
const result = extractComfyWorkflow({
metadata: {
extra_pnginfo: JSON.stringify({
workflow: JSON.stringify(JSON.stringify(workflow)),
}),
},
});
assert.equal(result.nodeCount, 1);
assert.deepEqual(result.workflow, workflow);
});
test('CivitAI Comfy workflow metadata tolerates non-finite Python JSON numbers', () => {
const result = extractComfyWorkflow({
metadata: {
comfy: '{"prompt":{},"workflow":{"nodes":[{"id":1,"type":"KSampler","widgets_values":[NaN,"NaN",Infinity,-Infinity]}],"links":[]}}',
},
});
assert.equal(result.nodeCount, 1);
assert.deepEqual(result.workflow.nodes[0].widgets_values, [null, 'NaN', null, null]);
assert.deepEqual(JSON.parse(result.text), result.workflow);
});
test('API prompt metadata is not offered as a pasteable editor workflow', () => {
const result = extractComfyWorkflow({
metadata: {
comfy: {
1: {
class_type: 'KSampler',
inputs: {},
},
},
},
});
assert.equal(result, null);
});
test('node context menu includes only existing resolved models from the selected node scope', () => {
const data = {
resolved_models: [
+17
View File
@@ -83,6 +83,23 @@ class UnifiedHelpersTests(unittest.TestCase):
self.assertEqual(normalized["steps"], 25)
self.assertEqual(normalized["sampler"], "Euler a")
def test_normalize_model_image_preserves_metadata_when_normalized_again(self):
workflow = '{"workflow":{"nodes":[{"id":1,"type":"KSampler"}]}}'
normalized_image = {
"url": "https://image.civitai.com/x/width=1200/12345.jpeg",
"seed": 424242,
"metadata": {
"comfy": workflow,
"prompt": "portrait",
},
}
normalized_again = normalize_model_image(normalized_image)
self.assertEqual(normalized_again["metadata"]["comfy"], workflow)
self.assertEqual(normalized_again["positive"], "portrait")
self.assertEqual(normalized_again["seed"], 424242)
def test_normalize_model_image_civarchive_format(self):
raw_image = {
"imageUrl": "https://civarchive.com/img/9999.png",
+27
View File
@@ -5233,6 +5233,33 @@ a.mr-image-preview-resource-name:hover {
color: var(--mr-link-color, #8fc2ff);
}
.mr-image-preview-other-metadata {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 7px;
border-radius: 6px;
background: rgba(46, 144, 230, 0.14);
border: 1px solid rgba(80, 170, 255, 0.2);
color: #b9d9ff;
font-size: 11px;
font-weight: 800;
}
.mr-image-preview-workflow-copy {
min-height: 24px;
padding: 3px 7px;
border-color: rgba(80, 170, 255, 0.25);
background: rgba(46, 144, 230, 0.16);
color: #dceeff;
}
.mr-image-preview-workflow-copy:hover,
.mr-image-preview-workflow-copy.is-copied {
background: rgba(46, 144, 230, 0.3);
border-color: rgba(80, 170, 255, 0.48);
}
.mr-image-preview-card p {
margin: 0;
color: #b9bbc4;
+122
View File
@@ -0,0 +1,122 @@
const WORKFLOW_CONTAINER_KEYS = new Set([
'comfy',
'comfyui',
'extra_metadata',
'extra_pnginfo',
'workflow'
]);
function normalizeNonFiniteJsonNumbers(text) {
let normalized = '';
let inString = false;
let escaped = false;
const tokens = ['-Infinity', '+Infinity', 'Infinity', 'NaN'];
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (inString) {
normalized += character;
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}
if (character === '"') {
inString = true;
normalized += character;
continue;
}
const token = tokens.find(candidate => text.startsWith(candidate, index));
if (token) {
const previous = index > 0 ? text[index - 1] : '';
const next = text[index + token.length] || '';
const validPrevious = !previous || /[\s[{:;,]/.test(previous);
const validNext = !next || /[\s\]},;]/.test(next);
if (validPrevious && validNext) {
normalized += 'null';
index += token.length - 1;
continue;
}
}
normalized += character;
}
return normalized;
}
function parseJsonStrings(value) {
let parsed = value;
for (let attempt = 0; attempt < 3 && typeof parsed === 'string'; attempt += 1) {
const text = parsed.trim();
if (!text) return null;
try {
parsed = JSON.parse(text);
} catch {
const normalizedText = normalizeNonFiniteJsonNumbers(text);
if (normalizedText === text) return null;
try {
parsed = JSON.parse(normalizedText);
} catch {
return null;
}
}
}
return parsed;
}
function findWorkflow(value, seen, depth = 0) {
if (depth > 6) return null;
const parsed = parseJsonStrings(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
if (seen.has(parsed)) return null;
seen.add(parsed);
if (Array.isArray(parsed.nodes)) {
return parsed;
}
for (const [key, nestedValue] of Object.entries(parsed)) {
if (!WORKFLOW_CONTAINER_KEYS.has(String(key).toLowerCase())) continue;
const workflow = findWorkflow(nestedValue, seen, depth + 1);
if (workflow) return workflow;
}
return null;
}
/**
* Extract a pasteable ComfyUI editor workflow from CivitAI image metadata.
*/
export function extractComfyWorkflow(image = {}) {
const candidates = [
image.workflow,
image.comfy,
image.meta,
image.metadata,
image.extra_metadata,
image.extra_pnginfo
];
for (const candidate of candidates) {
const workflow = findWorkflow(candidate, new WeakSet());
if (!workflow) continue;
return {
workflow,
nodeCount: workflow.nodes.length,
text: JSON.stringify(workflow)
};
}
return null;
}
+20 -1
View File
@@ -4,6 +4,7 @@ import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
import { escapeHtml, escapeJsString, getFilenameFromPath, sanitizeDescriptionHtml, pollBackgroundTask, safeStorage, copyTextWithFeedback } from "../utils/html_utils.js";
import { getModelCardUrl } from "../utils/url_utils.js";
import { extractComfyWorkflow } from "../utils/workflow_metadata.js";
import { getCivitaiModelUrl } from "../globals.js";
export const modelInfoMethods = {
escapeHtml,
@@ -2417,6 +2418,7 @@ export const modelInfoMethods = {
const image = images[index] || {};
const metadataRows = this.getInfoImageMetadataRows(image);
const resources = this.getInfoImageResources(image);
const comfyWorkflow = extractComfyWorkflow(image);
const hasPrevious = images.length > 1;
const positionText = images.length > 1 ? `${index + 1} / ${images.length}` : '1 image';
@@ -2484,6 +2486,20 @@ export const modelInfoMethods = {
<div class="mr-image-preview-resources">${resourcesHtml}</div>
</section>
` : ''}
${comfyWorkflow ? `
<section class="mr-image-preview-card">
<div class="mr-image-preview-card-head">
<h4>Other metadata</h4>
</div>
<div class="mr-image-preview-other-metadata">
<span>comfy:</span>
<button type="button" class="mr-image-preview-copy mr-image-preview-workflow-copy" data-copy-key="workflow">
${this.escapeHtml(comfyWorkflow.nodeCount)} ${comfyWorkflow.nodeCount === 1 ? 'Node' : 'Nodes'}
${getSvgIcon('copy')}
</button>
</div>
</section>
` : ''}
${this.renderInfoImagePreviewPrompt('Prompt', image.positive, 'positive')}
${this.renderInfoImagePreviewPrompt('Negative prompt', image.negative, 'negative')}
</aside>
@@ -2562,7 +2578,10 @@ export const modelInfoMethods = {
const key = button?.dataset?.copyKey || '';
const metadataRows = this.getInfoImageMetadataRows(image);
const resources = this.getInfoImageResources(image);
const text = key === 'positive'
const comfyWorkflow = extractComfyWorkflow(image);
const text = key === 'workflow'
? comfyWorkflow?.text
: key === 'positive'
? image.positive
: key === 'negative'
? image.negative