diff --git a/README.md b/README.md index 0809bc4..71b40d9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ # ComfyUI-PechaKucha Comfy UI nodes to generate pecha kucha presentations + +## Setup: +Copy the node in your ComfyUI/custom_nodes directory +install the dependencies: +``` +python -m pip install -r requirements.txt +``` +load the example workflow +The pecha Kucha pptx will be generated and saved in your `ComfyUI/output/pecha_kucha` folder + + +## Content: +a Node `Split Prompt` to convert a multi-line prompt in an array of prompt +a Node `Generate PechaKucha Powerpoint` which generate a PechaKucha presentation from an array of Images. + +## Contribution: +Open Source contribution and bug reports are welcome. +Please follow the standard open source contribution process. + +## Thanks +* ComfyUI Team +* ComfyUI-Inspire-Pack +* Alexis Rolland for the spot on StackOverflow questions :D diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e2e39a9 --- /dev/null +++ b/__init__.py @@ -0,0 +1,16 @@ +from .pecha_kucha import GeneratePowerpoint +from .split_prompt import SplitPrompt +# A dictionary that contains all nodes you want to export with their names +# NOTE: names should be globally unique +NODE_CLASS_MAPPINGS = { + "GeneratePowerpoint": GeneratePowerpoint, + "SplitPrompt": SplitPrompt +} + +# A dictionary that contains the friendly/humanly readable titles for the nodes +NODE_DISPLAY_NAME_MAPPINGS = { + "GeneratePowerpoint": "Generate PechaKucha Powerpoint", + "SplitPrompt": "Split Prompt (To Batch)" +} + +__all__ = [NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS] diff --git a/example_workflows/SimplePechaKucha.jpg b/example_workflows/SimplePechaKucha.jpg new file mode 100644 index 0000000..2c6b60d Binary files /dev/null and b/example_workflows/SimplePechaKucha.jpg differ diff --git a/example_workflows/SimplePechaKucha.json b/example_workflows/SimplePechaKucha.json new file mode 100644 index 0000000..0fad6f5 --- /dev/null +++ b/example_workflows/SimplePechaKucha.json @@ -0,0 +1,455 @@ +{ + "last_node_id": 29, + "last_link_id": 31, + "nodes": [ + { + "id": 19, + "type": "CLIPTextEncode", + "pos": [ + 143.13748168945312, + 333.75732421875 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 14 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 24 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 17 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "a pretty girl" + ] + }, + { + "id": 20, + "type": "CLIPTextEncode", + "pos": [ + 136.04656982421875, + 592.12109375 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 15 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 16 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 18, + "type": "KSampler", + "pos": [ + 696.86474609375, + 891.57568359375 + ], + "size": [ + 315, + 262 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 13 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 17 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 18 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 655165261555852, + "randomize", + 10, + 8, + "euler", + "normal", + 1 + ] + }, + { + "id": 22, + "type": "VAEDecode", + "pos": [ + 1087.822021484375, + 903.0579833984375 + ], + "size": [ + 210, + 46 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 19 + }, + { + "name": "vae", + "type": "VAE", + "link": 20 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 28 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 25, + "type": "SplitPrompt", + "pos": [ + -576.6425170898438, + 340.18206787109375 + ], + "size": [ + 412, + 196 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "Split Prompts", + "type": "STRING", + "shape": 6, + "links": [ + 24, + 31 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "SplitPrompt" + }, + "widgets_values": [ + "A rabbit walking with a suitcase\nAn old guy trying to carry a bag of money\nA space view from earth", + "\n", + true + ] + }, + { + "id": 21, + "type": "EmptyLatentImage", + "pos": [ + 288.1556396484375, + 1123.75732421875 + ], + "size": [ + 315, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 1280, + 720, + 1 + ] + }, + { + "id": 17, + "type": "CheckpointLoaderSimple", + "pos": [ + -622.9769897460938, + 888.4140625 + ], + "size": [ + 315, + 98 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 13 + ], + "slot_index": 0 + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 14, + 15 + ], + "slot_index": 1 + }, + { + "name": "VAE", + "type": "VAE", + "links": [ + 20 + ] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "epicrealismXL_v5Ultimate.safetensors" + ] + }, + { + "id": 27, + "type": "GeneratePowerpoint", + "pos": [ + 1417.4892578125, + 914.5468139648438 + ], + "size": [ + 378, + 130 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 28 + }, + { + "name": "titles", + "type": "STRING", + "widget": { + "name": "titles" + }, + "link": 31 + } + ], + "outputs": [ + { + "name": "filename", + "type": "STRING", + "links": null, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "GeneratePowerpoint" + }, + "widgets_values": [ + "My pecha", + "", + 20, + true + ] + } + ], + "links": [ + [ + 13, + 17, + 0, + 18, + 0, + "MODEL" + ], + [ + 14, + 17, + 1, + 19, + 0, + "CLIP" + ], + [ + 15, + 17, + 1, + 20, + 0, + "CLIP" + ], + [ + 16, + 20, + 0, + 18, + 2, + "CONDITIONING" + ], + [ + 17, + 19, + 0, + 18, + 1, + "CONDITIONING" + ], + [ + 18, + 21, + 0, + 18, + 3, + "LATENT" + ], + [ + 19, + 18, + 0, + 22, + 0, + "LATENT" + ], + [ + 20, + 17, + 2, + 22, + 1, + "VAE" + ], + [ + 24, + 25, + 0, + 19, + 1, + "STRING" + ], + [ + 28, + 22, + 0, + 27, + 0, + "IMAGE" + ], + [ + 31, + 25, + 0, + 27, + 1, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.9090909090909091, + "offset": [ + 812.004781836218, + -127.85274297232016 + ] + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/example_workflows/SimplePechaKucha.pptx b/example_workflows/SimplePechaKucha.pptx new file mode 100644 index 0000000..9202494 Binary files /dev/null and b/example_workflows/SimplePechaKucha.pptx differ diff --git a/pecha_kucha.py b/pecha_kucha.py new file mode 100644 index 0000000..59ab66b --- /dev/null +++ b/pecha_kucha.py @@ -0,0 +1,98 @@ +import io +import json +import logging +import os +import numpy +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +import folder_paths + +from .presentation_generation.pptx import Pptx + +log = logging.getLogger(__name__) + + +class GeneratePowerpoint: + """ + A node to generate a PechaKucha presentation + """ + def __init__(self): + self.output_dir = folder_paths.get_output_directory() + self.temp_dir = folder_paths.get_temp_directory() + self.compress_level = 4 + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), + "title": ("STRING", { + "multiline": False, + "default": "My pecha", + "tooltip": "The title of this Presentation" + }), + "titles": ("STRING", { + "multiline": False, + "default": "", + "tooltip": "the titles for each slide." + }), + "slide_duration": ("INT", { + "default": 20, + "min": 0, + "max": 300, + "step": 1, + "display": "number", + "tooltip": "Each slide duration in seconds." + }), + "show_titles": ("BOOLEAN", { + "default": False, + "tooltip": "Show each prompt as slide title. Needs to provide the titles input." + }) + }, + } + + CATEGORY = "pecha_kucha" + DESCRIPTION = "Generates a Pecha Kucha generation from a list of images and prompts" + FUNCTION = "generate_pptx" + INPUT_IS_LIST = True + OUTPUT_NODE = True + RETURN_NAMES = ("filename",) + RETURN_TYPES = ("STRING",) + + def save_images(self, images, filename_prefix="PechaKucha"): + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.temp_dir, images[0].shape[1], images[0].shape[0]) + results = list() + log.debug(f"received {len(images)} images") + for (batch_number, image) in enumerate(images): + log.debug(f"start saving image {batch_number}, shape: {image.shape}") + i = 255. * image.cpu().numpy().squeeze() + img = Image.fromarray(numpy.clip(i, 0, 255).astype(numpy.uint8)) + metadata = None + metadata = PngInfo() + + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.png" + path = os.path.join(full_output_folder, file) + img.save(path, pnginfo=metadata, compress_level=self.compress_level) + results.append(path) + counter += 1 + + return results + + def generate_pptx(self, images, title, titles, slide_duration, show_titles): + log.info(f"start pecha_kucha generation") + images = self.save_images(images) + # The following inputs are also considered as list but only contain a single value + title = title[0] + slide_duration = slide_duration[0] + show_titles = show_titles[0] + output_folder = "./output/pecha_kucha" + filename = Pptx(output_folder).generate(title, slide_duration, images, show_titles, titles) + log.info(f"Generated presentation {filename}") + return (filename,) + + @classmethod + def IS_CHANGED(s, images, title, titles, slide_duration, show_titles): + return float("NaN") diff --git a/presentation_generation/oxml_duration_template.xml b/presentation_generation/oxml_duration_template.xml new file mode 100644 index 0000000..43d6b07 --- /dev/null +++ b/presentation_generation/oxml_duration_template.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/presentation_generation/pptx.py b/presentation_generation/pptx.py new file mode 100755 index 0000000..53e224b --- /dev/null +++ b/presentation_generation/pptx.py @@ -0,0 +1,133 @@ +import logging +import os +import torch + +from pathlib import Path +from PIL import Image +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.oxml import parse_xml +from pptx.util import Inches, Pt + +log = logging.getLogger(__name__) + + +class Pptx: + + duration_template = "oxml_duration_template.xml" + pixels_per_inch = 96 # Subjective fixed value + + def __init__(self, output_folder): + self.output_folder = output_folder + Path(self.output_folder).mkdir(parents=True, exist_ok=True) + + def get_next_filepath(self, title): + """ + Generate the next filepath available by appending `_X` + """ + extension = 'pptx' + filename = title.lower().replace(' ', '_') + filepath = f"{self.output_folder}/{filename}.{extension}" + if os.path.isfile(filepath): + log.info(f"file exsists {filepath}") + i = 1 + filepath = f"{self.output_folder}/{filename}_{i}.{extension}" + while os.path.isfile(filepath): + log.info(filepath) + i = i+1 + filepath = f"{self.output_folder}/{filename}_{i}.{extension}" + return filepath + + def get_duration_fragment(self, duration=20): + """ + Get the duration xml fragment to add to each slide with automatic transition + duration: slide duration in seconds + returns: the xml fragment to add to the slide + """ + template_path = os.path.join(os.path.dirname(__file__), self.duration_template) + with open(template_path) as f: + xml_fragment = f.read() + xml_fragment = xml_fragment.replace("{duration}", str(duration * 1000)) + return parse_xml(xml_fragment) + + def add_presentation_title(self, root, title): + """ + Generate the first presentaiton slide + """ + title_slide = root.slides.add_slide(root.slide_layouts[0]) + + main_title = title_slide.shapes.title + main_title.width = root.slide_width + main_title.left = 0 + main_title.top = root.slide_height//2 + main_title.text = title + main_title.text_frame.paragraphs[0].font.color.rgb = RGBColor(188, 194, 200) + + sub_title = title_slide.placeholders[1] + sub_title.width = root.slide_width + sub_title.left = 0 + sub_title.top = root.slide_height - Pt(12) - Inches(0.2) + sub_title.text = "Generated using ComfyUI-PechaKucha" + sub_title.text_frame.paragraphs[0].font.color.rgb = RGBColor(188, 194, 200) + sub_title.text_frame.paragraphs[0].font.size = Pt(12) + + background = title_slide.background + fill = background.fill + fill.solid() + fill.fore_color.rgb = RGBColor(2, 3, 3) + + def generate(self, title, slide_duration, images, show_titles, titles): + """ + Generate the powerpoint presentation file + Ref for slide types: + 0 -> title and subtitle + 1 -> title and content + 2 -> section header + 3 -> two content + 4 -> Comparison + 5 -> Title only + 6 -> Blank + 7 -> Content with caption + 8 -> Pic with caption + """ + log.debug(f"{title} Pecha Kucha generation with {len(images)} images and duration {slide_duration}") + + root = Presentation() + first_image = Image.open(images[0]) + width, height = first_image.size + root.slide_width = Inches(width//self.pixels_per_inch) + root.slide_height = Inches(height//self.pixels_per_inch) + + self.add_presentation_title(root, title) + + if show_titles: + assert(len(images) == len(titles)) + + slide_layout = root.slide_layouts[0] + left = top = Inches(0) + width = root.slide_width + + for i, image in enumerate(images): + slide = root.slides.add_slide(slide_layout) + + pic = slide.shapes.add_picture(image, left, top, width) + # Send picture to background + slide.shapes._spTree.remove(pic._element) + slide.shapes._spTree.insert(2, pic._element) + + if show_titles: + slide.shapes.title.text = titles[i] + slide.shapes.title.width = root.slide_width + slide.shapes.title.left = 0 + slide.shapes.title.top = root.slide_height - Pt(16) - Inches(0.5) + slide.shapes.title.text_frame.paragraphs[0].font.color.rgb = RGBColor(2, 3, 3) + slide.shapes.title.text_frame.paragraphs[0].font.size = Pt(16) + + # Adding automatic transition + slide.element.insert(-1, self.get_duration_fragment(slide_duration)) + + filepath = self.get_next_filepath(title) + root.save(filepath) + + return filepath diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a84f57 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-pptx>=1.0.2 +reportlab>=4.4.0 \ No newline at end of file diff --git a/split_prompt.py b/split_prompt.py new file mode 100644 index 0000000..50b8080 --- /dev/null +++ b/split_prompt.py @@ -0,0 +1,48 @@ +import logging + +log = logging.getLogger(__name__) + + +class SplitPrompt: + """ + A simple node to split a prompt into a batch of prompt. + Inspired from [ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) + """ + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ( + "STRING", + {"multiline": True, "default": "First Prompt \nSecond Prompt", "tooltip": "A multiline prompt that will be split to batch generate"} + ) + }, + "optional": { + "split_on": ( + "STRING", + {"default": '\n', "tooltip": "Will split the input prompt on this character"} + ), + "trim": ( + "BOOLEAN", + {"default": True, "tooltip": "Will trim prompts and remove empty ones"} + ), + } + } + + CATEGORY = "pecha_kucha" + DESCRIPTION = "Split a prompt in a list of prompts" + FUNCTION = "split" + OUTPUT_IS_LIST = (True,) + RETURN_NAMES = ("Split Prompts",) + RETURN_TYPES = ("STRING",) + + @staticmethod + def IS_CHANGED(prompt, split_on='\n', trim=True): + return f"trim:{trim}_split:{split_on}_prompt:{prompt}" + + @staticmethod + def split(prompt, split_on='\n', trim=True): + prompts = prompt.split(split_on) + if trim: + prompts = [prompt.strip() for prompt in prompts] + return (prompts,)