Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/discord/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export const commandDefinitions = [
.setRequired(false)
.setMinValue(0)
.setMaxValue(30),
)
.addBooleanOption((option) =>
option
.setName("remove_background")
.setDescription("Remove magenta background (default: true)")
.setRequired(false),
),
new SlashCommandBuilder()
.setName("gif")
Expand Down Expand Up @@ -137,5 +143,11 @@ export const commandDefinitions = [
.setRequired(false)
.setMinValue(0)
.setMaxValue(30),
)
.addBooleanOption((option) =>
option
.setName("remove_background")
.setDescription("Remove magenta background (default: true)")
.setRequired(false),
),
].map((builder) => builder.toJSON());
3 changes: 2 additions & 1 deletion src/features/gif-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export class GifCommandFeature implements Feature {
const frames = interaction.options.getInteger("frames") ?? DEFAULT_GIF_OPTIONS.frames;
const fps = interaction.options.getInteger("fps") ?? DEFAULT_GIF_OPTIONS.fps;
const loopDelay = interaction.options.getInteger("loop_delay") ?? DEFAULT_GIF_OPTIONS.loopDelay;
const removeBackground = interaction.options.getBoolean("remove_background") ?? true;

const gifOptions: GifOptions = { frames, fps, loopDelay };
const gifOptions: GifOptions = { frames, fps, loopDelay, removeBackground };

await interaction.deferReply();

Expand Down
9 changes: 8 additions & 1 deletion src/features/gif-emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ export class GifEmojiFeature implements Feature {
const frames = interaction.options.getInteger("frames") ?? DEFAULT_GIF_OPTIONS.frames;
const fps = interaction.options.getInteger("fps") ?? DEFAULT_GIF_OPTIONS.fps;
const loopDelay = interaction.options.getInteger("loop_delay") ?? DEFAULT_GIF_OPTIONS.loopDelay;
const removeBackground = interaction.options.getBoolean("remove_background") ?? true;

const gifOptions: GifOptions = { frames, fps, loopDelay };
const gifOptions: GifOptions = { frames, fps, loopDelay, removeBackground };

let referenceImages: ReferenceImage[] | undefined;
if (
Expand Down Expand Up @@ -199,8 +200,13 @@ export class GifEmojiFeature implements Feature {
const nameInput = interaction.fields.getTextInputValue("emoji-name");
const promptInput = interaction.fields.getTextInputValue("emoji-prompt");
const gifSettingsInput = interaction.fields.getTextInputValue("gif-settings");
const removeBackgroundInput = interaction.fields.getTextInputValue("gif-remove-background");

const gifOptions = this.parseGifSettings(gifSettingsInput, preview.gifOptions);
if (removeBackgroundInput) {
const removeBackgroundValue = removeBackgroundInput.toLowerCase().trim();
gifOptions.removeBackground = removeBackgroundValue === "true" || removeBackgroundValue === "yes" || removeBackgroundValue === "1";
}

const message = await interaction.channel.messages.fetch(messageId);
if (!message) {
Expand Down Expand Up @@ -274,6 +280,7 @@ export class GifEmojiFeature implements Feature {
frames: validFrames.includes(frames) ? frames : defaults.frames,
fps: fps >= 1 && fps <= 20 ? fps : defaults.fps,
loopDelay: loopDelay >= 0 && loopDelay <= 30 ? loopDelay : defaults.loopDelay,
removeBackground: defaults.removeBackground,
};
}

Expand Down
17 changes: 16 additions & 1 deletion src/utils/emoji-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const DEFAULT_GIF_OPTIONS: GifOptions = {
frames: 9,
fps: 5,
loopDelay: 0,
removeBackground: true,
};

export interface EmojiPreview {
Expand Down Expand Up @@ -166,7 +167,8 @@ export class EmojiGenerator {
})();

const gridSize = Math.sqrt(gifOptions.frames);
const gifPrompt = buildGifPrompt(effectivePrompt, gridSize, true);
const removeBackground = gifOptions.removeBackground !== false;
const gifPrompt = buildGifPrompt(effectivePrompt, gridSize, true, removeBackground);
const imageOptions: Parameters<typeof this.ctx.openai.generateImage>[0] = {
prompt: gifPrompt,
aspectRatio: "1:1",
Expand Down Expand Up @@ -373,6 +375,19 @@ export class EmojiGenerator {
value: `${gifOptions.frames},${gifOptions.fps},${gifOptions.loopDelay}`,
},
});
baseComponents.push({
type: 18,
label: "Remove Background",
component: {
type: 4,
custom_id: "gif-remove-background",
style: 1,
placeholder: "true or false (default: true)",
required: false,
max_length: 5,
value: String(gifOptions.removeBackground !== false),
},
});
}

return {
Expand Down
68 changes: 44 additions & 24 deletions src/utils/image-processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,24 @@ export interface GifOptions {
frames: number;
fps: number;
loopDelay: number;
removeBackground?: boolean;
}

export function buildGifPrompt(
prompt: string,
gridSize: number,
isEmoji: boolean = false,
removeBackground: boolean = true,
): string {
const parts: string[] = [prompt];

if (isEmoji) {
if (removeBackground) {
parts.push(
"solid bright magenta background (#FF00FF) wherever it should be transparent",
);
}
parts.push(
"solid bright magenta background (#FF00FF) wherever it should be transparent",
"suitable as a Discord emoji",
"will be displayed very small so make things clear and avoid fine details or small text",
"",
Expand Down Expand Up @@ -150,6 +156,7 @@ export async function processGifEmojiGrid(
const frameHeight = Math.floor(metadata.height / gridSize);
const targetSize = 128;
const frameDelay = Math.round(1000 / options.fps);
const removeBackground = options.removeBackground !== false;

const encoder = new GIFEncoder(targetSize, targetSize, "neuquant", true);
encoder.start();
Expand All @@ -165,6 +172,16 @@ export async function processGifEmojiGrid(
const left = col * frameWidth;
const top = row * frameHeight;

const resizeOptions: {
fit: "contain";
background?: { r: number; g: number; b: number; alpha: number };
} = {
fit: "contain",
};
if (removeBackground) {
resizeOptions.background = { r: 255, g: 0, b: 255, alpha: 1 };
}

const { data } = await image
.clone()
.extract({
Expand All @@ -173,35 +190,38 @@ export async function processGifEmojiGrid(
width: frameWidth,
height: frameHeight,
})
.resize(targetSize, targetSize, {
fit: "contain",
background: { r: 255, g: 0, b: 255, alpha: 1 },
})
.resize(targetSize, targetSize, resizeOptions)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });

const pixels = new Uint8Array(data);

for (let i = 0; i < pixels.length; i += 4) {
const red = pixels[i] ?? 0;
const green = pixels[i + 1] ?? 0;
const blue = pixels[i + 2] ?? 0;

const magentaScore = calculateMagentaScore(red, green, blue);

if (magentaScore > 0.5) {
pixels[i] = 1;
pixels[i + 1] = 1;
pixels[i + 2] = 1;
pixels[i + 3] = 0;
} else if (magentaScore > 0.1) {
const despilled = despillMagenta(red, green, blue, magentaScore);
pixels[i] = despilled.red;
pixels[i + 1] = despilled.green;
pixels[i + 2] = despilled.blue;
pixels[i + 3] = 255;
} else {
if (removeBackground) {
for (let i = 0; i < pixels.length; i += 4) {
const red = pixels[i] ?? 0;
const green = pixels[i + 1] ?? 0;
const blue = pixels[i + 2] ?? 0;

const magentaScore = calculateMagentaScore(red, green, blue);

if (magentaScore > 0.5) {
pixels[i] = 1;
pixels[i + 1] = 1;
pixels[i + 2] = 1;
pixels[i + 3] = 0;
} else if (magentaScore > 0.1) {
const despilled = despillMagenta(red, green, blue, magentaScore);
pixels[i] = despilled.red;
pixels[i + 1] = despilled.green;
pixels[i + 2] = despilled.blue;
pixels[i + 3] = 255;
} else {
pixels[i + 3] = 255;
}
}
} else {
for (let i = 0; i < pixels.length; i += 4) {
pixels[i + 3] = 255;
}
}
Expand Down