Release standalone timeline editor 1.0.0

This commit is contained in:
Hopping Mad Games
2026-08-26 09:55:12 -06:00
commit 869537cefd
27 changed files with 7739 additions and 0 deletions
+357
View File
@@ -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)