Bound and deduplicate server image rendering per account

This commit is contained in:
syntaxbullet
2026-09-08 16:18:05 +02:00
parent e3e07404f8
commit ba049efdc8
8 changed files with 101 additions and 12 deletions

View File

@@ -55,7 +55,7 @@ export function commandGraphicAction(c: Context, action: string, data: ControlDa
];
const reply = message(c, content, [row(...controls)], false);
c.defer(async () => {
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
try { return { message: reply.data as import('./types').Message, image: await renderCommandCard(model, theme, c.service.user.id), imageDescription: `${model.title}. ${model.summary}. ${model.subtitle}. Page ${model.page + 1} of ${model.pages}.` }; }
catch { return { message: { ...(reply.data as import('./types').Message), content: `The graphic could not be rendered.\n${content}`.slice(0, 2000) } }; }
});
return { type: 5, data: c.interaction.guild_id ? { flags: 64 } : {} };

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas';
import '../sharing/live-card-image';
import { themePalette, type Theme } from '../lib/theme';
@@ -7,7 +8,10 @@ export type CardRow = { label: string; detail: string; value?: string; ratio?: n
export type CommandCard = { title: string; owner: string; subtitle: string; summary: string; note: string; rows: CardRow[]; page: number; pages: number; history?: { date: string; ratio: number | null; future: boolean; color: string }[] };
export const commandCardSize = (data: CommandCard) => ({ width: 1200, height: 350 + Math.max(1, data.rows.length) * 116 + (data.history ? 164 : 0) + 100 });
/** Fixed-size rows and text keep every page readable, including very large accounts. */
export async function renderCommandCard(data: CommandCard, theme: Theme = 'light') {
export function renderCommandCard(data: CommandCard, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('command', userId, data, theme, () => draw(data, theme));
}
async function draw(data: CommandCard, theme: Theme) {
if (data.rows.length > 6 || (data.history && data.history.length > 14)) throw new Error('Paginate command cards before rendering.');
const { width, height } = commandCardSize(data), canvas = createCanvas(width, height), ctx = canvas.getContext('2d');
const p = themePalette[theme];
@@ -61,5 +65,6 @@ export async function renderCommandCard(data: CommandCard, theme: Theme = 'light
rect(64, height - 92, 1072, 1, p.rule);
text(data.note, 64, height - 48, 21, 860, p.muted);
text(`${data.page + 1} / ${data.pages}`, 1035, height - 48, 21, 100, p.muted);
return new Uint8Array(await canvas.encode('png'));
try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
}

View File

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { createCanvas } from '@napi-rs/canvas';
// Import the existing renderer's bundled font registration and data contract.
import { type LiveImageData } from '../sharing/live-card-image';
@@ -8,7 +9,10 @@ import { cardDate } from '../lib/progress-card';
export const progressImageSize = { width: 1200, height: 860 };
/** Focused, chronological 30-day view; constant font/cell sizes at any habit count. */
export async function renderProgressImage(data: LiveImageData, theme: Theme = 'light') {
export function renderProgressImage(data: LiveImageData, theme: Theme = 'light', userId = 'unscoped') {
return renderImage('progress', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const habit = data.habits[0];
if (!habit || data.habits.length !== 1 || habit.days.length !== 30) throw new Error('Choose one habit and a 30-day range.');
const canvas = createCanvas(progressImageSize.width, progressImageSize.height);
@@ -62,5 +66,6 @@ export async function renderProgressImage(data: LiveImageData, theme: Theme = 'l
text(item.label, x + 36, 772, 21, 230, palette.muted);
});
text('A little, every day.', 64, 822, 27, 1072, palette.muted, true);
return new Uint8Array(await canvas.encode('png'));
try { return new Uint8Array(await canvas.encode('png')); }
finally { canvas.width = 1; canvas.height = 1; }
}

View File

@@ -24,7 +24,7 @@ function imageData(c: Context, data: ControlData) {
return { ...result, avatarUrl: null };
}
async function render(c: Context, data: ControlData, source = imageData(c, data)) {
return renderProgressImage(source, data.theme ?? 'light');
return renderProgressImage(source, data.theme ?? 'light', c.service.user.id);
}
function output(reply: Reply, image?: Uint8Array): DeferredMessage { return { message: reply.data as Message, image }; }
function defer(c: Context, job: () => Promise<DeferredMessage>): Reply {
@@ -87,7 +87,7 @@ export function progressAction(c: Context, action: string, data: ControlData): R
const source = imageData(c, data);
const image = await render(c, data, source);
if (fingerprint(imageData(c, data)) !== data.expected) throw new ApiError(409, 'Progress changed during rendering. Open a fresh preview.');
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([image], 'mina-habits-progress.png', { type: 'image/png' }));
const delivery = await ops.sharing.send(c.service.user, data.deliveryId!, new File([new Uint8Array(image)], 'mina-habits-progress.png', { type: 'image/png' }));
return output(message(c, delivery.status === 'sent' ? `Snapshot shared.${delivery.messageUrl ? ` [Open snapshot](${delivery.messageUrl})` : ''}` : 'Discord could not confirm delivery. Check the destination before creating another snapshot; this action will not be retried.', [], false));
}
const state = action === 'progress.pause' ? await ops.live.pause(c.service.user.id) : await ops.live.start(c.service.user, data.theme ?? 'light');