ComfyUI output metadata
Every image/video/audio file ComfyUI emits embeds the API-form prompt and the UI-form workflow that produced it. The encoding varies by file format and by which save-node wrote it (core vs. kijai vs. VHS). This skill is the canonical reference for where the data lives, how to read it back, and a small Python toolkit to do it reliably across every format on disk in this install.
When to Use This Skill
| Use this skill when... | Use instead when... |
|---|---|
| Figuring out what prompt/settings produced an existing output file | Inspecting a live in-graph value during a run -> comfy-debug-preview |
| Scanning, organizing, or comparing a directory of outputs | Auto-arranging the layout of a workflow JSON -> comfy-workflow-layout |
Quick reference
| Format | Saved by | Where the JSON lives | Encoding |
|---|---|---|---|
| PNG still | core SaveImage, kijai PNG path |
tEXt chunks (PIL Image.info["prompt"] and ["workflow"]) |
each value is a JSON string |
| Animated PNG | core SaveAnimatedPNG |
iTXt chunks (same keys) |
each value is a JSON string |
| WebP still + animated | core SaveAnimatedWEBP |
EXIF tags: 0x0110 (Model) holds "prompt:<json>", 0x010F (Make) holds "workflow:<json>", lower tags hold further extra_pnginfo keys |
one EXIF string per key, "key:json" prefix |
| MP4 native | core SaveVideo |
Container metadata, separate keys: prompt, workflow, plus any extra |
each value is a JSON string |
MP4 kijai (WanVideoWrapper_*.mp4) |
WanVideoWrapper.save_video |
Container metadata, single key comment |
one JSON object: {"prompt": "<json>", "workflow": "<json>"} (double-encoded) |
| WebM / Matroska | core SaveVideo; kijai/MMAudio |
Container metadata: per-key (native) or single COMMENT (kijai) |
same patterns as MP4 |
| FLAC / OGG / MP3 / WAV | core SaveAudio |
Container metadata: prompt, extra_pnginfo* keys |
each value is a JSON string |
.latent |
core SaveLatent |
safetensors header metadata: prompt, workflow |
each value is a JSON string |
The library handles all of these uniformly. See REFERENCE.md for code
anchors, exact byte-level details, and edge cases (the _create_webp_metadata
EXIF tag walk, extra_pnginfo keys beyond workflow, the kijai
double-encoded comment format, fp8-scaled safetensors metadata, etc.).
Toolkit
scripts/comfy_meta.py is a single self-contained Python file. It works
as both a library and a CLI with four subcommands. It uses PIL (for
PNG/WebP), PyAV (for MP4/WebM/audio), and safetensors (for
.latent) — all already installed in .venv/.
Run via the project venv:
.venv/bin/python .claude/skills/comfy-metadata/scripts/comfy_meta.py <subcommand> ...
Library use (batch scripts)
For ad-hoc batch work — renaming, indexing, clustering — calling the CLI
once per file is slow. Import comfy_meta directly instead. It has no
package wrapper, so add its dir to sys.path first:
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(".claude/skills/comfy-metadata/scripts")))
import comfy_meta
for p in pathlib.Path("output").iterdir():
if not p.is_file():
continue
ex = comfy_meta.extract(p) # {"prompt": <api-dict>, "workflow": <ui-dict>}
prompt = ex.get("prompt")
if not isinstance(prompt, dict) or not prompt:
continue # no embedded metadata
summary = comfy_meta.summarize(prompt)
print(p.name, summary.sampler, summary.scheduler, summary.seed)
extract() returns parsed JSON for both halves; summarize() walks the
API prompt and yields a Summary dataclass. See
scripts/rename_outputs.py for a full example that builds new filenames
from summary.samplers[0] and the source file's mtime.
The UI workflow half is useful too
summarize() covers the API prompt, but extract()["workflow"] (the
UI form) carries data the summarizer doesn't surface — most usefully
save-node widgets. A workflow that wrote itself to a dedicated output
bucket (<bucket>/<date>/…) self-labels its outputs, so the prefix is a
free classification signal:
BUCKET = "<bucket>/" # whatever prefix your install sorts into
ex = comfy_meta.extract(p)
workflow = ex.get("workflow") or {}
for n in workflow.get("nodes", []) or []:
wv = n.get("widgets_values")
# SaveImage / SaveWEBM: list[0] is the filename_prefix
if isinstance(wv, list) and wv and isinstance(wv[0], str) and wv[0].startswith(BUCKET):
return BUCKET.rstrip("/")
# VHS_VideoCombine: dict["filename_prefix"]
if isinstance(wv, dict) and str(wv.get("filename_prefix", "")).startswith(BUCKET):
return BUCKET.rstrip("/")
rename_outputs.py's NSFW classifier combines this self-label signal
with API-prompt asset-name token matching (model / text-encoder / LoRA
names). The same approach works for any other categorisation the UI
workflow encodes that the API prompt strips out: node titles, custom
properties, group names, etc.
extract — dump the embedded JSON
# Both prompt + workflow as one JSON object on stdout
.venv/bin/python .../comfy_meta.py extract output/WanVideoWrapper_I2V_00001.png
# Just one half (suitable for piping to jq)
.venv/bin/python .../comfy_meta.py extract -k prompt path/to.png | jq .
.venv/bin/python .../comfy_meta.py extract -k workflow path/to.mp4 | jq '.nodes | length'
# Re-import a downloaded JPEG/MP4 back into ComfyUI by saving its workflow:
.venv/bin/python .../comfy_meta.py extract -k workflow some.mp4 > user/default/workflows/2026-05/recovered.json
summary — one-line, analysis-friendly settings
The summarizer walks the API-form prompt and pulls out the fields that actually matter for "what was different between run A and run B": model, text encoders, VAE, every sampler invocation (sampler/scheduler/steps/ cfg/denoise/seed), latent dims, num_frames, every LoRA + strength, and the positive/negative prompt text.
.venv/bin/python .../comfy_meta.py summary output/WanVideoWrapper_I2V_00001.mp4
Output is JSON; use -p for a human-readable two-column print instead.
scan — index a directory into JSONL
Walk a tree (recursively by default) and emit one JSON record per output file. Use this to build a queryable index of every render on disk.
.venv/bin/python .../comfy_meta.py scan output/ -o /tmp/runs.jsonl
# Then analyze with jq:
jq -r '[.path, .summary.steps, .summary.cfg, .summary.sampler] | @tsv' /tmp/runs.jsonl
# Group by sampler+steps+cfg to see what combinations were used:
jq -s 'group_by(.summary.sampler+"|"+(.summary.steps|tostring)+"|"+(.summary.cfg|tostring))
| map({key: .[0].summary | "\(.sampler) steps=\(.steps) cfg=\(.cfg)", count: length})' \
/tmp/runs.jsonl
Files without embedded metadata (e.g. phone photos in the same tree)
get {"path": "...", "error": "no metadata"} so the index still
covers everything.
diff — what changed between two runs
.venv/bin/python .../comfy_meta.py diff a.mp4 b.mp4
Prints a unified diff of the summarized settings. Useful when one of two near-identical workflows produced a better result and you want to see which knob actually moved.
What the summary captures
model: UNETLoader.unet_name / CheckpointLoaderSimple.ckpt_name
/ WanVideoModelLoader.model / Image-Edit's diffusion path
text_encoders [list]: CLIPLoader / DualCLIPLoader / TripleCLIPLoader
/ LoadWanVideoT5TextEncoder / TextEncoderLoaderHiDream …
vae: VAELoader.vae_name / WanVideoVAELoader.model_name
samplers [list]: every KSampler / KSamplerAdvanced / WanVideoSampler /
WanVideoSamplerv2 / SamplerCustomAdvanced — each with
{sampler, scheduler, steps, cfg, denoise, seed, start_step,
end_step, add_noise} as found
latent_dims: width × height from EmptyLatentImage / EmptySD3LatentImage /
EmptyMochiLatentVideo / WanVideoEmptyEmbeds / etc.
num_frames: from WanVideoEmptyEmbeds.num_frames / Empty*Video.length
loras [list]: every LoraLoader / LoraLoaderModelOnly / Power Lora Loader
entry — {name, model_strength, clip_strength}
shift: ModelSamplingAuraFlow / ModelSamplingSD3 shift values
positive [list], negative [list]: CLIPTextEncode-style text inputs, with
the upstream node's title as a hint when present
Heuristic, not exhaustive — but covers ~95% of the workflows on this
install. New node-types missing from the summarizer are still preserved
in the raw prompt half of extract; add them to comfy_meta.py's
SUMMARIZERS registry when a class becomes worth pulling out.
When the toolkit returns "no metadata"
A few cases that look like ComfyUI outputs but lack the JSON:
- ComfyUI launched with
--disable-metadata— the save nodes short-circuit before adding tEXt/EXIF/container tags. - Re-encoded with ffmpeg —
ffmpeg -i in.mp4 -c copy out.mp4does preserve container metadata;-c:v libx264 …(re-encode) typically drops it unless-map_metadata 0is passed. - Re-saved through an image editor (Affinity, Photoshop, GIMP) — most strip tEXt chunks and rewrite EXIF.
- Output from a frontend that bypasses save nodes (custom HTTP pipelines, Hugging Face Spaces wrapping ComfyUI, …).
For the second case, when you mv or cp files between dirs the
metadata is fine — the OS-level operations preserve byte content. Only
re-encoding strips it.
Privacy note
The embedded prompt JSON contains the full positive and negative
text prompts, the exact seed, file paths to LoRAs/checkpoints/VAEs
(which can leak local directory structure like
models/loras/lgates/private_face_v1.safetensors), and sometimes
authoring metadata in extra_pnginfo. Before sharing a ComfyUI output
file publicly, decide whether you want to ship the metadata with it.
To strip metadata in-place (lossless):
- PNG:
oxipng --strip safe file.png(keeps colorspace, strips text) - WebP: re-encode with
cwebp -metadata noneormagick convert in.webp -strip out.webp - MP4/WebM:
ffmpeg -i in.mp4 -c copy -map_metadata -1 out.mp4
Or set --disable-metadata on the ComfyUI server (in comfyui.service)
if you want all future outputs to be metadata-free — but the toolkit
becomes useless then.
Related skills
comfy-workflow-layout— once you've extracted a workflow withextract -k workflow, run it throughscripts/layout_workflow.pyto tidy node positions before importing.comfy-cli—comfy node install-deps <workflow.json>consumes a workflow JSON file; pipeextract -k workflowstraight into a temp file to install the missing custom nodes for an imported workflow.