From 80aff4005460364e957cb268760ddf26a2dc74bd Mon Sep 17 00:00:00 2001 From: Sheldan <5037282+Sheldan@users.noreply.github.com> Date: Mon, 25 Dec 2023 23:43:34 +0100 Subject: [PATCH] [AB-xxx] adding pat gif generator command --- .../imagegeneration/command/Pat.java | 147 ++++++++++++++++++ .../imagegeneration/command/Triggered.java | 4 +- .../service/ImageGenerationServiceBean.java | 11 ++ .../image-generation-config.properties | 5 +- .../migrations/1.5.19/collection.xml | 10 ++ .../migrations/1.5.19/seedData/command.xml | 20 +++ .../migrations/1.5.19/seedData/data.xml | 10 ++ .../migrations/imageGeneration-changeLog.xml | 1 + .../service/ImageGenerationService.java | 1 + .../image-gen/python/endpoints/pat.py | 80 ++++++++++ .../image-gen/resources/img/pet_sprite.png | Bin 0 -> 11719 bytes 11 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Pat.java create mode 100644 abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/collection.xml create mode 100644 abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml create mode 100644 abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/data.xml create mode 100644 python/components/image-gen/python/endpoints/pat.py create mode 100644 python/components/image-gen/resources/img/pet_sprite.png diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Pat.java b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Pat.java new file mode 100644 index 000000000..071e1803d --- /dev/null +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Pat.java @@ -0,0 +1,147 @@ +package dev.sheldan.abstracto.imagegeneration.command; + +import dev.sheldan.abstracto.core.command.UtilityModuleDefinition; +import dev.sheldan.abstracto.core.command.condition.AbstractConditionableCommand; +import dev.sheldan.abstracto.core.command.config.CommandConfiguration; +import dev.sheldan.abstracto.core.command.config.HelpInfo; +import dev.sheldan.abstracto.core.command.config.Parameter; +import dev.sheldan.abstracto.core.command.execution.CommandContext; +import dev.sheldan.abstracto.core.command.execution.CommandResult; +import dev.sheldan.abstracto.core.config.FeatureDefinition; +import dev.sheldan.abstracto.core.interaction.InteractionService; +import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig; +import dev.sheldan.abstracto.core.interaction.slash.parameter.SlashCommandParameterService; +import dev.sheldan.abstracto.core.service.ChannelService; +import dev.sheldan.abstracto.core.templating.model.AttachedFile; +import dev.sheldan.abstracto.core.templating.model.MessageToSend; +import dev.sheldan.abstracto.core.templating.service.TemplateService; +import dev.sheldan.abstracto.core.utils.FileService; +import dev.sheldan.abstracto.core.utils.FutureUtils; +import dev.sheldan.abstracto.imagegeneration.config.ImageGenerationFeatureDefinition; +import dev.sheldan.abstracto.imagegeneration.config.ImageGenerationSlashCommandNames; +import dev.sheldan.abstracto.imagegeneration.service.ImageGenerationService; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Component +public class Pat extends AbstractConditionableCommand { + public static final String MEMBER_PARAMETER_KEY = "member"; + + @Autowired + private ImageGenerationService imageGenerationService; + + @Autowired + private TemplateService templateService; + + @Autowired + private ChannelService channelService; + + @Autowired + private FileService fileService; + + @Autowired + private InteractionService interactionService; + + @Autowired + private SlashCommandParameterService slashCommandParameterService; + + private static final String PAT_EMBED_TEMPLATE_KEY = "pat_response"; + + @Value("${abstracto.feature.imagegeneration.pat.imagesize}") + private Integer imageSize; + + @Override + public CompletableFuture executeAsync(CommandContext commandContext) { + Member member; + List parameters = commandContext.getParameters().getParameters(); + if(parameters.isEmpty()) { + member = commandContext.getAuthor(); + } else { + member = (Member) parameters.get(0); + } + File patGifFile = imageGenerationService.getPatGif(member.getEffectiveAvatar().getUrl(imageSize)); + MessageToSend messageToSend = templateService.renderEmbedTemplate(PAT_EMBED_TEMPLATE_KEY, new Object()); + // template support does not support binary files + AttachedFile file = AttachedFile + .builder() + .file(patGifFile) + .fileName("pat.gif") + .build(); + messageToSend.getAttachedFiles().add(file); + return FutureUtils.toSingleFutureGeneric(channelService.sendMessageToSendToChannel(messageToSend, commandContext.getChannel())) + .thenAccept(unused -> fileService.safeDeleteIgnoreException(messageToSend.getAttachedFiles().get(0).getFile())) + .thenApply(unused -> CommandResult.fromIgnored()); + } + + @Override + public CompletableFuture executeSlash(SlashCommandInteractionEvent event) { + event.deferReply().queue(); + Member targetMember; + if(slashCommandParameterService.hasCommandOption(MEMBER_PARAMETER_KEY, event)) { + targetMember = slashCommandParameterService.getCommandOption(MEMBER_PARAMETER_KEY, event, Member.class); + } else { + targetMember = event.getMember(); + } + File patGifFile = imageGenerationService.getPatGif(targetMember.getEffectiveAvatar().getUrl(imageSize)); + MessageToSend messageToSend = templateService.renderEmbedTemplate(PAT_EMBED_TEMPLATE_KEY, new Object()); + // template support does not support binary files + AttachedFile file = AttachedFile + .builder() + .file(patGifFile) + .fileName("pat.gif") + .build(); + messageToSend.getAttachedFiles().add(file); + return FutureUtils.toSingleFutureGeneric(interactionService.sendMessageToInteraction(messageToSend, event.getHook())) + .thenAccept(unused -> fileService.safeDeleteIgnoreException(messageToSend.getAttachedFiles().get(0).getFile())) + .thenApply(unused -> CommandResult.fromIgnored()); + } + + @Override + public CommandConfiguration getConfiguration() { + List parameters = new ArrayList<>(); + Parameter memberParameter = Parameter + .builder() + .name(MEMBER_PARAMETER_KEY) + .type(Member.class) + .templated(true) + .optional(true) + .build(); + parameters.add(memberParameter); + HelpInfo helpInfo = HelpInfo + .builder() + .templated(true) + .build(); + + SlashCommandConfig slashCommandConfig = SlashCommandConfig + .builder() + .enabled(true) + .rootCommandName(ImageGenerationSlashCommandNames.IMAGE_GENERATION) + .groupName("memes") + .commandName("pat") + .build(); + + return CommandConfiguration.builder() + .name("pat") + .module(UtilityModuleDefinition.UTILITY) + .templated(true) + .supportsEmbedException(true) + .async(true) + .slashCommandConfig(slashCommandConfig) + .parameters(parameters) + .help(helpInfo) + .build(); + } + + @Override + public FeatureDefinition getFeature() { + return ImageGenerationFeatureDefinition.IMAGE_GENERATION; + } +} diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Triggered.java b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Triggered.java index 4b69d0640..9d8eb194f 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Triggered.java +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Triggered.java @@ -73,7 +73,7 @@ public class Triggered extends AbstractConditionableCommand { AttachedFile file = AttachedFile .builder() .file(triggeredGifFile) - .fileName("avatar.gif") + .fileName("triggered.gif") .build(); messageToSend.getAttachedFiles().add(file); return FutureUtils.toSingleFutureGeneric(channelService.sendMessageToSendToChannel(messageToSend, commandContext.getChannel())) @@ -96,7 +96,7 @@ public class Triggered extends AbstractConditionableCommand { AttachedFile file = AttachedFile .builder() .file(triggeredGifFile) - .fileName("avatar.gif") + .fileName("triggered.gif") .build(); messageToSend.getAttachedFiles().add(file); return FutureUtils.toSingleFutureGeneric(interactionService.sendMessageToInteraction(messageToSend, event.getHook())) diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java index 6ca1840ba..20e67122d 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java @@ -16,6 +16,8 @@ public class ImageGenerationServiceBean implements ImageGenerationService { @Value("${abstracto.feature.imagegeneration.triggered.url}") private String triggeredUrl; + @Value("${abstracto.feature.imagegeneration.pat.url}") + private String patUrl; @Autowired private HttpService httpService; @@ -29,4 +31,13 @@ public class ImageGenerationServiceBean implements ImageGenerationService { } } + @Override + public File getPatGif(String imageUrl) { + try { + return httpService.downloadFileToTempFile(patUrl.replace("{1}", imageUrl)); + } catch (IOException e) { + throw new AbstractoRunTimeException(String.format("Failed to download pat gif for url %s with error %s", imageUrl, e.getMessage())); + } + } + } diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties index d71912bfb..1d794bd8f 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties @@ -2,4 +2,7 @@ abstracto.featureFlags.imageGeneration.featureName=imageGeneration abstracto.featureFlags.imageGeneration.enabled=false abstracto.feature.imagegeneration.triggered.url=http://${PRIVATE_REST_API_HOST}:${PRIVATE_REST_API_PORT}/memes/triggered/file.gif?url={1} -abstracto.feature.imagegeneration.triggered.imagesize=128 \ No newline at end of file +abstracto.feature.imagegeneration.triggered.imagesize=128 + +abstracto.feature.imagegeneration.pat.url=http://${PRIVATE_REST_API_HOST}:${PRIVATE_REST_API_PORT}/memes/pat/file.gif?url={1} +abstracto.feature.imagegeneration.pat.imagesize=128 diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/collection.xml b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/collection.xml new file mode 100644 index 000000000..121b5aadf --- /dev/null +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/collection.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml new file mode 100644 index 000000000..3bfa810e5 --- /dev/null +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/data.xml b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/data.xml new file mode 100644 index 000000000..c048f6f53 --- /dev/null +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/data.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/imageGeneration-changeLog.xml b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/imageGeneration-changeLog.xml index 78ba7b2e1..87fbbc4b6 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/imageGeneration-changeLog.xml +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/imageGeneration-changeLog.xml @@ -7,4 +7,5 @@ http://www.liquibase.org/xml/ns/dbchangelog-ext dbchangelog.xsd http://www.liquibase.org/xml/ns/pro dbchangelog.xsd" > + \ No newline at end of file diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java b/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java index 17aca1aed..e150e5582 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java @@ -4,4 +4,5 @@ import java.io.File; public interface ImageGenerationService { File getTriggeredGif(String imageUrl); + File getPatGif(String imageUrl); } diff --git a/python/components/image-gen/python/endpoints/pat.py b/python/components/image-gen/python/endpoints/pat.py new file mode 100644 index 000000000..451d7ab08 --- /dev/null +++ b/python/components/image-gen/python/endpoints/pat.py @@ -0,0 +1,80 @@ +from __main__ import app + +from PIL import Image, ImageOps +from flask import request +import requests +import validators +import logging +import io + +from utils import flask_utils + + +sprite_size = 112 +sprites = 5 +allowed_content_length = 4_000_000 +allowed_image_formats = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'] + + +@app.route('/memes/pat/file.gif') # to directly embed, discord _requires_ this file ending, it seems +def pat_animated(): + url = request.args.get('url') + if not validators.url(url): + return 'no valid url', 400 + session = requests.Session() + response = session.head(url) + content_type = response.headers['content-type'] + + if content_type not in allowed_image_formats: + return f'Incorrect image type {content_type}', 400 + + actual_content_length = int(response.headers['content-length']) + if actual_content_length > allowed_content_length: + return f'Image too large {actual_content_length}', 400 + + image_file = requests.get(url, stream=True) + input_image = Image.open(io.BytesIO(image_file.content)) + original_input_image = input_image + frame_count = original_input_image.n_frames + old_width, old_height = input_image.size + with Image.open('resources/img/pet_sprite.png') as pat_background: + pat_background = pat_background.convert('RGBA') + pat_width, pat_height = pat_background.size + pat_ratio = old_width / sprite_size + original_background_images = [] + for i in range(sprites): + background_part = pat_background.crop((i * sprite_size, 0, (i + 1) * sprite_size, pat_height)) + original_background_images.append(background_part) + desired_new_pat_width = int(sprite_size * pat_ratio) + resized_background_images = [] + for background_image in original_background_images: + if pat_ratio > 1: + resized_background = background_image.resize((desired_new_pat_width, pat_height)) + else: + resized_background = ImageOps.contain(background_image, (desired_new_pat_width, pat_height)) + resized_background_images.append(resized_background) + if content_type == 'image/gif': + logging.info(f'Rendering pet for gif.') + old_frames = [] + for frame_index in range(frame_count): + input_image.seek(frame_index) + frame = input_image.convert('RGBA') + old_frames.append(frame) + frames = [] + for index, old_frame in enumerate(old_frames): + frame = Image.new('RGBA', (old_width, old_height), (0, 0, 0, 0)) + frame.paste(old_frame, (0, 0), old_frame) + frame_background_image = resized_background_images[index % sprites] + frame.paste(frame_background_image, (0, 0), frame_background_image) + frames.append(frame) + return flask_utils.serve_pil_gif_image(frames, int(original_input_image.info['duration'])) + else: + frames = [] + logging.info(f'Rendering pet for static image.') + input_image = input_image.convert('RGBA') + for background_image in resized_background_images: + frame = Image.new('RGBA', (old_width, old_height), (0, 0, 0, 0)) + frame.paste(input_image, (0, 0), input_image) + frame.paste(background_image, (0, 0), background_image) + frames.append(frame) + return flask_utils.serve_pil_gif_image(frames, 100) diff --git a/python/components/image-gen/resources/img/pet_sprite.png b/python/components/image-gen/resources/img/pet_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..2cc87dfc7dfe1fa942b219f4119e08a46bdb541c GIT binary patch literal 11719 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