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

@@ -1,3 +1,4 @@
import { renderImage } from '../rendering/queue';
import { cachedAvatar } from './avatar-cache';
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
import { existsSync } from "node:fs";
@@ -48,7 +49,10 @@ export function liveCardLayout(count: number) {
}
/** A deterministic 16:9 PNG. Every active habit receives a panel; none are paginated away. */
export async function renderLiveCard(data: LiveImageData, theme: Theme) {
export function renderLiveCard(data: LiveImageData, theme: Theme, userId = 'unscoped') {
return renderImage('live', userId, data, theme, () => draw(data, theme));
}
async function draw(data: LiveImageData, theme: Theme) {
const layout = liveCardLayout(data.habits.length);
const canvas = createCanvas(layout.width, layout.height);
const ctx = canvas.getContext('2d');
@@ -127,5 +131,6 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
rect(64, 1010, 1792, 1, colors.rule);
text('Totals cover this entire period', 64, 1050, 28, 1200, colors.muted);
ctx.textAlign = 'right'; text(`${data.habits.length} active habits`, 1856, 1050, 28, 536, colors.muted);
return 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 { RenderBusyError } from '../rendering/queue';
import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
@@ -81,7 +82,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
guildId = channel.guild_id;
} catch { update(id, { status: 'error', error: 'Could not reach the Discord channel. Try again.' }); return; }
}
const png = await renderLiveCard(data, current.theme);
const png = await renderLiveCard(data, current.theme, id);
if (png.byteLength > 4 * 1024 * 1024) { update(id, { status: 'error', error: 'The rendered image exceeds the 4 MB sharing limit.' }); return; }
// A stop or account deletion during image generation cancels the outbound edit.
if (stopped || !['active', 'sending'].includes(row(id)?.status ?? '')) return;
@@ -139,7 +140,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
clearTimeout(timers.get(id)); timers.delete(id);
const existing = running.get(id);
if (existing) return existing;
const work = queue.then(() => deliver(id)).catch(() => {
const work = queue.then(() => deliver(id)).catch(error => {
if (error instanceof RenderBusyError && !stopped && ['active', 'sending'].includes(row(id)?.status ?? '')) {
update(id, { dirty: true, retryAt: now() + 1000, error: 'Image rendering is busy; update queued.' });
schedule(id, 1000); return;
}
if (!stopped && row(id)) { update(id, { status: 'error', error: 'Could not render or update the card. Try resuming updates.' }); }
}).finally(() => running.delete(id));
queue = work; running.set(id, work); return work;
@@ -152,7 +157,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/', c => c.json(state(c.get('user').id)));
app.get('/preview', async c => {
const theme = c.req.query('theme') === 'dark' ? 'dark' : 'light';
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme);
const image = await renderLiveCard(liveCardData(db, c.get('user').id, now()), theme, c.get('user').id);
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image));
});