diff --git a/core/type_utils.py b/core/type_utils.py index 1d0dcca..b94fce9 100644 --- a/core/type_utils.py +++ b/core/type_utils.py @@ -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 "" diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index f0a409a..ac806cb 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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: [ diff --git a/tests/test_unified_helpers.py b/tests/test_unified_helpers.py index 8de956f..668e42a 100644 --- a/tests/test_unified_helpers.py +++ b/tests/test_unified_helpers.py @@ -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", diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 0e7308e..a6e939a 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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; diff --git a/web/resolver/utils/workflow_metadata.js b/web/resolver/utils/workflow_metadata.js new file mode 100644 index 0000000..65298a2 --- /dev/null +++ b/web/resolver/utils/workflow_metadata.js @@ -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; +} diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index c9b3c4d..615bf04 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -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 = {
${resourcesHtml}
` : ''} + ${comfyWorkflow ? ` +
+
+

Other metadata

+
+
+ comfy: + +
+
+ ` : ''} ${this.renderInfoImagePreviewPrompt('Prompt', image.positive, 'positive')} ${this.renderInfoImagePreviewPrompt('Negative prompt', image.negative, 'negative')} @@ -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