diff --git a/apps/api/src/dtos/index.ts b/apps/api/src/dtos/index.ts index 975dd1697..69b814b90 100644 --- a/apps/api/src/dtos/index.ts +++ b/apps/api/src/dtos/index.ts @@ -24,3 +24,4 @@ export * from "./uuid.dto.js"; export * from "./player-rankings.dto.js"; export * from "./player-leaderboard.dto.js"; export * from "./session.dto.js"; +export * from "./skin-render.dto.js"; diff --git a/apps/api/src/dtos/skin-render.dto.ts b/apps/api/src/dtos/skin-render.dto.ts new file mode 100644 index 000000000..572f6aa42 --- /dev/null +++ b/apps/api/src/dtos/skin-render.dto.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNumber, IsOptional, Max, Min } from "class-validator"; +import { Transform } from "class-transformer"; +import { UuidDto } from "./uuid.dto.js"; + +export class SkinRenderDto extends UuidDto { + @IsOptional() + @Transform(({ value }) => +value) + @IsNumber() + @Min(-Math.PI * 2) + @Max(Math.PI * 4) + @ApiPropertyOptional({ description: "Camera horizontal angle in radians (yaw). Defaults to -0.3." }) + public yaw?: number; +} diff --git a/apps/api/src/skin/skin.controller.ts b/apps/api/src/skin/skin.controller.ts index ccc4a11dd..238e54758 100644 --- a/apps/api/src/skin/skin.controller.ts +++ b/apps/api/src/skin/skin.controller.ts @@ -10,7 +10,7 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOkResponse, ApiOperation import { Auth } from "#auth"; import { Controller, Get, Query, StreamableFile } from "@nestjs/common"; import { ErrorResponse, GetSkinTexturesResponse, PlayerNotFoundException } from "@statsify/api-client"; -import { HeadDto, PlayerDto, UuidDto } from "#dtos"; +import { HeadDto, PlayerDto, SkinRenderDto } from "#dtos"; import { SkinService } from "./skin.service.js"; @Controller("/skin") @@ -32,8 +32,8 @@ export class SkinController { @Auth() @ApiOperation({ summary: "Get a Player Render" }) @ApiBadRequestResponse({ type: ErrorResponse }) - public async getRender(@Query() { uuid }: UuidDto) { - const render = await this.skinService.getRender(uuid, false); + public async getRender(@Query() { uuid, yaw }: SkinRenderDto) { + const render = await this.skinService.getRender(uuid, false, yaw); return new StreamableFile(render, { type: "image/png" }); } @@ -41,8 +41,8 @@ export class SkinController { @Auth() @ApiOperation({ summary: "Get an Extruded Player Render" }) @ApiBadRequestResponse({ type: ErrorResponse }) - public async getExtrudedRender(@Query() { uuid }: UuidDto) { - const render = await this.skinService.getRender(uuid, true); + public async getExtrudedRender(@Query() { uuid, yaw }: SkinRenderDto) { + const render = await this.skinService.getRender(uuid, true, yaw); return new StreamableFile(render, { type: "image/png" }); } diff --git a/apps/api/src/skin/skin.service.ts b/apps/api/src/skin/skin.service.ts index b96b85cd3..51615a52f 100644 --- a/apps/api/src/skin/skin.service.ts +++ b/apps/api/src/skin/skin.service.ts @@ -43,12 +43,12 @@ export class SkinService { return canvas.toBuffer("png"); } - public async getRender(uuid: string, extruded: boolean): Promise { + public async getRender(uuid: string, extruded: boolean, yaw?: number): Promise { const skin = await this.getSkin(uuid); const { skin: image } = await this.resolveSkin(skin.skinUrl, skin.slim); // This field is set by loadImage from `@statsify/rendering` const { _data: buffer } = image as unknown as { _data: Buffer }; - return renderSkin(buffer, skin.slim ?? false, extruded); + return renderSkin(buffer, skin.slim ?? false, extruded, yaw); } public async getSkin(tag: string): Promise { diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index e81f21f5b..12548d615 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -28,6 +28,7 @@ "luxon": "^3.5.0", "mongoose": "^8.5.2", "reflect-metadata": "^0.2.2", + "sharp": "^0.34.5", "skia-canvas": "https://github.com/samizdatco/skia-canvas/releases/download/v0.9.30/skia-canvas-v0.9.30-linux-x64-glibc.tar.gz", "tiny-discord": "https://github.com/timotejroiko/tiny-discord.git#f6d020085ea88e33ebaf6ce323930deffe74fb0d", "typedi": "^0.10.0" diff --git a/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx b/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx index e404439ae..ebe978df0 100644 --- a/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx +++ b/apps/discord-bot/src/commands/bedwars/bedwars.command.tsx @@ -6,14 +6,25 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { BEDWARS_MODES, BedWarsModes } from "@statsify/schemas"; +import { BEDWARS_MODES, BedWarsModes, type GameMode } from "@statsify/schemas"; import { BaseHypixelCommand, BaseProfileProps, ProfileData, } from "#commands/base.hypixel-command"; import { BedWarsProfile } from "./bedwars.profile.js"; -import { Command } from "@statsify/discord"; +import { type BoxGeometry, buildShineFrames, buildShineTheme } from "../../util/shine.js"; +import { Command, CommandContext, Message, Page } from "@statsify/discord"; +import { encodeAnimatedWebP } from "../../util/animated-webp.js"; +import { getBackground, getLogo } from "@statsify/assets"; +import { getTheme } from "#themes"; +import { mapBackground } from "#constants"; +import { noop } from "@statsify/util"; +import { render } from "@statsify/rendering"; + +/** Frames × delay = total loop length: 30 × 100 ms = 3.0 s */ +const SHINE_FRAMES = 30; +const SHINE_DELAY_MS = 100; @Command({ description: (t) => t("commands.bedwars") }) export class BedWarsCommand extends BaseHypixelCommand { @@ -21,6 +32,65 @@ export class BedWarsCommand extends BaseHypixelCommand { super(BEDWARS_MODES); } + public override async run(context: CommandContext) { + const user = context.getUser(); + const player = await this.apiService.getPlayer(context.option("player"), user); + + const [logo, badge, skin] = await Promise.all([ + getLogo(user), + this.apiService.getUserBadge(player.uuid), + this.apiService.getPlayerSkin(player.uuid, user), + ]); + + const allModes = this.modes.getModes(); + const filteredModes = this.filterModes?.(player, allModes) ?? allModes; + const emojis = this.getModeEmojis?.(filteredModes) ?? []; + + const pages: Page[] = filteredModes.map((mode, index) => { + const gameMode = { ...mode, submode: undefined } as unknown as GameMode; + + return { + label: mode.formatted, + emoji: emojis[index], + generator: async (t) => { + const background = await getBackground(...mapBackground(this.modes, mode.api)); + const baseTheme = getTheme(user); + + const profileNode = this.getProfile( + { player, skin, background, logo, t, user, badge, time: "LIVE" }, + { mode: gameMode, data: noop() } + ); + + // Render the base card once, collecting every box's painted geometry + // via the theme interceptor — single pass, no re-render per frame. + const boxes: BoxGeometry[] = []; + const shineTheme = buildShineTheme(baseTheme, boxes); + const baseCanvas = render(profileNode, shineTheme); + + // Per-frame: copy base canvas + draw diagonal highlight on each box. + // Only the shine layer changes; all other pixels are identical. + const frameCanvases = buildShineFrames( + baseCanvas, + boxes, + baseCanvas.width, + baseCanvas.height, + { frames: SHINE_FRAMES } + ); + + // Looping (loop=0) — the shine sweeps continuously. + const webpData = await encodeAnimatedWebP(frameCanvases, SHINE_DELAY_MS, 0); + + return new Message({ + files: [{ name: "bedwars.webp", data: webpData, type: "image/webp" }], + attachments: [], + }); + }, + }; + }); + + return this.paginateService.paginate(context, pages); + } + public getProfile( base: BaseProfileProps, { mode }: ProfileData diff --git a/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx b/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx index 452699499..5b14f2116 100644 --- a/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx +++ b/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx @@ -72,36 +72,42 @@ export const BedWarsProfile = ({ /> - - - + + + - + - - - + + + - - + + t("commands.text"), args: [ @@ -50,19 +60,46 @@ export class TextCommand { }; } - const canvas = render( + // Detect animated codes. §k = obfuscated scramble; §y = flowing rainbow. + const hasObfuscated = text.includes("§k"); + const hasRainbow = text.includes("§y"); + const needsAnimation = hasObfuscated || hasRainbow; + + const profileNode = (
{text} -
, - theme + ); - const buffer = await canvas.toBuffer("png"); + if (!needsAnimation) { + const canvas = render(profileNode, theme); + const buffer = await canvas.toBuffer("png"); + return { + files: [{ data: buffer, name: "text.png", type: "image/png" }], + }; + } + + // Use more frames when rainbow is present so the hue advance per frame is + // small enough for a smooth flow. Both §k and §y are driven by the same + // render() call — animationPhase drives rainbow hue; Math.random() drives + // obfuscated reseed. ONE mechanism, ONE render call per frame. + const frames = hasRainbow ? RAINBOW_FRAMES : OBFUSCATED_FRAMES; + const renderer = theme.context.renderer; + + const frameCanvases = Array.from({ length: frames }, (_, i) => { + // phase ∈ [0, 1): advancing by 1/frames per frame. + // At phase = k/frames, hue shifts by 360 * (k/frames)° — completing a full + // 360° cycle after `frames` frames for a seamless loop. + renderer.animationPhase = i / frames; + return render(profileNode, theme); + }); + + const webpData = await encodeAnimatedWebP(frameCanvases, ANIMATED_DELAY_MS); return { - files: [{ data: buffer, name: "text.png", type: "image/png" }], + files: [{ data: webpData, name: "text.webp", type: "image/webp" }], }; } } diff --git a/apps/discord-bot/src/components/Table/TableData.tsx b/apps/discord-bot/src/components/Table/TableData.tsx index 5a38b1574..ff19466c1 100644 --- a/apps/discord-bot/src/components/Table/TableData.tsx +++ b/apps/discord-bot/src/components/Table/TableData.tsx @@ -11,6 +11,10 @@ export interface TableDataProps { value: string; color: string; size?: "small" | "regular" | "inline"; + /** Mark this cell's value text as animatable for count-up rendering. */ + animated?: boolean; + /** Raw numeric value — required when animated=true so count-up can interpolate. */ + numericValue?: number; } /** @@ -19,7 +23,14 @@ export interface TableDataProps { * * ``` */ -export const TableData = ({ title, value, color, size = "regular" }: TableDataProps) => { +export const TableData = ({ + title, + value, + color, + size = "regular", + animated, + numericValue, +}: TableDataProps) => { if (size === "small") { return ( {`${color}${title}`} - {`${color}${value}`} + {`${color}${value}`} ); } @@ -42,7 +53,7 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr §l{color}{title}
- {color}{value} + {color}{value} ); } @@ -52,6 +63,8 @@ export const TableData = ({ title, value, color, size = "regular" }: TableDataPr {`${color}${title}`} {`§^4^${color}${value}`} diff --git a/apps/discord-bot/src/util/animated-webp.ts b/apps/discord-bot/src/util/animated-webp.ts new file mode 100644 index 000000000..4fa41179c --- /dev/null +++ b/apps/discord-bot/src/util/animated-webp.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import sharp from "sharp"; +import type { Canvas } from "skia-canvas"; + +function writeUint24LE(buf: Buffer, offset: number, value: number): void { + buf[offset] = value & 0xff; + buf[offset + 1] = (value >> 8) & 0xff; + buf[offset + 2] = (value >> 16) & 0xff; +} + +function buildChunk(tag: string, payload: Buffer): Buffer { + const header = Buffer.allocUnsafe(8); + header.write(tag.padEnd(4, " "), 0, "ascii"); + header.writeUInt32LE(payload.length, 4); + const pad = payload.length % 2 === 1 ? Buffer.alloc(1) : Buffer.alloc(0); + return Buffer.concat([header, payload, pad]); +} + +/** + * Encodes an array of canvas frames as a single looping animated WebP using + * manual RIFF assembly. Each frame is lossless-encoded via sharp; falls back + * to quality-85 lossy if the combined lossless size exceeds 6 MB. + * + * Used by both the BedWars spinning-skin path and the /text obfuscated path. + */ +export async function encodeAnimatedWebP( + frameCanvases: Canvas[], + delayMs: number, + /** 0 = loop forever (default), 1 = play once, N = play N times. */ + loop = 0 +): Promise { + const frameWidth = frameCanvases[0].width; + const frameHeight = frameCanvases[0].height; + + const frameWebPs: Buffer[] = []; + for (const canvas of frameCanvases) { + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(0, 0, frameWidth, frameHeight); + const webp = await sharp(Buffer.from(data.buffer), { + raw: { width: frameWidth, height: frameHeight, channels: 4 }, + }) + .webp({ lossless: true }) + .toBuffer(); + frameWebPs.push(webp); + } + + const estimatedSize = frameWebPs.reduce((sum, b) => sum + b.length, 0); + if (estimatedSize > 6 * 1024 * 1024) { + frameWebPs.length = 0; + for (const canvas of frameCanvases) { + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(0, 0, frameWidth, frameHeight); + const webp = await sharp(Buffer.from(data.buffer), { + raw: { width: frameWidth, height: frameHeight, channels: 4 }, + }) + .webp({ quality: 85 }) + .toBuffer(); + frameWebPs.push(webp); + } + } + + // Strip the 12-byte RIFF/WEBP header; what remains is the VP8L/VP8 chunk. + const frameStreams = frameWebPs.map((webp) => webp.subarray(12)); + + const vp8xPayload = Buffer.alloc(10); + vp8xPayload[0] = 0x12; // Animation (bit 1) | Alpha (bit 4) + writeUint24LE(vp8xPayload, 4, frameWidth - 1); + writeUint24LE(vp8xPayload, 7, frameHeight - 1); + + const animPayload = Buffer.alloc(6); + animPayload.writeUInt16LE(loop, 4); + + const anmfChunks = frameStreams.map((stream) => { + const payload = Buffer.allocUnsafe(16 + stream.length); + writeUint24LE(payload, 0, 0); + writeUint24LE(payload, 3, 0); + writeUint24LE(payload, 6, frameWidth - 1); + writeUint24LE(payload, 9, frameHeight - 1); + writeUint24LE(payload, 12, delayMs); + payload[15] = 0x02; // no-blend flag + stream.copy(payload, 16); + return buildChunk("ANMF", payload); + }); + + const chunks = Buffer.concat([ + buildChunk("VP8X", vp8xPayload), + buildChunk("ANIM", animPayload), + ...anmfChunks, + ]); + + const riff = Buffer.allocUnsafe(12); + riff.write("RIFF", 0, "ascii"); + riff.writeUInt32LE(chunks.length + 4, 4); + riff.write("WEBP", 8, "ascii"); + + const result = Buffer.concat([riff, chunks]); + + if (result.byteLength > 7 * 1024 * 1024) { + console.warn( + `[animated-webp] output is ${(result.byteLength / 1024 / 1024).toFixed(1)} MB — exceeds 7 MB soft cap` + ); + } + + return result; +} diff --git a/apps/discord-bot/src/util/count-up.ts b/apps/discord-bot/src/util/count-up.ts new file mode 100644 index 000000000..99fe89705 --- /dev/null +++ b/apps/discord-bot/src/util/count-up.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { type AnimatedRegion, FontRenderer, type TextNode, createCanvas } from "@statsify/rendering"; +import { Container } from "typedi"; +import type { Canvas } from "skia-canvas"; + +export { renderStatic } from "@statsify/rendering"; + +/** Cubic ease-out: fast start, decelerates to final value. */ +export const easeOut = (t: number) => 1 - (1 - t) ** 3; + +/** + * Formats a non-negative integer with comma separators, matching the project's + * default locale formatting (en-US style: 1,234,567). + */ +export const formatCount = (n: number): string => + Math.round(n).toLocaleString("en-US"); + +export interface CountUpOptions { + /** Number of animation frames. Default: 16 (~1 s at 60 ms/frame). */ + frames?: number; + /** Delay between frames in milliseconds. Default: 60. */ + delayMs?: number; +} + +/** + * Given a static card canvas and the animated regions collected by `renderStatic`, + * produces an array of composited `Canvas` frames where: + * frame 0 → all animated values at ~0 + * frame N → all animated values at their final `numericValue` + * + * Only the text overlay is re-drawn per frame — the static card is copied once. + */ +export function buildCountUpFrames( + staticCanvas: Canvas, + regions: AnimatedRegion[], + { frames = 16, delayMs: _delayMs = 60 }: CountUpOptions = {} +): Canvas[] { + const renderer = Container.get(FontRenderer); + const { width, height } = staticCanvas; + + return Array.from({ length: frames }, (_, i) => { + const t = easeOut(i / Math.max(frames - 1, 1)); + + const frame = createCanvas(width, height); + const ctx = frame.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + // Layer 1: blit the static card (background, images, non-animated text) + ctx.drawImage(staticCanvas, 0, 0); + + // Layer 2: draw each animated text region with an interpolated value + for (const region of regions) { + const node = region.text[0]; + if (!node) continue; + + const interpolated = region.numericValue * t; + + // Detect decimal places from the final text so ratios (e.g., "2.46") + // use toFixed(2) while integer stats (e.g., "1,234") use comma formatting. + const finalStr = node.text; + const dotIdx = finalStr.indexOf("."); + const formatted = + dotIdx === -1 ? + formatCount(interpolated) : + interpolated.toFixed(finalStr.length - dotIdx - 1); + + // Build the text node directly — NOT via lex() — because lex() prepends §f + // (white) when the string has no § prefix, which would override the color. + // The color is already resolved to a CSS hex string by the initial lex pass. + const newNodes: TextNode[] = [{ + text: formatted, + color: node.color, + bold: node.bold, + italic: node.italic, + underline: node.underline, + strikethrough: node.strikethrough, + obfuscated: false, + rainbow: false, + size: node.size, + }]; + + renderer.fillText(ctx, newNodes, region.x, region.y); + } + + return frame; + }); +} diff --git a/apps/discord-bot/src/util/shine.ts b/apps/discord-bot/src/util/shine.ts new file mode 100644 index 000000000..459cdc7de --- /dev/null +++ b/apps/discord-bot/src/util/shine.ts @@ -0,0 +1,171 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { Box, FontRenderer, type Render, type Theme, createCanvas } from "@statsify/rendering"; +import { Container } from "typedi"; +import type { Canvas } from "skia-canvas"; + +// ─── Tune these for the visual feel ────────────────────────────────────────── + +/** Half-width of the shine band, as a fraction of each box's diagonal. */ +const BAND_HALF = 0.12; + +/** + * Gradient extends this far past each box edge (in diagonal fractions) so the + * band enters and exits fully off-canvas — making t=0 and t=1 both opacity-0 + * for a seamless loop. Must be > BAND_HALF. + */ +const EXTEND = BAND_HALF * 2; + +const TOTAL_EXT = 1 + 2 * EXTEND; + +/** Peak alpha of the white highlight at the centre of the band. */ +const PEAK_OPACITY = 0.18; + +/** How much boxes phase-offset each other, as a fraction of the loop. */ +const WAVE_SPREAD = 0.25; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BoxBorderRadius { + topLeft: number; + topRight: number; + bottomLeft: number; + bottomRight: number; +} + +export interface BoxGeometry { + x: number; + y: number; + width: number; + height: number; + border: BoxBorderRadius; +} + +// Derive prop type from Box.render's signature — avoids importing the interface directly. +type BoxRenderProps = Parameters[1]; + +// ─── Geometry collector ─────────────────────────────────────────────────────── + +/** + * Returns a Theme whose `box` element interceptor draws boxes normally AND + * appends each box's final painted geometry to `boxes`. Works for any base + * theme (including premium per-user box overrides). + */ +export function buildShineTheme( + baseTheme: Theme | undefined, + boxes: BoxGeometry[] +): Theme { + const baseBoxRender: Render = baseTheme?.elements?.box ?? Box.render; + + const interceptBox: Render = (ctx, props, location, context, component) => { + baseBoxRender(ctx, props, location, context, component); + + // Mirror Box.render's own rounding so geometry matches the pixel-perfect draw. + const x = Math.round(location.x); + const y = Math.round(location.y); + const width = Math.round(location.width + location.padding.left + location.padding.right); + const height = Math.round(location.height + location.padding.top + location.padding.bottom); + boxes.push({ x, y, width, height, border: props.border }); + }; + + return { + context: baseTheme?.context ?? { renderer: Container.get(FontRenderer) }, + elements: { ...baseTheme?.elements, box: interceptBox }, + }; +} + +// ─── Frame renderer ─────────────────────────────────────────────────────────── + +export interface ShineOptions { + /** Number of animation frames. Default: 30 (→ 3 s at 100 ms/frame). */ + frames?: number; +} + +/** + * Produces `frames` Canvas frames where the base card is composited with a + * diagonal specular-shine band that sweeps across every box in sequence. + * + * Only the highlight layer changes between frames; base card pixels are static. + */ +export function buildShineFrames( + baseCanvas: Canvas, + boxes: BoxGeometry[], + canvasWidth: number, + canvasHeight: number, + { frames = 30 }: ShineOptions = {} +): Canvas[] { + // Normaliser for the wave-offset — sum of canvas extents gives a good proxy + // for the card diagonal without the sqrt. + const canvasMeasure = canvasWidth + canvasHeight; + + return Array.from({ length: frames }, (_, frameIndex) => { + // Global phase: 0..1 exclusive (frame N loops back to frame 0 seamlessly). + const t = frameIndex / frames; + + const frame = createCanvas(canvasWidth, canvasHeight); + const ctx = frame.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + // Layer 1 — identical base card. + ctx.drawImage(baseCanvas, 0, 0); + + // Layer 2 — per-box diagonal highlight, clipped to each box's rounded-rect. + for (const box of boxes) { + // Skip degenerate boxes (zero area — dividers, hairlines, etc.). + if (box.width <= 0 || box.height <= 0) continue; + + // Per-box phase offset creates a wave across the card from top-left to bottom-right. + const offset = ((box.x + box.y) / canvasMeasure) * WAVE_SPREAD; + const boxT = (t + offset) % 1; + + // ── Build the extended diagonal gradient ────────────────────────────── + // The gradient starts/ends EXTEND * (w,h) past each corner so the band + // is invisible at t=0 and t=1 (seamless loop proof: band centre is + // entirely outside the box at both endpoints). + const dx = box.width * EXTEND; + const dy = box.height * EXTEND; + const gx0 = box.x - dx; + const gy0 = box.y - dy; + const gx1 = box.x + box.width + dx; + const gy1 = box.y + box.height + dy; + + const grad = ctx.createLinearGradient(gx0, gy0, gx1, gy1); + + // Band centre in "band space" (−EXTEND → 1+EXTEND over the loop). + const bandCenter = boxT * TOTAL_EXT - EXTEND; + + // Map to gradient coords [0, 1]. + const peakPos = (bandCenter + EXTEND) / TOTAL_EXT; + const loPos = Math.max(0, (bandCenter - BAND_HALF + EXTEND) / TOTAL_EXT); + const hiPos = Math.min(1, (bandCenter + BAND_HALF + EXTEND) / TOTAL_EXT); + + const clampedPeak = Math.max(0.001, Math.min(0.999, peakPos)); + + grad.addColorStop(0, "rgba(255,255,255,0)"); + if (loPos > 0.001) grad.addColorStop(loPos, "rgba(255,255,255,0)"); + grad.addColorStop(clampedPeak, `rgba(255,255,255,${PEAK_OPACITY})`); + if (hiPos < 0.999) grad.addColorStop(hiPos, "rgba(255,255,255,0)"); + grad.addColorStop(1, "rgba(255,255,255,0)"); + + // ── Draw, clipped to the box's rounded-rect path ────────────────────── + ctx.save(); + Box.buildBoxPath(ctx, box.x, box.y, box.width, box.height, box.border); + ctx.clip(); + + ctx.globalCompositeOperation = "screen"; + ctx.fillStyle = grad; + ctx.fillRect(box.x, box.y, box.width, box.height); + ctx.globalCompositeOperation = "source-over"; + + ctx.restore(); + } + + return frame; + }); +} diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index b2d1d7677..c503848b5 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -175,6 +175,14 @@ export class ApiService { }); } + public getPlayerSkinAtYaw(uuid: string, user: User | null, yaw: number) { + const route = User.hasExtrudedSkins(user) ? "skin/extruded" : "skin"; + return this.requestImage(isProduction ? `https://api.statsify.net/${route}` : `/${route}`, { + uuid, + yaw, + }); + } + public getPlayerSkinTextures(tag: string) { return this.requestKey("/skin/textures", "skin", { player: tag, diff --git a/packages/rendering/src/font/font-renderer.ts b/packages/rendering/src/font/font-renderer.ts index bd5d6d775..99b7ae1ef 100644 --- a/packages/rendering/src/font/font-renderer.ts +++ b/packages/rendering/src/font/font-renderer.ts @@ -23,9 +23,32 @@ import type { Fill } from "#jsx"; const sizes: Sizes = _sizes; const positions: string[][] = _positions; +// Memoised pool for §k (obfuscated): maps atlas pixel-width → candidate chars. +// Built once at module init from printable ASCII U+0021–U+007E that exist in sizes.ascii. +// Same-width substitution guarantees layout never jitters regardless of text size or bold. +const obfuscatedPool: Map = (() => { + const pool = new Map(); + for (let cp = 0x21; cp <= 0x7e; cp++) { + const unicode = cp.toString(16).padStart(4, "0").toUpperCase(); + const entry = sizes.ascii[unicode]; + if (!entry?.width) continue; + let bucket = pool.get(entry.width); + if (!bucket) { + bucket = []; + pool.set(entry.width, bucket); + } + bucket.push(String.fromCodePoint(cp)); + } + return pool; +})(); + const GRADIENT_TOP_OVERLAY = "rgb(255 255 255 / 0.85)"; const GRADIENT_BOTTOM_OVERLAY = "rgb(0 0 0 / 0.60)"; +// Hue advance per character for §y rainbow text. +// 16 chars ≈ one full rainbow cycle — enough to show vivid variation in a short word. +const HUE_STEP_PER_CHAR = 22.5; // degrees + type CharacterSizes = Record; interface Sizes { @@ -34,6 +57,9 @@ interface Sizes { } export class FontRenderer { + /** Set to [0, 1) before each animated frame to drive §y rainbow hue and §k reseed. */ + public animationPhase = 0; + private images: Map; private canvases: WeakMap; private scales: WeakMap; @@ -95,6 +121,9 @@ export class FontRenderer { y: number ) { const largestSize = Math.max(...nodes.map((node) => node.size)); + // Global char index advances across all nodes so rainbow hue is continuous + // across a full text run (including spaces, maintaining hue through gaps). + let charIndex = 0; for (const { text, @@ -103,14 +132,24 @@ export class FontRenderer { italic, underline, strikethrough, + obfuscated, + rainbow, size, } of nodes) { const adjustY = y + size + (largestSize - size) * 5; for (const char of text) { + const drawChar = + obfuscated && char !== " " && char !== "\n" ? + this.pickObfuscatedChar(char) : + char; + const charColor = rainbow ? + this.hueToHex(charIndex * HUE_STEP_PER_CHAR + this.animationPhase * 360) : + color; + charIndex++; x += this.fillCharacter( ctx, - char, + drawChar, Math.round(x), Math.round(adjustY), size, @@ -118,7 +157,7 @@ export class FontRenderer { italic, underline, strikethrough, - color + charColor ); } } @@ -130,6 +169,8 @@ export class FontRenderer { italic: false, underline: false, strikethrough: false, + obfuscated: false, + rainbow: false, color: "#FFFFFF", size: 2, ...inputState, @@ -183,6 +224,46 @@ export class FontRenderer { return nodes; } + private pickObfuscatedChar(char: string): string { + const unicode = this.getUnicode(char).toUpperCase(); + const width = sizes.ascii[unicode]?.width; + if (!width) return char; + const pool = obfuscatedPool.get(width); + if (!pool?.length) return char; + return pool[Math.floor(Math.random() * pool.length)]; + } + + /** Converts a hue angle (degrees, any value) to a full-saturation CSS hex color. */ + private hueToHex(hue: number): string { + const h = ((hue % 360) + 360) % 360; + const sector = h / 60; + const x = 1 - Math.abs(sector % 2 - 1); + let r = 0; + let g = 0; + let b = 0; + if (sector < 1) { + r = 1; + g = x; + } else if (sector < 2) { + r = x; + g = 1; + } else if (sector < 3) { + g = 1; + b = x; + } else if (sector < 4) { + g = x; + b = 1; + } else if (sector < 5) { + r = x; + b = 1; + } else { + r = 1; + b = x; + } + const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, "0"); + return `#${hex(r)}${hex(g)}${hex(b)}`; + } + private getUnicode(char: string) { const hex = (char.codePointAt(0) ?? 0).toString(16); return `${"0000".slice(0, Math.max(0, 4 - hex.length))}${hex}`; diff --git a/packages/rendering/src/font/tokens.ts b/packages/rendering/src/font/tokens.ts index 974ae2c17..a80357ce4 100644 --- a/packages/rendering/src/font/tokens.ts +++ b/packages/rendering/src/font/tokens.ts @@ -16,6 +16,8 @@ export interface TextNode { italic: boolean; underline: boolean; strikethrough: boolean; + obfuscated: boolean; + rainbow: boolean; size: number; } @@ -47,7 +49,12 @@ const strikethrough: Token = { const obfuscated: Token = { regex: /^k/, - effect: () => ({}), + effect: () => ({ obfuscated: true }), +}; + +const rainbow: Token = { + regex: /^y/, + effect: () => ({ rainbow: true }), }; const reset: Token = { @@ -89,4 +96,5 @@ export const tokens: Token[] = [ underline, strikethrough, obfuscated, + rainbow, ]; diff --git a/packages/rendering/src/intrinsics/Box.ts b/packages/rendering/src/intrinsics/Box.ts index e375c0994..1efb80a6a 100644 --- a/packages/rendering/src/intrinsics/Box.ts +++ b/packages/rendering/src/intrinsics/Box.ts @@ -90,6 +90,35 @@ export const component: JSX.RawFC = ({ children, }); +/** + * Traces the stepped-corner rounded-rect path that Box uses for its fill and clip. + * Callers must open a `beginPath` / `closePath` pair around this or call it alone + * (this function calls both internally). Pass post-rounding, post-padding values. + */ +export const buildBoxPath = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + border: BoxBorderRadius +): void => { + ctx.beginPath(); + ctx.moveTo(x + border.topLeft, y); + ctx.lineTo(x + width - border.topRight, y); + ctx.lineTo(x + width - border.topRight, y + border.topRight); + ctx.lineTo(x + width, y + border.topRight); + ctx.lineTo(x + width, y + height - border.bottomRight); + ctx.lineTo(x + width - border.bottomRight, y + height - border.bottomRight); + ctx.lineTo(x + width - border.bottomRight, y + height); + ctx.lineTo(x + border.bottomLeft, y + height); + ctx.lineTo(x + border.bottomLeft, y + height - border.bottomLeft); + ctx.lineTo(x, y + height - border.bottomLeft); + ctx.lineTo(x, y + border.topLeft); + ctx.lineTo(x + border.topLeft, y + border.topLeft); + ctx.closePath(); +}; + export const renderOverlay = ( ctx: CanvasRenderingContext2D, x: number, @@ -131,20 +160,7 @@ export const render: JSX.Render = ( width = Math.round(width); height = Math.round(height); - ctx.beginPath(); - ctx.moveTo(x + border.topLeft, y); - ctx.lineTo(x + width - border.topRight, y); - ctx.lineTo(x + width - border.topRight, y + border.topRight); - ctx.lineTo(x + width, y + border.topRight); - ctx.lineTo(x + width, y + height - border.bottomRight); - ctx.lineTo(x + width - border.bottomRight, y + height - border.bottomRight); - ctx.lineTo(x + width - border.bottomRight, y + height); - ctx.lineTo(x + border.bottomLeft, y + height); - ctx.lineTo(x + border.bottomLeft, y + height - border.bottomLeft); - ctx.lineTo(x, y + height - border.bottomLeft); - ctx.lineTo(x, y + border.topLeft); - ctx.lineTo(x + border.topLeft, y + border.topLeft); - ctx.closePath(); + buildBoxPath(ctx, x, y, width, height, border); ctx.fill(); renderOverlay(ctx, x, y, height); diff --git a/packages/rendering/src/intrinsics/Text.ts b/packages/rendering/src/intrinsics/Text.ts index 7b82975dd..acaaddc2f 100644 --- a/packages/rendering/src/intrinsics/Text.ts +++ b/packages/rendering/src/intrinsics/Text.ts @@ -21,10 +21,14 @@ export interface TextProps { italic?: boolean; underline?: boolean; size?: number; + animated?: boolean; + numericValue?: number; } export interface TextRenderProps { text: TextNode[]; + animated?: boolean; + numericValue?: number; } export const component: JSX.RawFC< @@ -40,6 +44,8 @@ export const component: JSX.RawFC< italic = false, underline = false, size = 2, + animated, + numericValue, }) => { let text: string; @@ -73,7 +79,7 @@ export const component: JSX.RawFC< height, }, style: { location: "center", direction: "row", align }, - props: { text: nodes }, + props: { text: nodes, animated, numericValue }, children: [], }; }; diff --git a/packages/rendering/src/jsx/render.ts b/packages/rendering/src/jsx/render.ts index 2dea261d8..61760c789 100644 --- a/packages/rendering/src/jsx/render.ts +++ b/packages/rendering/src/jsx/render.ts @@ -16,6 +16,7 @@ import { createInstructions } from "./create-instructions.js"; import { getPositionalDelta, getTotalSize } from "./util.js"; import { noop } from "@statsify/util"; import type { + AnimatedRegion, ComputedThemeContext, ElementNode, Instruction, @@ -52,6 +53,25 @@ const _render = ( }, }; + if ( + context.paintMode === "static-only" && + instruction.type === "text" && + instruction.props.animated + ) { + // Record the absolute text position for the animated overlay, then skip drawing. + if (context.animatedRegions && instruction.props.numericValue !== undefined) { + context.animatedRegions.push({ + x, + y, + location, + text: instruction.props.text, + numericValue: instruction.props.numericValue, + }); + } + // Text nodes have no children; return here is safe. + return; + } + intrinsicElements[instruction.type]( ctx, instruction.props, @@ -164,3 +184,47 @@ export function render(node: ElementNode, theme?: Theme): Canvas { return canvas; } + +/** + * Renders the card WITHOUT animated text nodes, and returns: + * - `canvas`: the static background frame (all boxes, images, non-animated text) + * - `regions`: position + style info for every animated text element, + * ready for count-up / progress-bar overlay rendering. + */ +export function renderStatic( + node: ElementNode, + theme?: Theme +): { canvas: Canvas; regions: AnimatedRegion[] } { + const instructions = createInstructions(node); + + const width = Math.round(getTotalSize(instructions.x)); + const height = Math.round(getTotalSize(instructions.y)); + + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + const regions: AnimatedRegion[] = []; + + const context: ComputedThemeContext = { + renderer: noop(), + ...theme?.context, + canvasWidth: width, + canvasHeight: height, + paintMode: "static-only", + animatedRegions: regions, + }; + + if (!context.renderer) context.renderer = Container.get(FontRenderer); + + _render( + ctx, + context, + { ...intrinsicRenders, ...theme?.elements }, + instructions, + 0, + 0 + ); + + return { canvas, regions }; +} diff --git a/packages/rendering/src/jsx/types.ts b/packages/rendering/src/jsx/types.ts index cd25ae45f..7baf75a6b 100644 --- a/packages/rendering/src/jsx/types.ts +++ b/packages/rendering/src/jsx/types.ts @@ -19,9 +19,25 @@ export interface BaseThemeContext { renderer: FontRenderer; } +export interface AnimatedRegion { + /** Absolute pixel position of the text baseline (x, y as passed to fillText). */ + x: number; + y: number; + /** Layout location — width/height of the text's own bounding box. */ + location: Location; + /** Final-frame TextNodes; use their style (color, bold, size) to restyle interpolated values. */ + text: import("../font/tokens.js").TextNode[]; + /** Raw numeric value to ease from 0 → numericValue over the animation. */ + numericValue: number; +} + export interface ComputedThemeContext extends BaseThemeContext { canvasWidth: number; canvasHeight: number; + /** When "static-only": skip animated text nodes but record their positions. */ + paintMode?: "static-only"; + /** Populated by renderStatic() — collects animated text regions during static-only render. */ + animatedRegions?: AnimatedRegion[]; } export interface Theme { diff --git a/packages/skin-renderer/index.d.ts b/packages/skin-renderer/index.d.ts index 73c14b8c6..78505f228 100644 --- a/packages/skin-renderer/index.d.ts +++ b/packages/skin-renderer/index.d.ts @@ -1,3 +1,3 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ -export declare function renderSkin(skin: Uint8Array, slim: boolean, extruded: boolean): Promise +export declare function renderSkin(skin: Uint8Array, slim: boolean, extruded: boolean, yaw?: number | undefined | null): Promise diff --git a/packages/skin-renderer/src/camera/orbital.rs b/packages/skin-renderer/src/camera/orbital.rs index 9e0dbec4d..d9843d9c8 100644 --- a/packages/skin-renderer/src/camera/orbital.rs +++ b/packages/skin-renderer/src/camera/orbital.rs @@ -44,6 +44,10 @@ pub struct OrbitalCamera { impl OrbitalCamera { pub fn new(target: Point3, width: f32, height: f32) -> Self { + Self::with_theta(target, width, height, -0.3) + } + + pub fn with_theta(target: Point3, width: f32, height: f32, theta: f32) -> Self { Self { target, @@ -54,7 +58,7 @@ impl OrbitalCamera { max_radius: 100.0, radius: 50.0, - theta: -0.3, + theta, phi: 1.383, min_polar_angle: f32::NEG_INFINITY, diff --git a/packages/skin-renderer/src/native.rs b/packages/skin-renderer/src/native.rs index 1c32482dd..42cfab391 100644 --- a/packages/skin-renderer/src/native.rs +++ b/packages/skin-renderer/src/native.rs @@ -2,6 +2,7 @@ use async_once::AsyncOnce; use lazy_static::lazy_static; use napi::bindgen_prelude::*; use napi_derive::napi; +use tokio::sync::Mutex; use crate::model::{ModelKind, ModelOuterLayer}; use crate::renderer::native::NativeBackend; @@ -13,10 +14,17 @@ lazy_static! { .await .expect("skin renderer initialized") }); + // Serialises renders that mutate the shared GPU camera buffer. + static ref RENDER_YAW_LOCK: Mutex<()> = Mutex::new(()); } #[napi] -pub async fn render_skin(skin: &[u8], slim: bool, extruded: bool) -> Result { +pub async fn render_skin( + skin: &[u8], + slim: bool, + extruded: bool, + yaw: Option, +) -> Result { let renderer = SKIN_RENDERER.get().await; let model_kind = if slim { @@ -32,7 +40,14 @@ pub async fn render_skin(skin: &[u8], slim: bool, extruded: bool) -> Result { + let _guard = RENDER_YAW_LOCK.lock().await; + renderer.render_at_yaw(&model, y as f32).await? + } + None => renderer.render(&model).await?, + }; Ok(buffer.into()) } diff --git a/packages/skin-renderer/src/renderer/mod.rs b/packages/skin-renderer/src/renderer/mod.rs index a762dba01..d1b7cc9ad 100644 --- a/packages/skin-renderer/src/renderer/mod.rs +++ b/packages/skin-renderer/src/renderer/mod.rs @@ -415,4 +415,53 @@ impl SkinRenderer { let (encoder, _) = self.render_sync(model); self.backend.handle_encoder_after_render(encoder).await } + + // Renders at an explicit horizontal yaw (theta). Caller must hold RENDER_YAW_LOCK. + pub async fn render_at_yaw(&self, model: &Model, yaw: f32) -> Result { + let cam = Camera::Orbital(OrbitalCamera::with_theta( + (0.0, 0.0, 0.0).into(), + self.config.width as f32, + self.config.height as f32, + yaw, + )); + + let queue = self.backend.queue(); + + let cam_uniform = CameraUniform::new(&cam); + queue.write_buffer(&self.camera_buffer, 0, cast_slice(&[cam_uniform])); + + let light_uniform = LightUniform::from(&cam); + queue.write_buffer( + &self.light_buffer, + 0, + bytemuck::cast_slice(&[light_uniform]), + ); + + let (encoder, _) = self.render_sync(model); + let result = self.backend.handle_encoder_after_render(encoder).await; + + // Restore default camera so no-yaw renders remain correct after lock release. + let default_cam = Camera::Orbital(OrbitalCamera::new( + (0.0, 0.0, 0.0).into(), + self.config.width as f32, + self.config.height as f32, + )); + let default_cam_uniform = CameraUniform::new(&default_cam); + queue.write_buffer(&self.camera_buffer, 0, cast_slice(&[default_cam_uniform])); + let default_light_uniform = LightUniform::from(&default_cam); + queue.write_buffer( + &self.light_buffer, + 0, + bytemuck::cast_slice(&[default_light_uniform]), + ); + + // Flush restore writes before releasing the caller's lock. + let flush_encoder = self + .backend + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + queue.submit(Some(flush_encoder.finish())); + + result + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e289028fb..eb9d8e75a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,9 @@ importers: reflect-metadata: specifier: ^0.2.2 version: 0.2.2 + sharp: + specifier: ^0.34.5 + version: 0.34.5 skia-canvas: specifier: 3.0.8 version: 3.0.8 @@ -701,6 +704,8 @@ importers: packages/skin-renderer: {} + packages/skin-renderer/pkg: {} + packages/util: dependencies: '@swc/helpers': @@ -829,9 +834,6 @@ packages: '@emnapi/core@1.5.0': resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/runtime@1.5.0': - resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} @@ -6530,6 +6532,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validator@13.15.15: @@ -6818,7 +6821,7 @@ snapshots: '@commitlint/is-ignored@19.8.1': dependencies: '@commitlint/types': 19.8.1 - semver: 7.7.2 + semver: 7.7.3 '@commitlint/lint@19.8.1': dependencies: @@ -6892,11 +6895,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.5.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 @@ -7123,8 +7121,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': - optional: true + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -7772,14 +7769,14 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.0 optional: true '@napi-rs/wasm-runtime@1.0.3': dependencies: '@emnapi/core': 1.5.0 - '@emnapi/runtime': 1.5.0 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.0 optional: true @@ -8804,7 +8801,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -8820,7 +8817,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.2 + semver: 7.7.3 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -9315,7 +9312,7 @@ snapshots: bin-version-check@5.1.0: dependencies: bin-version: 6.0.0 - semver: 7.7.2 + semver: 7.7.3 semver-truncate: 3.0.0 bin-version@6.0.0: @@ -10275,7 +10272,7 @@ snapshots: process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.0.0 - semver: 7.7.2 + semver: 7.7.3 toad-cache: 3.7.0 fastify@5.5.0: @@ -10793,7 +10790,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 is-callable@1.2.7: {} @@ -11010,7 +11007,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.2 + semver: 7.7.3 jsx-ast-utils@3.3.5: dependencies: @@ -11213,7 +11210,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 math-intrinsics@1.1.0: {} @@ -12100,7 +12097,7 @@ snapshots: semver-truncate@3.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 semver@6.3.1: {} @@ -12110,8 +12107,7 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} set-cookie-parser@2.7.1: {} @@ -12169,7 +12165,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true shebang-command@2.0.0: dependencies: