Skip to content
Merged
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
Binary file removed assets/fonts/DroidSans-Bold.ttf
Binary file not shown.
Binary file removed assets/fonts/DroidSans.ttf
Binary file not shown.
Binary file removed assets/fonts/OpenSans-Bold.ttf
Binary file not shown.
Binary file removed assets/fonts/OpenSans-Regular.ttf
Binary file not shown.
Binary file added assets/fonts/Roboto-Bold.ttf
Binary file not shown.
Binary file added assets/fonts/Roboto-Regular.ttf
Binary file not shown.
8 changes: 4 additions & 4 deletions src/drawers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,12 @@ export const drawText = (

context.textAlign = 'center';
context.textBaseline = 'middle';
context.font = '300 16px "OpenSans"';
context.font = '300 16px "Roboto"';
context.fillStyle = textColor(tier);
context.fillText(name, x, 280);

if (description) {
context.font = '12px "OpenSans"';
context.font = '12px "Roboto"';
let start = 300;
const lines = description.split('\n');

Expand All @@ -119,7 +119,7 @@ export const drawText = (
}

if (compatName) {
context.font = '12px "OpenSans"';
context.font = '12px "Roboto"';
context.fillText(compatName, 125, 404);
}
};
Expand Down Expand Up @@ -156,7 +156,7 @@ export const drawBackground = async (mod: Mod, width: number, height: number, ra
drawText(context, mod.name, modDescription(mod.description, mod.levelStats, rank), mod.compatName, tier);

if (mod.baseDrain) {
context.font = '100 16px "DroidSans"';
context.font = '300 16px "Roboto"';

let drain = mod.baseDrain;
if (drain < 0) {
Expand Down
39 changes: 31 additions & 8 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { createCanvas, loadImage } from '@napi-rs/canvas';
import { Mod, RivenMod } from 'warframe-items';

import { CommonFrameParams, drawBackground, drawFrame, drawLegendaryFrame } from './drawers.js';
import { flip, getBackground, getFrame, modRarityMap } from './utils.js';
import { CanvasOutput, exportCanvas, flip, getBackground, getFrame, modRarityMap } from './utils.js';

interface CanvasSize {
width: number;
height: number;
}

export const generateBasicMod = async (mod: Mod, rank: number = 0): Promise<Buffer> => {
export const generateBasicMod = async (
mod: Mod,
rank?: number,
output: CanvasOutput = { format: 'png' }
): Promise<Buffer | undefined> => {
const { width, height }: CanvasSize = { width: 256, height: 512 };
const canvas = createCanvas(width, height);
const context = canvas.getContext('2d');
Expand All @@ -18,7 +22,14 @@ export const generateBasicMod = async (mod: Mod, rank: number = 0): Promise<Buff
const background = await drawBackground(mod, width, height, rank);
context.drawImage(await loadImage(background), 0, 0);

const commonFrameParams: CommonFrameParams = { tier, currentRank: rank, maxRank: mod.fusionLimit, width, height };
const commonFrameParams: CommonFrameParams = {
tier,
currentRank: rank ?? mod.fusionLimit,
maxRank: mod.fusionLimit,
width,
height,
};

let frame = await drawFrame(commonFrameParams);
if (tier === 'Legendary') {
frame = await drawLegendaryFrame(commonFrameParams);
Expand All @@ -28,13 +39,15 @@ export const generateBasicMod = async (mod: Mod, rank: number = 0): Promise<Buff
const outterCanvas = createCanvas(width, 372);
const outterContext = outterCanvas.getContext('2d');

const image = await canvas.encode('png');
outterContext.drawImage(await loadImage(image), 0, -80);
outterContext.drawImage(canvas, 0, -80);

return outterCanvas.encode('png');
return exportCanvas(canvas, output);
};

export const generateRivenMod = async (riven: RivenMod): Promise<Buffer> => {
export const generateRivenMod = async (
riven: RivenMod,
output: CanvasOutput = { format: 'png' }
): Promise<Buffer | undefined> => {
const canvas = createCanvas(282, 512);
const context = canvas.getContext('2d');
const tier = modRarityMap.riven;
Expand Down Expand Up @@ -71,5 +84,15 @@ export const generateRivenMod = async (riven: RivenMod): Promise<Buffer> => {
flipped = await flip(frame.cornerLights, 64, 64);
context.drawImage(await loadImage(flipped), 0, 380);

return canvas.encode('png');
return exportCanvas(canvas, output);
};

export const generateMod = async (
mod: Mod,
rank?: number,
output: CanvasOutput = { format: 'png' }
): Promise<Buffer | undefined> => {
const isRiven = mod.uniqueName.match(/Randomized/);

return isRiven ? generateRivenMod(mod as RivenMod, output) : generateBasicMod(mod, rank ?? mod.fusionLimit, output);
};
66 changes: 52 additions & 14 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { existsSync } from 'fs';
import { join } from 'path';
import { readFile, mkdir, writeFile } from 'fs/promises';

import { GlobalFonts, Image, SKRSContext2D, createCanvas, loadImage } from '@napi-rs/canvas';
import { AvifConfig, Canvas, GlobalFonts, Image, SKRSContext2D, createCanvas, loadImage } from '@napi-rs/canvas';
import { LevelStat } from 'warframe-items';

const assetPath = join('.', 'assets', 'modFrames');
Expand Down Expand Up @@ -38,13 +39,17 @@ const downloadModPiece = async (name: string) => {
};

const fetchModPiece = async (name: string) => {
if (existsSync(join(assetPath, name))) readFileSync(join(assetPath, name));
const filePath = join(assetPath, name);
if (existsSync(filePath)) {
const image = await readFile(filePath);

const image = await downloadModPiece(name);
return loadImage(image);
}

if (!existsSync(assetPath)) mkdirSync(assetPath, { recursive: true });
writeFileSync(join(assetPath, name), image);
const image = await downloadModPiece(name);
if (!existsSync(assetPath)) await mkdir(assetPath, { recursive: true });

await writeFile(filePath, image);
return loadImage(image);
};

Expand Down Expand Up @@ -95,15 +100,20 @@ export const getBackground = async (tier: string): Promise<ModBackground> => {
};

export const fetchPolarity = async (polarity: string): Promise<Image> => {
if (existsSync(join(assetPath, polarity))) readFileSync(join(assetPath, polarity));
const filePath = join(assetPath, `${polarity}.png`);
if (existsSync(filePath)) {
const image = await readFile(filePath);

return loadImage(image);
}

const base = 'https://cdn.warframestat.us/genesis/img/polarities';
const res = await fetch(`${base}/${polarity}.png`);
const image = Buffer.from(await (await res.blob()).arrayBuffer());

if (!existsSync(assetPath)) mkdirSync(assetPath, { recursive: true });
writeFileSync(join(assetPath, `${polarity}.png`), image);
const image = Buffer.from(await (await res.blob()).arrayBuffer());
if (!existsSync(assetPath)) await mkdir(assetPath, { recursive: true });

await writeFile(join(assetPath, `${polarity}.png`), image);
return loadImage(image);
};

Expand Down Expand Up @@ -149,10 +159,8 @@ export const wrapText = (context: SKRSContext2D, text: string, maxWidth: number)

export const registerFonts = () => {
const fontPath = join('.', 'assets', 'fonts');
GlobalFonts.registerFromPath(join(fontPath, 'DroidSans.ttf'), 'DroidSans');
GlobalFonts.registerFromPath(join(fontPath, 'DroidSans-Bold.ttf'), 'DroidSans');
GlobalFonts.registerFromPath(join(fontPath, 'OpenSans-Regular.ttf'), 'OpenSans');
GlobalFonts.registerFromPath(join(fontPath, 'OpenSans-Bold.ttf'), 'OpenSans');
GlobalFonts.registerFromPath(join(fontPath, 'Roboto-Bold.ttf'), 'Roboto');
GlobalFonts.registerFromPath(join(fontPath, 'Roboto-Regular.ttf'), 'Roboto');
};

export const textColor = (tier: string) => {
Expand All @@ -165,3 +173,33 @@ export const textColor = (tier: string) => {
return '#FFFFFF';
}
};

export type Format = 'webp' | 'jpeg' | 'avif' | 'png';

export interface CanvasOutput {
quality?: number;
format: Format;
cfg?: AvifConfig;
}

export const exportCanvas = async (canvas: Canvas, output: CanvasOutput = { format: 'png' }) => {
const quality = output.quality || output.cfg?.quality;
if (quality !== undefined && (quality < 0 || quality > 100)) {
throw new Error('quality cannot be less then 0 or more then 100');
}

try {
switch (output.format) {
case 'png':
return await canvas.encode('png');
case 'webp':
return await canvas.encode('webp', output.quality);
case 'jpeg':
return await canvas.encode('jpeg', output.quality);
case 'avif':
return await canvas.encode('avif', output.cfg);
}
} catch {
console.error(`failed to export canvas as ${output.format}`);
}
};
43 changes: 28 additions & 15 deletions tests/generator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
import * as assert from 'node:assert';
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { readdir, writeFile } from 'node:fs/promises';

import { describe, test } from 'mocha';
import { Mod, RivenMod } from 'warframe-items';
import { Mod } from 'warframe-items';
import { find } from 'warframe-items/utilities';

import { generateBasicMod, generateRivenMod } from '../src/generator.js';
import { generateMod } from '../src/generator.js';
import { Format } from '../src/utils.js';

describe('Generate a mod', () => {
test('run test', async () => {
test('run test', () => {
const formats = ['webp', 'jpeg', 'avif', 'png'];
const mods = [
'/Lotus/Upgrades/Mods/Warframe/Kahl/KahlAvatarAbilityStrengthMod',
'/Lotus/Upgrades/Mods/Warframe/AvatarAbilityEfficiencyMod',
'/Lotus/Upgrades/Mods/Warframe/AvatarHealthMaxMod',
'/Lotus/Upgrades/Mods/Aura/PlayerMeleeAuraMod',
'/Lotus/Upgrades/Mods/Randomized/LotusArchgunRandomModRare',
'/Lotus/Powersuits/Dragon/DragonBreathAugmentCard',
'/Lotus/Upgrades/Mods/Randomized/RawShotgunRandomMod',
];

const testPath = join('.', 'assets', 'tests');
if (!existsSync(testPath)) mkdirSync(testPath, { recursive: true });

for (let i = 0; i < mods.length; i += 1) {
const mod = find.findItem(mods[i]) as Mod;
if (!mod) continue;
const isRiven = mod.name?.includes('Riven');
const modCanvas = isRiven
? await generateRivenMod(mod as RivenMod)
: await generateBasicMod(mod, mod.fusionLimit);
if (!modCanvas) assert.equal(true, false, 'Failed to generate mod');

writeFileSync(join('.', 'assets', 'tests', `${mod.name}.png`), modCanvas);
formats.forEach((format) => {
return async () => {
const imagePath = join(testPath, format);
if (!existsSync(imagePath)) mkdirSync(imagePath, { recursive: true });

const mod = find.findItem(mods[i]) as Mod;
if (!mod) return;
const modCanvas = await generateMod(mod, undefined, { format: format as Format });
if (!modCanvas) assert.equal(true, false, 'Failed to generate mod');

if (modCanvas) await writeFile(join(imagePath, `${mod.name}.${format}`), modCanvas);
};
});
}
const testFiles = readdirSync(join('.', 'assets/tests'));
assert.equal(testFiles.length, mods.length);

formats.forEach((format) => {
return async () => {
const testFiles = await readdir(join(testPath, format));
assert.equal(testFiles.length, mods.length);
};
});
});
});
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
],
"compilerOptions": {
"target": "ESNext",
"module": "Node16",
"moduleResolution": "Node16",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolvePackageJsonExports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
Expand Down