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.
336 lines
13 KiB
Python
336 lines
13 KiB
Python
'''
|
||
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",
|
||
]
|
||
|
||
|