Adding a first draft of the Pecha Kucha node.

This commit is contained in:
Laurent Erignoux
2025-05-11 23:07:18 +08:00
parent 48c9af6c2d
commit ad713b896e
10 changed files with 785 additions and 0 deletions
+23
View File
@@ -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
+16
View File
@@ -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]
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+455
View File
@@ -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
}
Binary file not shown.
+98
View File
@@ -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")
@@ -0,0 +1,10 @@
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
<mc:Choice xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" Requires="p14">
<p:transition xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" spd="slow" advTm="{duration}" p14:dur="1000">
</p:transition>
</mc:Choice>
<mc:Fallback>
<p:transition xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" spd="slow" advTm="{duration}">
</p:transition>
</mc:Fallback>
</mc:AlternateContent>
+133
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
python-pptx>=1.0.2
reportlab>=4.4.0
+48
View File
@@ -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,)