facemorph.ts 12 KB

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