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:
scofano
2026-06-01 11:32:25 -03:00
co-authored by Claude Sonnet 4.6
parent 3cc287ddc7
commit 028eda753c
2 changed files with 777 additions and 1 deletions
+54 -1
View File
@@ -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
+723
View File
@@ -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,
}