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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3cc287ddc7
commit
028eda753c
@@ -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 (`<b>`, `<i>`, `<br>`, `<span color="…">`, `\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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user