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:
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user