From 84df0d5db6e0c4650689c6bc2b4cf758997ee663 Mon Sep 17 00:00:00 2001 From: Lilac-Rose Date: Fri, 13 Mar 2026 05:46:05 +0100 Subject: [PATCH] feat: add bad apple avatar command and fix infraction removal views avatar: - add /avatar bad_apple command: tiles user pfp as fill for Bad Apple frames - support two-user mode (user1=silhouette, user2=background), outputs MP4 - single-user mode outputs WebM with alpha transparency - audio sync: detect and trim leading silence via atrim filter, skip equivalent video frames so Discord cannot strip silence at playback - avatar_type global/server fix: use user.avatar explicitly for global - delta_only option: only render pixels that changed from previous frame - parallel frame processing with ThreadPoolExecutor (load + composite) - np.tile replaces PIL paste loop for tiled canvas (major speedup) - frame list cached at cog_load to avoid re-globbing 6572 files each call - uses imageio-ffmpeg bundled binary (no system ffmpeg required) moderation: - fix InfractionRemovalView buttons erroring with closed database after cog reload by opening a fresh connection per button click - add approval_message_id column to infractions table - store message ID when sending approval embed - re-register persistent views on cog_load so buttons survive bot restarts - add inf resend command to re-send a broken approval embed --- image/avatar.py | 234 ++++++++++++++++++++++++++++++++++++++- moderation/infraction.py | 109 ++++++++++++++---- 2 files changed, 317 insertions(+), 26 deletions(-) diff --git a/image/avatar.py b/image/avatar.py index 4c9986a..e9e2bc1 100644 --- a/image/avatar.py +++ b/image/avatar.py @@ -7,23 +7,44 @@ from embed.embed_color import get_embed_color import asyncio import aiohttp import os +import tempfile from pathlib import Path import numpy as np from scipy.ndimage import uniform_filter import cv2 +import imageio_ffmpeg from utils.logger import get_logger logger = get_logger(__name__) +def _composite_frame(dark, changed, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha): + if dual: + output = np.zeros((frame_h, frame_w, 3), dtype=np.uint8) + show1 = dark & changed & (t1_alpha > 0) + show2 = ~dark & changed & (t2_alpha > 0) + output[show1] = t1_rgb[show1] + output[show2] = t2_rgb[show2] + else: + output = np.zeros((frame_h, frame_w, 4), dtype=np.uint8) + show1 = dark & changed & (t1_alpha > 0) + output[show1, :3] = t1_rgb[show1] + output[show1, 3] = t1_alpha[show1] + return output.tobytes() + class AvatarCommands(commands.Cog): def __init__(self, bot): self.bot = bot self.session = None self.explosion_path = Path(__file__).parent.parent / "media" / "explosion-deltarune.gif" self.obama_path = Path(__file__).parent.parent / "media" / "obama.jpg" + self.bad_apple_path = Path(__file__).parent.parent / "media" / "bad-apple" + self.bad_apple_audio_path = Path(__file__).parent.parent / "media" / "bad_apple.mp3" + self._bad_apple_frames: list[Path] = [] async def cog_load(self): self.session = aiohttp.ClientSession() + if self.bad_apple_path.exists(): + self._bad_apple_frames = sorted(self.bad_apple_path.glob("*.jpg")) async def cog_unload(self): if self.session: @@ -33,11 +54,12 @@ class AvatarCommands(commands.Cog): """Returns a valid avatar object (never None).""" use_global = avatar_type_choice and avatar_type_choice.value == "global" - # always safe - if isinstance(user, discord.Member) and not use_global and user.guild_avatar: + if use_global: + # user.avatar is always the global avatar; fall back for default-avatar accounts + return user.avatar or user.display_avatar + if isinstance(user, discord.Member) and user.guild_avatar: return user.guild_avatar - else: - return user.display_avatar + return user.display_avatar avatar_group = app_commands.Group(name="avatar", description="Avatar manipulation commands") @@ -508,5 +530,209 @@ class AvatarCommands(commands.Cog): buf.seek(0) return buf + @avatar_group.command(name="bad_apple", description="Play Bad Apple with avatar(s) tiled as the fill") + @app_commands.describe( + user="User for the black silhouette (defaults to you)", + user2="User for the white background (omit for transparent background)", + tile_count="Number of avatar tiles per row (default 16, range 1–64)", + delta_only="Only show pixels that changed from the previous frame", + avatar_type="Choose between server or global avatar" + ) + @app_commands.choices( + avatar_type=[ + app_commands.Choice(name="Server Avatar", value="server"), + app_commands.Choice(name="Global Avatar", value="global") + ] + ) + async def avatar_bad_apple(self, interaction: discord.Interaction, user: discord.User = None, user2: discord.User = None, tile_count: int = 16, delta_only: bool = False, avatar_type: app_commands.Choice[str] = None): + await interaction.response.defer(thinking=True) + + user = user or interaction.user + + if tile_count < 1 or tile_count > 64: + await interaction.followup.send("Tile count must be 1–64.", ephemeral=True) + return + + if not self.bad_apple_path.exists(): + await interaction.followup.send("Error: Bad Apple frames not found.", ephemeral=True) + return + + try: + if not self.session or self.session.closed: + self.session = aiohttp.ClientSession() + + avatar = self.get_avatar_url(user, avatar_type) + async with self.session.get(str(avatar.with_format("png").with_size(512))) as resp: + resp.raise_for_status() + avatar_bytes = await resp.read() + + avatar2_bytes = None + if user2 is not None: + avatar2 = self.get_avatar_url(user2, avatar_type) + async with self.session.get(str(avatar2.with_format("png").with_size(512))) as resp: + resp.raise_for_status() + avatar2_bytes = await resp.read() + + tmp_raw = tempfile.NamedTemporaryFile(suffix=".raw", delete=False) + tmp_raw.close() + raw_path = tmp_raw.name + + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + audio_offset = await self._detect_audio_offset(ffmpeg_exe) + frame_skip = int(audio_offset * 30) + + frame_w, frame_h, has_alpha = await asyncio.to_thread( + self._process_bad_apple_frames, avatar_bytes, tile_count, raw_path, avatar2_bytes, frame_skip, delta_only + ) + + buf = await self._encode_bad_apple_video(raw_path, frame_w, frame_h, has_alpha, ffmpeg_exe, audio_offset) + + label = f"{user.display_name} vs {user2.display_name}" if user2 else user.display_name + ext = "webm" if has_alpha else "mp4" + await interaction.followup.send( + f"Bad Apple, but it's {label}:", + file=discord.File(buf, filename=f"bad_apple.{ext}") + ) + except Exception: + logger.exception("Error in avatar bad_apple") + await interaction.followup.send("An error occurred while generating the video.", ephemeral=True) + + def _build_tiled(self, avatar_bytes: bytes, frame_w: int, frame_h: int, tile_count: int) -> np.ndarray: + """Build a tiled RGBA canvas of the avatar at frame resolution.""" + img = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA") + tile_w = frame_w // tile_count + tile_h = tile_w + tile_arr = np.array(img.resize((tile_w, tile_h), Image.Resampling.LANCZOS)) + tiles_x = -(-frame_w // tile_w) # ceiling division + tiles_y = -(-frame_h // tile_h) + return np.tile(tile_arr, (tiles_y, tiles_x, 1))[:frame_h, :frame_w] + + def _process_bad_apple_frames(self, avatar_bytes: bytes, tile_count: int, raw_path: str, avatar2_bytes: bytes | None = None, frame_skip: int = 0, delta_only: bool = False) -> tuple[int, int, bool]: + """Process frames and write raw video data to raw_path. Returns (frame_w, frame_h, has_alpha).""" + from concurrent.futures import ThreadPoolExecutor + + FPS = 30 + MAX_FRAMES = FPS * 30 + + frame_files = (self._bad_apple_frames or sorted(self.bad_apple_path.glob("*.jpg")))[frame_skip:frame_skip + MAX_FRAMES] + if not frame_files: + raise ValueError("No Bad Apple frames found") + + first = Image.open(frame_files[0]) + frame_w, frame_h = first.size + + t1 = self._build_tiled(avatar_bytes, frame_w, frame_h, tile_count) + t1_rgb, t1_alpha = t1[:, :, :3], t1[:, :, 3] + + dual = avatar2_bytes is not None + if dual: + t2 = self._build_tiled(avatar2_bytes, frame_w, frame_h, tile_count) + t2_rgb, t2_alpha = t2[:, :, :3], t2[:, :, 3] + else: + t2_rgb = t2_alpha = None + + workers = min(8, os.cpu_count() or 4) + + def load_mask(path): + return np.array(Image.open(path).convert("L").resize((frame_w, frame_h), Image.Resampling.NEAREST)) + + if delta_only: + # Need all masks up front so each frame can reference the previous one + with ThreadPoolExecutor(max_workers=workers) as ex: + masks = list(ex.map(load_mask, frame_files)) + + def composite(i): + mask = masks[i] + dark = mask < 128 + changed = np.abs(mask.astype(np.int16) - masks[i - 1].astype(np.int16)) > 20 if i > 0 else np.ones((frame_h, frame_w), dtype=bool) + return _composite_frame(dark, changed, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha) + + with ThreadPoolExecutor(max_workers=workers) as ex: + frame_bytes = list(ex.map(composite, range(len(frame_files)))) + else: + # Single pass: load + composite together, no intermediate mask storage + ones = np.ones((frame_h, frame_w), dtype=bool) + + def load_and_composite(path): + mask = load_mask(path) + return _composite_frame(mask < 128, ones, dual, frame_h, frame_w, t1_rgb, t1_alpha, t2_rgb, t2_alpha) + + with ThreadPoolExecutor(max_workers=workers) as ex: + frame_bytes = list(ex.map(load_and_composite, frame_files)) + + with open(raw_path, "wb") as f: + f.writelines(frame_bytes) + + return frame_w, frame_h, not dual + + async def _detect_audio_offset(self, ffmpeg_exe: str) -> float: + """Detect duration of silence at the start of the Bad Apple audio.""" + proc = await asyncio.create_subprocess_exec( + ffmpeg_exe, + "-i", str(self.bad_apple_audio_path), + "-af", "silencedetect=n=-50dB:d=0.1", + "-f", "null", "-", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + for line in stderr.decode().splitlines(): + if "silence_end" in line: + try: + return float(line.split("silence_end:")[1].split("|")[0].strip()) + except (IndexError, ValueError): + pass + return 0.0 + + async def _encode_bad_apple_video(self, raw_path: str, frame_w: int, frame_h: int, has_alpha: bool = True, ffmpeg_exe: str = None, audio_offset: float = 0.0) -> io.BytesIO: + """Encode raw frames + audio into MP4 (dual-user) or WebM with alpha (single-user).""" + if ffmpeg_exe is None: + ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe() + + suffix = ".webm" if has_alpha else ".mp4" + tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) + tmp.close() + out_path = tmp.name + + if has_alpha: + video_flags = ["-vcodec", "libvpx-vp9", "-pix_fmt", "yuva420p", "-auto-alt-ref", "0", "-crf", "30", "-b:v", "0"] + else: + video_flags = ["-vcodec", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", "-crf", "23"] + + # Use atrim filter to cut silence at sample level and reset timestamps to 0, + # so the output file contains zero leading silence for players to strip. + audio_filter = f"atrim=start={audio_offset:.6f},asetpts=PTS-STARTPTS" if audio_offset > 0 else "anull" + + cmd = [ + ffmpeg_exe, "-y", + "-f", "rawvideo", "-vcodec", "rawvideo", + "-s", f"{frame_w}x{frame_h}", + "-pix_fmt", "rgba" if has_alpha else "rgb24", "-r", "30", + "-i", raw_path, + "-i", str(self.bad_apple_audio_path), + "-map", "0:v", "-map", "1:a", + "-af", audio_filter, + *video_flags, + "-t", "30", + "-shortest", + out_path, + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + + with open(out_path, "rb") as f: + video_bytes = f.read() + + os.unlink(out_path) + os.unlink(raw_path) + + return io.BytesIO(video_bytes) + + async def setup(bot): await bot.add_cog(AvatarCommands(bot)) \ No newline at end of file diff --git a/moderation/infraction.py b/moderation/infraction.py index 88243d6..100777a 100644 --- a/moderation/infraction.py +++ b/moderation/infraction.py @@ -19,6 +19,23 @@ class InfractionCommand(ModerationBase): self.check_auto_removals.cancel() super().cog_unload() + async def cog_load(self): + """Re-register persistent views for all pending approval messages.""" + self.c.execute(""" + SELECT id, user_id, guild_id, type, reason, timestamp, moderator_id, approval_message_id + FROM infractions + WHERE pending_approval=1 AND approval_message_id IS NOT NULL + """) + for row in self.c.fetchall(): + inf_id, user_id, guild_id, inf_type, reason, timestamp, mod_id, msg_id = row + try: + user = await self.bot.fetch_user(user_id) + user_tag = f"{user.name}#{user.discriminator}" + except Exception: + user_tag = f"Unknown User ({user_id})" + view = InfractionRemovalView(self, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp) + self.bot.add_view(view, message_id=msg_id) + def migrate_existing_infractions(self): """Add new columns to existing infractions table for auto-removal system.""" try: @@ -50,7 +67,13 @@ class InfractionCommand(ModerationBase): self.c.execute("ALTER TABLE infractions ADD COLUMN pending_approval INTEGER DEFAULT 0") except Exception: pass - + + try: + # Add approval_message_id column so views can be re-registered after restarts + self.c.execute("ALTER TABLE infractions ADD COLUMN approval_message_id INTEGER") + except Exception: + pass + self.conn.commit() @tasks.loop(hours=24) @@ -185,14 +208,14 @@ class InfractionCommand(ModerationBase): # Create approval view view = InfractionRemovalView(self, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp) - await approval_channel.send(embed=embed, view=view) - - # Mark infraction as pending approval so we don't send duplicate requests + msg = await approval_channel.send(embed=embed, view=view) + + # Mark infraction as pending and store message ID for view re-registration on restart self.c.execute(""" - UPDATE infractions - SET pending_approval=1 + UPDATE infractions + SET pending_approval=1, approval_message_id=? WHERE id=? - """, (inf_id,)) + """, (msg.id, inf_id)) self.conn.commit() except Exception as e: @@ -414,8 +437,42 @@ class InfractionCommand(ModerationBase): return + elif action == "resend": + if not args: + await ctx.send("You must provide an infraction ID to resend the approval embed for.") + return + + try: + inf_id = int(args[0]) + except ValueError: + await ctx.send("Invalid infraction ID.") + return + + self.c.execute(""" + SELECT user_id, guild_id, type, reason, timestamp, moderator_id, pending_approval + FROM infractions + WHERE id=? AND guild_id=? + """, (inf_id, ctx.guild.id)) + row = self.c.fetchone() + + if not row: + await ctx.send(f"Infraction {inf_id} not found.") + return + + user_id, guild_id, inf_type, reason, timestamp, mod_id, pending = row + + # Clear pending flag and message ID so send_removal_approval runs fresh + self.c.execute(""" + UPDATE infractions SET pending_approval=0, approval_message_id=NULL WHERE id=? + """, (inf_id,)) + self.conn.commit() + + await self.send_removal_approval(guild_id, user_id, inf_id, inf_type, reason, timestamp, mod_id) + await ctx.send(f"Re-sent approval embed for infraction {inf_id}. Delete the old one.") + return + else: - await ctx.send("Unknown action. Use search, search_full, list, or delete.") + await ctx.send("Unknown action. Use search, search_full, list, delete, or resend.") return # Cache users to avoid repeated API calls (for search and list) @@ -513,13 +570,17 @@ class InfractionRemovalView(discord.ui.View): async def approve_button(self, interaction: discord.Interaction, button: discord.ui.Button): """Approve the removal - mark infraction as removed.""" try: - # Mark infraction as removed and clear pending flag - self.cog.c.execute(""" - UPDATE infractions - SET removed=1, removed_date=?, removed_by=?, pending_approval=0 - WHERE id=? - """, (datetime.utcnow().isoformat(), interaction.user.id, self.inf_id)) - self.cog.conn.commit() + import sqlite3 + conn = sqlite3.connect(self.cog.db_path) + try: + conn.execute(""" + UPDATE infractions + SET removed=1, removed_date=?, removed_by=?, pending_approval=0 + WHERE id=? + """, (datetime.utcnow().isoformat(), interaction.user.id, self.inf_id)) + conn.commit() + finally: + conn.close() # Update embed embed = discord.Embed( @@ -564,13 +625,17 @@ class InfractionRemovalView(discord.ui.View): async def deny_button(self, interaction: discord.Interaction, button: discord.ui.Button): """Deny the removal - mark to skip future auto-removal checks.""" try: - # Mark infraction to skip future checks and clear pending flag - self.cog.c.execute(""" - UPDATE infractions - SET skip_auto_removal=1, pending_approval=0 - WHERE id=? - """, (self.inf_id,)) - self.cog.conn.commit() + import sqlite3 + conn = sqlite3.connect(self.cog.db_path) + try: + conn.execute(""" + UPDATE infractions + SET skip_auto_removal=1, pending_approval=0 + WHERE id=? + """, (self.inf_id,)) + conn.commit() + finally: + conn.close() # Update embed embed = discord.Embed(