* Add a security baseline and pin dependencies to current safe releases. The repo had no dependency pins, so installs could keep vulnerable requests, urllib3, certifi, idna, Pillow, and aiohttp. Pin the newest releases that are at least 7 days old, with the certifi security fix called out because it is newer than that window. Add private vulnerability reporting, and a minimal lint and import CI with actions pinned to commit SHAs. Co-authored-by: pilotless-ai <pilotless-ai@users.noreply.github.com> * Run CI on every push so the branch is checked without waiting on a pull request. Co-authored-by: pilotless-ai <pilotless-ai@users.noreply.github.com> * Use minimum version floors so ComfyUI Manager does not replace shared packages. Exact pins were forcing Pillow, aiohttp, and yarl in the user's environment. requirements.txt now sets the oldest secure release, with no upper bounds, and CI installs the tested set from constraints.txt. Co-authored-by: pilotless-ai <pilotless-ai@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: pilotless-ai <pilotless-ai@users.noreply.github.com>
135 lines
5.8 KiB
Python
135 lines
5.8 KiB
Python
from PIL import Image, ImageDraw, ImageFont
|
|
import requests
|
|
from io import BytesIO
|
|
import os
|
|
from .draggen_client import DraggenMoodboard
|
|
|
|
class DraggenCompositor:
|
|
@staticmethod
|
|
def get_image_from_url_or_path(src: str, base_path: str = None) -> Image.Image:
|
|
"""
|
|
Resolves an image source to a PIL Image.
|
|
Handles both remote URLs and local file paths.
|
|
Prioritizes local file if base_path is provided and file exists.
|
|
"""
|
|
try:
|
|
# 1. Try resolving locally if base_path is set
|
|
if base_path:
|
|
# Strategy A: src is a relative path (e.g. "images/foo.png")
|
|
candidates = [
|
|
os.path.join(base_path, src),
|
|
os.path.join(base_path, 'images', os.path.basename(src)),
|
|
os.path.join(base_path, os.path.basename(src))
|
|
]
|
|
|
|
# If src is a URL, extracting basename might give "foo.png" from "http://.../foo.png"
|
|
if src.startswith('http'):
|
|
from urllib.parse import urlparse
|
|
path = urlparse(src).path
|
|
filename = os.path.basename(path)
|
|
candidates.append(os.path.join(base_path, 'images', filename))
|
|
candidates.append(os.path.join(base_path, filename))
|
|
|
|
for path in candidates:
|
|
if os.path.exists(path) and os.path.isfile(path):
|
|
return Image.open(path).convert("RGBA")
|
|
|
|
# 2. Fallback to URL fetch
|
|
if src.startswith('http'):
|
|
response = requests.get(src)
|
|
response.raise_for_status()
|
|
img = Image.open(BytesIO(response.content))
|
|
else:
|
|
# Local file fallback (absolute path or relative without base_path somehow)
|
|
img = Image.open(src)
|
|
|
|
return img.convert("RGBA")
|
|
except Exception as e:
|
|
print(f"Failed to load image {src}: {e}")
|
|
return Image.new("RGBA", (100, 100), (255, 0, 0, 255)) # Error placeholder
|
|
|
|
@staticmethod
|
|
def render(moodboard: DraggenMoodboard, base_path: str = None) -> Image.Image:
|
|
# 1. Determine canvas size
|
|
# We can either use a fixed size or calculate bounding box of all elements.
|
|
# The user JSON shows x/y positions, some negative.
|
|
# Let's calculate the bounding box.
|
|
|
|
min_x = 0
|
|
min_y = 0
|
|
max_x = 1000
|
|
max_y = 1000
|
|
|
|
if moodboard.elements:
|
|
min_x = min(el.position.x for el in moodboard.elements)
|
|
min_y = min(el.position.y for el in moodboard.elements)
|
|
max_x = max(el.position.x + el.size.width for el in moodboard.elements)
|
|
max_y = max(el.position.y + el.size.height for el in moodboard.elements)
|
|
|
|
# Add some padding
|
|
padding = 50
|
|
width = int(max_x - min_x + padding * 2)
|
|
height = int(max_y - min_y + padding * 2)
|
|
|
|
# Create canvas
|
|
# Default background color? Let's assume transparent or white.
|
|
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# 2. Draw elements in order (already sorted by zIndex in load)
|
|
for el in moodboard.elements:
|
|
# Calculate dest position relative to canvas (shifting by min_x/min_y)
|
|
dest_x = int(el.position.x - min_x + padding)
|
|
dest_y = int(el.position.y - min_y + padding)
|
|
dest_w = int(el.size.width)
|
|
dest_h = int(el.size.height)
|
|
|
|
if el.type == 'image' and el.src:
|
|
img = DraggenCompositor.get_image_from_url_or_path(el.src, base_path)
|
|
img = img.resize((dest_w, dest_h), Image.Resampling.LANCZOS)
|
|
canvas.alpha_composite(img, (dest_x, dest_y))
|
|
|
|
elif el.type == 'box':
|
|
# Parse colors
|
|
fill = el.fill_color if el.fill_color else None
|
|
outline = el.border_color if el.border_color else None
|
|
# Create a rectangle
|
|
# PIL Draw.rectangle doesn't support alpha in fill color string directly if it's hex without alpha?
|
|
# User hex: #1a1a2e. We might need to assume alpha=255.
|
|
|
|
# Helper to parse hex
|
|
def parse_color(c):
|
|
if not c:
|
|
return None
|
|
c = c.lstrip('#')
|
|
return tuple(int(c[i:i+2], 16) for i in (0, 2, 4)) + (255,)
|
|
|
|
fill_rgba = parse_color(fill)
|
|
outline_rgba = parse_color(outline)
|
|
|
|
shape_layer = Image.new("RGBA", (width, height), (0,0,0,0))
|
|
shape_draw = ImageDraw.Draw(shape_layer)
|
|
shape_draw.rectangle(
|
|
[dest_x, dest_y, dest_x + dest_w, dest_y + dest_h],
|
|
fill=fill_rgba,
|
|
outline=outline_rgba,
|
|
width=1 # user provided borderWidth in json, but simplified for now
|
|
)
|
|
canvas.alpha_composite(shape_layer)
|
|
|
|
elif el.type == 'text' and el.text:
|
|
# Text rendering is complex without specific fonts.
|
|
# Attempt to load a default font.
|
|
# For now, minimal implementation.
|
|
try:
|
|
font = ImageFont.truetype("arial.ttf", size=24) # System font assumption
|
|
except OSError:
|
|
font = ImageFont.load_default()
|
|
|
|
# Text color
|
|
fill = el.color if el.color else "#000000"
|
|
|
|
draw.text((dest_x, dest_y), el.text, font=font, fill=fill)
|
|
|
|
return canvas
|