Skip to content

Commit de1c1ee

Browse files
fix: add support for parsing python generated json with NaN/infinite (#12217)
## Summary API and other legacy JSON generated by python `json.dumps` can contain `NaN` and `Infinity` which cannot be parsed with JS `JSON.parse`. This adds regex to replace these invalid tokens with `null`. ## Changes - **What**: - add regex replace on bare NaN/infinity tokens after JSON.parse fails - update call sites - tests ## Review Focus - The regex should only rewrite bare NaN/-Infinity/Infinity and not touch string values or other invalid tokens. - A small regex was chosen over JSON5 due to package size (30.3kB Minified, 9kB Minified + Gzipped) or a manual parser due to the unnecessarily complexity vs a single regex replace. - The happy path is run first, the safe parse is only executed if that failed, meaning no overhead the vast majority of the time and no possiblity of corrupting valid workflows due to a bug in the fallback parser - Multiple call sites had to be updated due to pre-existing architecture of the various parsers, an issue for unifying these is logged for future cleanup - New binary fixtures added for validating e2e import using real files ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12217-fix-add-support-for-parsing-python-generated-json-with-NaN-infinite-35f6d73d365081889fc7f4af823f29c1) by [Unito](https://www.unito.io)
1 parent 86b1e1a commit de1c1ee

32 files changed

Lines changed: 692 additions & 42 deletions

browser_tests/tests/metadataWorkflowImport.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ const FIXTURES: readonly MetadataFixture[] = [
2525
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
2626
] as const
2727

28+
// NaN-variant fixtures embed only an API-format prompt containing bare
29+
// `NaN`/`Infinity` tokens (Python's `json.dumps` default). The loader must
30+
// tolerate Python generated JSON for these to import successfully.
31+
const NAN_FIXTURES: readonly MetadataFixture[] = [
32+
{ fileName: 'with_nan_metadata.json', parser: 'json' },
33+
{ fileName: 'with_nan_metadata.png', parser: 'png' },
34+
{ fileName: 'with_nan_metadata.avif', parser: 'avif' },
35+
{ fileName: 'with_nan_metadata.webp', parser: 'webp' },
36+
{ fileName: 'with_nan_metadata.flac', parser: 'flac' },
37+
{ fileName: 'with_nan_metadata.mp3', parser: 'mp3' },
38+
{ fileName: 'with_nan_metadata.opus', parser: 'ogg' },
39+
{ fileName: 'with_nan_metadata.mp4', parser: 'isobmff' },
40+
{ fileName: 'with_nan_metadata.webm', parser: 'ebml (webm)' }
41+
] as const
42+
2843
test.describe(
2944
'Metadata drop-to-load workflow import',
3045
{ tag: ['@workflow'] },
@@ -58,5 +73,42 @@ test.describe(
5873
})
5974
})
6075
}
76+
77+
for (const { fileName, parser } of NAN_FIXTURES) {
78+
test(`loads Python JSON prompt with NaN/Infinity from ${fileName} (${parser})`, async ({
79+
comfyPage
80+
}) => {
81+
await test.step(`drop ${fileName} on canvas`, async () => {
82+
await comfyPage.dragDrop.dragAndDropFilePath(
83+
metadataFixturePath(fileName)
84+
)
85+
})
86+
87+
await test.step('graph contains only the embedded KSampler', async () => {
88+
await expect
89+
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
90+
.toBe(1)
91+
92+
const ksamplers =
93+
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
94+
expect(
95+
ksamplers,
96+
'exactly one KSampler should have been loaded from the NaN-laden prompt'
97+
).toHaveLength(1)
98+
})
99+
100+
await test.step('NaN-coerced widget values are 0', async () => {
101+
const [ksampler] =
102+
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
103+
for (const widgetName of ['cfg', 'denoise']) {
104+
const widget = await ksampler.getWidgetByName(widgetName)
105+
expect(
106+
await widget.getValue(),
107+
`${widgetName} should be 0 after NaN coercion to null`
108+
).toBe(0)
109+
}
110+
})
111+
})
112+
}
61113
}
62114
)

scripts/generate-embedded-metadata-test-files.py

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,19 @@
3636
}
3737
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
3838

