Release standalone timeline editor 1.0.0
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
"""Layer rendering, compositing, alpha conversion, and unedited-source output."""
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
from PIL import Image, ImageColor, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
|
||||
|
||||
from .images import (
|
||||
_comfy_image_tensor_batch_to_pil,
|
||||
_fit_etk_timeline_image,
|
||||
_pil_to_comfy_image_tensor,
|
||||
_resolve_etk_timeline_image_path,
|
||||
)
|
||||
from .schema import (
|
||||
_etk_normalize_timeline_text_align,
|
||||
_etk_normalize_timeline_text_color,
|
||||
)
|
||||
|
||||
|
||||
def _etk_file_info_key(image_info):
|
||||
if isinstance(image_info, str):
|
||||
return (image_info, "", "input")
|
||||
if isinstance(image_info, dict):
|
||||
return (
|
||||
image_info.get("filename") or image_info.get("name") or "",
|
||||
image_info.get("subfolder") or "",
|
||||
image_info.get("type") or "input",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _load_etk_timeline_layer_source(keyframe, layer):
|
||||
if keyframe.get("_input_image") is not None and _etk_file_info_key(layer.get("image")) == _etk_file_info_key(keyframe.get("image")):
|
||||
return ImageOps.exif_transpose(keyframe["_input_image"]).copy()
|
||||
image_path = _resolve_etk_timeline_image_path(layer["image"])
|
||||
with Image.open(image_path) as loaded:
|
||||
return ImageOps.exif_transpose(loaded).copy()
|
||||
|
||||
|
||||
def _apply_etk_timeline_filters(image, brightness=1.0, contrast=1.0):
|
||||
brightness = max(0.0, min(2.0, float(brightness)))
|
||||
contrast = max(0.0, min(2.0, float(contrast)))
|
||||
if abs(brightness - 1.0) < 1e-6 and abs(contrast - 1.0) < 1e-6:
|
||||
return image
|
||||
|
||||
has_alpha = image.mode in {"RGBA", "LA"} or "transparency" in image.info
|
||||
if has_alpha:
|
||||
rgba = image.convert("RGBA")
|
||||
alpha = rgba.getchannel("A")
|
||||
rgb = rgba.convert("RGB")
|
||||
rgb = ImageEnhance.Brightness(rgb).enhance(brightness)
|
||||
rgb = ImageEnhance.Contrast(rgb).enhance(contrast)
|
||||
filtered = Image.merge("RGBA", (*rgb.split(), alpha))
|
||||
return filtered
|
||||
|
||||
filtered = image.convert("RGB")
|
||||
filtered = ImageEnhance.Brightness(filtered).enhance(brightness)
|
||||
filtered = ImageEnhance.Contrast(filtered).enhance(contrast)
|
||||
return filtered
|
||||
|
||||
|
||||
def _etk_timeline_text_font_candidates(font_family, bold=False, italic=False):
|
||||
requested = str(font_family or "").strip()
|
||||
if requested and os.path.isfile(requested):
|
||||
yield requested
|
||||
aliases = {
|
||||
"sans-serif": "dejavu sans",
|
||||
"sans": "dejavu sans",
|
||||
"serif": "dejavu serif",
|
||||
"monospace": "dejavu sans mono",
|
||||
"mono": "dejavu sans mono",
|
||||
}
|
||||
target = aliases.get(requested.lower(), requested.lower())
|
||||
tokens = [token for token in target.replace("_", " ").replace("-", " ").split() if token]
|
||||
font_roots = ["/usr/share/fonts", "/usr/local/share/fonts"]
|
||||
scored = []
|
||||
for root in font_roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
if not filename.lower().endswith((".ttf", ".otf")):
|
||||
continue
|
||||
lower = filename.lower().replace("_", " ").replace("-", " ")
|
||||
if tokens and not all(token in lower for token in tokens):
|
||||
continue
|
||||
has_bold = "bold" in lower
|
||||
has_italic = "italic" in lower or "oblique" in lower
|
||||
score = 0
|
||||
if bold == has_bold:
|
||||
score += 10
|
||||
if italic == has_italic:
|
||||
score += 10
|
||||
if not has_italic:
|
||||
score += 1
|
||||
scored.append((score, os.path.join(dirpath, filename)))
|
||||
for _score, path in sorted(scored, reverse=True):
|
||||
yield path
|
||||
|
||||
|
||||
def _etk_timeline_text_font(font_family, size, bold=False, italic=False):
|
||||
size = max(1, int(round(float(size))))
|
||||
for candidate in _etk_timeline_text_font_candidates(font_family, bold, italic):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
return ImageFont.truetype(str(font_family or ""), size)
|
||||
except OSError:
|
||||
try:
|
||||
return ImageFont.load_default(size=size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _render_etk_timeline_text_layer(layer, width, height):
|
||||
canvas = Image.new("RGBA", (int(width), int(height)), (0, 0, 0, 0))
|
||||
text = str(layer.get("text", "") or "")
|
||||
if not text:
|
||||
return canvas
|
||||
font = _etk_timeline_text_font(
|
||||
layer.get("font_family", "sans-serif"),
|
||||
layer.get("font_size", 72),
|
||||
layer.get("bold", False),
|
||||
layer.get("italic", False),
|
||||
)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
color = ImageColor.getcolor(_etk_normalize_timeline_text_color(layer.get("color", "#ffffff")), "RGBA")
|
||||
outline_width = max(0, int(round(float(layer.get("outline_width", 0) or 0))))
|
||||
outline_color = ImageColor.getcolor(_etk_normalize_timeline_text_color(layer.get("outline_color", "#000000"), "#000000"), "RGBA")
|
||||
shadow_color = ImageColor.getcolor(_etk_normalize_timeline_text_color(layer.get("shadow_color", "#000000"), "#000000"), "RGBA")
|
||||
shadow_blur = max(0.0, float(layer.get("shadow_blur", 0) or 0))
|
||||
shadow_offset_x = float(layer.get("shadow_offset_x", 0) or 0)
|
||||
shadow_offset_y = float(layer.get("shadow_offset_y", 0) or 0)
|
||||
align = _etk_normalize_timeline_text_align(layer.get("align", "center"))
|
||||
lines = text.splitlines() or [text]
|
||||
boxes = [draw.textbbox((0, 0), line if line else " ", font=font, stroke_width=outline_width) for line in lines]
|
||||
heights = [max(1, bottom - top) for _left, top, _right, bottom in boxes]
|
||||
line_step = max(1, int(round(max(heights) * 1.18)))
|
||||
total_height = line_step * len(lines)
|
||||
y = (height - total_height) / 2
|
||||
placements = []
|
||||
for line, box in zip(lines, boxes):
|
||||
left, top, right, _bottom = box
|
||||
text_width = right - left
|
||||
if align == "left":
|
||||
x = -left
|
||||
elif align == "right":
|
||||
x = width - text_width - left
|
||||
else:
|
||||
x = (width - text_width) / 2 - left
|
||||
placements.append((line, x, y - top))
|
||||
y += line_step
|
||||
|
||||
if shadow_color[3] > 0 and (shadow_blur > 0 or shadow_offset_x or shadow_offset_y):
|
||||
shadow_layer = Image.new("RGBA", (int(width), int(height)), (0, 0, 0, 0))
|
||||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||||
for line, x, text_y in placements:
|
||||
shadow_draw.text(
|
||||
(x + shadow_offset_x, text_y + shadow_offset_y),
|
||||
line,
|
||||
font=font,
|
||||
fill=shadow_color,
|
||||
stroke_width=outline_width,
|
||||
stroke_fill=shadow_color,
|
||||
)
|
||||
if shadow_blur > 0:
|
||||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
|
||||
canvas = Image.alpha_composite(canvas, shadow_layer)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
for line, x, text_y in placements:
|
||||
draw.text(
|
||||
(x, text_y),
|
||||
line,
|
||||
font=font,
|
||||
fill=color,
|
||||
stroke_width=outline_width,
|
||||
stroke_fill=outline_color,
|
||||
)
|
||||
return canvas
|
||||
|
||||
|
||||
def _fit_etk_timeline_keyframe_image(keyframe, width, height):
|
||||
layers = [
|
||||
layer for layer in keyframe.get("layers", [])
|
||||
if layer.get("image") or layer.get("type") == "text"
|
||||
]
|
||||
if layers:
|
||||
composite = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
used_layer = False
|
||||
for layer in layers:
|
||||
if layer.get("type") == "text":
|
||||
source = _render_etk_timeline_text_layer(layer, width, height)
|
||||
else:
|
||||
source = _load_etk_timeline_layer_source(keyframe, layer)
|
||||
source = _apply_etk_timeline_filters(
|
||||
source,
|
||||
layer.get("brightness", 1.0),
|
||||
layer.get("contrast", 1.0),
|
||||
)
|
||||
fitted = _fit_etk_timeline_image(
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
layer.get("fit_mode", "crop"),
|
||||
layer.get("pan_x", 0.0),
|
||||
layer.get("pan_y", 0.0),
|
||||
layer.get("zoom", 1.0),
|
||||
transparent_pad=True,
|
||||
)
|
||||
composite = Image.alpha_composite(composite, fitted.convert("RGBA"))
|
||||
used_layer = True
|
||||
return composite if used_layer else None
|
||||
|
||||
connected_image = keyframe.get("_input_image")
|
||||
if connected_image is not None:
|
||||
return _fit_etk_timeline_image(
|
||||
_apply_etk_timeline_filters(connected_image, keyframe.get("brightness", 1.0), keyframe.get("contrast", 1.0)),
|
||||
width,
|
||||
height,
|
||||
keyframe["fit_mode"],
|
||||
keyframe["pan_x"],
|
||||
keyframe["pan_y"],
|
||||
keyframe["zoom"],
|
||||
)
|
||||
if not keyframe["image"]:
|
||||
return None
|
||||
image_path = _resolve_etk_timeline_image_path(keyframe["image"])
|
||||
with Image.open(image_path) as loaded:
|
||||
return _fit_etk_timeline_image(
|
||||
_apply_etk_timeline_filters(loaded, keyframe.get("brightness", 1.0), keyframe.get("contrast", 1.0)),
|
||||
width,
|
||||
height,
|
||||
keyframe["fit_mode"],
|
||||
keyframe["pan_x"],
|
||||
keyframe["pan_y"],
|
||||
keyframe["zoom"],
|
||||
)
|
||||
|
||||
|
||||
def _etk_passthrough_connected_image_list(images):
|
||||
if images is None:
|
||||
return None
|
||||
if not torch.is_tensor(images):
|
||||
return None
|
||||
tensor = images.detach().clone().float().clamp(0.0, 1.0)
|
||||
if tensor.ndim == 3:
|
||||
tensor = tensor.unsqueeze(0)
|
||||
if tensor.ndim != 4:
|
||||
raise ValueError(f"connected timeline images must have shape [B,H,W,C], got {tuple(tensor.shape)}")
|
||||
if tensor.shape[-1] == 1:
|
||||
tensor = tensor.expand(*tensor.shape[:-1], 3).clone()
|
||||
if tensor.shape[-1] == 4:
|
||||
tensor = tensor[..., :3].clone()
|
||||
if tensor.shape[-1] != 3:
|
||||
raise ValueError("connected timeline images must have 1, 3, or 4 channels")
|
||||
return [image.unsqueeze(0).contiguous() for image in tensor]
|
||||
|
||||
|
||||
def _etk_passthrough_connected_image_batch(images):
|
||||
image_list = _etk_passthrough_connected_image_list(images)
|
||||
if image_list is None:
|
||||
return None
|
||||
return torch.cat(image_list, dim=0).contiguous()
|
||||
|
||||
|
||||
def _etk_unedited_source_image_tensor(image, width, height):
|
||||
fitted = _fit_etk_timeline_image(
|
||||
ImageOps.exif_transpose(image),
|
||||
width,
|
||||
height,
|
||||
"pad",
|
||||
)
|
||||
return _pil_to_comfy_image_tensor(fitted.convert("RGBA")).unsqueeze(0).contiguous()
|
||||
|
||||
|
||||
def _etk_connected_unedited_source_batch(images, width, height):
|
||||
pil_images = _comfy_image_tensor_batch_to_pil(images)
|
||||
if not pil_images:
|
||||
return None
|
||||
return torch.cat(
|
||||
[_etk_unedited_source_image_tensor(image, width, height) for image in pil_images],
|
||||
dim=0,
|
||||
).contiguous()
|
||||
|
||||
|
||||
def _load_etk_timeline_keyframe_unedited_sources(keyframe, width=None, height=None):
|
||||
layers = [
|
||||
layer for layer in keyframe.get("layers", [])
|
||||
if layer.get("image") or layer.get("type") == "text"
|
||||
]
|
||||
if layers:
|
||||
sources = []
|
||||
for layer in layers:
|
||||
if layer.get("type") == "text":
|
||||
if width is None or height is None:
|
||||
continue
|
||||
sources.append(_render_etk_timeline_text_layer(layer, width, height))
|
||||
else:
|
||||
sources.append(_load_etk_timeline_layer_source(keyframe, layer))
|
||||
return sources
|
||||
|
||||
if keyframe.get("_input_image") is not None:
|
||||
return [ImageOps.exif_transpose(keyframe["_input_image"]).copy()]
|
||||
|
||||
if keyframe.get("image"):
|
||||
image_path = _resolve_etk_timeline_image_path(keyframe["image"])
|
||||
with Image.open(image_path) as loaded:
|
||||
return [ImageOps.exif_transpose(loaded).copy()]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _load_etk_timeline_keyframe_unedited_source(keyframe):
|
||||
sources = _load_etk_timeline_keyframe_unedited_sources(keyframe)
|
||||
return sources[0] if sources else None
|
||||
|
||||
|
||||
def _etk_timeline_unedited_source_list(images, width, height):
|
||||
if not images:
|
||||
return [torch.zeros((1, height, width, 4), dtype=torch.float32)]
|
||||
return [
|
||||
_etk_unedited_source_image_tensor(image, width, height)
|
||||
for image in images
|
||||
]
|
||||
|
||||
|
||||
def _etk_timeline_unedited_source_batch(images, width, height):
|
||||
return torch.cat(_etk_timeline_unedited_source_list(images, width, height), dim=0).contiguous()
|
||||
|
||||
|
||||
def _etk_timeline_image_batch(images, width, height):
|
||||
if not images:
|
||||
return torch.zeros((1, height, width, 3), dtype=torch.float32)
|
||||
return torch.stack([_pil_to_comfy_image_tensor(image.convert("RGBA")) for image in images], dim=0)
|
||||
|
||||
|
||||
def _etk_timeline_alpha_tensor(image):
|
||||
tensor = _pil_to_comfy_image_tensor(image)
|
||||
if tensor.ndim == 3 and tensor.shape[-1] >= 4:
|
||||
return tensor[:, :, 3].clamp(0.0, 1.0)
|
||||
return torch.ones((image.height, image.width), dtype=torch.float32)
|
||||
|
||||
|
||||
def _etk_alpha_noise_mask(image, strength, latent_height, latent_width, device, dtype):
|
||||
alpha = _etk_timeline_alpha_tensor(image)
|
||||
if torch.all(alpha >= 1.0):
|
||||
return None
|
||||
alpha = alpha.to(device=device, dtype=dtype).view(1, 1, 1, image.height, image.width)
|
||||
alpha = torch.nn.functional.interpolate(
|
||||
alpha.view(1, 1, image.height, image.width),
|
||||
size=(latent_height, latent_width),
|
||||
mode="area",
|
||||
).view(1, 1, 1, latent_height, latent_width)
|
||||
return (1.0 - (float(strength) * alpha)).clamp(0.0, 1.0)
|
||||
Reference in New Issue
Block a user