mirror of
https://git.squeakyrat.moe/faggot-chan/
synced 2026-11-04 18:35:45 +01:00
218 lines
9.8 KiB
Python
218 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
fills in the empty descriptions in files/config/reactions.json by showing every sticker / emoji / media to the
|
|
local gemma-4 on ollama. run it on the box with the files, ollama has to be up (main.py doesn't).
|
|
stickers and emojis are downloaded from discord with the bot token (login only, the bot doesn't need to be running).
|
|
|
|
describe.py only entries with no description yet
|
|
describe.py --all redo everything (overwrites the ones you wrote by hand too)
|
|
describe.py --model <name> ollama model, defaults to chatmodel from main.py (it has to be able to see images)
|
|
describe.py --gemini use the gemini api instead (chatmodel from gemini.py, key from GEMINI_API_KEY or config/gemini_key.txt)
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
CONFIG_DIR = SCRIPT_DIR / "files" / "config"
|
|
MEDIA_DIR = SCRIPT_DIR / "files" / "media"
|
|
REACTIONS_FILE = CONFIG_DIR / "reactions.json"
|
|
VIDEO_EXTS = (".mp4", ".webm", ".mov", ".mkv")
|
|
|
|
PROMPT = ("this is a {kind} from a discord server, its name is \"{hint}\" (the name is a hint, it may or may not help). "
|
|
"describe what is literally in the image in one short line, under 15 words: the character or object (name the character if you recognize them, "
|
|
"the name above can help), what they're doing, and any text written in the image. "
|
|
"don't guess when to use it, no preamble, no quotes.")
|
|
|
|
# only for media, the bot sends [media: name] so the file name is what she sees, a hash tells her nothing
|
|
NAME_PROMPT = (" answer in exactly two lines. line 1: a short file name for it, 1 to 3 lowercase words joined by underscores, no extension "
|
|
"(the character's name if you recognize them, otherwise the main subject). line 2: the description.")
|
|
|
|
|
|
def model_from(script: str) -> str:
|
|
# same trick as colab_setup.py, read the model name straight out of main.py / gemini.py so they can't drift apart
|
|
text = (SCRIPT_DIR / script).read_text(encoding="utf-8")
|
|
return re.search(r'^chatmodel\s*=\s*["\'](.+?)["\']', text, re.M).group(1)
|
|
|
|
|
|
def read_lines(path: Path) -> list[str]:
|
|
return [l.strip() for l in path.read_text(encoding="utf-8").splitlines() if l.strip() and not l.strip().startswith("#")]
|
|
|
|
|
|
async def fetch_discord(wanted: dict) -> dict:
|
|
# {(kind, name): bytes}. login only, no gateway, so this works while the bot is running too
|
|
import discord
|
|
token, guild_id = read_lines(CONFIG_DIR / "tokens.txt")[:2]
|
|
found = {}
|
|
client = discord.Client(intents=discord.Intents.none())
|
|
await client.login(token)
|
|
try:
|
|
guild = await client.fetch_guild(int(guild_id))
|
|
for kind, items in (("stickers", await guild.fetch_stickers()), ("emojis", await guild.fetch_emojis())):
|
|
for item in items:
|
|
if item.name not in wanted[kind] or not item.available: # locked ones (boost level dropped) can't be sent anyway
|
|
continue
|
|
if kind == "stickers" and item.format == discord.StickerFormatType.lottie: # json animation, nothing to look at
|
|
continue
|
|
found[(kind, item.name)] = await item.read()
|
|
finally:
|
|
await client.close()
|
|
return found
|
|
|
|
|
|
def to_png(data: bytes, dest: Path) -> bool:
|
|
# first frame on a white background (transparent stickers come out black otherwise), small so gemma doesn't chew on it
|
|
try:
|
|
img = Image.open(io.BytesIO(data))
|
|
img.seek(getattr(img, "n_frames", 1) // 2) # middle frame, the first one of an animation is often blank or half drawn
|
|
img = img.convert("RGBA")
|
|
except Exception:
|
|
return False
|
|
bg = Image.new("RGBA", img.size, "white")
|
|
bg.alpha_composite(img)
|
|
bg = bg.convert("RGB")
|
|
bg.thumbnail((512, 512))
|
|
bg.save(dest, "PNG")
|
|
return True
|
|
|
|
|
|
def video_frame(path: Path, dest: Path) -> bool:
|
|
if not shutil.which("ffmpeg"):
|
|
return False
|
|
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(path), "-frames:v", "1", "-vf", "thumbnail,scale=512:-1", str(dest)])
|
|
return r.returncode == 0 and dest.exists()
|
|
|
|
|
|
def describe(model: str, png: Path, prompt: str) -> str:
|
|
import ollama
|
|
r = ollama.chat(model=model, messages=[{"role": "user", "content": prompt, "images": [str(png)]}], options={"temperature": 0.2})
|
|
return strip_think(r["message"]["content"])
|
|
|
|
|
|
def describe_gemini(model: str, png: Path, prompt: str) -> str:
|
|
from google import genai
|
|
from google.genai import types
|
|
key = os.environ.get("GEMINI_API_KEY", "").strip() or (SCRIPT_DIR / "config" / "gemini_key.txt").read_text(encoding="utf-8").strip()
|
|
# a sticker with a slur in it would get blocked by the default filters, same reason gemini.py turns them off
|
|
safety = [types.SafetySetting(category=c, threshold="BLOCK_NONE") for c in (
|
|
"HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH", "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT")]
|
|
client = genai.Client(api_key=key) # has to stay in a variable, as a temporary it gets garbage collected mid call and closes the connection
|
|
r = client.models.generate_content(
|
|
model=model,
|
|
contents=[types.Part.from_bytes(data=png.read_bytes(), mime_type="image/png"), prompt],
|
|
config=types.GenerateContentConfig(safety_settings=safety, temperature=0.2),
|
|
)
|
|
time.sleep(4) # free tier rate limit, rerun the script if some fail, it only does the empty ones
|
|
return strip_think(r.text or "")
|
|
|
|
|
|
def strip_think(text: str) -> str:
|
|
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
|
|
|
|
|
def clean(text: str) -> str:
|
|
return " ".join(strip_think(text).split()).strip(' "')
|
|
|
|
|
|
def split_name(text: str) -> tuple[str, str]:
|
|
# media answers come back as "name\ndescription", no name (model ignored the format) = keep the file as it is
|
|
lines = [l for l in strip_think(text).splitlines() if l.strip()]
|
|
if len(lines) < 2:
|
|
return "", clean(text)
|
|
name = re.sub(r"[^a-z0-9]+", "_", lines[0].lower()).strip("_")[:40]
|
|
return name, clean(" ".join(lines[1:]))
|
|
|
|
|
|
def rename_media(old: str, name: str) -> str:
|
|
# keeps the extension, adds _2 _3 if the name is taken
|
|
suffix = Path(old).suffix
|
|
new = f"{name}{suffix}"
|
|
n = 2
|
|
while (MEDIA_DIR / new).exists() and new != old:
|
|
new = f"{name}_{n}{suffix}"
|
|
n += 1
|
|
if new == old:
|
|
return old
|
|
(MEDIA_DIR / old).rename(MEDIA_DIR / new)
|
|
return new
|
|
|
|
|
|
def save_description(kind: str, name: str, text: str, force: bool, new_name: str = None):
|
|
# read it fresh every time, the bot adds new entries to this file while we run
|
|
data = json.loads(REACTIONS_FILE.read_text(encoding="utf-8"))
|
|
if name in data.get(kind, {}) and (force or not data[kind][name]):
|
|
if new_name and new_name != name:
|
|
data[kind][new_name] = data[kind].pop(name)
|
|
name = new_name
|
|
data[kind][name] = text
|
|
REACTIONS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--all", action="store_true")
|
|
ap.add_argument("--model")
|
|
ap.add_argument("--gemini", action="store_true")
|
|
args = ap.parse_args()
|
|
model = args.model or model_from("gemini.py" if args.gemini else "main.py")
|
|
|
|
if not REACTIONS_FILE.exists():
|
|
raise SystemExit("no files/config/reactions.json yet, start bot.py once so it lists your stickers/emojis/media")
|
|
data = json.loads(REACTIONS_FILE.read_text(encoding="utf-8"))
|
|
todo = {k: [n for n, d in data.get(k, {}).items() if args.all or not d] for k in ("stickers", "emojis", "media")}
|
|
if not any(todo.values()):
|
|
raise SystemExit("nothing without a description")
|
|
|
|
remote = asyncio.run(fetch_discord(todo)) if todo["stickers"] or todo["emojis"] else {}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
png = Path(tmp) / "thing.png"
|
|
for kind in ("stickers", "emojis", "media"):
|
|
for name in todo[kind]:
|
|
png.unlink(missing_ok=True)
|
|
if kind == "media":
|
|
path = MEDIA_DIR / name
|
|
if not path.is_file():
|
|
print(f"skip media/{name}: file is gone")
|
|
continue
|
|
ok = video_frame(path, png) if path.suffix.lower() in VIDEO_EXTS else to_png(path.read_bytes(), png)
|
|
else:
|
|
ok = (kind, name) in remote and to_png(remote[(kind, name)], png)
|
|
if not ok:
|
|
print(f"skip {kind}/{name}: couldn't get an image out of it")
|
|
continue
|
|
try:
|
|
hint = re.sub(r"^\d+_", "", Path(name).stem) if kind == "media" else name # media is saved as id_filename
|
|
prompt = PROMPT.format(kind=kind.rstrip("s") if kind != "media" else "media file", hint=hint)
|
|
if kind == "media":
|
|
prompt += NAME_PROMPT
|
|
text = (describe_gemini if args.gemini else describe)(model, png, prompt)
|
|
except Exception as e:
|
|
print(f"skip {kind}/{name}: describe failed: {e}")
|
|
continue
|
|
new_name = None
|
|
if kind == "media":
|
|
new_name, text = split_name(text)
|
|
else:
|
|
text = clean(text)
|
|
if not text:
|
|
continue
|
|
if new_name:
|
|
new_name = rename_media(name, new_name)
|
|
save_description(kind, name, text, args.all, new_name)
|
|
print(f"{kind}/{name}{f' -> {new_name}' if new_name and new_name != name else ''}: {text}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|