39+
# API-format prompt with bare NaN/Infinity tokens (as Python's json.dumps emits
40+
# by default). The NaN variant fixtures omit the workflow field so the loader
41+
# must route through prompt-parsing, which trips JSON.parse on bare NaN.
42+
PROMPT_NAN = {
43+
'1': {
44+
'class_type': 'KSampler',
45+
'inputs': {'cfg': float('nan'), 'denoise': float('inf')},
46+
}
47+
}
48+
3949
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
4050
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
51+
PROMPT_NAN_JSON = json.dumps(PROMPT_NAN, separators=(',', ':'))
4152

4253

4354
def out(name: str) -> str:
@@ -53,15 +64,21 @@ def make_1x1_image() -> Image.Image:
5364
return Image.new('RGB', (1, 1), (255, 0, 0))
5465

5566

56-
def build_exif_bytes() -> bytes:
67+
def build_exif_bytes(
68+
workflow_str: str | None = WORKFLOW_JSON,
69+
prompt_str: str | None = PROMPT_JSON,
70+
) -> bytes:
5771
"""Build EXIF bytes matching the backend's tag assignments.
5872
5973
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
74+
Pass ``None`` to omit a tag.
6075
"""
6176
img = make_1x1_image()
6277
exif = img.getexif()
63-
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
64-
exif[0x0110] = f'prompt:{PROMPT_JSON}'
78+
if workflow_str is not None:
79+
exif[0x010F] = f'workflow:{workflow_str}'
80+
if prompt_str is not None:
81+
exif[0x0110] = f'prompt:{prompt_str}'
6582
return exif.tobytes()
6683

6784

@@ -93,15 +110,20 @@ def generate_av_fixture(
93110
codec: str,
94111
rate: int = 44100,
95112
options: dict | None = None,
113+
*,
114+
prompt_json: str | None = PROMPT_JSON,
115+
workflow_json: str | None = WORKFLOW_JSON,
96116
):
97117
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
98118
path = out(name)
99119
container = av.open(path, mode='w', format=fmt, options=options or {})
100120
stream = container.add_stream(codec, rate=rate)
101121
stream.layout = 'mono'
102122

103-
container.metadata['prompt'] = PROMPT_JSON
104-
container.metadata['workflow'] = WORKFLOW_JSON
123+
if prompt_json is not None:
124+
container.metadata['prompt'] = prompt_json
125+
if workflow_json is not None:
126+
container.metadata['workflow'] = workflow_json
105127

106128
sample_fmt = stream.codec_context.codec.audio_formats[0].name
107129
samples = stream.codec_context.frame_size or 1024
@@ -175,6 +197,63 @@ def generate_webm():
175197
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
176198

177199

200+
def generate_nan_variants():
201+
"""Per-format fixtures carrying ONLY a NaN/Infinity-laden API prompt.
202+
203+
These force the loader through the prompt-parsing path, where Python's
204+
bare NaN/Infinity tokens trip JSON.parse.
205+
"""
206+
img = make_1x1_image()
207+
info = PngInfo()
208+
info.add_text('prompt', PROMPT_NAN_JSON)
209+
img.save(out('with_nan_metadata.png'), 'PNG', pnginfo=info)
210+
report('with_nan_metadata.png')
211+
212+
exif_nan = build_exif_bytes(workflow_str=None, prompt_str=PROMPT_NAN_JSON)
213+
214+
img = make_1x1_image()
215+
img.save(out('with_nan_metadata.webp'), 'WEBP', exif=exif_nan)
216+
report('with_nan_metadata.webp')
217+
218+
img = make_1x1_image()
219+
img.save(out('with_nan_metadata.avif'), 'AVIF', exif=exif_nan)
220+
report('with_nan_metadata.avif')
221+
222+
generate_av_fixture(
223+
'with_nan_metadata.flac', 'flac', 'flac',
224+
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
225+
)
226+
generate_av_fixture(
227+
'with_nan_metadata.opus', 'opus', 'libopus', rate=48000,
228+
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
229+
)
230+
generate_av_fixture(
231+
'with_nan_metadata.mp3', 'mp3', 'libmp3lame',
232+
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
233+
)
234+
generate_av_fixture(
235+
'with_nan_metadata.webm', 'webm', 'libvorbis',
236+
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
237+
)
238+
239+
path = out('with_nan_metadata.mp4')
240+
subprocess.run([
241+
'ffmpeg', '-y', '-loglevel', 'error',
242+
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
243+
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
244+
'-movflags', 'use_metadata_tags',
245+
'-metadata', f'prompt={PROMPT_NAN_JSON}',
246+
path,
247+
], check=True)
248+
report('with_nan_metadata.mp4')
249+
250+
# Direct JSON file containing API-format prompt with bare NaN/Infinity.
251+
json_path = out('with_nan_metadata.json')
252+
with open(json_path, 'w', encoding='utf-8') as f:
253+
f.write(PROMPT_NAN_JSON)
254+
report('with_nan_metadata.json')
255+
256+
178257
if __name__ == '__main__':
179258
print('Generating fixtures...')
180259
generate_png()
@@ -185,4 +264,5 @@ def generate_webm():
185264
generate_mp3()
186265
generate_mp4()
187266
generate_webm()
267+
generate_nan_variants()
188268
print('Done.')

