facemorph.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import { isValidImage } from "../util";
  2. import Jimp from "jimp";
  3. import { client } from "../client";
  4. import { Message, MessageAttachment } from "discord.js";
  5. import { getRepository } from "typeorm";
  6. import { FaceCaptionMessage, FaceCaptionType } from "@shared/db/entity/FaceCaptionMessage";
  7. import { KnownChannel } from "@shared/db/entity/KnownChannel";
  8. import { logger } from "src/logging";
  9. import got from "got";
  10. import FormData from "form-data";
  11. import { Event, BotEventData, Command, ICommandData, Plugin } from "src/model/plugin";
  12. const EMOTE_GUILD = "505333548694241281";
  13. const CAPTION_IMG_SIZE = 300;
  14. const CAPTION_PROBABILITY = 0.33;
  15. interface Rect {
  16. x: number,
  17. y: number,
  18. w: number,
  19. h: number
  20. }
  21. interface ErrorInfo {
  22. ok: boolean,
  23. error: string;
  24. }
  25. interface FaceData {
  26. ok: boolean,
  27. animeFaces: Rect[],
  28. normalFaces: Rect[]
  29. }
  30. type FaceDetectionResponse = FaceData | ErrorInfo;
  31. function isError(resp: FaceDetectionResponse): resp is ErrorInfo {
  32. return !resp.ok;
  33. }
  34. type ImageProcessor = (faces: Rect[], data: Buffer) => Promise<Jimp>;
  35. const CAPTION_OFFSET = 5;
  36. @Plugin
  37. export class Facemorph {
  38. squareFace(rect: Rect): Rect {
  39. const s = Math.min(rect.w, rect.h);
  40. return {...rect, w: s, h: s};
  41. }
  42. intersects(r1: Rect, r2: Rect): boolean {
  43. return (
  44. r1.x <= r2.x + r2.w &&
  45. r1.x + r1.w >= r2.x &&
  46. (r1.y <= r2.y + r2.h && r1.y + r1.h >= r2.y)
  47. );
  48. }
  49. morphFaces = async (faces: Rect[], data: Buffer): Promise<Jimp> => {
  50. const padoru = Math.random() <= this.getPadoruChance();
  51. let jimpImage = await Jimp.read(data);
  52. const emoteGuild = client.bot.guilds.resolve(EMOTE_GUILD);
  53. if (!emoteGuild)
  54. return jimpImage;
  55. const emojiKeys = process.env.FOOLS != "TRUE" ? [
  56. ...emoteGuild
  57. .emojis.cache.filter(e => !e.animated && e.name?.startsWith("PADORU") == padoru)
  58. .keys()
  59. ]:
  60. [
  61. "505335829565276160",
  62. "430434087157760003",
  63. "456472341874999297",
  64. "649677767348060170",
  65. "589706788782342183",
  66. "665272109227835422"
  67. ];
  68. for (const rect of faces) {
  69. const dx = rect.x + rect.w / 2;
  70. const dy = rect.y + rect.h / 2;
  71. const emojiKey = emojiKeys[Math.floor(Math.random() * emojiKeys.length)];
  72. const emoji = client.bot.emojis.resolve(emojiKey);
  73. if (!emoji)
  74. throw new Error("Failed to resolve emoji!");
  75. let emojiImage = await Jimp.read(emoji.url);
  76. let ew = emojiImage.getWidth();
  77. let eh = emojiImage.getHeight();
  78. const CONSTANT_SCALE = 1.1;
  79. const scaleFactor = (Math.max(rect.w, rect.h) / Math.min(ew, eh)) * CONSTANT_SCALE;
  80. ew *= scaleFactor;
  81. eh *= scaleFactor;
  82. emojiImage = emojiImage.scale(scaleFactor);
  83. jimpImage = jimpImage.composite(emojiImage, dx - ew / 2, dy - eh / 2);
  84. }
  85. return jimpImage;
  86. }
  87. async getRandomCaption(type: FaceCaptionType): Promise<FaceCaptionMessage | null> {
  88. const repo = getRepository(FaceCaptionMessage);
  89. const caption = await repo.query(`select message
  90. from face_caption_message
  91. where type = $1
  92. order by random()
  93. limit 1`, [type]) as FaceCaptionMessage[];
  94. if (caption.length == 0)
  95. return null;
  96. return caption[0];
  97. }
  98. captionFace = async (faces: Rect[], data: Buffer): Promise<Jimp> => {
  99. const padoru = Math.random() <= this.getPadoruChance();
  100. const face = faces[Math.floor(Math.random() * faces.length)];
  101. const squaredFace = this.squareFace(face);
  102. const targetSize = CAPTION_IMG_SIZE;
  103. const img = await Jimp.read(data);
  104. let tempImg = await Jimp.create(squaredFace.w, squaredFace.h);
  105. tempImg = await tempImg.blit(
  106. img,
  107. 0,
  108. 0,
  109. squaredFace.x,
  110. squaredFace.y,
  111. squaredFace.w,
  112. squaredFace.h
  113. );
  114. tempImg = await tempImg.scale(targetSize / squaredFace.w);
  115. const font = await Jimp.loadFont(padoru ? Jimp.FONT_SANS_16_WHITE : Jimp.FONT_SANS_16_BLACK);
  116. let text = "";
  117. if(padoru)
  118. text = "PADORU PADORU";
  119. else if(process.env.FOOLS == "TRUE") {
  120. const titles = ["They are horse", "Neigh!", "Insert carrots into them!", "They will become horse!", "They will serve Geoffrey!", "tfw no carrots"];
  121. text = titles[Math.floor(Math.random() * titles.length)];
  122. }
  123. else {
  124. const prefixMessage = (await this.getRandomCaption(FaceCaptionType.PREFIX))?.message ?? "Feed them";
  125. const postfixMessage = (await this.getRandomCaption(FaceCaptionType.POSTFIX))?.message ?? "carrots";
  126. text = `${prefixMessage} ${postfixMessage}`;
  127. }
  128. const h = Jimp.measureTextHeight(font, text, targetSize - CAPTION_OFFSET * 2);
  129. let finalImage = await Jimp.create(targetSize, targetSize + h + CAPTION_OFFSET * 2, padoru ? "#FD2027" : "#FFFFFF");
  130. finalImage = await finalImage.print(
  131. font,
  132. CAPTION_OFFSET,
  133. CAPTION_OFFSET,
  134. text,
  135. finalImage.getWidth() - CAPTION_OFFSET
  136. );
  137. finalImage = await finalImage.composite(tempImg, 0, CAPTION_OFFSET * 2 + h);
  138. return finalImage;
  139. }
  140. getPadoruChance(): number {
  141. const now = new Date();
  142. if (now.getUTCMonth() != 11 || now.getUTCDate() > 25)
  143. return 0;
  144. return 1 / (27.0 - now.getUTCDate());
  145. }
  146. async processFaceSwap(message: Message, attachmentUrl: string, processor?: ImageProcessor, failMessage?: string, successMessage?: string): Promise<void> {
  147. const dataResponse = await got.get(attachmentUrl, { responseType: "buffer" });
  148. if (dataResponse.statusCode != 200) {
  149. logger.error("Failed to get attachment %s. Got code %s", attachmentUrl, dataResponse.statusCode);
  150. return;
  151. }
  152. const data = dataResponse.body;
  153. const form = new FormData();
  154. form.append("img_data", data, {
  155. filename: "image.png",
  156. contentType: "application/octet-stream"
  157. });
  158. const result = await got.post<FaceDetectionResponse>(`http://${process.env.FACEDETECT_URL}/process`, {
  159. responseType: "json",
  160. body: form
  161. });
  162. if(result.statusCode != 200) {
  163. logger.error("Face detection failed! Got response %s", result.statusCode);
  164. return;
  165. }
  166. const faceRects = result.body;
  167. if (isError(faceRects)) {
  168. logger.error("Face detection failed! Got response %s", result.statusCode);
  169. return;
  170. }
  171. if (faceRects.animeFaces.length == 0 && faceRects.normalFaces.length == 0) {
  172. if (failMessage) message.channel.send(failMessage);
  173. return;
  174. }
  175. const faces = [...faceRects.normalFaces, ...faceRects.animeFaces];
  176. let normalCount = faceRects.normalFaces.length;
  177. let animeCount = faceRects.animeFaces.length;
  178. for (let i = 0; i < normalCount; i++) {
  179. const rNormal = faces[i];
  180. if (animeCount == 0) break;
  181. for (let j = normalCount; j < faces.length; j++) {
  182. const rAnime = faces[j];
  183. if (this.intersects(rAnime, rNormal)) {
  184. const animeA = rAnime.w * rAnime.h;
  185. const faceA = rNormal.w * rNormal.h;
  186. if (animeA > faceA) {
  187. faces.splice(i, 1);
  188. normalCount--;
  189. i--;
  190. break;
  191. } else {
  192. faces.splice(j, 1);
  193. animeCount--;
  194. j--;
  195. }
  196. }
  197. }
  198. }
  199. let jimpImage: Jimp;
  200. if (processor)
  201. jimpImage = await processor(faces, data);
  202. else {
  203. if (Math.random() <= CAPTION_PROBABILITY)
  204. jimpImage = await this.captionFace(faces, data);
  205. else
  206. jimpImage = await this.morphFaces(faces, data);
  207. }
  208. jimpImage.quality(90);
  209. const buffer = await jimpImage.getBufferAsync(Jimp.MIME_JPEG);
  210. const messageContents =
  211. successMessage ||
  212. `I noticed a face in the image. I think this looks better ${client.bot.emojis.resolve("505076258753740810")?.toString() ?? ":)"}`;
  213. message.channel.send({
  214. content: messageContents,
  215. files: [buffer]
  216. });
  217. }
  218. processLastImage(msg: Message, processor: ImageProcessor): void {
  219. type AttachedMessage = {msg: Message, att: MessageAttachment};
  220. const lastImagedMessage = msg.channel.messages.cache.mapValues(m => ({msg: m, att: m.attachments.find(v => isValidImage(v.name))}))
  221. .filter(v => !v.msg.author.bot && v.att != undefined).last() as AttachedMessage;
  222. if (!lastImagedMessage) {
  223. msg.reply("sorry, I couldn't find any recent messages with images.");
  224. return;
  225. }
  226. const replyEmoji = client.bot.emojis.resolve("505076258753740810");
  227. const emojiText = replyEmoji ? replyEmoji.toString() : "Jiiii~";
  228. this.processFaceSwap(
  229. msg,
  230. lastImagedMessage.att.url,
  231. processor,
  232. `${msg.author.toString()} Nice image! I don't see anything interesting, though.`,
  233. `${msg.author.toString()} ${emojiText}`
  234. ).catch(err => logger.error(`Failed to run faceapp on message ${msg.id}`, err));
  235. }
  236. @Event("message")
  237. async morphRandomImage(data: BotEventData, msg: Message): Promise<void> {
  238. if (data.actionsDone)
  239. return;
  240. if (msg.mentions.users.size > 0 && msg.mentions.users.first()?.id == client.botUser.id)
  241. return;
  242. const imageAttachment = msg.attachments.find(v => isValidImage(v.name));
  243. if (imageAttachment) {
  244. const repo = getRepository(KnownChannel);
  245. const knownChannel = await repo.findOne({
  246. where: { channelId: msg.channel.id },
  247. select: ["faceMorphProbability"]
  248. });
  249. if (!knownChannel || Math.random() > knownChannel.faceMorphProbability)
  250. return;
  251. this.processFaceSwap(msg, imageAttachment.url).catch(err =>
  252. logger.error(`Failed to run faceapp on message ${msg.id}`, err)
  253. );
  254. data.actionsDone = true;
  255. }
  256. }
  257. @Event("directMention")
  258. async morphProvidedImage(data: BotEventData, msg: Message, content: string): Promise<void> {
  259. if (data.actionsDone)
  260. return;
  261. const image = msg.attachments.find(v => isValidImage(v.name));
  262. if (!image) {
  263. if (msg.attachments.size > 0) {
  264. msg.channel.send(
  265. `${msg.author.toString()} Nice, but I can't do anything to it! (Invalid file type)`
  266. );
  267. data.actionsDone = true;
  268. }
  269. return;
  270. }
  271. let processor;
  272. if (content.startsWith("caption this"))
  273. processor = this.captionFace;
  274. else
  275. processor = this.morphFaces;
  276. const replyEmoji = client.bot.emojis.resolve("505076258753740810");
  277. const emojiText = replyEmoji ? replyEmoji.toString() : "Jiiii~";
  278. this.processFaceSwap(
  279. msg,
  280. image.url,
  281. processor,
  282. `${msg.author.toString()} Nice image! I don't see anything interesting, though.`,
  283. `${msg.author.toString()} ${emojiText}`
  284. ).catch(err => logger.error(`Failed to run faceapp because ${msg.id}`, err));
  285. data.actionsDone = true;
  286. }
  287. @Command({
  288. type: "mention",
  289. pattern: "caption last image"
  290. })
  291. captionLastImage({ message }: ICommandData): void {
  292. this.processLastImage(message, this.captionFace);
  293. }
  294. @Command({
  295. type: "mention",
  296. pattern: "look at last image"
  297. })
  298. lookLastImage({ message }: ICommandData): void {
  299. this.processLastImage(message, this.morphFaces);
  300. }
  301. }