25 Commits

Author SHA1 Message Date
34dbc91d1e Profile: Implement custom profile backgrounds 2023-08-07 16:23:18 +02:00
2d11fdfdad DB: Add fields for custom profile backgrounds 2023-08-07 16:22:39 +02:00
db98cc21b2 GeneralUtil: Add download file function 2023-08-07 16:21:29 +02:00
696d0f136d Profile: Fix showcase card being out of order.
The showcase cards were rendered based on the order in which
the render calls returned within the slots.map call.
Using the original slot keys fixes this issue.
2023-08-04 12:07:47 +02:00
24510dcc4c Bot: Change ENV to NODE_ENV in ready.js 2023-08-01 00:41:59 +02:00
ac54231ced Bot: Clear global commands when running as dev 2023-07-31 17:18:22 +02:00
b0311a3bb3 Rendering: Better card debug output
- Add timestamp
- Larger font
2023-07-31 17:17:30 +02:00
44eb1783ac Debug: Catch and print errors when fetching from Jose 2023-07-31 15:53:30 +02:00
e2dab416f2 GeneralUtils/Profile: Shorten numbers to K M B format
- Implemented for currencies on the profile
2023-07-31 15:52:42 +02:00
724621f8da Rendering: Add debug output in develop mode
- Burn node info into rendered card
- Log job def onto console
2023-07-31 14:49:00 +02:00
56ff2f96a5 Merge pull request #58 from JanGross/master
Merge master into dev-new-rendering
2023-07-31 11:38:40 +02:00
fe9b52c02d Editprofile: Properly defer updates on non-modal interactions
Also fixes interaction filtering by user id.
Also fixes collector filtering by modal customID
2023-06-12 22:31:28 +02:00
2d9f66acd4 Bump node versiom to 20
node:20-alpine
2023-06-12 21:56:45 +02:00
487ee866ba Cleanup legacy rendering code and files
also removes dockerfile and imagemagick.
2023-06-12 21:55:13 +02:00
14fbe1ab5d Rendering: Reposition labels for top-aligned default 2023-06-12 21:27:27 +02:00
7175271233 Rendering: Add placeholder frame
and fix profile card alignment
2023-06-12 16:40:11 +02:00
f84a1deddb Profile: Add stats bar to new rendering 2023-06-12 14:38:40 +02:00
5dd106354b Profile: Add userimage and status to new profile 2023-06-12 01:36:56 +02:00
9a79521a27 Profile: Add new rendering for username and showcase 2023-06-11 23:55:27 +02:00
b0d08f119e Editprofile: Remove top level defer which broke modal inputs 2023-06-11 21:55:30 +02:00
ce5cfafd1d Rendering: Hide card if it's unclaimed 2023-06-08 14:13:37 +02:00
8b35534517 Rendering: Replace card stack rendering with external API call 2023-06-08 13:47:49 +02:00
795d3b444e API: Serve static assets via express 2023-06-08 12:17:16 +02:00
46bc9ae711 Rendering: Add configurable Jose endpoint 2023-06-08 10:13:03 +02:00
a98f5e0ebc Rendering: WIP Replacing renderer with external API 2023-06-02 12:36:18 +02:00
17 changed files with 401 additions and 197 deletions

View File

@@ -10,3 +10,7 @@ DB_PORT=
API_PORT=3080
API_ACCESS_TOKEN=
HOMEPAGE_URL=
#Rendering
JOSE_ENDPOINT=
ASSET_URL=

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
assets/image_cache
assets/cards
assets/import
assets/userdata
### Visual Studio Code ###
.vscode

View File

@@ -1,3 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:16.9.0-alpine
RUN apk add --no-cache imagemagick

View File

@@ -93,4 +93,6 @@ app.use(PREFIX, router);
app.use(PREFIX, groupRoutes);
app.use(PREFIX, badgeRoutes);
app.use(PREFIX, characterRoutes);
app.use('/assets', express.static('assets'));
module.exports = app;

View File

View File

