feat(email-renderer): generate bounded animated countdown images
This commit is contained in:
1 parent
1143da808f
commit
4df4173dfb
5 files changed
+83
-2
No files matched your search
@@ -428,10 +428,11 @@
|
|||||||
},
|
},
|
||||||
"packages/email-renderer": {
|
"packages/email-renderer": {
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/blocks": "workspace:*",
|
"@wrnexus/blocks": "workspace:*",
|
||||||
"amphtml-validator": "1.0.38",
|
"amphtml-validator": "1.0.38",
|
||||||
|
"gifenc": "1.0.3",
|
||||||
"mjml": "5.4.0",
|
"mjml": "5.4.0",
|
||||||
"mjml-parser-xml": "5.4.0",
|
"mjml-parser-xml": "5.4.0",
|
||||||
},
|
},
|
||||||
@@ -1395,6 +1396,8 @@
|
|||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
|
|
||||||
|
"gifenc": ["gifenc@1.0.3", "", {}, "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw=="],
|
||||||
|
|
||||||
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/blocks": "workspace:*",
|
"@wrnexus/blocks": "workspace:*",
|
||||||
"amphtml-validator": "1.0.38",
|
"amphtml-validator": "1.0.38",
|
||||||
|
"gifenc": "1.0.3",
|
||||||
"mjml": "5.4.0",
|
"mjml": "5.4.0",
|
||||||
"mjml-parser-xml": "5.4.0"
|
"mjml-parser-xml": "5.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const { GIFEncoder } = createRequire(import.meta.url)("gifenc") as {
|
||||||
|
GIFEncoder(): {
|
||||||
|
writeFrame(pixels: Uint8Array, width: number, height: number, options: {
|
||||||
|
palette: number[][]; delay: number; repeat: number;
|
||||||
|
}): void;
|
||||||
|
finish(): void;
|
||||||
|
bytes(): Uint8Array;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const glyphs: Record<string, string> = {
|
||||||
|
"0": "111101101101111", "1": "010110010010111", "2": "111001111100111",
|
||||||
|
"3": "111001111001111", "4": "101101111001001", "5": "111100111001111",
|
||||||
|
"6": "111100111101111", "7": "111001001001001", "8": "111101111101111",
|
||||||
|
"9": "111101111001111", ":": "000010000010000", " ": "000000000000000",
|
||||||
|
D: "110101101101110", H: "101101111101101", M: "101111111101101", S: "111100111001111",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function countdownLabel(deadline: number, now: number): string {
|
||||||
|
if (!Number.isFinite(deadline) || !Number.isFinite(now)) throw new Error("Invalid countdown time.");
|
||||||
|
const seconds = Math.max(0, Math.ceil((deadline - now) / 1000));
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
if (days > 999) throw new Error("Countdown cannot exceed 999 days.");
|
||||||
|
const pair = (value: number) => String(value).padStart(2, "0");
|
||||||
|
return `${String(days).padStart(3, "0")}D ${pair(Math.floor(seconds / 3600) % 24)}H ${pair(Math.floor(seconds / 60) % 60)}M ${pair(seconds % 60)}S`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bounded, non-looping animation. The caller injects its clock for deterministic rendering. */
|
||||||
|
export function renderCountdownGif(deadline: number, now: number, frames = 60): Uint8Array {
|
||||||
|
if (!Number.isInteger(frames) || frames < 1 || frames > 60) throw new Error("Countdown requires 1–60 frames.");
|
||||||
|
countdownLabel(deadline, now);
|
||||||
|
const width = 320, height = 64, scale = 4;
|
||||||
|
const encoder = GIFEncoder();
|
||||||
|
const count = Math.min(frames, Math.max(1, Math.ceil((deadline - now) / 1000) + 1));
|
||||||
|
for (let frame = 0; frame < count; frame++) {
|
||||||
|
const label = countdownLabel(deadline, now + frame * 1000);
|
||||||
|
const pixels = new Uint8Array(width * height);
|
||||||
|
const left = Math.floor((width - label.length * 4 * scale) / 2);
|
||||||
|
for (let character = 0; character < label.length; character++) {
|
||||||
|
const glyph = glyphs[label[character]!]!;
|
||||||
|
for (let row = 0; row < 5; row++) for (let col = 0; col < 3; col++) {
|
||||||
|
if (glyph[row * 3 + col] !== "1") continue;
|
||||||
|
for (let y = 0; y < scale; y++) for (let x = 0; x < scale; x++) {
|
||||||
|
pixels[(22 + row * scale + y) * width + left + character * 4 * scale + col * scale + x] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoder.writeFrame(pixels, width, height, { palette: [[17, 24, 39], [255, 255, 255]], delay: 1000, repeat: -1 });
|
||||||
|
}
|
||||||
|
encoder.finish();
|
||||||
|
return encoder.bytes();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./blocks.ts";
|
export * from "./blocks.ts";
|
||||||
|
export * from "./countdown.ts";
|
||||||
export * from "./email.ts";
|
export * from "./email.ts";
|
||||||
export * from "./mjml.ts";
|
export * from "./mjml.ts";
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { countdownLabel, renderCountdownGif } from "../src/countdown.ts";
|
||||||
|
|
||||||
|
test("countdown ticks across boundaries and stops at zero", () => {
|
||||||
|
expect(countdownLabel(86401000, 0)).toBe("001D 00H 00M 01S");
|
||||||
|
expect(countdownLabel(86401000, 2000)).toBe("000D 23H 59M 59S");
|
||||||
|
expect(countdownLabel(1000, 2000)).toBe("000D 00H 00M 00S");
|
||||||
|
expect(() => countdownLabel(NaN, 0)).toThrow();
|
||||||
|
expect(() => renderCountdownGif(1000, 0, 61)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("countdown generates deterministic bounded non-looping GIF output", () => {
|
||||||
|
const first = renderCountdownGif(60000, 0);
|
||||||
|
expect(first).toEqual(renderCountdownGif(60000, 0));
|
||||||
|
expect(new TextDecoder().decode(first.subarray(0,6))).toBe("GIF89a");
|
||||||
|
expect(first[6]! + first[7]! * 256).toBe(320);
|
||||||
|
expect(first[8]! + first[9]! * 256).toBe(64);
|
||||||
|
expect(first.at(-1)).toBe(0x3b);
|
||||||
|
expect(first.byteLength).toBeLessThan(100_000);
|
||||||
|
expect(new TextDecoder().decode(first)).not.toContain("NETSCAPE2.0");
|
||||||
|
expect(renderCountdownGif(0, 1000).byteLength).toBeLessThan(first.byteLength);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user