354 lines
11 KiB
Python
354 lines
11 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Copyright 2025-2026 Raykosan (RaykoStudio)
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import os
|
|
import json
|
|
from PIL import Image
|
|
import folder_paths
|
|
from server import PromptServer
|
|
from aiohttp import web
|
|
|
|
WEB_DIRECTORY = "./web"
|
|
|
|
KNOWN_PROMPT_NODES = {"CLIPTextEncode", "RSPrompts", "RS_ImagePrompt", "PromptStashPassthrough"}
|
|
TEXT_KEYS = ["prompt_text", "text", "string", "parameters", "Comment", "Description", "Title"]
|
|
|
|
|
|
def get_image_files():
|
|
input_dir = folder_paths.get_input_directory()
|
|
files = []
|
|
for f in os.listdir(input_dir):
|
|
if os.path.isfile(os.path.join(input_dir, f)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
|
|
files.append(f)
|
|
files.sort()
|
|
return files
|
|
|
|
def _get_text_from_node(prompt_data, node_id, visited):
|
|
node_id = str(node_id)
|
|
if node_id in visited or node_id not in prompt_data:
|
|
return ""
|
|
visited.add(node_id)
|
|
|
|
node = prompt_data[node_id]
|
|
inputs = node.get("inputs", {})
|
|
class_type = node.get("class_type", "")
|
|
|
|
if inputs.get("enable_text_input"):
|
|
ti = inputs.get("text_input")
|
|
if isinstance(ti, list) and len(ti) >= 1:
|
|
upstream = _get_text_from_node(prompt_data, ti[0], visited)
|
|
if upstream:
|
|
return upstream
|
|
|
|
text = inputs.get("text")
|
|
if isinstance(text, str) and text:
|
|
return text
|
|
|
|
if class_type == "RS_ImagePrompt":
|
|
prompt_preview = inputs.get("prompt_preview")
|
|
if isinstance(prompt_preview, str) and prompt_preview:
|
|
return prompt_preview
|
|
|
|
if class_type == "PromptStashPassthrough":
|
|
if not inputs.get("use_input_text", False):
|
|
prompt_text = inputs.get("prompt_text")
|
|
if isinstance(prompt_text, str) and prompt_text:
|
|
return prompt_text
|
|
|
|
for key in ("conditioning", "conditioning_1", "conditioning_2", "clip", "text_input", "text"):
|
|
ref = inputs.get(key)
|
|
if isinstance(ref, list) and len(ref) >= 1:
|
|
result = _get_text_from_node(prompt_data, ref[0], visited)
|
|
if result:
|
|
return result
|
|
return ""
|
|
|
|
|
|
def _extract_prompt_from_dict(prompt_data):
|
|
if not isinstance(prompt_data, dict):
|
|
return ""
|
|
|
|
negative_refs = set()
|
|
for node_data in prompt_data.values():
|
|
if not isinstance(node_data, dict):
|
|
continue
|
|
ref = node_data.get("inputs", {}).get("negative")
|
|
if isinstance(ref, list) and len(ref) >= 1:
|
|
negative_refs.add((str(ref[0]), ref[1] if len(ref) > 1 else 0))
|
|
|
|
for node_id, node_data in prompt_data.items():
|
|
if not isinstance(node_data, dict):
|
|
continue
|
|
inputs = node_data.get("inputs", {})
|
|
if "positive" in inputs:
|
|
pos_ref = inputs.get("positive")
|
|
if isinstance(pos_ref, list) and pos_ref:
|
|
text = _get_text_from_node(prompt_data, pos_ref[0], set())
|
|
if text:
|
|
return text
|
|
|
|
for node_id, node_data in prompt_data.items():
|
|
if not isinstance(node_data, dict):
|
|
continue
|
|
if node_data.get("class_type", "") in KNOWN_PROMPT_NODES:
|
|
if (str(node_id), 0) in negative_refs:
|
|
continue
|
|
inputs = node_data.get("inputs", {})
|
|
|
|
text = inputs.get("text")
|
|
if isinstance(text, str) and text:
|
|
return text
|
|
|
|
prompt_preview = inputs.get("prompt_preview")
|
|
if isinstance(prompt_preview, str) and prompt_preview:
|
|
return prompt_preview
|
|
|
|
if node_data.get("class_type", "") == "PromptStashPassthrough":
|
|
if not inputs.get("use_input_text", False):
|
|
prompt_text = inputs.get("prompt_text")
|
|
if isinstance(prompt_text, str) and prompt_text:
|
|
return prompt_text
|
|
|
|
return ""
|
|
|
|
|
|
def _extract_prompt(prompt_json_str):
|
|
try:
|
|
data = json.loads(prompt_json_str)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return ""
|
|
return _extract_prompt_from_dict(data)
|
|
|
|
def _extract_prompt_from_workflow(workflow_json_str):
|
|
try:
|
|
wf = json.loads(workflow_json_str)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return ""
|
|
if not isinstance(wf, dict):
|
|
return ""
|
|
|
|
nodes = wf.get("nodes", [])
|
|
links = wf.get("links", [])
|
|
if not isinstance(nodes, list):
|
|
return ""
|
|
|
|
nodes_by_id = {}
|
|
for n in nodes:
|
|
if isinstance(n, dict) and "id" in n:
|
|
nodes_by_id[str(n["id"])] = n
|
|
|
|
link_by_target = {}
|
|
for lk in links:
|
|
if isinstance(lk, list) and len(lk) >= 5:
|
|
link_by_target[(str(lk[3]), lk[4])] = (str(lk[1]), lk[2])
|
|
|
|
def widget_text(node):
|
|
wv = node.get("widgets_values", [])
|
|
if isinstance(wv, list) and wv and isinstance(wv[0], str):
|
|
return wv[0]
|
|
return ""
|
|
|
|
def trace(node_id, slot_index, visited):
|
|
key = (node_id, slot_index)
|
|
if key in visited:
|
|
return ""
|
|
visited.add(key)
|
|
src = link_by_target.get(key)
|
|
if not src:
|
|
return ""
|
|
src_id, _ = src
|
|
src_node = nodes_by_id.get(src_id)
|
|
if not src_node:
|
|
return ""
|
|
if src_node.get("type", "") in KNOWN_PROMPT_NODES:
|
|
t = widget_text(src_node)
|
|
if t:
|
|
return t
|
|
for idx in range(len(src_node.get("inputs", []))):
|
|
res = trace(src_id, idx, visited)
|
|
if res:
|
|
return res
|
|
return ""
|
|
|
|
for nid, node in nodes_by_id.items():
|
|
ntype = str(node.get("type", ""))
|
|
if "ampler" in ntype:
|
|
for idx, inp in enumerate(node.get("inputs", [])):
|
|
if isinstance(inp, dict) and inp.get("name") == "positive":
|
|
t = trace(nid, idx, set())
|
|
if t:
|
|
return t
|
|
|
|
for nid, node in nodes_by_id.items():
|
|
if node.get("type", "") in KNOWN_PROMPT_NODES:
|
|
t = widget_text(node)
|
|
if t:
|
|
return t
|
|
return ""
|
|
|
|
def _looks_like_settings(line):
|
|
keys = ("Steps:", "Sampler:", "CFG scale:", "Seed:", "Size:", "Model hash:",
|
|
"Model:", "Denoising strength:", "Clip skip:", "ENSAM:", "TI_hashes:")
|
|
return any(k in line for k in keys)
|
|
|
|
|
|
def _clean_text_prompt(text):
|
|
if not isinstance(text, str):
|
|
return ""
|
|
text = text.strip()
|
|
if not text:
|
|
return ""
|
|
|
|
if text.startswith("{"):
|
|
try:
|
|
data = json.loads(text)
|
|
res = _extract_prompt_from_dict(data)
|
|
if res:
|
|
return res
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
idx = text.find("Negative prompt:")
|
|
if idx != -1:
|
|
text = text[:idx]
|
|
lines = text.split("\n")
|
|
while lines and _looks_like_settings(lines[-1]):
|
|
lines.pop()
|
|
return "\n".join(lines).strip()
|
|
|
|
def _decode_user_comment(data):
|
|
if isinstance(data, str):
|
|
return data
|
|
if not isinstance(data, (bytes, bytearray)):
|
|
return str(data)
|
|
|
|
if data.startswith(b"ASCII\x00\x00\x00"):
|
|
return data[8:].decode("latin-1", errors="replace")
|
|
if data.startswith(b"UNICODE\x00"):
|
|
return data[8:].decode("utf-16", errors="replace")
|
|
if data.startswith(b"JIS\x00\x00\x00\x00\x00"):
|
|
return data[8:].decode("shift_jis", errors="replace")
|
|
|
|
for encoding in ("utf-8", "latin-1"):
|
|
try:
|
|
return data.decode(encoding)
|
|
except (UnicodeDecodeError, UnicodeError):
|
|
continue
|
|
return data.decode("latin-1", errors="replace")
|
|
|
|
|
|
def _extract_a1111_from_exif(pil_img):
|
|
try:
|
|
exif = pil_img.getexif()
|
|
if not exif:
|
|
return ""
|
|
exif_ifd = exif.get_ifd(0x8769)
|
|
if not exif_ifd:
|
|
return ""
|
|
user_comment = exif_ifd.get(0x9286)
|
|
if not user_comment:
|
|
return ""
|
|
return _decode_user_comment(user_comment)
|
|
except Exception as e:
|
|
print(f"[RS Image-Prompt] EXIF error: {e}")
|
|
return ""
|
|
|
|
def extract_prompt_from_file(input_path):
|
|
try:
|
|
with Image.open(input_path) as pil_img:
|
|
info = dict(pil_img.info)
|
|
exif_text = _extract_a1111_from_exif(pil_img)
|
|
except Exception as e:
|
|
print(f"[RS Image-Prompt] Cannot open image: {e}")
|
|
return ""
|
|
|
|
prompt_json = info.get("prompt", "")
|
|
if prompt_json:
|
|
result = _extract_prompt(prompt_json)
|
|
if result:
|
|
return result
|
|
|
|
workflow_json = info.get("workflow", "")
|
|
if workflow_json:
|
|
result = _extract_prompt_from_workflow(workflow_json)
|
|
if result:
|
|
return result
|
|
|
|
for key in TEXT_KEYS:
|
|
value = info.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
result = _clean_text_prompt(value)
|
|
if result:
|
|
return result
|
|
|
|
if exif_text:
|
|
result = _clean_text_prompt(exif_text)
|
|
if result:
|
|
return result
|
|
|
|
return ""
|
|
|
|
|
|
@PromptServer.instance.routes.get("/rayko/get_prompt")
|
|
async def rayko_get_prompt(request):
|
|
filename = request.rel_url.query.get("filename", "")
|
|
if not filename:
|
|
return web.json_response({"prompt": ""})
|
|
try:
|
|
path = folder_paths.get_annotated_filepath(filename)
|
|
return web.json_response({"prompt": extract_prompt_from_file(path)})
|
|
except Exception as e:
|
|
return web.json_response({"prompt": "", "error": str(e)})
|
|
|
|
|
|
class RS_ImagePrompt:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("STRING", {"default": ""}),
|
|
"prompt_preview": ("STRING", {
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": "Extracted prompt preview (updates on file select)",
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("prompt",)
|
|
FUNCTION = "process"
|
|
CATEGORY = "🦊 RaykoStudio"
|
|
DESCRIPTION = "Extracts the positive prompt from image metadata (ComfyUI, RS Prompts, RS Image-Prompt, PromptStash, A1111 PNG/JPG/WebP)."
|
|
|
|
def process(self, image, prompt_preview=""):
|
|
try:
|
|
input_path = folder_paths.get_annotated_filepath(image)
|
|
if not os.path.exists(input_path):
|
|
raise FileNotFoundError(f"File not found: {input_path}")
|
|
return (extract_prompt_from_file(input_path),)
|
|
except Exception as e:
|
|
print(f"[RS Image-Prompt] Error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return ("",)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"RS_ImagePrompt": RS_ImagePrompt,
|
|
}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"RS_ImagePrompt": "🦊 RS Image-Prompt",
|
|
} |