@@ -1,7 +1,7 @@
const { SlashCommandBuilder, ComponentType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js");
const { customAlphabet } = require("nanoid");
const { Card, User, Wishlist, Character } = require("../models");
const { UserUtils, CardUtils, GeneralUtils } = require("../util");
const { UserUtils, CardUtils, GeneralUtils, Rendering } = require("../util");
const { PATREON } = require("../config/constants");
const stores = require("../stores");
require('dotenv').config();
@@ -28,6 +28,7 @@ module.exports = {
{ name: 'toggle_maintenance', value: 'toggle_maintenance' },
{ name: 'store', value: 'store' },
{ name: 'wishlist', value: 'wishlist' },
{ name: 'rendering', value: 'rendering' },
{ name: 'patreon', value: 'patreon' }
)
)
@@ -181,6 +182,55 @@ module.exports = {
let patreon = await UserUtils.getPatreonPerks(interaction.client, extUser ? extUser : user);
interaction.channel.send(JSON.stringify(patreon));
break;
case "rendering":
const row = new ActionRowBuilder();
row.addComponents(
new ButtonBuilder()
.setCustomId(`testbatch`)
.setLabel(`Render test batch`)
.setStyle(ButtonStyle.Primary),
);
interaction.editReply({
content: `Jose endpoint: ${process.env.JOSE_ENDPOINT}\n Asset URL: ${process.env.ASSET_URL}`,
components: [row],
ephemeral: false
});
const filter = (m) => m.user.id === interaction.user.id;
const collector = interaction.channel.createMessageComponentCollector({ filter: filter, componentType: ComponentType.Button, time: 60000 });
collector.on('collect', async (i) => {
switch (i.customId) {
case 'testbatch':
i.deferUpdate();
interaction.channel.send("Beep boop test batch of 5");
let testCard = await Card.build({
characterId: 0,
userId: 1,
identifier: "0xffff",
quality: 1,
printNr: 0,
});
let testCharacter = Character.build({
id: 0,
groupId: 0,
name: "test",
imageIdentifier: "azur-lane/akashi.png",
enabled: true
})
for (let index = 0; index < 5; index++) {
testCard.printNr = index;
let render = await Rendering.renderCard(testCard, testCharacter).catch(async function(error){
await interaction.channel.send(JSON.stringify(error));
await interaction.channel.send(JSON.stringify(error.response?.data));
return;
});
await interaction.channel.send(render);
}
break;
}
});
break;
default:
interaction.editReply({

View File

@@ -1,6 +1,6 @@
const { SlashCommandBuilder, ComponentType, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder } = require("discord.js");
const { SlashCommandBuilder, ComponentType, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, Attachment } = require("discord.js");
const { Card, User, Character } = require("../models");
const { UserUtils, ReplyUtils } = require("../util");
const { UserUtils, ReplyUtils, GeneralUtils } = require("../util");
const pageSize = 8;
@@ -8,12 +8,18 @@ const pageSize = 8;
module.exports = {
data: new SlashCommandBuilder()
.setName("editprofile")
.setDescription("Edit your profile"),
.setDescription("Edit your profile")
.addAttachmentOption((option) =>
option
.setName("attachement")
.setDescription("Attachement to be used")
.setRequired(false)
),
permissionLevel: 0,
async execute(interaction) {
await interaction.deferReply();
let user = await UserUtils.getUserByDiscordId(interaction.member.id);
let patreon = await UserUtils.getPatreonPerks(interaction.client, user);
let profile = await user.getProfile();
@@ -31,6 +37,15 @@ module.exports = {
.setStyle(ButtonStyle.Primary)
);
if (patreon.perks?.["custom_bg"] === true && interaction.options.getAttachment("attachement")) {
mainRow.addComponents(
new ButtonBuilder()
.setLabel('Set attachment as custom background')
.setCustomId('setCustomBg')
.setStyle(ButtonStyle.Primary)
);
}
const pingRow = new ActionRowBuilder();
pingRow.addComponents(
new ButtonBuilder()
@@ -51,11 +66,10 @@ module.exports = {
let message = await interaction.editReply({ content: "", components: [mainRow, pingRow], fetchReply: true });
//filter only events from the user who triggered the command
const filter = (m) => m.author.id === interaction.author.id;
const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, time: 25000 })
const filter = (m) => m.user.id === interaction.user.id;
const collector = message.createMessageComponentCollector({ filter: filter, componentType: ComponentType.Button, time: 300000 })
collector.on('collect', async (i) => {
await i.deferReply();
switch (i.customId) {
case 'editStatus':
await this.openStatusModal(i, user, profile);
@@ -63,19 +77,36 @@ module.exports = {
case 'editShowcase':
await this.openShowcaseModal(i, user, profile);
break;
case 'setCustomBg':
await i.deferReply();
let allowedContentTypes = [ "image/png", "image/jpeg" ];
let image = interaction.options.getAttachment("attachement");
if (!allowedContentTypes.includes(image.contentType)) {
await i.editReply({ content: "An invalid image has been attached. Allowed are .png and .jpeg", ephemeral: true });
return;
}
await GeneralUtils.downloadFile(image.url, `/app/assets/userdata/profiles/${image.id}_${image.name}`);
profile.customBackground = `${process.env.ASSET_URL}/userdata/profiles/${image.id}_${image.name}`;
await profile.save();
await i.editReply('Custom profile background has been set!');
break;
case 'toggle-wishlist-ping':
await i.deferUpdate();
user.wishlistPing = !user.wishlistPing;
user.save();
break;
case 'toggle-drop-ping':
await i.deferUpdate();
user.dropPing = !user.dropPing;
user.save();
break;
case 'toggle-daily-ping':
await i.deferUpdate();
user.dailyPing = !user.dailyPing;
user.save();
break;
default:
await i.deferReply();
i.editReply({ content: "Invalid selection" });
return;
break;
@@ -89,8 +120,6 @@ module.exports = {
}
});
await message.edit({ components: newComponents });
let msg = await i.editReply({content: '...'});
await msg.delete();
});
},
async openShowcaseModal(interaction, user, profile) {
@@ -118,13 +147,12 @@ module.exports = {
let submitted = await interaction.awaitModalSubmit({
time: 60000,
filter: i => i.user.id === interaction.user.id,
filter: i => i.user.id === interaction.user.id && i.customId === 'cardSlotModal',
}).catch(error => {
//Error includes timeout
console.error(error)
return null
})
if (submitted) {
let updatePayload = {};
for (slot of slots) {
@@ -144,7 +172,7 @@ module.exports = {
},
async openStatusModal(interaction, user, profile) {
const modal = new ModalBuilder()
.setCustomId('descriptionModal')
.setCustomId('statusModal')
.setTitle('Edit profile status/description');
let row = new ActionRowBuilder();
@@ -162,7 +190,7 @@ module.exports = {
let submitted = await interaction.awaitModalSubmit({
time: 300000,
filter: i => i.user.id === interaction.user.id,
filter: i => i.user.id === interaction.user.id && i.customId === 'statusModal',
}).catch(error => {
//Error includes timeout
console.error(error)

View File

@@ -1,10 +1,9 @@
const { SlashCommandBuilder, MessageAttachment, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js");
const { Card, User, Character } = require("../models");
const { UserUtils, Compositing, Rendering } = require("../util");
require("dotenv").config();
const { SlashCommandBuilder } = require("discord.js");
const { Card } = require("../models");
const { UserUtils, Rendering, GeneralUtils } = require("../util");
const axios = require("axios");
const sharp = require("sharp");
const { CURRENCY_NAMES } = require("../config/constants");
const fs = require('fs');
const pageSize = 8;
@@ -25,56 +24,154 @@ module.exports = {
let discordUser = interaction.options.getUser("user") ? interaction.options.getUser("user") : interaction.member.user;
let user = await UserUtils.getUserByDiscordId(discordUser.id);
let patreon = await UserUtils.getPatreonPerks(interaction.client, user);
let profile = await user.getProfile();
let customStatus = this.encodeStr(profile.customStatus);
customStatus = customStatus.replace(/(.{0,40}[\s])/g, '<tspan x="443" dy="1.2em">$1</tspan>');
let customStatus = profile.customStatus;
let profileTemplate = fs.readFileSync('/app/assets/profile/profile.svg').toString();
profileTemplate = profileTemplate.replace(/{{USERNAME}}/g, this.encodeStr(discordUser.username.substr(0,15)+(discordUser.username.length>15?'...':'')));
profileTemplate = profileTemplate.replace(/{{PROFILE_TEXT}}/g, customStatus );
profileTemplate = profileTemplate.replace(/{{HEADER_COLOR}}/g, '190,31,97');
profileTemplate = profileTemplate.replace(/{{CC}}/g, await Card.count({where: {userId: user.id}}));
profileTemplate = profileTemplate.replace(/{{LVL}}/g, await user.level().currentLevel);
profileTemplate = profileTemplate.replace(/{{CUR_1}}/g, `${await user.primaryCurrency} ${CURRENCY_NAMES[1]}`);
profileTemplate = profileTemplate.replace(/{{CUR_2}}/g, `${await user.secondaryCurrency} ${CURRENCY_NAMES[2]}`);
let userImageBuffer = await axios.get(discordUser.displayAvatarURL({format: 'png', size: 128}), { responseType: 'arraybuffer' });
userImage = await sharp(userImageBuffer.data);
const rect = new Buffer.from(
'<svg><rect x="0" y="0" width="128" height="128" rx="100%" ry="100%"/></svg>'
);
userImage = await userImage.composite([{input: rect, blend: 'dest-in' }]).png().toBuffer();
let background = await sharp(Buffer.from(profileTemplate, 'utf8'))
.composite([{ input: userImage, left: 360, top: 20 }]).png().toBuffer();
let userImage = discordUser.displayAvatarURL({format: 'png', size: 128}).split('webp')[0] + 'png';
let slots = ['slotOne', 'slotTwo', 'slotThree', 'slotFour'];
let renderedCards = [];
for (slot of slots) {
await Promise.all(slots.map(async slot => {
let card = await Card.findOne({ where: { id: profile[slot], burned: false } });
if (card) {
console.log(`Iterating card ${card.id}`);
let cardImage = await Rendering.renderCard(card);
renderedCards.push(cardImage);
renderedCards[slot] = cardImage;
} else {
renderedCards.push('/app/assets/cards/missing_image.png');
renderedCards[slot] = `${process.env.ASSET_URL}/cards/card_cover.png`;
}
}));
let job = {
"type": "profile",
"size": {
"width": 1200,
"height": 600
},
"elements": [
{
"type": "image",
"asset": `${renderedCards['slotOne']}`,
"x": 25,
"y": 85,
"width": 300,
"height": 500
},
{
"type": "image",
"asset": `${renderedCards['slotTwo']}`,
"x": 375,
"y": 310,
"width": 175,
"height": 275
},
{
"type": "image",
"asset": `${renderedCards['slotThree']}`,
"x": 560,
"y": 310,
"width": 175,
"height": 275
},
{
"type": "image",
"asset": `${renderedCards['slotFour']}`,
"x": 745,
"y": 310,
"width": 175,
"height": 275
},
{
"type": "image",
"asset": userImage,
"x": 350,
"y": 50,
"width": 150,
"height": 150
},
{
"type": "text",
"text": discordUser.username.substr(0,15)+(discordUser.username.length>15?'...':''),
"fontSize": 32,
"x": 25,
"y": 20,
"width": 300,
"height": 30,
"horizontalAlignment": "center"
},
{
"type": "text",
"text": `CC: ${GeneralUtils.formatNumber(await Card.count({where: {userId: user.id}}))}`,
"fontSize": 30,
"x": 550,
"y": 20,
"width": 150,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": `LVL: ${await user.level().currentLevel}`,
"fontSize": 30,
"x": 700,
"y": 20,
"width": 150,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": `${GeneralUtils.formatNumber(await user.primaryCurrency)} ${CURRENCY_NAMES[1]}`,
"fontSize": 30,
"x": 850,
"y": 20,
"width": 170,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": `${await GeneralUtils.formatNumber(user.secondaryCurrency)} ${CURRENCY_NAMES[2]}`,
"fontSize": 30,
"x": 1020,
"y": 20,
"width": 170,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": customStatus,
"fontSize": 25,
"x": 550,
"y": 65,
"width": 625,
"height": 300,
"horizontalAlignment": "left"
}
]
}
let profileImage = await Compositing.renderProfile(profile, background, renderedCards);
await interaction.editReply({ files: [profileImage] });
},
encodeStr: function(str) {
let charMapping = {
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;'
};
return str.replace(/([\&"<>])/g, function(str, item) {
return charMapping[item];
});
if (patreon.perks?.['custom_bg'] && profile.customBackground) {
job.elements.unshift(
{
"type": "image",
"asset": profile.customBackground,
"x": 0,
"y": 0,
"width": 1200,
"height": 600
}
);
}
console.log("Fetching ", );
if(process.env.NODE_ENV === "development") {
await interaction.channel.send(`\`\`\`${JSON.stringify(job)}\`\`\``);
}
let { data } = await axios.post(`${process.env.JOSE_ENDPOINT}/jobs`, job);
console.log("Fetched ", data);
await interaction.editReply({ files: [data["path"]] });
}
}

View File

@@ -71,8 +71,6 @@ module.exports = {
return;
}
let cardImage = await Rendering.renderCard(card);
//get base filename
let filename = cardImage.split("/").pop();
let description = "";
//Add a new line after every 4th (long) word or after a full stop
@@ -92,7 +90,7 @@ module.exports = {
const embed = new EmbedBuilder()
.setTitle(`${card.Character.name}`)
.setDescription(description)
.setImage(`attachment://${filename}`)
.setImage(cardImage)
.setThumbnail(card.Character.Group.imageURL)
.addFields(
{ name: "Owned by", value: `<@${card.User.discordId}>` },
@@ -108,7 +106,7 @@ module.exports = {
embed.setColor(0xff0000);
embed.addFields({ name: "Burned", value: "This card has been burned" });
}
const message = await interaction.editReply({ embeds: [embed], files: [cardImage], fetchReply: true });
const message = await interaction.editReply({ embeds: [embed], fetchReply: true });
},
/**

View File

@@ -101,7 +101,8 @@ const PATREON = {
wishlist: 15,
currency: 1.25,
daily: 1.5
}
},
custom_bg: true
},
4 : {
modifiers: {
@@ -110,7 +111,8 @@ const PATREON = {
wishlist: 25,
currency: 1.75,
daily: 2
}
},
custom_bg: true
},
5 : {
modifiers: {
@@ -119,7 +121,8 @@ const PATREON = {
wishlist: 45,
currency: 2,
daily: 4
}
},
custom_bg: true
}
}
}

View File

@@ -2,7 +2,7 @@ version: "3.7"
services:
bot:
build: .
image: node:20-alpine
command: sh -c "npm config set cache /app/.npm_cache --global && npm install && npx sequelize db:migrate && node ."
restart: unless-stopped
environment:

View File

@@ -16,11 +16,12 @@ module.exports = {
(async () => {
try {
console.log("Registering commands...");
if(process.env.ENV === "production") {
if(process.env.NODE_ENV === "production") {
await rest.put(Routes.applicationCommands(CLIENT_ID), {body: commands });
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, process.env.GUILD_ID), {body: [] }); //Clear Guild commands on prod
console.log("Global commands registered");
} else {
await rest.put(Routes.applicationCommands(CLIENT_ID), {body: [] }); //Clear global commands on dev
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, process.env.GUILD_ID), {body: commands });
console.log("Local commands registered");
}

View File

@@ -0,0 +1,15 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn('Profiles', 'customBackground', {
type: Sequelize.STRING,
allowNull: true
});
},
async down (queryInterface, Sequelize) {
await queryInterface.removeColumn('Profiles', 'customBackground');
}
};

View File

@@ -20,6 +20,7 @@ module.exports = (sequelize, DataTypes) => {
Profile.init({
userId: DataTypes.INTEGER,
customStatus: DataTypes.STRING,
customBackground: DataTypes.STRING,
slotOne: DataTypes.INTEGER,
slotTwo: DataTypes.INTEGER,
slotThree: DataTypes.INTEGER,

View File

@@ -1,42 +0,0 @@
const { spawn } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const { Card } = require('../models');
//TODO: Handle missing images
module.exports = {
name: "Compositing",
renderProfile: async function(profile, background, renderedCards) {
let hash = crypto.createHash('md5').update(JSON.stringify(profile) + background).digest('hex');
let outFile = `/app/assets/image_cache/profiles/${hash}.gif`;
console.log('Rendering profile to ' + outFile);
//composite {overlay} {background} [{mask}] [-compose {method}] {result}
let args = ['png:-', 'null:',
'\(', `${renderedCards[0]}`, '-coalesce', '\)',
'-geometry', '+25+85', '-compose', 'over', '-layers', 'composite',
'null:', '\(', `${renderedCards[1]}`, '-coalesce', '-resize', '170x283', '\)',
'-geometry', '+350+300', '-compose', 'over', '-layers', 'composite',
'null:', '\(', `${renderedCards[2]}`, '-coalesce', '-resize', '170x283', '\)',
'-geometry', '+535+300', '-compose', 'over', '-layers', 'composite',
'null:', '\(', `${renderedCards[3]}`, '-coalesce', '-resize', '170x283', '\)',
'-geometry', '+720+300', '-compose', 'over', '-layers', 'composite',
'-layers', 'optimize', outFile];
console.log('GM Args: ' + args);
const composite = spawn('convert', args);
composite.stdin.write(background);
composite.stdin.end();
composite.stderr.on('data', (data) => {
console.log(`stdout: ${data}`);
});
const exitCode = await new Promise( (resolve, reject) => {
composite.on('close', resolve);
})
return outFile;
}
}

View File

@@ -1,6 +1,8 @@
const { Bot } = require("../models");
const crypto = require("crypto");
const { ReactionUserManager } = require("discord.js");
const axios = require("axios");
const fs = require("fs");
module.exports = {
name: "GeneralUtils",
@@ -17,5 +19,28 @@ module.exports = {
generateLogID: async function() {
return crypto.randomBytes(4).toString("hex");
}
},
formatNumber: function(num, precision = 1) {
const map = [
{ suffix: 'T', threshold: 1e12 },
{ suffix: 'B', threshold: 1e9 },
{ suffix: 'M', threshold: 1e6 },
{ suffix: 'K', threshold: 1e3 },
{ suffix: '', threshold: 1 },
];
const found = map.find((x) => Math.abs(num) >= x.threshold);
if (found) {
const formatted = (Math.floor((num / found.threshold)*10) / 10) + found.suffix;
return formatted;
}
return num;
},
downloadFile: async function(url, path) {
let imageBuffer = await axios.get(url, { responseType: 'arraybuffer' });
fs.writeFileSync(path, imageBuffer.data);
},
}

View File

@@ -1,7 +1,6 @@
const sharp = require('sharp');
const crypto = require('crypto');
const fs = require('fs');
require("dotenv").config();
const { Character } = require('../models');
const axios = require('axios').default
const QualityColors = {
1: {r: 0, g: 0, b: 0}, //bad
@@ -12,102 +11,127 @@ const QualityColors = {
6: {r: 255, g: 255, b: 0} //shiny
}
//TODO: Handle missing images
module.exports = {
name: "Rendering",
renderCardStack: async function(cards) {
for (let card of cards) {
await Promise.all(cards.map(async card => {
console.log(`Iterating card ${card.id}`);
card['render'] = await this.renderCard(card);
}));
let job = {
"type": "stack",
"size": {
"width": 900,
"height": 500
},
"elements": [
{
"type": "image",
"asset": `${cards[0].render}`,
"x": 0,
"y": 0,
"width": 300,
"height": 500
},
{
"type": "image",
"asset": `${cards[1].render}`,
"x": 300,
"y": 0,
"width": 300,
"height": 500
},
{
"type": "image",
"asset": `${cards[2].render}`,
"x": 600,
"y": 0,
"width": 300,
"height": 500
}
]
}
let image = await sharp({
create: {
width: 900,
height: 500,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0.0 },
animated: true
}
}).composite([
{ input: cards[0].render, gravity: 'northwest' },
{ input: cards[1].render, gravity: 'centre' },
{ input: cards[2].render, gravity: 'northeast' },
]);
let hash = crypto.createHash('md5').update("CHANGEME").digest('hex');
try {
await image.gif({effort: 1}).toFile(`./assets/image_cache/${hash}.gif`);
} catch (error) {
console.log(error);
}
return `./assets/image_cache/${hash}.gif`;
console.log("Fetching ", );
let { data } = await axios.post(`${process.env.JOSE_ENDPOINT}/jobs`, job);
console.log("Fetched ", data);
return data["path"];
},
renderCard: async function(card) {
const character = await Character.findOne({
where: {
id: card.characterId
}
});
if (!card.userId) {
return './assets/cards/card_cover.png';
}
let hash = crypto.createHash('md5').update(character.imageIdentifier + card.quality + (card.userId == 1 ? 'unclaimed' : 'claimed')).digest('hex');
//TODO: Add switch to turn off or bypass caching
if (fs.existsSync(`./assets/image_cache/${hash}.gif`)) {
return `./assets/image_cache/${hash}.gif`;
}
console.log(`Rendering card ${hash} for character ${character.name} ${character.imageIdentifier}`);
let filetype = character.imageIdentifier.split('.').pop();
let isAnimated = ['gif', 'webp'].includes(filetype);
let border = await sharp(`./assets/overlays/border.svg`).tint(QualityColors[card.quality]).toBuffer();
//BUGBUG: Custom fonts not loading
let label = Buffer.from(`
<svg width="300" height="500">
<text x="50%" y="95%" text-anchor="middle" style="font-size:28px;">${character.name}</text>
</svg>
`);
let cardImage;
try {
console.log("Loading character image");
cardImage = await sharp(`./assets/cards/${character.imageIdentifier}`,
{ animated: isAnimated, pages: (isAnimated ? -1 : 1) });
await cardImage.toBuffer();
} catch (error) {
console.log(`Missing character image: ${character.imageIdentifier}`);
cardImage = await sharp(`./assets/cards/missing_image.png`);
}
console.log("rendering");
await cardImage.resize(300, 500);
await cardImage.composite([
{input: border, top:0, left: 0, tile: true},
{input: label, top:0, left: 0, tile: true}]);
//BUGBUG: Grayscale does not apply to card border
if (card.userId === 1) {
await cardImage.grayscale()
.modulate({
brightness: 0.5
renderCard: async function(card, character=null) {
if(!character) {
character = await Character.findOne({
where: {
id: card.characterId
}
});
}
if (isAnimated) {
await cardImage.gif({effort: 1})
} else {
await cardImage.png();
console.log(`Rendering card ${card.id} ${character.name} ${character.imageIdentifier}`);
let characterImage = `${process.env.ASSET_URL}/cards/${character.imageIdentifier}`;
//Hide character info if the card is unclaimed
if (!card.userId) {
characterImage = `${process.env.ASSET_URL}/cards/card_cover.png`;
character.name = ' ';
}
let extension = isAnimated ? 'gif' : 'png';
await cardImage.toFile(`./assets/image_cache/${hash}.${extension}`);
let job = {
"type": "card",
"size": {
"width": 600,
"height": 1000
},
"elements": [
{
"type": "image",
"asset": `${characterImage}`,
"x": 10,
"y": 10,
"width": 580,
"height": 980
},
{
"type": "image",
"asset": `${process.env.ASSET_URL}/overlays/default_frame.png`,
"x": 0,
"y": 0,
"width": 600,
"height": 1000
},
{
"type": "text",
"text": `${character.name}`,
"fontSize": 55,
"x": 0,
"y": 850,
"width": 600,
"height": 300,
"horizontalAlignment": "center"
}
]
}
return `./assets/image_cache/${hash}.${extension}`;
if(process.env.NODE_ENV === "development") {
debugElement = {
"type": "text",
"text": `Jose-Endpoint: \n${process.env.JOSE_ENDPOINT}\nNode: \n%nodeid% \nPrint: ${card.printNr} uid: ${card.identifier}\n Serve-Mode: %servemode%\n TS:%timestamp%`,
"fontSize": 35,
"x": 0,
"y": 50,
"width": 600,
"height": 800,
"horizontalAlignment": "center"
}
job.elements.push(debugElement)
}
console.log("Fetching ", JSON.stringify(job));
let { data } = await axios.post(`${process.env.JOSE_ENDPOINT}/jobs`, job);
console.log("Fetched ", JSON.stringify(data));
return data["path"];
}
}