src/platform/workflow/utils/workflowExtractionUtil.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataS
99
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
1010
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
1111
import { getJobWorkflow } from '@/services/jobOutputCache'
12+
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
1213

1314
/**
1415
* Extract workflow from AssetItem using jobs API
@@ -51,11 +52,11 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
5152
// Handle both string and object workflow data
5253
const workflow =
5354
typeof workflowData.workflow === 'string'
54-
? JSON.parse(workflowData.workflow)
55-
: workflowData.workflow
55+
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflowData.workflow)
56+
: (workflowData.workflow as ComfyWorkflowJSON)
5657

5758
return {
58-
workflow: workflow as ComfyWorkflowJSON,
59+
workflow,
5960
filename: baseFilename
6061
}
6162
}

src/scripts/app.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
8484
import type { ExtensionManager } from '@/types/extensionTypes'
8585
import type { NodeExecutionId } from '@/types/nodeIdentification'
8686
import { graphToPrompt } from '@/utils/executionUtil'
87+
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
8788
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
8889
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
8990
import {
@@ -1091,7 +1092,7 @@ export class ComfyApp {
10911092
}
10921093

10931094
// Check for old clipboard format
1094-
const data = JSON.parse(template.data)
1095+
const data = parseJsonWithNonFinite<{ reroutes?: unknown }>(template.data)
10951096
if (!data.reroutes) {
10961097
deserialiseAndCreate(template.data, app.canvas)
10971098
} else {
@@ -1802,7 +1803,9 @@ export class ComfyApp {
18021803
let workflowObj: ComfyWorkflowJSON | undefined = undefined
18031804
try {
18041805
workflowObj =
1805-
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
1806+
typeof workflow === 'string'
1807+
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow)
1808+
: (workflow as ComfyWorkflowJSON)
18061809

18071810
// Only load workflow if parsing succeeded AND validation passed
18081811
if (
@@ -1831,7 +1834,9 @@ export class ComfyApp {
18311834
if (prompt) {
18321835
try {
18331836
const promptObj =
1834-
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
1837+
typeof prompt === 'string'
1838+
? parseJsonWithNonFinite<ComfyApiWorkflow>(prompt)
1839+
: prompt
18351840
if (this.isApiJson(promptObj)) {
18361841
this.loadApiJson(promptObj, fileName)
18371842
return

src/scripts/metadata/__fixtures__/helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export const EXPECTED_PROMPT = {
88
'1': { class_type: 'KSampler', inputs: {} }
99
}
1010

11+
// API prompt as parsed from the `with_nan_metadata.*` fixtures, after the
12+
// loader coerces bare NaN/Infinity tokens to null.
13+
export const EXPECTED_PROMPT_NAN_COERCED = {
14+
'1': { class_type: 'KSampler', inputs: { cfg: null, denoise: null } }
15+
}
16+
1117
type ReadMethod = 'readAsText' | 'readAsArrayBuffer'
1218

1319
export function mockFileReaderError(method: ReadMethod): void {
486 Bytes
Binary file not shown.
8.69 KB
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"1":{"class_type":"KSampler","inputs":{"cfg":NaN,"denoise":Infinity}}}
733 Bytes
Binary file not shown.
1011 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)