feat: add meme generator tool with 101 templates

Full-featured meme generator with 101 bundled templates from Imgflip,
searchable gallery with category filters, real-time CSS preview,
server-side rendering via Sharp + opentype.js SVG paths, custom image
upload with preset layouts, 7 bundled fonts, shared Zustand store.
This commit is contained in:
SnapOtter
2026-05-08 18:56:05 +08:00
committed by GitHub
231 changed files with 6105 additions and 4 deletions
+5 -3
View File
@@ -12,9 +12,6 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@snapotter/ai": "workspace:*",
"@snapotter/image-engine": "workspace:*",
"@snapotter/shared": "workspace:*",
"@fastify/cors": "^11.0.0",
"@fastify/multipart": "^9.0.0",
"@fastify/rate-limit": "^10.2.0",
@@ -22,6 +19,9 @@
"@neplex/vectorizer": "^0.0.5",
"@scalar/fastify-api-reference": "^1.49.5",
"@sentry/node": "^10.49.0",
"@snapotter/ai": "workspace:*",
"@snapotter/image-engine": "workspace:*",
"@snapotter/shared": "workspace:*",
"archiver": "^7.0.1",
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.0",
@@ -31,6 +31,7 @@
"fflate": "^0.8.2",
"js-yaml": "^4.1.1",
"mupdf": "^1.27.0",
"opentype.js": "^2.0.0",
"p-queue": "^9.1.0",
"pdfkit": "^0.18.0",
"piscina": "^5.1.4",
@@ -47,6 +48,7 @@
"@types/better-sqlite3": "^7.6.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"@types/opentype.js": "^1.3.9",
"@types/pdfkit": "^0.17.5",
"@types/potrace": "^2.1.5",
"@types/qrcode": "^1.5.6",
+4
View File
@@ -24,6 +24,7 @@ import { registerBatchRoutes } from "./routes/batch.js";
import { docsRoutes } from "./routes/docs.js";
import { registerFeatureRoutes } from "./routes/features.js";
import { fileRoutes } from "./routes/files.js";
import { registerMemeTemplates } from "./routes/meme-templates.js";
import { registerPipelineRoutes } from "./routes/pipeline.js";
import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js";
import { rolesRoutes } from "./routes/roles.js";
@@ -155,6 +156,9 @@ await fileRoutes(app);
// User file library routes (persistent file management with versioning)
await userFileRoutes(app);
// Meme template listing and static serving (before tool routes which have catch-all)
await registerMemeTemplates(app);
// Tool routes (generic factory-based)
await registerToolRoutes(app);
+270
View File
@@ -0,0 +1,270 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import opentype from "opentype.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TextBox {
/** The text content to render */
text: string;
/** X position as percentage (0-100) of image width */
x: number;
/** Y position as percentage (0-100) of image height */
y: number;
/** Width as percentage (0-100) of image width */
width: number;
/** Height as percentage (0-100) of image height */
height: number;
}
export interface MemeTextOptions {
imageWidth: number;
imageHeight: number;
textBoxes: TextBox[];
fontFamily: string;
fontSize?: number;
textColor: string;
strokeColor: string;
textAlign: "left" | "center" | "right";
allCaps: boolean;
}
// ---------------------------------------------------------------------------
// Font loading & caching
// ---------------------------------------------------------------------------
const FONT_DIR = join(import.meta.dirname, "../../static/fonts");
const FONT_MAP: Record<string, string> = {
anton: "Anton-Regular.ttf",
"arial-black": "Anton-Regular.ttf",
"comic-sans": "Anton-Regular.ttf",
montserrat: "Montserrat-Black.ttf",
"bebas-neue": "BebasNeue-Regular.ttf",
"permanent-marker": "PermanentMarker-Regular.ttf",
roboto: "Roboto-Black.ttf",
};
const fontCache = new Map<string, opentype.Font>();
/**
* Load a font by family key. Unknown fonts fall back to Anton.
* Results are cached so repeated calls return the same instance.
*/
export function loadFont(family: string): opentype.Font {
const filename = FONT_MAP[family] ?? FONT_MAP.anton;
if (fontCache.has(filename)) {
return fontCache.get(filename)!;
}
const buf = readFileSync(join(FONT_DIR, filename));
let font: opentype.Font;
try {
font = opentype.parse(buf.buffer as ArrayBuffer);
} catch {
if (filename !== FONT_MAP.anton) {
return loadFont("anton");
}
throw new Error(`Failed to parse font: ${filename}`);
}
fontCache.set(filename, font);
return font;
}
// ---------------------------------------------------------------------------
// Text measurement
// ---------------------------------------------------------------------------
/**
* Measure the advance width of a string in the given font at the given size.
*/
export function measureText(text: string, fontFamily: string, fontSize: number): number {
if (text === "") return 0;
const font = loadFont(fontFamily);
return font.getAdvanceWidth(text, fontSize);
}
// ---------------------------------------------------------------------------
// Word wrapping
// ---------------------------------------------------------------------------
/**
* Wrap text into lines that fit within maxWidth pixels.
* Words that individually exceed maxWidth are placed on their own line.
*/
export function wrapText(
text: string,
fontFamily: string,
fontSize: number,
maxWidth: number,
): string[] {
if (text === "") return [""];
const words = text.split(/\s+/).filter((w) => w.length > 0);
if (words.length === 0) return [""];
const lines: string[] = [];
let currentLine = words[0];
for (let i = 1; i < words.length; i++) {
const candidate = `${currentLine} ${words[i]}`;
const width = measureText(candidate, fontFamily, fontSize);
if (width <= maxWidth) {
currentLine = candidate;
} else {
lines.push(currentLine);
currentLine = words[i];
}
}
lines.push(currentLine);
return lines;
}
// ---------------------------------------------------------------------------
// Auto-sizing
// ---------------------------------------------------------------------------
const MIN_FONT_SIZE = 8;
const DEFAULT_MAX_FONT_SIZE = 200;
const LINE_HEIGHT_FACTOR = 1.2;
/**
* Binary search for the largest font size where the text wraps to fit
* within boxWidth x boxHeight pixels.
*/
export function autoSizeFontToFit(
text: string,
fontFamily: string,
boxWidth: number,
boxHeight: number,
maxFontSize = DEFAULT_MAX_FONT_SIZE,
): number {
const effectiveMax = Math.min(maxFontSize, Math.floor(boxHeight / 5), Math.floor(boxWidth / 8));
let lo = MIN_FONT_SIZE;
let hi = Math.max(MIN_FONT_SIZE, effectiveMax);
let best = MIN_FONT_SIZE;
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2);
const lines = wrapText(text, fontFamily, mid, boxWidth);
const totalHeight = lines.length * mid * LINE_HEIGHT_FACTOR;
const maxLineWidth = Math.max(...lines.map((l) => measureText(l, fontFamily, mid)));
if (totalHeight <= boxHeight && maxLineWidth <= boxWidth) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return best;
}
// ---------------------------------------------------------------------------
// SVG rendering
// ---------------------------------------------------------------------------
/** Escape XML special characters in attribute values. */
function escapeXmlAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Render meme text boxes to an SVG buffer using opentype.js path conversion.
* The SVG uses <path> elements (not <text>) so no system fonts are needed
* when Sharp/librsvg rasterises it.
*/
export function renderMemeTextSvg(opts: MemeTextOptions): Buffer {
const {
imageWidth,
imageHeight,
textBoxes,
fontFamily,
fontSize: fixedFontSize,
textColor,
strokeColor,
textAlign,
allCaps,
} = opts;
const font = loadFont(fontFamily);
const paths: string[] = [];
for (const box of textBoxes) {
let text = box.text;
if (!text || text.trim() === "") continue;
if (allCaps) text = text.toUpperCase();
// Convert percentage coords to pixels
const bx = (box.x / 100) * imageWidth;
const by = (box.y / 100) * imageHeight;
const bw = (box.width / 100) * imageWidth;
const bh = (box.height / 100) * imageHeight;
const pad = Math.max(8, Math.round(bw * 0.05));
const innerW = bw - pad * 2;
const innerH = bh - pad * 2;
const fontSize = fixedFontSize ?? autoSizeFontToFit(text, fontFamily, innerW, innerH);
const lineHeight = fontSize * LINE_HEIGHT_FACTOR;
const lines = wrapText(text, fontFamily, fontSize, innerW);
const strokeWidth = Math.max(1, Math.round(fontSize * 0.04));
const fillAttr = escapeXmlAttr(textColor);
const strokeAttr = escapeXmlAttr(strokeColor);
const totalTextHeight = lines.length * lineHeight;
const yOffset = by + pad + (innerH - totalTextHeight) / 2 + fontSize * 0.85;
const innerX = bx + pad;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const lineWidth = font.getAdvanceWidth(line, fontSize);
let x: number;
if (textAlign === "left") {
x = innerX;
} else if (textAlign === "right") {
x = innerX + innerW - lineWidth;
} else {
x = innerX + (innerW - lineWidth) / 2;
}
const y = yOffset + i * lineHeight;
let d: string;
try {
const pathObj = font.getPath(line, x, y, fontSize);
d = pathObj.toPathData(2);
} catch {
// Some fonts have unsupported GSUB features in opentype.js v2.
// Fall back to Anton for the failing line.
const fallback = loadFont("anton");
const pathObj = fallback.getPath(line, x, y, fontSize);
d = pathObj.toPathData(2);
}
paths.push(
`<path d="${d}" fill="${fillAttr}" stroke="${strokeAttr}" stroke-width="${strokeWidth}" paint-order="stroke fill" stroke-linejoin="round"/>`,
);
}
}
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${imageWidth}" height="${imageHeight}" viewBox="0 0 ${imageWidth} ${imageHeight}">`,
...paths,
"</svg>",
].join("\n");
return Buffer.from(svg, "utf-8");
}
+124
View File
@@ -0,0 +1,124 @@
import { createReadStream, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
const STATIC_DIR = join(import.meta.dirname, "../../static");
const TEMPLATES_DIR = join(STATIC_DIR, "meme-templates");
const FONTS_DIR = join(STATIC_DIR, "fonts");
const CONTENT_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".ttf": "font/ttf",
};
/** Cached manifest (read once at first request, served from memory). */
let manifestCache: string | null = null;
let parsedManifestCache: unknown = null;
function getManifest(): unknown {
if (parsedManifestCache === null) {
manifestCache = readFileSync(join(TEMPLATES_DIR, "meme-templates.json"), "utf-8");
parsedManifestCache = JSON.parse(manifestCache);
}
return parsedManifestCache;
}
function hasPathTraversal(filename: string): boolean {
return (
filename.includes("..") ||
filename.includes("/") ||
filename.includes("\\") ||
filename.includes("\0")
);
}
function getContentType(filename: string): string | undefined {
const dot = filename.lastIndexOf(".");
if (dot === -1) return undefined;
return CONTENT_TYPES[filename.slice(dot).toLowerCase()];
}
function serveStaticFile(
dir: string,
filename: string,
reply: FastifyReply,
cacheControl: string,
): FastifyReply | void {
if (hasPathTraversal(filename)) {
return reply.status(400).send({ error: "Invalid filename" });
}
const contentType = getContentType(filename);
if (!contentType) {
return reply.status(400).send({ error: "Unsupported file type" });
}
const filePath = join(dir, filename);
if (!existsSync(filePath)) {
return reply.status(404).send({ error: "File not found" });
}
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!reply.raw.headersSent) {
reply.status(404).send({ error: "File not found" });
}
});
return reply
.header("Content-Type", contentType)
.header("Cache-Control", cacheControl)
.send(stream);
}
export async function registerMemeTemplates(app: FastifyInstance): Promise<void> {
// GET /api/v1/meme-templates -- Return the full manifest JSON
app.get("/api/v1/meme-templates", async (_request: FastifyRequest, reply: FastifyReply) => {
const manifest = getManifest();
return reply
.header("Content-Type", "application/json")
.header("Cache-Control", "public, max-age=3600")
.send(manifest);
});
// GET /api/v1/meme-templates/full/:filename -- Serve full-size template images
app.get(
"/api/v1/meme-templates/full/:filename",
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
const { filename } = request.params;
return serveStaticFile(
join(TEMPLATES_DIR, "full"),
filename,
reply,
"public, max-age=31536000, immutable",
);
},
);
// GET /api/v1/meme-templates/thumbs/:filename -- Serve thumbnail images
app.get(
"/api/v1/meme-templates/thumbs/:filename",
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
const { filename } = request.params;
return serveStaticFile(
join(TEMPLATES_DIR, "thumbs"),
filename,
reply,
"public, max-age=31536000, immutable",
);
},
);
// GET /api/v1/meme-templates/fonts/:filename -- Serve font files
app.get(
"/api/v1/meme-templates/fonts/:filename",
async (request: FastifyRequest<{ Params: { filename: string } }>, reply: FastifyReply) => {
const { filename } = request.params;
return serveStaticFile(FONTS_DIR, filename, reply, "public, max-age=31536000, immutable");
},
);
}
+2
View File
@@ -28,6 +28,7 @@ import { registerImageEnhancement } from "./image-enhancement.js";
import { registerImageToBase64 } from "./image-to-base64.js";
import { registerImageToPdf } from "./image-to-pdf.js";
import { registerInfo } from "./info.js";
import { registerMemeGenerator } from "./meme-generator.js";
import { registerNoiseRemoval } from "./noise-removal.js";
import { registerOcr } from "./ocr.js";
import { registerOptimizeForWeb } from "./optimize-for-web.js";
@@ -103,6 +104,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
{ id: "watermark-image", register: registerWatermarkImage },
{ id: "text-overlay", register: registerTextOverlay },
{ id: "compose", register: registerCompose },
{ id: "meme-generator", register: registerMemeGenerator },
// Utilities
{ id: "info", register: registerInfo },
+285
View File
@@ -0,0 +1,285 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { renderMemeTextSvg } from "../../lib/meme-text-renderer.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const settingsSchema = z.object({
templateId: z.string().optional(),
textLayout: z
.enum(["top-bottom", "top-only", "bottom-only", "center", "side-by-side"])
.default("top-bottom"),
textBoxes: z.array(z.object({ id: z.string(), text: z.string() })).default([]),
fontFamily: z
.enum([
"anton",
"arial-black",
"comic-sans",
"montserrat",
"bebas-neue",
"permanent-marker",
"roboto",
])
.default("anton"),
fontSize: z.number().min(8).max(200).optional(),
textColor: z.string().default("#ffffff"),
strokeColor: z.string().default("#000000"),
textAlign: z.enum(["left", "center", "right"]).default("center"),
allCaps: z.boolean().default(true),
});
type Settings = z.infer<typeof settingsSchema>;
// ---------------------------------------------------------------------------
// Preset text layouts (for custom images)
// ---------------------------------------------------------------------------
const PRESET_LAYOUTS: Record<
string,
Array<{ id: string; x: number; y: number; width: number; height: number }>
> = {
"top-bottom": [
{ id: "top", x: 5, y: 2, width: 90, height: 20 },
{ id: "bottom", x: 5, y: 78, width: 90, height: 20 },
],
"top-only": [{ id: "top", x: 5, y: 2, width: 90, height: 25 }],
"bottom-only": [{ id: "bottom", x: 5, y: 75, width: 90, height: 23 }],
center: [{ id: "center", x: 10, y: 35, width: 80, height: 30 }],
"side-by-side": [
{ id: "left", x: 2, y: 35, width: 46, height: 30 },
{ id: "right", x: 52, y: 35, width: 46, height: 30 },
],
};
// ---------------------------------------------------------------------------
// Template manifest
// ---------------------------------------------------------------------------
interface TemplateTextBox {
id: string;
x: number;
y: number;
width: number;
height: number;
defaultText?: string;
}
interface Template {
id: string;
filename: string;
width: number;
height: number;
textBoxes: TemplateTextBox[];
}
interface Manifest {
templates: Template[];
}
const STATIC_DIR = join(import.meta.dirname, "../../../static");
const TEMPLATES_DIR = join(STATIC_DIR, "meme-templates");
let manifestCache: Manifest | null = null;
function getManifest(): Manifest {
if (manifestCache === null) {
const raw = readFileSync(join(TEMPLATES_DIR, "meme-templates.json"), "utf-8");
manifestCache = JSON.parse(raw) as Manifest;
}
return manifestCache;
}
function findTemplate(templateId: string): Template | undefined {
return getManifest().templates.find((t) => t.id === templateId);
}
// ---------------------------------------------------------------------------
// Core processing function (shared by HTTP route and pipeline registry)
// ---------------------------------------------------------------------------
async function processMeme(
imageBuffer: Buffer,
settings: Settings,
filename: string,
templateTextBoxes?: TemplateTextBox[],
): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
const meta = await sharp(imageBuffer).metadata();
const imageWidth = meta.width ?? 800;
const imageHeight = meta.height ?? 600;
// Resolve text box positions: template boxes or preset layout
const layoutBoxes =
templateTextBoxes ?? PRESET_LAYOUTS[settings.textLayout] ?? PRESET_LAYOUTS["top-bottom"];
// Map settings.textBoxes onto layout positions
const textBoxes = layoutBoxes
.map((box) => {
const userBox = settings.textBoxes.find((tb) => tb.id === box.id);
return {
text: userBox?.text ?? "",
x: box.x,
y: box.y,
width: box.width,
height: box.height,
};
})
.filter((box) => box.text.length > 0);
let result: Buffer;
if (textBoxes.length > 0) {
const svgBuffer = renderMemeTextSvg({
imageWidth,
imageHeight,
textBoxes,
fontFamily: settings.fontFamily,
fontSize: settings.fontSize,
textColor: settings.textColor,
strokeColor: settings.strokeColor,
textAlign: settings.textAlign,
allCaps: settings.allCaps,
});
result = await sharp(imageBuffer)
.composite([{ input: svgBuffer }])
.toBuffer();
} else {
// No text -- just pass the image through
result = await sharp(imageBuffer).toBuffer();
}
return {
buffer: result,
filename,
contentType: "image/png",
};
}
// ---------------------------------------------------------------------------
// Route registration
// ---------------------------------------------------------------------------
export function registerMemeGenerator(app: FastifyInstance) {
// Register process function for pipeline/batch compatibility
registerToolProcessFn({
toolId: "meme-generator",
settingsSchema: settingsSchema as z.ZodType<unknown, z.ZodTypeDef, unknown>,
process: async (inputBuffer: Buffer, settings: unknown, filename: string) => {
const parsed = settingsSchema.parse(settings);
const buf = await autoOrient(await ensureSharpCompat(inputBuffer));
return processMeme(buf, parsed, filename);
},
});
app.post("/api/v1/tools/meme-generator", async (request, reply) => {
const contentTypeHeader = request.headers["content-type"] ?? "";
const isMultipart = contentTypeHeader.includes("multipart/form-data");
let imageBuffer: Buffer | null = null;
let settingsRaw: unknown = null;
let filename = "meme.png";
// ── Parse request ───────────────────────────────────────────────
if (isMultipart) {
// Custom image mode: multipart with file + settings
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file" && part.fieldname === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
imageBuffer = Buffer.concat(chunks);
if (part.filename) {
filename = part.filename;
}
} else if (part.type === "field" && part.fieldname === "settings") {
try {
settingsRaw = JSON.parse(part.value as string);
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
} else {
// Template mode: JSON body
settingsRaw = request.body;
}
// ── Validate settings ───────────────────────────────────────────
const result = settingsSchema.safeParse(settingsRaw ?? {});
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: formatZodErrors(result.error.issues),
});
}
const settings = result.data;
// ── Resolve image source ────────────────────────────────────────
let templateTextBoxes: TemplateTextBox[] | undefined;
if (settings.templateId) {
// Template mode
const template = findTemplate(settings.templateId);
if (!template) {
return reply.status(400).send({ error: `Template not found: ${settings.templateId}` });
}
const templatePath = join(TEMPLATES_DIR, "full", template.filename);
if (!existsSync(templatePath)) {
return reply.status(400).send({ error: `Template image not found: ${template.filename}` });
}
imageBuffer = readFileSync(templatePath);
templateTextBoxes = template.textBoxes;
filename = `meme-${template.id}.png`;
} else if (!imageBuffer || imageBuffer.length === 0) {
return reply.status(400).send({ error: "Either templateId or an image file is required" });
}
// ── Process ─────────────────────────────────────────────────────
try {
// Normalize the image for Sharp compatibility
imageBuffer = await autoOrient(await ensureSharpCompat(imageBuffer!));
const output = await processMeme(imageBuffer, settings, filename, templateTextBoxes);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", output.filename);
await writeFile(outputPath, output.buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(output.filename)}`,
originalSize: imageBuffer.length,
processedSize: output.buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Processing failed",
details: err instanceof Error ? err.message : "Meme generation failed",
});
}
});
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Some files were not shown because too many files have changed in this diff Show More