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 000000000..2cc87dfc7 Binary files /dev/null and b/python/components/image-gen/resources/img/pet_sprite.png differ