mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-08-27 19:24:17 -05:00
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
This commit is contained in:
234
image/avatar.py
234
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))
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user