From 028eda753c4264559da6b221740ed4d77d9e39b1 Mon Sep 17 00:00:00 2001 From: scofano <24534225+scofano@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:32:25 -0300 Subject: [PATCH] feat: add Instagram Question Box nodes (image + video) Two new nodes under Advanced Text Overlay: - Instagram Question Box (image) - Instagram Question Box - Video Features: - Two-panel sticker layout: dark header + light body with independent pixel heights (header_height, body_height) - Anti-aliased rounded corners via 4x supersampled rendering + LANCZOS downscale - Separate font, size, letter_spacing, fill color/alpha for question (header) and answer (body) - answer_line_spacing and answer_padding controls - HTML rich text + \n line breaks in answer text (reuses InlineRichTextParser) - Soft drop-shadow with Gaussian blur - Same animation system as main nodes (fade, move_from_*, easing, pause) - Video variant preserves audio via ffmpeg re-mux - Defaults tuned for 1080-wide portrait stories Co-Authored-By: Claude Sonnet 4.6 --- README.md | 55 ++++- nodes.py | 723 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 777 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ea5707c..6526dbe 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,12 @@ This module provides the **most feature‑rich and precise text overlay system a * Full animation engine (fade + directional movement) * Batch-aware rendering with smart caching * Full video processing node with audio‑preserving re‑mux -* **NEW: Font dropdown with system font discovery** +* Font dropdown with system font discovery - Automatically detects installed fonts on Windows, macOS, and Linux - Cross-platform font scanning - Fallback to common font names for compatibility - Reusable font system for future nodes +* **NEW: Instagram Question Box nodes** — render a two-panel sticker overlay (dark header + light body) matching the Instagram "Ask me a question" story format, with full typography, color, shadow, and animation controls ## ✨ Key Features @@ -188,6 +189,58 @@ The **Advanced Text Overlay – Video** node: * **Automatically re‑injects the original audio track with ffmpeg** * Optional `delete_original` flag +### ✔️ 9. Instagram Question Box + +Two dedicated nodes — **`Instagram Question Box`** (image) and **`Instagram Question Box – Video`** — render a two-panel sticker that replicates the Instagram "Ask me a question" story format. + +**Layout** + +``` +┌──────────────────────────────────┐ +│ Ask me a question ← header │ ← header_height px, header_color_hex +├──────────────────────────────────┤ +│ │ +│ Your answer text here │ ← body_height px, body_color_hex +│ │ +└──────────────────────────────────┘ +``` + +* All four outer corners are fully rounded (`corner_radius`) with **anti-aliased edges** (4× supersampled rendering) +* The header/body divider is flat — only the outer corners are rounded +* Optional soft drop-shadow (`box_shadow_enable`, `box_shadow_blur`, `box_shadow_distance`) + +**Typography** + +* Separate font, size, letter spacing, and fill color/alpha for **question** (header) and **answer** (body) +* `answer_line_spacing` controls vertical gap between wrapped lines +* `answer_padding` sets inner padding on all sides of the body text area +* Answer text supports the same **HTML-like rich text** as the main overlay nodes (``, ``, `
`, ``, `\n`) — bold/italic load the correct font variant automatically + +**Positioning & Animation** + +* `box_width`, `header_height`, `body_height` — independent pixel dimensions +* `horizontal_alignment` / `vertical_alignment` + `x_shift` / `y_shift` for placement +* Same full animation system as the main nodes (`fade_in`, `move_from_*`, easing, `pause_frames_before_start`) + +**Video variant extras** + +* `pause_seconds_before_start` (converted to frames using source FPS) +* Audio preserved via ffmpeg re-mux +* Optional `delete_original` + +**Default values** are tuned for a 1080-wide portrait story: + +| Parameter | Default | +| --------- | ------- | +| `box_width` | 950 | +| `header_height` | 90 | +| `body_height` | 200 | +| `corner_radius` | 32 | +| `question_font` | Arial Black | +| `question_font_size` | 30 | +| `answer_font_size` | 50 | +| `box_shadow_blur` | 15 | + --- ## 📥 Installation diff --git a/nodes.py b/nodes.py index cce463f..5a7821e 100644 --- a/nodes.py +++ b/nodes.py @@ -1265,7 +1265,730 @@ class TextOverlayVideo: return (out_path,) +class IGQuestionBox: + """ + Instagram-style question box overlay. + Renders a two-panel sticker (dark header + light body) on an image. + Supports the same animation system as Advanced Text Overlay. + """ + + _horizontal_alignments = ["left", "center", "right"] + _vertical_alignments = ["top", "middle", "bottom"] + _animation_kinds = ["fade_in", "fade_out", + "move_from_top", "move_from_bottom", + "move_from_left", "move_from_right"] + _ease_options = ["linear", "ease_in", "ease_out", "ease_in_out"] + + @classmethod + def INPUT_TYPES(cls): + fonts = get_available_fonts() + default_font = fonts[0] if fonts else "Arial" + return { + "required": { + "image": ("IMAGE",), + + # Text content + "question_text": ("STRING", {"default": "Ask me a question", "multiline": False}), + "answer_text": ("STRING", {"default": "", "multiline": True}), + + # Question / header font + "question_font": (fonts, {"default": "Arial Black"}), + "question_font_size": ("INT", {"default": 30, "min": 1, "max": 999, "step": 1}), + "question_letter_spacing": ("FLOAT", {"default": -0.5, "min": -10.0,"max": 50.0, "step": 0.5}), + "question_fill_color_hex": ("STRING", {"default": "#FFFFFF"}), + "question_fill_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}), + + # Answer / body font + "answer_font": (fonts, {"default": "Arial"}), + "answer_font_size": ("INT", {"default": 50, "min": 1, "max": 999, "step": 1}), + "answer_letter_spacing": ("FLOAT", {"default": 0.0, "min": -10.0,"max": 50.0, "step": 0.5}), + "answer_line_spacing": ("FLOAT", {"default": 4.0, "min": 0.0, "max": 100.0,"step": 0.5}), + "answer_fill_color_hex": ("STRING", {"default": "#000000"}), + "answer_fill_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}), + + # Box dimensions + "box_width": ("INT", {"default": 950, "min": 50, "max": 4096, "step": 1}), + "header_height": ("INT", {"default": 90, "min": 10, "max": 2048, "step": 1}), + "body_height": ("INT", {"default": 200, "min": 10, "max": 2048, "step": 1}), + "answer_padding":("INT", {"default": 16, "min": 0, "max": 512, "step": 1}), + + # Colors + "header_color_hex": ("STRING", {"default": "#323b42"}), + "header_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}), + "body_color_hex": ("STRING", {"default": "#FFFFFF"}), + "body_alpha": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}), + + # Shape + "corner_radius": ("INT", {"default": 32, "min": 0, "max": 200, "step": 1}), + + # Box shadow + "box_shadow_enable": ("BOOLEAN", {"default": True}), + "box_shadow_color_hex": ("STRING", {"default": "#000000"}), + "box_shadow_alpha": ("FLOAT", {"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}), + "box_shadow_distance": ("INT", {"default": 10, "min": 0, "max": 100, "step": 1}), + "box_shadow_blur": ("INT", {"default": 15, "min": 0, "max": 60, "step": 1}), + + # Box positioning + "horizontal_alignment": (cls._horizontal_alignments, {"default": "center"}), + "vertical_alignment": (cls._vertical_alignments, {"default": "middle"}), + "x_shift": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + "y_shift": ("INT", {"default": 0, "min": -4096, "max": 4096, "step": 1}), + + # Animation + "animate": ("BOOLEAN", {"default": False}), + "animation_kind": (cls._animation_kinds, {"default": "fade_in"}), + "animation_frames": ("INT", {"default": 32, "min": 1, "max": 1000, "step": 1}), + "animation_ease": (cls._ease_options, {"default": "ease_in_out"}), + "animation_opacity_target": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "display": "slider"}), + "pause_frames_before_start":("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "batch_process" + CATEGORY = "Advanced Text Overlay" + + # ── helpers ────────────────────────────────────────────────────────────── + + def hex_to_rgb(self, hex_color: str, fallback=(255, 255, 255)): + try: + h = (hex_color or "").strip().lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + if len(h) != 6: + return fallback + return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) + except Exception: + return fallback + + def _load_font(self, font_name, font_size, bold=False, italic=False): + if not hasattr(self, "_font_cache"): + self._font_cache = {} + key = (font_name, font_size, bool(bold), bool(italic)) + if key in self._font_cache: + return self._font_cache[key] + font_path = get_font_variant_path(font_name, bold=bold, italic=italic) + try: + obj = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default() + except Exception: + obj = ImageFont.load_default() + self._font_cache[key] = obj + return obj + + def _parse_answer_lines(self, draw, text, font_name, font_size, max_width, + letter_spacing, default_fill_hex): + """Parse HTML + \\n text into lines of (word, fill_hex, font_obj) tuples.""" + # Normalise escape sequences + text = (text or "").replace("\\n", "\n").replace("\\N", "\n") + + parser = InlineRichTextParser() + parser.feed(text) + + # Build a flat word stream: ("\n", ...) marks a forced line break + word_stream = [] # list of (word, fill_hex, bold, italic) + for run in parser.runs: + style = run["style"] + fill = style.get("fill") or default_fill_hex + bold = bool(style.get("bold")) + italic = bool(style.get("italic")) + for i, part in enumerate(run["text"].split("\n")): + for w in part.split(" "): + if w: + word_stream.append((w, fill, bold, italic)) + if i < len(run["text"].split("\n")) - 1: + word_stream.append(("\n", fill, bold, italic)) + + # Greedy word-wrap + space_w = draw.textlength(" ", font=self._load_font(font_name, font_size)) + lines = [] + cur_line = [] # list of (word, fill_hex, font_obj) + cur_w = 0.0 + + for (word, fill, bold, italic) in word_stream: + if word == "\n": + lines.append(cur_line) + cur_line, cur_w = [], 0.0 + continue + fobj = self._load_font(font_name, font_size, bold=bold, italic=italic) + ww = self._text_width_spaced(draw, word, fobj, letter_spacing) + need = (cur_w + space_w + ww) if cur_line else ww + if cur_line and need > max_width: + lines.append(cur_line) + cur_line, cur_w = [(word, fill, fobj)], ww + else: + if cur_line: + cur_w += space_w + cur_line.append((word, fill, fobj)) + cur_w += ww + + lines.append(cur_line) # flush last line (may be empty) + return lines + + def _text_width_spaced(self, draw, text, font, letter_spacing): + """Total advance width of `text` with inter-character spacing.""" + if not text: + return 0.0 + total = 0.0 + for i, ch in enumerate(text): + total += draw.textlength(ch, font=font) + if i < len(text) - 1: + total += letter_spacing + return total + + def _draw_text_spaced(self, draw, xy, text, font, fill, letter_spacing): + """Draw `text` character by character respecting `letter_spacing`.""" + x, y = xy + for i, ch in enumerate(text): + draw.text((x, y), ch, font=font, fill=fill) + x += draw.textlength(ch, font=font) + if i < len(text) - 1: + x += letter_spacing + + def _wrap_text(self, draw, text, font, max_width, letter_spacing=0.0): + """Greedy word-wrap honouring letter_spacing; returns list of lines.""" + words = (text or "").split() + if not words: + return [] + lines, current = [], "" + for word in words: + test = (current + " " + word).strip() + if self._text_width_spaced(draw, test, font, letter_spacing) <= max_width: + current = test + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines + + # ── core drawing ───────────────────────────────────────────────────────── + + def draw_question_box( + self, + image, + question_text, + answer_text, + question_font_name, + question_font_size, + question_letter_spacing, + question_fill_color_hex, + question_fill_alpha, + answer_font_name, + answer_font_size, + answer_letter_spacing, + answer_line_spacing, + answer_fill_color_hex, + answer_fill_alpha, + box_width, + header_height, + body_height, + answer_padding, + header_color_hex, + header_alpha, + body_color_hex, + body_alpha, + corner_radius, + box_shadow_enable, + box_shadow_color_hex, + box_shadow_alpha, + box_shadow_distance, + box_shadow_blur, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + opacity_scale=1.0, + dx=0, + dy=0, + ): + from PIL import ImageFilter + + if image.mode != "RGBA": + image = image.convert("RGBA") + + img_w, img_h = image.size + bw = max(10, int(box_width)) + header_h = max(1, int(header_height)) + body_h = max(1, int(body_height)) + bh = header_h + body_h + cr = max(0, int(corner_radius)) + ap = max(0, int(answer_padding)) + opacity_scale = max(0.0, min(1.0, float(opacity_scale))) + + # Box top-left + if horizontal_alignment == "left": + bx = int(x_shift) + elif horizontal_alignment == "right": + bx = int(img_w - bw - x_shift) + else: + bx = int((img_w - bw) / 2 + x_shift) + + if vertical_alignment == "top": + by = int(y_shift) + elif vertical_alignment == "bottom": + by = int(img_h - bh - y_shift) + else: + by = int((img_h - bh) / 2 + y_shift) + + bx = int(round(bx + dx)) + by = int(round(by + dy)) + + # Load fonts + q_font = self._load_font(question_font_name, question_font_size) + a_font = self._load_font(answer_font_name, answer_font_size) + + # Resolve RGBA tuples + def _rgba(hex_col, alpha_f): + rgb = self.hex_to_rgb(hex_col) + a = int(max(0.0, min(1.0, float(alpha_f) * opacity_scale)) * 255) + return (*rgb, a) + + header_rgba = _rgba(header_color_hex, header_alpha) + body_rgba = _rgba(body_color_hex, body_alpha) + q_rgba = _rgba(question_fill_color_hex, question_fill_alpha) + a_rgba = _rgba(answer_fill_color_hex, answer_fill_alpha) + + # ── shadow ─────────────────────────────────────────────────────────── + if box_shadow_enable: + sh_rgba = _rgba(box_shadow_color_hex, box_shadow_alpha) + sd = int(box_shadow_distance) + shadow_layer = Image.new("RGBA", image.size, (0, 0, 0, 0)) + sd_draw = ImageDraw.Draw(shadow_layer, "RGBA") + s_rect = [bx + sd, by + sd, bx + bw + sd, by + bh + sd] + try: + sd_draw.rounded_rectangle(s_rect, radius=cr, fill=sh_rgba) + except Exception: + sd_draw.rectangle(s_rect, fill=sh_rgba) + blur_r = max(0, int(box_shadow_blur)) + if blur_r > 0: + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=blur_r)) + image = Image.alpha_composite(image, shadow_layer) + + # ── box layers (supersampled for anti-aliased corners) ────────────── + SS = 4 + ss_box = Image.new("RGBA", (bw * SS, bh * SS), (0, 0, 0, 0)) + ss_od = ImageDraw.Draw(ss_box, "RGBA") + lcr = cr * SS + lhh = header_h * SS + lbh = body_h * SS + lw = bw * SS + lh = bh * SS + + # 1. Full rounded box in header color + try: + ss_od.rounded_rectangle([0, 0, lw, lh], radius=lcr, fill=header_rgba) + except Exception: + ss_od.rectangle([0, 0, lw, lh], fill=header_rgba) + + # 2. Body section: rounded bottom, flat top + if body_h > 0: + try: + ss_od.rounded_rectangle([0, lhh, lw, lh], radius=lcr, fill=body_rgba) + # Flatten the top corners of the body section + ss_od.rectangle([0, lhh, lw, lhh + min(lcr, lbh)], fill=body_rgba) + except Exception: + ss_od.rectangle([0, lhh, lw, lh], fill=body_rgba) + + # Downscale to native size with LANCZOS for smooth anti-aliased corners + box_img = ss_box.resize((bw, bh), Image.LANCZOS) + box_layer = Image.new("RGBA", image.size, (0, 0, 0, 0)) + box_layer.paste(box_img, (bx, by)) + image = Image.alpha_composite(image, box_layer) + + # Separate overlay for text (drawn at native resolution on top of the box) + overlay = Image.new("RGBA", image.size, (0, 0, 0, 0)) + od = ImageDraw.Draw(overlay, "RGBA") + + # ── question text (header, single line) ────────────────────────────── + q_ls = float(question_letter_spacing) + if question_text and q_rgba[3] > 0: + q_text = question_text.replace("\\n", " ").replace("\\N", " ").replace("\n", " ").strip() + q_max_w = bw - 16 + # Truncate with ellipsis if too wide + if self._text_width_spaced(od, q_text, q_font, q_ls) > q_max_w: + while len(q_text) > 0 and self._text_width_spaced(od, q_text + "...", q_font, q_ls) > q_max_w: + q_text = q_text[:-1] + q_text = q_text.rstrip() + "..." + q_w = self._text_width_spaced(od, q_text, q_font, q_ls) + q_x = bx + (bw - q_w) / 2 + try: + qb = od.textbbox((0, 0), q_text, font=q_font) + q_h, q_top = qb[3] - qb[1], qb[1] + except Exception: + q_h, q_top = question_font_size, 0 + q_y = by + (header_h - q_h) / 2 - q_top + self._draw_text_spaced(od, (q_x, q_y), q_text, q_font, q_rgba, q_ls) + + # ── answer text (body, HTML + \n aware, word-wrapped, centered) ───────── + a_ls = float(answer_letter_spacing) + if answer_text and a_rgba[3] > 0: + a_max_w = max(1, bw - 2 * ap) + lines = self._parse_answer_lines( + od, answer_text, answer_font_name, answer_font_size, + a_max_w, a_ls, answer_fill_color_hex, + ) + if lines: + try: + ref_bbox = od.textbbox((0, 0), "Ay", font=a_font) + line_h = ref_bbox[3] - ref_bbox[1] + ref_top = ref_bbox[1] + except Exception: + line_h, ref_top = answer_font_size + 4, 0 + line_gap = max(0, int(answer_line_spacing)) + total_h = len(lines) * line_h + max(0, len(lines) - 1) * line_gap + avail_h = body_h - 2 * ap + text_y = by + header_h + ap + max(0, (avail_h - total_h) / 2) - ref_top + space_w = od.textlength(" ", font=a_font) + + for line in lines: + # Measure full line width for centering + if line: + lw = sum(self._text_width_spaced(od, w, fobj, a_ls) + for w, _, fobj in line) + lw += space_w * max(0, len(line) - 1) + else: + lw = 0.0 + lx = bx + ap + max(0, (a_max_w - lw) / 2) + + for i, (word, fill_hex, fobj) in enumerate(line): + seg_rgb = self.hex_to_rgb(fill_hex) if fill_hex else None + seg_rgba = (*seg_rgb, a_rgba[3]) if seg_rgb else a_rgba + self._draw_text_spaced(od, (lx, text_y), word, fobj, seg_rgba, a_ls) + lx += self._text_width_spaced(od, word, fobj, a_ls) + if i < len(line) - 1: + lx += space_w + + text_y += line_h + line_gap + + image = Image.alpha_composite(image, overlay) + return image.convert("RGB") + + # ── ComfyUI entrypoint ─────────────────────────────────────────────────── + + def batch_process( + self, + image, + question_text, + answer_text, + question_font, + question_font_size, + question_letter_spacing, + question_fill_color_hex, + question_fill_alpha, + answer_font, + answer_font_size, + answer_letter_spacing, + answer_line_spacing, + answer_fill_color_hex, + answer_fill_alpha, + box_width, + header_height, + body_height, + answer_padding, + header_color_hex, + header_alpha, + body_color_hex, + body_alpha, + corner_radius, + box_shadow_enable, + box_shadow_color_hex, + box_shadow_alpha, + box_shadow_distance, + box_shadow_blur, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + animate=False, + animation_kind="fade_in", + animation_frames=32, + animation_ease="ease_in_out", + animation_opacity_target=1.0, + pause_frames_before_start=0, + ): + pause_frames = max(0, int(pause_frames_before_start)) + + def _draw(pil_img, opacity_scale=1.0, dx=0, dy=0): + return self.draw_question_box( + pil_img, + question_text, answer_text, + question_font, question_font_size, question_letter_spacing, + question_fill_color_hex, question_fill_alpha, + answer_font, answer_font_size, answer_letter_spacing, answer_line_spacing, + answer_fill_color_hex, answer_fill_alpha, + box_width, header_height, body_height, answer_padding, + header_color_hex, header_alpha, + body_color_hex, body_alpha, + corner_radius, + box_shadow_enable, box_shadow_color_hex, + box_shadow_alpha, box_shadow_distance, box_shadow_blur, + horizontal_alignment, vertical_alignment, + x_shift, y_shift, + opacity_scale=opacity_scale, dx=dx, dy=dy, + ) + + # Single image (H, W, C) + if len(image.shape) == 3: + np_img = image.cpu().numpy() + pil_img = Image.fromarray((np_img * 255).astype(np.uint8)) + + if not animate: + out_img = _draw(pil_img) + return (torch.tensor(np.array(out_img).astype(np.float32) / 255.0),) + + T = max(1, int(animation_frames)) + outs = [] + for t_idx in range(T): + if t_idx < pause_frames: + out_img = pil_img.copy() + else: + active = max(1, T - pause_frames) + local = t_idx - pause_frames + eff = min(local, active - 1) + p = animations.progress(eff, max(1, active - 1), animation_ease) + op = animations.compute_opacity(animation_kind, p, float(animation_opacity_target)) + dx, dy = animations.compute_offsets(animation_kind, p, pil_img.width, pil_img.height) + out_img = _draw(pil_img, opacity_scale=op, dx=dx, dy=dy) + outs.append(np.array(out_img).astype(np.float32) / 255.0) + return (torch.tensor(np.stack(outs)),) + + # Batch (B, H, W, C) + if not (hasattr(image, "shape") and len(image.shape) == 4): + raise ValueError("Unsupported image tensor shape") + + B = image.shape[0] + T = max(1, int(animation_frames)) + out_list = [] + + for i in range(B): + np_img = image[i].cpu().numpy() + pil_img = Image.fromarray((np_img * 255).astype(np.uint8)) + + if i < pause_frames: + out_img = pil_img + elif not animate: + out_img = _draw(pil_img) + else: + eff_t = min(i - pause_frames, T - 1) + p = animations.progress(eff_t, max(1, T - 1), animation_ease) + op = animations.compute_opacity(animation_kind, p, float(animation_opacity_target)) + dx, dy = animations.compute_offsets(animation_kind, p, pil_img.width, pil_img.height) + out_img = _draw(pil_img, opacity_scale=op, dx=dx, dy=dy) + + out_list.append(np.array(out_img).astype(np.float32) / 255.0) + + return (torch.tensor(np.stack(out_list)),) + + +class IGQuestionBoxVideo: + """ + Video version of Instagram Question Box overlay. + Accepts a video file path, overlays the question box on every frame, + preserves the original audio track, and returns the output path. + """ + + OUTPUT_NODE = True + + _horizontal_alignments = IGQuestionBox._horizontal_alignments + _vertical_alignments = IGQuestionBox._vertical_alignments + _animation_kinds = IGQuestionBox._animation_kinds + _ease_options = IGQuestionBox._ease_options + + @classmethod + def INPUT_TYPES(cls): + base = IGQuestionBox.INPUT_TYPES()["required"].copy() + base.pop("image") + base["pause_seconds_before_start"] = base.pop("pause_frames_before_start") + + required = { + "video_path": ("STRING", {"multiline": False, "default": ""}), + "filename_prefix": ("STRING", {"default": "IGQuestion"}), + "delete_original": ("BOOLEAN", {"default": False}), + } + required.update(base) + return {"required": required} + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("video_path",) + FUNCTION = "process_video" + CATEGORY = "Advanced Text Overlay" + + def _get_output_dir(self): + try: + import folder_paths + return folder_paths.get_output_directory() + except Exception: + out_dir = os.path.join(os.getcwd(), "output") + os.makedirs(out_dir, exist_ok=True) + return out_dir + + def _make_unique_path(self, out_dir, filename_prefix, src_path): + src_base = os.path.splitext(os.path.basename(src_path))[0] + base_name = f"{filename_prefix}_{src_base}.mp4" + out_full = os.path.join(out_dir, base_name) + idx = 1 + while os.path.exists(out_full): + base_name = f"{filename_prefix}_{src_base}_{idx}.mp4" + out_full = os.path.join(out_dir, base_name) + idx += 1 + return out_full + + def process_video( + self, + video_path, + filename_prefix, + delete_original, + question_text, + answer_text, + question_font, + question_font_size, + question_letter_spacing, + question_fill_color_hex, + question_fill_alpha, + answer_font, + answer_font_size, + answer_letter_spacing, + answer_line_spacing, + answer_fill_color_hex, + answer_fill_alpha, + box_width, + header_height, + body_height, + answer_padding, + header_color_hex, + header_alpha, + body_color_hex, + body_alpha, + corner_radius, + box_shadow_enable, + box_shadow_color_hex, + box_shadow_alpha, + box_shadow_distance, + box_shadow_blur, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + animate, + animation_kind, + animation_frames, + animation_ease, + animation_opacity_target, + pause_seconds_before_start, + ): + if not video_path or not os.path.exists(video_path): + raise FileNotFoundError(f"Video file not found: {video_path}") + + out_dir = self._get_output_dir() + out_path = self._make_unique_path(out_dir, filename_prefix, video_path) + + box_node = IGQuestionBox() + + reader = imageio.get_reader(video_path) + meta = reader.get_meta_data() + fps = meta.get("fps", 30) + + try: + pause_frames = max(0, int(round(float(pause_seconds_before_start) * float(fps)))) + except Exception: + pause_frames = max(0, int(pause_seconds_before_start)) + + nframes_meta = meta.get("nframes", None) + duration = meta.get("duration", None) + total_frames = None + if isinstance(nframes_meta, (int, float)) and 0 < nframes_meta < 1e8: + total_frames = int(nframes_meta) + elif isinstance(duration, (int, float)) and duration > 0 and fps > 0: + total_frames = int(duration * fps) + + comfy_pbar = None + if ProgressBar is not None and isinstance(total_frames, int) and total_frames > 0: + comfy_pbar = ProgressBar(total_frames) + + if isinstance(total_frames, int) and total_frames > 0: + frame_iter = tqdm(reader, total=total_frames, desc="IGQuestionBoxVideo") + else: + frame_iter = tqdm(reader, desc="IGQuestionBoxVideo") + + T = max(1, int(animation_frames)) if animate else 1 + + writer = imageio.get_writer(out_path, fps=fps, macro_block_size=1) + + try: + for i, frame in enumerate(frame_iter): + pil_img = Image.fromarray(frame) + + if i < pause_frames: + out_img = pil_img + else: + if animate: + eff_t = min(i - pause_frames, T - 1) + p = animations.progress(eff_t, max(1, T - 1), animation_ease) + op = animations.compute_opacity(animation_kind, p, float(animation_opacity_target)) + dx, dy = animations.compute_offsets(animation_kind, p, pil_img.width, pil_img.height) + else: + op, dx, dy = 1.0, 0, 0 + + out_img = box_node.draw_question_box( + pil_img, + question_text, answer_text, + question_font, question_font_size, question_letter_spacing, + question_fill_color_hex, question_fill_alpha, + answer_font, answer_font_size, answer_letter_spacing, answer_line_spacing, + answer_fill_color_hex, answer_fill_alpha, + box_width, header_height, body_height, answer_padding, + header_color_hex, header_alpha, + body_color_hex, body_alpha, + corner_radius, + box_shadow_enable, box_shadow_color_hex, + box_shadow_alpha, box_shadow_distance, box_shadow_blur, + horizontal_alignment, vertical_alignment, + x_shift, y_shift, + opacity_scale=op, dx=dx, dy=dy, + ) + + writer.append_data(np.array(out_img)) + if comfy_pbar is not None: + comfy_pbar.update(1) + + finally: + writer.close() + reader.close() + + # Mux original audio back + try: + tmp_out = out_path + ".tmp_audio.mp4" + cmd = [ + "ffmpeg", "-y", + "-i", out_path, + "-i", video_path, + "-c", "copy", + "-map", "0:v:0", + "-map", "1:a:0", + tmp_out, + ] + completed = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if completed.returncode == 0: + os.replace(tmp_out, out_path) + else: + print("[IGQuestionBoxVideo] ffmpeg failed to mux audio, keeping silent video.") + print(completed.stderr.decode("utf-8", errors="ignore")) + except Exception as e: + print(f"[IGQuestionBoxVideo] Could not mux audio: {e}") + + if delete_original: + try: + os.remove(video_path) + except Exception as e: + print(f"[IGQuestionBoxVideo] Failed to delete original: {e}") + + return (out_path,) + + NODE_CLASS_MAPPINGS = { "Advanced Text Overlay": TextOverlay, "Advanced Text Overlay - Video": TextOverlayVideo, + "Instagram Question Box": IGQuestionBox, + "Instagram Question Box - Video": IGQuestionBoxVideo, }