Files
Iris McKinnon abdec4b885 metadata: add 'undefined' as proper enum member for tonal_emphasis
Jeff's call: no x: escape hatch, 'undefined' as first-class enum
member, prompt forge skips rather than rendering.

Changes:
- node_config.py: add 'undefined' to WOF_TONAL_EMPHASIS_VALUES,
  WOF_TONAL_EMPHASIS_MAP, and set as WOF_TONAL_EMPHASIS_DEFAULT
- wof_print_lab.py: early return with null intent when 'undefined'
  — downstream nodes see None curve/zone/dev and know to skip
- prompt_lab.py: return empty prompts + '_SKIPPED' filename when
  tonal_emphasis is 'undefined' — forge pipeline skips cleanly

Closes Q5 (freeform x: hatch rejected). Schema hygiene: 'undefined'
means unevaluated, not a fallback to neutral.
2026-06-05 07:52:41 -05:00

336 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'''
ComfyUI Nodes for Film Simulation and Photographic Principles
-------------------------------------------------------------
Copyright (C) 2026 Albert J. Burton
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
import json
import os
from server import PromptServer
from aiohttp import web
CURRENT_DIR = os.path.dirname(os.path.realpath(__file__))
IMAGES_DIR = os.path.join(CURRENT_DIR, "images")
CONFIG_DIR = os.path.join(CURRENT_DIR, "config")
GLSL_DIR = os.path.join(CURRENT_DIR, "glsl")
FILM_PROMPT_SDXL_PATH = os.path.join(CONFIG_DIR, "film_stock_prompt_v4.sdxl.json")
FILM_PROMPT_FLUX_PATH = os.path.join(CONFIG_DIR, "film_stock_prompt_v4.flux.json")
FILM_STOCK_JSON_PATH = os.path.join(CONFIG_DIR, "film_stocks_v2.json")
FILM_FORMAT_JSON_PATH = os.path.join(CONFIG_DIR, "film_formats.json")
FILTER_JSON_PATH = os.path.join(CONFIG_DIR, "wratten_filters.json")
ILLUMINANT_JSON_PATH = os.path.join(CONFIG_DIR, "illuminants.json")
CONTRAST_FILTER_JSON_PATH = os.path.join(CONFIG_DIR, "contrast_filters.json")
PAPER_JSON_PATH = os.path.join(CONFIG_DIR, "papers.json")
GRAYSCALE_JSON_PATH = os.path.join(CONFIG_DIR, "grayscale.json")
CAMERA_JSON_PATH = os.path.join(CONFIG_DIR, "cameras.json")
def _load_json(path, default=None):
"""Safely load a JSON file, returning default (empty dict) on failure."""
try:
with open(path, 'r') as f:
return json.load(f)
except FileNotFoundError:
print(f"[comfyui-jbnodes] WARNING: Config file not found: {path}")
except json.JSONDecodeError as e:
print(f"[comfyui-jbnodes] ERROR: Malformed JSON in {path}: {e}")
except Exception as e:
print(f"[comfyui-jbnodes] ERROR: Failed to load {path}: {e}")
return default if default is not None else {}
FILM_STOCK_DATA = _load_json(FILM_STOCK_JSON_PATH, {"film_stocks": []})
FILM_PROMPT_SDXL_DATA = _load_json(FILM_PROMPT_SDXL_PATH, {"decks": []})
FILM_PROMPT_FLUX_DATA = _load_json(FILM_PROMPT_FLUX_PATH, {"decks": []})
# Backward-compatible alias for unchanged API routes — defaults to FLUX (text-based)
FILM_PROMPT_DATA = FILM_PROMPT_FLUX_DATA
FORMAT_DATA = _load_json(FILM_FORMAT_JSON_PATH, {"film_formats": []})
FILTER_DATA = _load_json(FILTER_JSON_PATH, {"filters": []})
ILLUMINANT_DATA = _load_json(ILLUMINANT_JSON_PATH, {"illuminants": []})
CONTRAST_FILTER_DATA = _load_json(CONTRAST_FILTER_JSON_PATH, {"filters": []})
PAPER_DATA = _load_json(PAPER_JSON_PATH, {"graded_papers": []})
CAMERA_DATA = _load_json(CAMERA_JSON_PATH, {"cameras": []})
GRAYSCALE_DATA = _load_json(GRAYSCALE_JSON_PATH, {"grayscale": []})
# H&D Curves API route
@PromptServer.instance.routes.get("/jbnodes/hd_curves")
async def get_hd_curves(request):
"""Returns a list of HD curve names for a given film stock name."""
stock_name = request.rel_url.query.get("stock_name", "")
curves = ["None"] # default
for stock in FILM_STOCK_DATA.get("film_stocks", []):
if stock.get("name") == stock_name:
hd_curves = stock.get("hd_curves", [])
if hd_curves:
# Format names as "Developer @ Time mins (Temp C)"
for dev_group in hd_curves:
dev_name = dev_group.get("developer", "Generic Developer")
for c in dev_group.get("curves", []):
curves.append(f"{dev_name} ({c['time']}m at {c['temp']}C)")
break
return web.json_response(curves)
# Film Format -> Latent Image Size API route
@PromptServer.instance.routes.get("/jbnodes/latent_sizes")
async def get_latent_sizes(request):
"""Returns a list of latent sizes for a given film format by name."""
film_format_name = request.rel_url.query.get("film_format", "")
film_format_id = FILM_FORMAT_NAME_TO_ID.get(film_format_name, film_format_name)
sizes = [] # default
for format in FORMAT_DATA.get("film_formats", []):
if format.get("id") == film_format_id:
sizes = format.get("latent_sizes", [])
if sizes:
sizes = [f"{ls['name']}" for ls in sizes]
break
return web.json_response(sizes)
# Film Formats -> Cameras API route
@PromptServer.instance.routes.get("/jbnodes/cameras")
async def get_cameras(request):
"""Returns a list of cameras for a given film format by name."""
film_format_name = request.rel_url.query.get("film_format", "")
film_format_id = FILM_FORMAT_NAME_TO_ID.get(film_format_name, film_format_name)
cameras = []
for camera in CAMERA_DATA.get("cameras", []):
if camera.get("film_format") == film_format_id or camera.get("film_format") == film_format_name:
cameras.append(camera.get("name"))
return web.json_response(cameras)
# Film Formats -> Film API route
@PromptServer.instance.routes.get("/jbnodes/film_stocks")
async def get_film_stocks(request):
"""Returns a list of film stocks for a given film format by name."""
film_format_name = request.rel_url.query.get("film_format", "")
film_format_id = FILM_FORMAT_NAME_TO_ID.get(film_format_name, film_format_name)
films = []
for stock in FILM_STOCK_DATA.get("film_stocks", []):
formats = stock.get("film_formats", [])
if film_format_id in formats or film_format_name in formats:
films.append(stock.get("name"))
return web.json_response(films)
# Prompt Deck -> Sets API route
@PromptServer.instance.routes.get("/jbnodes/deck_sets")
async def get_deck_sets(request):
"""Returns a list of sets for a given prompt deck by name and prompt mode."""
deck_name = request.rel_url.query.get("deck_name", "")
prompt_mode = request.rel_url.query.get("prompt_mode", "text")
sets = []
# Select the correct data source based on prompt mode
if prompt_mode == "sdxl":
data = FILM_PROMPT_SDXL_DATA
else:
data = FILM_PROMPT_FLUX_DATA
for deck in data.get("decks", []):
if deck.get("display_name") == deck_name:
sets = deck.get("sets", [])
if sets:
sets = [f"{s['name']}" for s in sets]
break
return web.json_response(sets)
# Create mappings
FILM_STOCK_MAP = {}
FILM_STOCK_NAMES = []
for stock in FILM_STOCK_DATA["film_stocks"]:
FILM_STOCK_MAP[stock["name"]] = stock
FILM_STOCK_NAMES.append(stock["name"])
FILM_PROMPT_DECK_MAP = {}
FILM_PROMPT_DECK_NAMES = []
for deck in FILM_PROMPT_DATA["decks"]:
FILM_PROMPT_DECK_MAP[deck["display_name"]] = deck
FILM_PROMPT_DECK_NAMES.append(deck["display_name"])
# Dual-mode prompt maps: SDXL (tag-based) and FLUX (text-based)
FILM_PROMPT_SDXL_DECK_MAP = {}
FILM_PROMPT_SDXL_DECK_NAMES = []
for deck in FILM_PROMPT_SDXL_DATA["decks"]:
FILM_PROMPT_SDXL_DECK_MAP[deck["display_name"]] = deck
FILM_PROMPT_SDXL_DECK_NAMES.append(deck["display_name"])
FILM_PROMPT_FLUX_DECK_MAP = {}
FILM_PROMPT_FLUX_DECK_NAMES = []
for deck in FILM_PROMPT_FLUX_DATA["decks"]:
FILM_PROMPT_FLUX_DECK_MAP[deck["display_name"]] = deck
FILM_PROMPT_FLUX_DECK_NAMES.append(deck["display_name"])
# Mode labels used in the node's dropdown
FILM_PROMPT_MODE_LABELS = ["Text-based prompts", "Tag-based prompts"]
FILM_PROMPT_MODE_MAP = {
"Text-based prompts": "flux",
"Tag-based prompts": "sdxl",
}
FILM_PROMPT_MODE_DECK_MAP = {
"flux": FILM_PROMPT_FLUX_DECK_MAP,
"sdxl": FILM_PROMPT_SDXL_DECK_MAP,
}
def get_prompt_deck(mode, deck_name):
"""
Get a deck dict for the given prompt mode and deck name.
mode: 'flux' or 'sdxl'
deck_name: the display_name of the deck
"""
mode_key = FILM_PROMPT_MODE_MAP.get(mode, "flux")
deck_map = FILM_PROMPT_MODE_DECK_MAP.get(mode_key, FILM_PROMPT_FLUX_DECK_MAP)
return deck_map.get(deck_name, {})
FILM_FORMAT_MAP = {}
FILM_FORMAT_NAMES = []
FILM_FORMAT_NAME_TO_ID = {}
for film_size in FORMAT_DATA["film_formats"]:
FILM_FORMAT_MAP[film_size["id"]] = film_size
FILM_FORMAT_NAMES.append(film_size["name"])
FILM_FORMAT_NAME_TO_ID[film_size["name"]] = film_size["id"]
GRAYSCALE_MAP = {}
GRAYSCALE_NAMES = []
for grayscale in GRAYSCALE_DATA["grayscale"]:
GRAYSCALE_MAP[grayscale["name"]] = grayscale
GRAYSCALE_NAMES.append(grayscale["name"])
FILTER_MAP = {}
FILTER_NAMES = []
FILTER_MAP["None"] = None
FILTER_NAMES.append("None")
for filter in FILTER_DATA["filters"]:
FILTER_MAP[filter["name"]] = filter
FILTER_NAMES.append(filter["name"])
BW_FILTER_MAP = {}
BW_FILTER_NAMES = []
BW_FILTER_MAP["None"] = None
BW_FILTER_NAMES.append("None")
for filter in FILTER_DATA["filters"]:
if filter.get("id") in [8, 11, 15, 25, 38, 66]:
name = filter["name"].split("/")[-1].strip()
BW_FILTER_MAP[name] = filter
BW_FILTER_NAMES.append(name)
ILLUMINANT_MAP = {}
ILLUMINANT_NAMES = []
ILLUMINANT_LABEL_TO_KEY = {}
for illuminant in ILLUMINANT_DATA["illuminants"]:
ILLUMINANT_MAP[illuminant["key"]] = illuminant
ILLUMINANT_NAMES.append(illuminant["label"])
ILLUMINANT_LABEL_TO_KEY[illuminant["label"]] = illuminant["key"]
CONTRAST_FILTER_MAP = {}
CONTRAST_FILTER_NAMES = []
for filter in CONTRAST_FILTER_DATA["filters"]:
CONTRAST_FILTER_MAP[filter["label"]] = filter
CONTRAST_FILTER_NAMES.append(filter["label"])
GRADED_PAPER_MAP = {}
GRADED_PAPER_NAMES = []
for paper in PAPER_DATA["graded_papers"]:
GRADED_PAPER_MAP[paper["name"]] = paper
GRADED_PAPER_NAMES.append(paper["name"])
CAMERA_MAP = {}
CAMERA_NAMES = []
for camera in CAMERA_DATA["cameras"]:
CAMERA_MAP[camera["name"]] = camera
CAMERA_NAMES.append(camera["name"])
# ──────────────────────────────────────────────
# WOF Schema Integration — Post-Processing Node
# ──────────────────────────────────────────────
# Source: WOF Metadata Gap Summary & WOF_PostProcessing_Schema_v1.json
# These constants are used by nodes/wof_print_lab.py and downstream darkroom nodes.
WOF_TONAL_EMPHASIS_VALUES = [
"undefined",
"neutral",
"full_scale",
"high_key_soft",
"high_key_contrast",
"low_key",
"crushed_blacks",
"zone_system_iii_viii",
"highlight_detail_viii",
"shadow_detail_iii",
"compressed_midtones",
"solarized",
]
WOF_TONAL_EMPHASIS_MAP = {
"undefined": {"curve": "none", "zone_anchor": "N/A (unevaluated)", "default_dev": "N/A"},
"neutral": {"curve": "straight", "zone_anchor": "none", "default_dev": "Dektol 1:2"},
"full_scale": {"curve": "stretch", "zone_anchor": "II–IX", "default_dev": "Dektol 1:2"},
"high_key_soft": {"curve": "soft_shoulder", "zone_anchor": "VIII (highlight)", "default_dev": "Dektol 1:3"},
"high_key_contrast": {"curve": "crisp_midtones", "zone_anchor": "VIII (highlight)", "default_dev": "Dektol 1:2"},
"low_key": {"curve": "narrow_highlight", "zone_anchor": "0–III (shadow mass)", "default_dev": "Dektol 1:2"},
"crushed_blacks": {"curve": "hard_clip", "zone_anchor": "0 (hard clip)", "default_dev": "Dektol 1:2"},
"zone_system_iii_viii": {"curve": "standard_zone", "zone_anchor": "III (shadow), VIII (hl)", "default_dev": "D-76 + Dektol"},
"highlight_detail_viii": {"curve": "rolled_shoulder", "zone_anchor": "VIII (critical hl)", "default_dev": "Dektol 1:3"},
"shadow_detail_iii": {"curve": "extended_toe", "zone_anchor": "III (critical shadow)", "default_dev": "Dektol 1:1"},
"compressed_midtones": {"curve": "flattened_mid", "zone_anchor": "V (held constant)", "default_dev": "Dektol 1:2"},
"solarized": {"curve": "reversal", "zone_anchor": "N/A (post-process)", "default_dev": "N/A"},
}
WOF_TONAL_EMPHASIS_DEFAULT = "undefined"
WOF_CONTRAST_CLASS_VALUES = [
"grade_00",
"grade_0",
"grade_1",
"grade_2",
"grade_3",
"grade_4",
"grade_5",
]
WOF_CONTRAST_CLASS_MAP = {
"grade_00": {"grade": 0.0, "label": "Ultra-Flat — diffused, compressed range"},
"grade_0": {"grade": 0.5, "label": "Flat — extended scale, low contrast"},
"grade_1": {"grade": 1.0, "label": "Soft — gentle separation"},
"grade_2": {"grade": 2.0, "label": "Normal — standard paper contrast"},
"grade_3": {"grade": 3.0, "label": "Contrasty — increased snap"},
"grade_4": {"grade": 4.0, "label": "Hard — aggressive separation"},
"grade_5": {"grade": 5.0, "label": "Ultra-Hard — near-binary"},
}
WOF_CONTRAST_CLASS_DEFAULT = "grade_2"
WOF_PAPER_STOCK_DEFAULT = "ilford_galerie_fb_matt"
WOF_PRINT_FINISH_VALUES = [
"straight",
"selenium_light",
"selenium_heavy",
"warmtone",
"cooltone",
"lith",
]