"""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)