mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-24 23:16:53 -05:00
362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import { Request } from 'express';
|
|
import sharp from 'sharp';
|
|
import { CoopRule, FestVoteState, FriendOnlineState, StageScheduleResult } from 'splatnet3-types/splatnet3';
|
|
import { dir } from '../util/product.js';
|
|
import createDebug from '../util/debug.js';
|
|
import { Game, PresenceState } from '../api/coral-types.js';
|
|
import { RawValueSymbol, htmlentities } from '../util/misc.js';
|
|
import { PresenceResponse } from '../cli/presence-server.js';
|
|
|
|
const debug = createDebug('nxapi:common:presence-embed');
|
|
|
|
type VsSchedule_event = StageScheduleResult['eventSchedules']['nodes'][0];
|
|
type LeagueMatchSetting_schedule = VsSchedule_event['leagueMatchSetting'];
|
|
|
|
export enum PresenceEmbedFormat {
|
|
SVG,
|
|
PNG,
|
|
JPEG,
|
|
WEBP,
|
|
}
|
|
|
|
export enum PresenceEmbedTheme {
|
|
LIGHT,
|
|
DARK,
|
|
}
|
|
interface PresenceEmbedThemeColours {
|
|
background: string;
|
|
separator: string;
|
|
text: string;
|
|
online: string;
|
|
online_border: string;
|
|
}
|
|
|
|
const embed_themes: Record<PresenceEmbedTheme, PresenceEmbedThemeColours> = {
|
|
[PresenceEmbedTheme.LIGHT]: {
|
|
background: '#ebebeb',
|
|
separator: '#7b7b7b',
|
|
text: '#000000',
|
|
online: '#2db742',
|
|
online_border: '#0eb728',
|
|
},
|
|
[PresenceEmbedTheme.DARK]: {
|
|
background: '#2d2d2d',
|
|
separator: '#7e7e7e',
|
|
text: '#ffffff',
|
|
online: '#47e85f',
|
|
online_border: '#19e838',
|
|
},
|
|
};
|
|
|
|
interface UserEmbedOptions {
|
|
show_splatoon3_fest_team?: boolean;
|
|
}
|
|
|
|
const embed_titles: Partial<Record<string, (
|
|
result: PresenceResponse,
|
|
url_map: Record<string, string | readonly [url: string, data: Uint8Array, type: string]>,
|
|
image: (url: string) => string | undefined,
|
|
theme?: PresenceEmbedTheme,
|
|
options?: UserEmbedOptions,
|
|
) => readonly [svg: string, height: number, override_description: string | null]>> = {
|
|
'0100c2500fc20000': renderUserSplatoon3EmbedPartialSvg,
|
|
};
|
|
|
|
export function getUserEmbedOptionsFromRequest(req: Request) {
|
|
const url = new URL(req.url, 'https://localhost');
|
|
|
|
const theme = url.searchParams.get('theme') === 'dark' ? PresenceEmbedTheme.DARK : PresenceEmbedTheme.LIGHT;
|
|
const friend_code = url.searchParams.getAll('friend-code').find(c => c.match(/^\d{4}-\d{4}-\d{4}$/));
|
|
const transparent = url.searchParams.get('transparent') === '1';
|
|
|
|
let width = url.searchParams.getAll('width')
|
|
.map(w => parseInt(w))
|
|
.map(w => transparent ? w + 60 : w)
|
|
.find(w => !isNaN(w) && w >= 500);
|
|
|
|
if (!width) width = 500;
|
|
if (width > 1500) width = 1500;
|
|
|
|
let scale = url.searchParams.getAll('scale')
|
|
.map(s => parseInt(s))
|
|
.find(s => !isNaN(s) && s >= 1 && s <= 4);
|
|
|
|
const options: UserEmbedOptions = {
|
|
show_splatoon3_fest_team: url.searchParams.get('show-splatoon3-fest-team') === '1',
|
|
};
|
|
|
|
return {theme, friend_code, transparent, width, scale, options};
|
|
}
|
|
|
|
export async function renderUserEmbedImage(
|
|
svg: string,
|
|
format: PresenceEmbedFormat,
|
|
): Promise<[data: Buffer, type: string]> {
|
|
if (format === PresenceEmbedFormat.SVG) {
|
|
return [Buffer.from(svg), 'image/svg+xml'];
|
|
}
|
|
|
|
const start = Date.now();
|
|
debug('generating image, format %s', PresenceEmbedFormat[format]);
|
|
|
|
let image = sharp(Buffer.from(svg)).withMetadata({
|
|
density: 72 * 2,
|
|
});
|
|
|
|
if (format === PresenceEmbedFormat.PNG) image = image.png();
|
|
if (format === PresenceEmbedFormat.JPEG) image = image.jpeg();
|
|
if (format === PresenceEmbedFormat.WEBP) image = image.webp();
|
|
|
|
const data = await image.toBuffer();
|
|
|
|
debug('generated %s in %d ms', PresenceEmbedFormat[format], Date.now() - start);
|
|
|
|
if (format === PresenceEmbedFormat.PNG) return [data, 'image/png'];
|
|
if (format === PresenceEmbedFormat.JPEG) return [data, 'image/jpeg'];
|
|
if (format === PresenceEmbedFormat.WEBP) return [data, 'image/webp'];
|
|
|
|
throw new TypeError('Invalid format');
|
|
}
|
|
|
|
export function renderUserEmbedSvg(
|
|
result: PresenceResponse,
|
|
url_map: Record<string, string | readonly [url: string, data: Uint8Array, type: string]>,
|
|
theme = PresenceEmbedTheme.LIGHT,
|
|
friend_code?: string,
|
|
options?: UserEmbedOptions,
|
|
scale = 1,
|
|
transparent = false,
|
|
width = 500,
|
|
) {
|
|
if (width < 500) width = 500;
|
|
let height = 180;
|
|
if (friend_code) height += 40;
|
|
|
|
const colours = embed_themes[theme];
|
|
const font_family = '\'Open Sans\', -apple-system, BlinkMacSystemFont, Arial, sans-serif';
|
|
|
|
const state = result.friend.presence.state;
|
|
const game = 'name' in result.friend.presence.game ? result.friend.presence.game : null;
|
|
|
|
const image = (url: string) =>
|
|
url_map[url] instanceof Array ?
|
|
'data:' + url_map[url][2] + ';base64,' +
|
|
Buffer.from(url_map[url][1]).toString('base64') :
|
|
url_map[url] as string | undefined;
|
|
|
|
const show_splatoon3_fest_team = options?.show_splatoon3_fest_team &&
|
|
result.splatoon3_fest_team?.myVoteState === FestVoteState.VOTED ? result.splatoon3_fest_team : null;
|
|
|
|
const title_extra = result.title ? embed_titles[result.title.id]?.call(null, result, url_map, image, theme, options) : null;
|
|
if (title_extra) height += title_extra[1];
|
|
|
|
return htmlentities`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
<svg
|
|
width="${(width + (transparent ? -60 : 0)) * scale}"
|
|
height="${(height + (transparent ? -60 : 0)) * scale}"
|
|
viewBox="${transparent ? '30 30' : '0 0'} ${width + (transparent ? -60 : 0)} ${height + (transparent ? -60 : 0)}"
|
|
version="1.1"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<style>${embed_style}</style>
|
|
|
|
<defs>
|
|
<linearGradient id="gradient-out">
|
|
<stop offset="0%" stop-opacity="1" stop-color="#ffffff"></stop>
|
|
<stop offset="100%" stop-opacity="0" stop-color="#ffffff"></stop>
|
|
</linearGradient>
|
|
|
|
<mask id="mask-out">
|
|
<rect x="0" y="0" width="${width - 50}" height="${height}" fill="#ffffff"></rect>
|
|
<rect x="${width - 50}" y="0" width="20" height="${height}" fill="url(#gradient-out)"></rect>
|
|
</mask>
|
|
|
|
<mask id="mask-out-title">
|
|
<rect x="0" y="0" width="${width - 50 - (show_splatoon3_fest_team ? 40 : 0)}" height="${height}"
|
|
fill="#ffffff"></rect>
|
|
<rect x="${width - 50 - (show_splatoon3_fest_team ? 40 : 0)}" y="0" width="20" height="${height}"
|
|
fill="url(#gradient-out)"></rect>
|
|
</mask>
|
|
</defs>
|
|
|
|
${{[RawValueSymbol]: transparent ? '' : htmlentities`
|
|
<rect x="0" y="0" width="${width}" height="${height}" fill="${colours.background}" />
|
|
`}}
|
|
|
|
<image x="30" y="30" width="120" height="120"
|
|
href="${image(result.friend.imageUri) ?? result.friend.imageUri}" />
|
|
<text x="180" y="57" fill="${colours.text}" font-size="26" font-family="${font_family}" font-weight="500" mask="url(#mask-out-title)">${result.friend.name}</text>
|
|
|
|
<line x1="180" y1="73" x2="${width - 30}" y2="73" stroke="${colours.separator}" />
|
|
|
|
${{[RawValueSymbol]: game && (state === PresenceState.ONLINE || state === PresenceState.PLAYING) ? htmlentities`
|
|
<image x="180" y="87" width="60" height="60"
|
|
href="${image(game.imageUri) ?? game.imageUri}" />
|
|
|
|
${{[RawValueSymbol]: renderUserTitleEmbedPartialSvg(game, title_extra?.[2], colours, font_family)}}
|
|
` : htmlentities`
|
|
<text x="180" y="97" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="400">Offline</text>
|
|
`}}
|
|
|
|
${{[RawValueSymbol]: friend_code ? htmlentities`
|
|
<text x="30" y="${186 + (title_extra?.[1] ?? 0)}" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="400">Friend code: SW-${friend_code}</text>
|
|
` : ''}}
|
|
|
|
${{[RawValueSymbol]: show_splatoon3_fest_team ? htmlentities`
|
|
<image x="${width - 60}" y="33" width="30" height="30"
|
|
href="${image(show_splatoon3_fest_team.image.url) ?? show_splatoon3_fest_team.image.url}" />
|
|
` : ''}}
|
|
|
|
${{[RawValueSymbol]: title_extra?.[0] ?? ''}}
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
function renderUserTitleEmbedPartialSvg(
|
|
game: Game, description: string | null | undefined,
|
|
colours: PresenceEmbedThemeColours, font_family: string,
|
|
) {
|
|
if (typeof description !== 'string') description = game.sysDescription;
|
|
|
|
const playing_text_offset = description ? 92 : 97;
|
|
const title_name_text_offset = description ? 122 : 133;
|
|
|
|
return htmlentities`
|
|
<rect x="255" y="${playing_text_offset}" width="10" height="10" fill="${colours.online}"
|
|
stroke="${colours.online_border}" stroke-width="1" rx="1" ry="1" stroke-linejoin="round"
|
|
/>
|
|
<text x="272" y="${playing_text_offset + 10}" fill="${colours.online}" font-size="14" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">Online</text>
|
|
|
|
<text x="255" y="${title_name_text_offset}" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${game.name}</text>
|
|
` + (description ? htmlentities`
|
|
<text x="255" y="142" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="300" mask="url(#mask-out)">${description ?? ''}</text>
|
|
` : '');
|
|
}
|
|
|
|
function renderUserSplatoon3EmbedPartialSvg(
|
|
result: PresenceResponse,
|
|
url_map: Record<string, string | readonly [url: string, data: Uint8Array, type: string]>,
|
|
image: (url: string) => string | undefined,
|
|
theme = PresenceEmbedTheme.LIGHT,
|
|
options?: UserEmbedOptions,
|
|
) {
|
|
const x = 180;
|
|
const y = 165;
|
|
const colours = embed_themes[theme];
|
|
const font_family = '\'Open Sans\', -apple-system, BlinkMacSystemFont, Arial, sans-serif';
|
|
|
|
if (result.splatoon3?.vsMode && (
|
|
result.splatoon3.onlineState === FriendOnlineState.VS_MODE_FIGHTING ||
|
|
result.splatoon3.onlineState === FriendOnlineState.VS_MODE_MATCHING
|
|
)) {
|
|
const mode_name =
|
|
result.splatoon3.vsMode.mode === 'REGULAR' ? 'Regular Battle' :
|
|
result.splatoon3.vsMode.id === 'VnNNb2RlLTI=' ? 'Anarchy Battle (Series)' : // VsMode-2
|
|
result.splatoon3.vsMode.id === 'VnNNb2RlLTUx' ? 'Anarchy Battle (Open)' : // VsMode-51
|
|
result.splatoon3.vsMode.mode === 'BANKARA' ? 'Anarchy Battle' :
|
|
result.splatoon3.vsMode.id === 'VnNNb2RlLTY=' ? 'Splatfest Battle (Open)' : // VsMode-6
|
|
result.splatoon3.vsMode.id === 'VnNNb2RlLTc=' ? 'Splatfest Battle (Pro)' : // VsMode-7
|
|
result.splatoon3.vsMode.id === 'VnNNb2RlLTg=' ? 'Tricolour Battle' : // VsMode-8
|
|
result.splatoon3.vsMode.mode === 'FEST' ? 'Splatfest Battle' :
|
|
result.splatoon3.vsMode.id === 'VnNNb2RlLTQ=' ? 'Challenge' : // VsMode-4
|
|
result.splatoon3.vsMode.mode === 'LEAGUE' ? 'Challenge' :
|
|
result.splatoon3.vsMode.mode === 'X_MATCH' ? 'X Battle' : // VsMode-3
|
|
undefined;
|
|
|
|
const setting = result.splatoon3_vs_setting;
|
|
const fest_team = result.splatoon3_fest_team;
|
|
|
|
const description =
|
|
(mode_name ?? result.splatoon3.vsMode.name) +
|
|
(result.splatoon3.vsMode.mode === 'FEST' && fest_team ?
|
|
' - Team ' + fest_team.teamName : '') +
|
|
(result.splatoon3.vsMode.mode === 'LEAGUE' && setting && 'leagueMatchEvent' in setting ?
|
|
': ' + (setting as LeagueMatchSetting_schedule).leagueMatchEvent.name : '') +
|
|
(result.splatoon3.vsMode.mode !== 'FEST' && result.splatoon3.vsMode.mode !== 'LEAGUE' && setting ?
|
|
' - ' + setting.vsRule.name : '') +
|
|
(result.splatoon3.onlineState === FriendOnlineState.VS_MODE_MATCHING ? ' (matching)' : '');
|
|
|
|
if (result.splatoon3.vsMode.id === 'VnNNb2RlLTg=' && result.splatoon3_fest) {
|
|
const stage = result.splatoon3_fest.tricolorStage;
|
|
|
|
return [htmlentities`
|
|
<image x="${x}" y="${y}" width="80" height="40"
|
|
href="${image(stage.image.url) ?? stage.image.url}" />
|
|
|
|
<text x="${x + 95}" y="${y + 25}" fill="${colours.text}" font-size="12" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${stage.name}</text>
|
|
`, 55, description] as const;
|
|
}
|
|
|
|
if (setting?.vsStages.length) {
|
|
return [htmlentities`
|
|
${{[RawValueSymbol]: setting.vsStages.map((stage, i) => htmlentities`
|
|
<image x="${x}" y="${y + (i * 50)}" width="80" height="40"
|
|
href="${image(stage.image.url) ?? stage.image.url}" />
|
|
|
|
<text x="${x + 95}" y="${y + (i * 50) + 25}" fill="${colours.text}" font-size="12" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${stage.name}</text>
|
|
`).join('')}}
|
|
`, (setting.vsStages.length * 50) + 5, description] as const;
|
|
}
|
|
|
|
return ['', 0, description] as const;
|
|
}
|
|
|
|
if (result.splatoon3?.onlineState === FriendOnlineState.COOP_MODE_FIGHTING ||
|
|
result.splatoon3?.onlineState === FriendOnlineState.COOP_MODE_MATCHING
|
|
) {
|
|
const rule_name =
|
|
result.splatoon3.coopRule === CoopRule.REGULAR ? 'Salmon Run' :
|
|
result.splatoon3.coopRule === CoopRule.BIG_RUN ? 'Big Run' :
|
|
result.splatoon3.coopRule === CoopRule.TEAM_CONTEST ? 'Eggstra Work' : null;
|
|
|
|
const description = (rule_name ?? 'Salmon Run') +
|
|
(result.splatoon3.onlineState === FriendOnlineState.COOP_MODE_MATCHING ? ' (matching)' : '');
|
|
|
|
const setting = result.splatoon3_coop_setting;
|
|
|
|
if (setting) {
|
|
return [htmlentities`
|
|
<image x="${x}" y="${y}" width="80" height="40"
|
|
href="${image(setting.coopStage.thumbnailImage.url) ?? setting.coopStage.thumbnailImage.url}" />
|
|
|
|
<text x="${x + 95}" y="${y + 13}" fill="${colours.text}" font-size="12" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${setting.coopStage.name}</text>
|
|
|
|
${{[RawValueSymbol]: setting.weapons.map((weapon, i) => htmlentities`
|
|
<image x="${x + 95 + (i * 22)}" y="${y + 21}" width="18" height="18"
|
|
href="${image(weapon.image.url) ?? weapon.image.url}" />
|
|
`).join('')}}
|
|
`, 55, description] as const;
|
|
}
|
|
return ['', 0, description] as const;
|
|
}
|
|
|
|
if (result.splatoon3?.onlineState === FriendOnlineState.MINI_GAME_PLAYING) {
|
|
const description = 'Tableturf Battle';
|
|
return ['', 0, description] as const;
|
|
}
|
|
|
|
return ['', 0, null] as const;
|
|
}
|
|
|
|
const embed_fonts: [name: string, style: string, weight: string, files: [format: string, type: string, path: string][]][] = [
|
|
['Open Sans', 'normal', '400', [['opentype', 'font/ttf', 'opensans-normal-400.ttf']]],
|
|
['Open Sans', 'normal', '500', [['opentype', 'font/ttf', 'opensans-normal-500.ttf']]],
|
|
];
|
|
|
|
const embed_style = `
|
|
text {
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
` + (await Promise.all(embed_fonts.map(async ([name, style, weight, files]) => `@font-face {
|
|
font-family: '${name}';
|
|
font-style: ${style};
|
|
font-weight: ${weight};
|
|
src: ${(await Promise.all(files.map(async ([format, type, file]) => `url('data:${type};base64,${
|
|
(await fs.readFile(path.join(dir, 'resources', 'cli', 'fonts', file))).toString('base64')
|
|
}') format('${format}')`))).join(',')};
|
|
}`))).join('\n');
|