mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-11 17:07:14 +08:00
Merge branch 'master' into cloud-openapi-projection
This commit is contained in:
commit
ccf32f315f
@ -56,6 +56,9 @@ PREVIEWABLE_MEDIA_TYPES = frozenset({'images', 'video', 'audio', '3d', 'text'})
|
|||||||
# 3D file extensions for preview fallback (no dedicated media_type exists)
|
# 3D file extensions for preview fallback (no dedicated media_type exists)
|
||||||
THREE_D_EXTENSIONS = frozenset({'.obj', '.fbx', '.gltf', '.glb', '.usdz'})
|
THREE_D_EXTENSIONS = frozenset({'.obj', '.fbx', '.gltf', '.glb', '.usdz'})
|
||||||
|
|
||||||
|
# Text file extensions for preview fallback (the formats SaveText can produce)
|
||||||
|
TEXT_EXTENSIONS = frozenset({'.txt', '.md', '.json'})
|
||||||
|
|
||||||
|
|
||||||
def has_3d_extension(filename: str) -> bool:
|
def has_3d_extension(filename: str) -> bool:
|
||||||
lower = filename.lower()
|
lower = filename.lower()
|
||||||
@ -143,9 +146,10 @@ def is_previewable(media_type: str, item: dict) -> bool:
|
|||||||
Maintains backwards compatibility with existing logic.
|
Maintains backwards compatibility with existing logic.
|
||||||
|
|
||||||
Priority:
|
Priority:
|
||||||
1. media_type is 'images', 'video', 'audio', or '3d'
|
1. media_type is 'images', 'video', 'audio', '3d', or 'text'
|
||||||
2. format field starts with 'video/' or 'audio/'
|
2. format field starts with 'video/' or 'audio/'
|
||||||
3. filename has a 3D extension (.obj, .fbx, .gltf, .glb, .usdz)
|
3. filename has a 3D extension (.obj, .fbx, .gltf, .glb, .usdz)
|
||||||
|
4. filename has a text extension (.txt, .md, .json, ...)
|
||||||
"""
|
"""
|
||||||
if media_type in PREVIEWABLE_MEDIA_TYPES:
|
if media_type in PREVIEWABLE_MEDIA_TYPES:
|
||||||
return True
|
return True
|
||||||
@ -156,10 +160,12 @@ def is_previewable(media_type: str, item: dict) -> bool:
|
|||||||
if fmt and (fmt.startswith('video/') or fmt.startswith('audio/')):
|
if fmt and (fmt.startswith('video/') or fmt.startswith('audio/')):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check for 3D files by extension
|
# Check for 3D and text files by extension
|
||||||
filename = item.get('filename', '').lower()
|
filename = item.get('filename', '').lower()
|
||||||
if any(filename.endswith(ext) for ext in THREE_D_EXTENSIONS):
|
if any(filename.endswith(ext) for ext in THREE_D_EXTENSIONS):
|
||||||
return True
|
return True
|
||||||
|
if any(filename.endswith(ext) for ext in TEXT_EXTENSIONS):
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -255,6 +261,10 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
|
|||||||
Preview priority (matching frontend):
|
Preview priority (matching frontend):
|
||||||
1. type="output" with previewable media
|
1. type="output" with previewable media
|
||||||
2. Any previewable media
|
2. Any previewable media
|
||||||
|
|
||||||
|
Text content entries (strings under 'text') are preview-only metadata,
|
||||||
|
matching the frontend's METADATA_KEYS: they can serve as the fallback
|
||||||
|
preview but are not counted as outputs.
|
||||||
"""
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
preview_output = None
|
preview_output = None
|
||||||
@ -275,7 +285,6 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
|
|||||||
if normalized is None:
|
if normalized is None:
|
||||||
# Not a 3D file string — check for text preview
|
# Not a 3D file string — check for text preview
|
||||||
if media_type == 'text':
|
if media_type == 'text':
|
||||||
count += 1
|
|
||||||
if preview_output is None:
|
if preview_output is None:
|
||||||
if isinstance(item, tuple):
|
if isinstance(item, tuple):
|
||||||
text_value = item[0] if item else ''
|
text_value = item[0] if item else ''
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFont
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont
|
||||||
@ -166,6 +168,111 @@ def boxes_to_regions(boxes, width: int, height: int) -> list:
|
|||||||
return regions
|
return regions
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_incoming_boxes(bboxes) -> list:
|
||||||
|
if isinstance(bboxes, dict):
|
||||||
|
frame = [bboxes]
|
||||||
|
elif not isinstance(bboxes, list) or not bboxes:
|
||||||
|
frame = []
|
||||||
|
elif isinstance(bboxes[0], dict):
|
||||||
|
frame = bboxes
|
||||||
|
else:
|
||||||
|
frame = bboxes[0] if isinstance(bboxes[0], list) else []
|
||||||
|
boxes = []
|
||||||
|
for box in frame:
|
||||||
|
if not isinstance(box, dict):
|
||||||
|
continue
|
||||||
|
norm = {
|
||||||
|
"x": box.get("x", 0),
|
||||||
|
"y": box.get("y", 0),
|
||||||
|
"width": box.get("width", 0),
|
||||||
|
"height": box.get("height", 0),
|
||||||
|
}
|
||||||
|
meta = box.get("metadata")
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
norm["metadata"] = meta
|
||||||
|
boxes.append(norm)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_element(box: dict) -> bool:
|
||||||
|
bbox = box.get("bbox")
|
||||||
|
return isinstance(bbox, (list, tuple)) and len(bbox) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_bbox(box: dict) -> bool:
|
||||||
|
return all(key in box for key in ("x", "y", "width", "height"))
|
||||||
|
|
||||||
|
|
||||||
|
def elements_to_boxes(elements: list, width: int, height: int) -> list:
|
||||||
|
boxes = []
|
||||||
|
for element in elements:
|
||||||
|
if not isinstance(element, dict):
|
||||||
|
continue
|
||||||
|
bbox = element.get("bbox")
|
||||||
|
if not (isinstance(bbox, (list, tuple)) and len(bbox) == 4):
|
||||||
|
raise ValueError("bboxes element is missing a valid 'bbox' [ymin, xmin, ymax, xmax]")
|
||||||
|
try:
|
||||||
|
ymin, xmin, ymax, xmax = (float(v) / 1000.0 for v in bbox)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("bboxes element 'bbox' must contain four numbers")
|
||||||
|
etype = "text" if element.get("type") == "text" else "obj"
|
||||||
|
boxes.append({
|
||||||
|
"x": round(min(xmin, xmax) * width),
|
||||||
|
"y": round(min(ymin, ymax) * height),
|
||||||
|
"width": round(abs(xmax - xmin) * width),
|
||||||
|
"height": round(abs(ymax - ymin) * height),
|
||||||
|
"metadata": {
|
||||||
|
"type": etype,
|
||||||
|
"text": element.get("text", "") if etype == "text" else "",
|
||||||
|
"desc": element.get("desc", ""),
|
||||||
|
"palette": element.get("color_palette", []) or [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
def boxes_from_input(data, width: int, height: int) -> list:
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
if isinstance(data, str):
|
||||||
|
text = data.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError(f"bboxes string input is not valid JSON: {exc}") from exc
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if _looks_like_element(data):
|
||||||
|
return elements_to_boxes([data], width, height)
|
||||||
|
if _looks_like_bbox(data):
|
||||||
|
return normalize_incoming_boxes(data)
|
||||||
|
raise ValueError(
|
||||||
|
"bboxes dict must be a bounding box (x, y, width, height) or an element (with a 'bbox')"
|
||||||
|
)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError(
|
||||||
|
"bboxes input must be bounding boxes, elements, or a JSON string, "
|
||||||
|
f"got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
first = data[0]
|
||||||
|
if isinstance(first, list):
|
||||||
|
return normalize_incoming_boxes(data)
|
||||||
|
if isinstance(first, dict):
|
||||||
|
if _looks_like_element(first):
|
||||||
|
return elements_to_boxes(data, width, height)
|
||||||
|
if _looks_like_bbox(first):
|
||||||
|
return normalize_incoming_boxes(data)
|
||||||
|
raise ValueError(
|
||||||
|
"bboxes items must be bounding boxes (x, y, width, height) or elements (with a 'bbox')"
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"bboxes list must contain bounding boxes or elements, got {type(first).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _norm_bbox(region: dict) -> list[int]:
|
def _norm_bbox(region: dict) -> list[int]:
|
||||||
def grid(value: float) -> int:
|
def grid(value: float) -> int:
|
||||||
return max(0, min(1000, round(value * 1000)))
|
return max(0, min(1000, round(value * 1000)))
|
||||||
@ -217,29 +324,48 @@ class CreateBoundingBoxes(io.ComfyNode):
|
|||||||
optional=True,
|
optional=True,
|
||||||
tooltip="Optional image used as background in the canvas and preview.",
|
tooltip="Optional image used as background in the canvas and preview.",
|
||||||
),
|
),
|
||||||
|
io.MultiType.Input(
|
||||||
|
"bboxes",
|
||||||
|
[io.BoundingBox, io.Array, io.String],
|
||||||
|
optional=True,
|
||||||
|
tooltip="Bounding boxes, elements, or a JSON string to initialize the canvas. A new upstream value initializes the canvas; edits made on the canvas take priority and are kept until the upstream value changes again.",
|
||||||
|
),
|
||||||
io.Int.Input("width", default=1024, min=64, max=16384, step=16,
|
io.Int.Input("width", default=1024, min=64, max=16384, step=16,
|
||||||
tooltip="Width of the canvas and the pixel grid for the bounding boxes."),
|
tooltip="Width of the canvas and the pixel grid for the bounding boxes."),
|
||||||
io.Int.Input("height", default=1024, min=64, max=16384, step=16,
|
io.Int.Input("height", default=1024, min=64, max=16384, step=16,
|
||||||
tooltip="Height of the canvas and the pixel grid for the bounding boxes."),
|
tooltip="Height of the canvas and the pixel grid for the bounding boxes."),
|
||||||
editor_state,
|
editor_state,
|
||||||
|
io.BoundingBoxes.Input(
|
||||||
|
"last_incoming",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Internal state managed by the canvas: the upstream bboxes value that last initialized it. Leave empty to re-initialize the canvas from the bboxes input on the next run.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
outputs=[
|
outputs=[
|
||||||
io.Image.Output(display_name="preview"),
|
io.Image.Output(display_name="preview"),
|
||||||
io.BoundingBox.Output(display_name="bboxes"),
|
io.BoundingBox.Output(display_name="bboxes"),
|
||||||
io.Array.Output(display_name="elements"),
|
io.Array.Output(display_name="elements"),
|
||||||
],
|
],
|
||||||
|
is_output_node=True,
|
||||||
is_experimental=True,
|
is_experimental=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, width, height, editor_state=None, background=None) -> io.NodeOutput:
|
def execute(cls, width, height, editor_state=None, last_incoming=None, background=None, bboxes=None) -> io.NodeOutput:
|
||||||
regions = boxes_to_regions(editor_state, width, height)
|
incoming = boxes_from_input(bboxes, width, height)
|
||||||
|
applied = last_incoming if isinstance(last_incoming, list) else []
|
||||||
|
upstream_changed = bool(incoming) and incoming != applied
|
||||||
|
source = incoming if upstream_changed else (editor_state or [])
|
||||||
|
regions = boxes_to_regions(source, width, height)
|
||||||
preview = render_preview(regions, width, height, _bg_from_image(background))
|
preview = render_preview(regions, width, height, _bg_from_image(background))
|
||||||
|
ui = {"dims": [width, height]}
|
||||||
|
if incoming:
|
||||||
|
ui["input_bboxes"] = incoming
|
||||||
return io.NodeOutput(
|
return io.NodeOutput(
|
||||||
preview,
|
preview,
|
||||||
fractions_to_bbox_frame(regions, width, height),
|
fractions_to_bbox_frame(regions, width, height),
|
||||||
build_elements(regions),
|
build_elements(regions),
|
||||||
ui={"dims": [width, height]},
|
ui=ui,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ from typing_extensions import override
|
|||||||
|
|
||||||
import folder_paths
|
import folder_paths
|
||||||
from comfy.cli_args import args
|
from comfy.cli_args import args
|
||||||
from comfy_api.latest import ComfyExtension, IO, Types
|
from comfy_api.latest import ComfyExtension, IO, Types, UI
|
||||||
|
|
||||||
|
|
||||||
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, unlit=False):
|
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, unlit=False):
|
||||||
@ -406,10 +406,164 @@ class SaveGLB(IO.ComfyNode):
|
|||||||
return IO.NodeOutput(ui={"3d": results})
|
return IO.NodeOutput(ui={"3d": results})
|
||||||
|
|
||||||
|
|
||||||
|
def _save_file3d_to_output(model_3d: Types.File3D, filename_prefix: str) -> str:
|
||||||
|
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||||
|
filename_prefix, folder_paths.get_output_directory()
|
||||||
|
)
|
||||||
|
ext = model_3d.format or "glb"
|
||||||
|
saved_filename = f"{filename}_{counter:05}.{ext}"
|
||||||
|
model_3d.save_to(os.path.join(full_output_folder, saved_filename))
|
||||||
|
return f"{subfolder}/{saved_filename}" if subfolder else saved_filename
|
||||||
|
|
||||||
|
|
||||||
|
def execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs) -> IO.NodeOutput:
|
||||||
|
model_file = _save_file3d_to_output(model_3d, filename_prefix)
|
||||||
|
camera_info_input = kwargs.get("camera_info", None)
|
||||||
|
camera_info = camera_info_input if camera_info_input is not None else viewport_state['camera_info']
|
||||||
|
model_3d_info_input = kwargs.get("model_3d_info", None)
|
||||||
|
model_3d_info = model_3d_info_input if model_3d_info_input is not None else viewport_state.get('model_3d_info', [])
|
||||||
|
return IO.NodeOutput(
|
||||||
|
model_3d,
|
||||||
|
model_3d_info,
|
||||||
|
camera_info,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
ui=UI.PreviewUI3DAdvanced(model_file, camera_info, model_3d_info),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Save3DAdvanced(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="Save3DAdvanced",
|
||||||
|
display_name="Save 3D (Advanced)",
|
||||||
|
search_aliases=["save 3d", "export 3d model", "save mesh advanced"],
|
||||||
|
category="3d",
|
||||||
|
is_experimental=True,
|
||||||
|
is_output_node=True,
|
||||||
|
inputs=[
|
||||||
|
IO.MultiType.Input(
|
||||||
|
"model_3d",
|
||||||
|
types=[
|
||||||
|
IO.File3DGLB,
|
||||||
|
IO.File3DGLTF,
|
||||||
|
IO.File3DFBX,
|
||||||
|
IO.File3DOBJ,
|
||||||
|
IO.File3DSTL,
|
||||||
|
IO.File3DUSDZ,
|
||||||
|
IO.File3DAny,
|
||||||
|
],
|
||||||
|
tooltip="3D model file from an upstream 3D node.",
|
||||||
|
),
|
||||||
|
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
||||||
|
IO.Load3D.Input("viewport_state"),
|
||||||
|
IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True),
|
||||||
|
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
|
||||||
|
IO.Int.Input("width", default=1024, min=1, max=4096, step=1),
|
||||||
|
IO.Int.Input("height", default=1024, min=1, max=4096, step=1),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
IO.File3DAny.Output(display_name="model_3d"),
|
||||||
|
IO.Load3DModelInfo.Output(display_name="model_3d_info"),
|
||||||
|
IO.Load3DCamera.Output(display_name="camera_info"),
|
||||||
|
IO.Int.Output(display_name="width"),
|
||||||
|
IO.Int.Output(display_name="height"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, model_3d: Types.File3D, viewport_state, width: int, height: int, filename_prefix: str, **kwargs) -> IO.NodeOutput:
|
||||||
|
return execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveGaussianSplat(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="SaveGaussianSplat",
|
||||||
|
display_name="Save Splat",
|
||||||
|
search_aliases=["save splat", "save gaussian splat", "export gaussian", "export splat"],
|
||||||
|
category="3d",
|
||||||
|
is_experimental=True,
|
||||||
|
is_output_node=True,
|
||||||
|
inputs=[
|
||||||
|
IO.MultiType.Input(
|
||||||
|
"model_3d",
|
||||||
|
types=[
|
||||||
|
IO.File3DSplatAny,
|
||||||
|
IO.File3DPLY,
|
||||||
|
IO.File3DSPLAT,
|
||||||
|
IO.File3DSPZ,
|
||||||
|
IO.File3DKSPLAT,
|
||||||
|
],
|
||||||
|
tooltip="A gaussian splat 3D file.",
|
||||||
|
),
|
||||||
|
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
||||||
|
IO.Load3D.Input("viewport_state"),
|
||||||
|
IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True),
|
||||||
|
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
|
||||||
|
IO.Int.Input("width", default=1024, min=1, max=4096, step=1),
|
||||||
|
IO.Int.Input("height", default=1024, min=1, max=4096, step=1),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
IO.File3DSplatAny.Output(display_name="model_3d"),
|
||||||
|
IO.Load3DModelInfo.Output(display_name="model_3d_info"),
|
||||||
|
IO.Load3DCamera.Output(display_name="camera_info"),
|
||||||
|
IO.Int.Output(display_name="width"),
|
||||||
|
IO.Int.Output(display_name="height"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, model_3d: Types.File3D, viewport_state, width: int, height: int, filename_prefix: str, **kwargs) -> IO.NodeOutput:
|
||||||
|
return execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SavePointCloud(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="SavePointCloud",
|
||||||
|
display_name="Save Point Cloud",
|
||||||
|
search_aliases=["save point cloud", "save pointcloud", "export point cloud"],
|
||||||
|
category="3d",
|
||||||
|
is_experimental=True,
|
||||||
|
is_output_node=True,
|
||||||
|
inputs=[
|
||||||
|
IO.MultiType.Input(
|
||||||
|
"model_3d",
|
||||||
|
types=[
|
||||||
|
IO.File3DPointCloudAny,
|
||||||
|
IO.File3DPLY,
|
||||||
|
],
|
||||||
|
tooltip="Point cloud file (.ply)",
|
||||||
|
),
|
||||||
|
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
||||||
|
IO.Load3D.Input("viewport_state"),
|
||||||
|
IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True),
|
||||||
|
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
|
||||||
|
IO.Int.Input("width", default=1024, min=1, max=4096, step=1),
|
||||||
|
IO.Int.Input("height", default=1024, min=1, max=4096, step=1),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
IO.File3DPointCloudAny.Output(display_name="model_3d"),
|
||||||
|
IO.Load3DModelInfo.Output(display_name="model_3d_info"),
|
||||||
|
IO.Load3DCamera.Output(display_name="camera_info"),
|
||||||
|
IO.Int.Output(display_name="width"),
|
||||||
|
IO.Int.Output(display_name="height"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, model_3d: Types.File3D, viewport_state, width: int, height: int, filename_prefix: str, **kwargs) -> IO.NodeOutput:
|
||||||
|
return execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Save3DExtension(ComfyExtension):
|
class Save3DExtension(ComfyExtension):
|
||||||
@override
|
@override
|
||||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
return [SaveGLB]
|
return [SaveGLB, Save3DAdvanced, SaveGaussianSplat, SavePointCloud]
|
||||||
|
|
||||||
|
|
||||||
async def comfy_entrypoint() -> Save3DExtension:
|
async def comfy_entrypoint() -> Save3DExtension:
|
||||||
|
|||||||
71
comfy_extras/nodes_text.py
Normal file
71
comfy_extras/nodes_text.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing_extensions import override
|
||||||
|
from comfy_api.latest import io, ComfyExtension, ui
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
|
||||||
|
class SaveTextNode(io.ComfyNode):
|
||||||
|
"""Save text content to .txt, .md, or .json."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return io.Schema(
|
||||||
|
node_id="SaveText",
|
||||||
|
search_aliases=["save text", "write text", "export text"],
|
||||||
|
display_name="Save Text",
|
||||||
|
category="text",
|
||||||
|
description="Save text content to a file in the output directory.",
|
||||||
|
inputs=[
|
||||||
|
io.String.Input("text", force_input=True),
|
||||||
|
io.String.Input("filename_prefix", default="ComfyUI"),
|
||||||
|
io.Combo.Input("format", options=["txt", "md", "json"], default="txt"),
|
||||||
|
],
|
||||||
|
outputs=[io.String.Output(display_name="text")],
|
||||||
|
is_output_node=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def execute(cls, text, filename_prefix, format):
|
||||||
|
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||||
|
filename_prefix,
|
||||||
|
folder_paths.get_output_directory(),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
file = f"{filename}_{counter:05}.{format}"
|
||||||
|
filepath = os.path.join(full_output_folder, file)
|
||||||
|
|
||||||
|
if format == "json":
|
||||||
|
# tries to pretty print otherwise saves normally
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
else:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
return io.NodeOutput(
|
||||||
|
text,
|
||||||
|
ui={
|
||||||
|
"text": (text,),
|
||||||
|
"files": [
|
||||||
|
ui.SavedResult(file, subfolder, io.FolderType.output)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
class TextExtension(ComfyExtension):
|
||||||
|
@override
|
||||||
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||||
|
return [
|
||||||
|
SaveTextNode
|
||||||
|
]
|
||||||
|
|
||||||
|
async def comfy_entrypoint() -> TextExtension:
|
||||||
|
return TextExtension()
|
||||||
Loading…
Reference in New Issue
Block a user