Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ data/template-creators/
data/template-assets/
data/template-uses.json
data/template-uses.json.lock
data/template-previews/
123 changes: 119 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,127 @@ Artwork lookup is best effort. If the service has no image or is temporarily una

## Choose a template

The `template` field is an official or approved community template slug. It defaults to `black`. List available templates with:
The `template` field is an official or approved community template slug. It defaults to `black`. Use the template catalog below to find slugs. Existing template dimensions are preserved, subject to the API render size limit.

## Template catalog

The catalog endpoints let you show the template library on your own site: list templates, page through them, and embed preview images. They are read-only `GET` requests with CORS enabled, so they work straight from the browser.

### List templates

```sh
curl 'https://wrapped.devmatei.com/api/v1/templates?limit=12&sort=popular'
```

| Parameter | Values | Default |
| --- | --- | --- |
| `limit` | `1` to `50` | `20` |
| `sort` | `popular`, `newest`, `name` | `popular` |
| `origin` | `official`, `community` | all |
| `category` | `dark`, `light`, `minimal`, `vibrant`, `retro`, `abstract`, `bold`, `soft` | all |
| `featured` | `true`, `false` | all |
| `q` | search text, up to 60 characters, matched against name, slug, tags and creator | none |
| `cursor` | `page.next_cursor` from the previous response | first page |

Unknown parameters return `400`, so typos don't silently return the wrong list.

```json
{
"templates": [
{
"slug": "black",
"name": "Midnight",
"origin": "official",
"category": "dark",
"tags": ["dark", "classic"],
"featured": true,
"uses": 260,
"created_at": null,
"creator": {"id": "official", "name": "Make a Wrapped", "website": "https://wrapped.devmatei.com"},
"canvas": {"width": 1080, "height": 1920},
"version": "506e50c0b6686ddb",
"links": {
"self": "https://wrapped.devmatei.com/api/v1/templates/black",
"preview": "https://wrapped.devmatei.com/api/v1/templates/black/preview.png?v=506e50c0b6686ddb",
"use": "https://wrapped.devmatei.com/?template=black"
}
}
],
"page": {"limit": 12, "count": 1, "total": 9, "has_more": true, "next_cursor": "eyJzIjoi..."},
"query": {"sort": "popular"}
}
```

`links.use` opens the generator with that template already selected. `created_at` is `null` for official templates. `creator` can be `null`, and `creator.website` is only included when it is an http(s) link.

### Paging

Pass `page.next_cursor` back as `cursor` with the same filters and sort to get the next page. When `has_more` is `false`, `next_cursor` is `null` and you have everything. A cursor only works with the query that produced it; mixing it with different filters returns `400`, so start again without a cursor.

Cursors point at a position in the sorted list rather than a page number, so templates being added or removed between requests won't make you skip or repeat items. The exception is `sort=popular`: use counts change all the time, so a template can move between pages while you are paging. If you need a complete, stable sync, use `sort=newest` or `sort=name`.

### Get one template

```sh
curl https://wrapped.devmatei.com/api/templates
curl https://wrapped.devmatei.com/api/v1/templates/black
```

Returns the same fields as the list under `template`, plus `definition`: the full template JSON (canvas, background, artwork slot, elements) in case you want to draw it yourself. Image background paths are returned as absolute URLs.

### Preview images

```html
<img
src="https://wrapped.devmatei.com/api/v1/templates/black/preview.png?size=sm&v=506e50c0b6686ddb"
width="360" height="640" loading="lazy" alt="Midnight template">
```

Previews are PNGs of the template filled with sample stats and placeholder artwork. `size` is `sm` (360px wide), `md` (540px, the default) or `lg` (1080px). Height follows the template's canvas ratio.

Use `links.preview` from the catalog, and append `&size=sm` if you want a different size. It includes `v`, the template version. With a matching `v`, the image is cached for a year, since a template edit changes the version and therefore the URL. Without `v`, the image is cached for five minutes.

The first request for a preview renders it; after that it is served from disk. If the renderer is busy, you get `503` with `Retry-After`. Show a placeholder and retry later.

### Embed example

```js
async function loadTemplates(cursor) {
const url = new URL("https://wrapped.devmatei.com/api/v1/templates");
url.searchParams.set("limit", "12");
url.searchParams.set("sort", "newest");
if (cursor) url.searchParams.set("cursor", cursor);

const response = await fetch(url);
if (!response.ok) {
const { error } = await response.json().catch(() => ({}));
throw new Error(error ? error.message : `HTTP ${response.status}`);
}
return response.json();
}

const grid = document.querySelector("#templates");
let cursor = null;

async function showMore() {
const { templates, page } = await loadTemplates(cursor);
for (const template of templates) {
const link = document.createElement("a");
link.href = template.links.use;
const img = document.createElement("img");
img.src = `${template.links.preview}&size=sm`;
img.alt = template.name;
img.loading = "lazy";
link.append(img, template.name);
grid.append(link);
}
cursor = page.next_cursor;
document.querySelector("#more").hidden = !page.has_more;
}
```

The response contains a `templates` array. Use a template's `slug` as the `template` value. Existing template dimensions are preserved, subject to the API render size limit.
### Caching

Catalog responses carry an `ETag` and `Cache-Control: public, max-age=60` (`max-age=300` for a single template). Send `If-None-Match` with the last `ETag` to get an empty `304` when nothing changed. The server builds the catalog in memory and refreshes it every 30 seconds (`TEMPLATE_CATALOG_TTL`), so a newly approved template can take that long to show up.

## Errors

Expand All @@ -193,6 +307,7 @@ Every API error has a JSON body with a stable shape, including invalid requests,
| Status | Meaning | What to do |
| --- | --- | --- |
| `400` | Invalid input or unsupported field | Check the JSON schema and field values. |
| `304` | Catalog or preview unchanged since your `If-None-Match` | Reuse your cached copy. |
| `404` | Unknown template or API path | Check the template slug or endpoint URL. |
| `413` | Request body or generated image exceeds its size limit | Send a smaller request or use another template. |
| `415` | Request is not JSON | Send `Content-Type: application/json`. |
Expand All @@ -206,7 +321,7 @@ The server returns `Retry-After` for rate limiting and temporary render capacity

## Limits and deployment notes

No API key is required. Requests are limited per client IP by the existing image rate limit, 15 per minute by default (`APP_IMAGE_RATE_LIMIT`). JSON request bodies are limited to 64 KiB. Templates are capped at 2.5 million canvas pixels, source images at 16 million pixels, and generated files at 12 MiB.
No API key is required. Requests are limited per client IP: `POST /api/v1/wrapped` by the image rate limit, 15 per minute by default (`APP_IMAGE_RATE_LIMIT`), catalog requests at 120 per minute (`APP_CATALOG_RATE_LIMIT`), and preview images at 240 per minute (`APP_PREVIEW_RATE_LIMIT`). The preview limit is higher because a single page of thumbnails makes many requests. Rendered previews are cached in `data/template-previews` (`TEMPLATE_PREVIEW_DIR`). JSON request bodies are limited to 64 KiB. Templates are capped at 2.5 million canvas pixels, source images at 16 million pixels, and generated files at 12 MiB.

Rendering uses CPU and memory, so the server limits concurrent renders and keeps a bounded wait queue. Docker Compose runs four Gunicorn workers with eight request threads each, one active render per worker, and Redis-backed rate limits shared between workers. Requests that cannot be served in a reasonable time receive `503` with `Retry-After`; clients should retry with exponential backoff and jitter. These limits protect the service during bursts, but no finite server can promise zero errors during unlimited traffic.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "make-a-wrapped",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
"engines": {
"node": ">=20"
Expand Down
30 changes: 28 additions & 2 deletions scripts/render-wrapped.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
import { createCanvas, GlobalFonts, loadImage, SvgExportFlag } from '@napi-rs/canvas';
import { normaliseTemplate, renderTemplate } from '../static/js/templates/engine.js';
import { paintSampleArt } from '../static/js/templates/sample-art.js';

const MAX_CANVAS_PIXELS = 2_500_000;
const MAX_OUTPUT_BYTES = 12 * 1024 * 1024;
const SAMPLE_ART_SIZE = 600;

async function sampleArtImage() {
const canvas = createCanvas(SAMPLE_ART_SIZE, SAMPLE_ART_SIZE);
paintSampleArt(canvas.getContext('2d'), SAMPLE_ART_SIZE, 'Make a Wrapped');
return loadImage(canvas.toBuffer('image/png'));
}

function downscale(canvas, width, height, outputWidth) {
if (!Number.isInteger(outputWidth) || outputWidth < 1 || outputWidth >= width) {
return canvas;
}
const outputHeight = Math.max(1, Math.round((height * outputWidth) / width));
const scaled = createCanvas(outputWidth, outputHeight);
const ctx = scaled.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(canvas, 0, 0, outputWidth, outputHeight);
return scaled;
}

async function readInput() {
const chunks = [];
Expand All @@ -28,7 +49,10 @@ async function render() {

const assets = input.assets || {};
const background = assets.background ? await loadImage(Buffer.from(assets.background, 'base64')) : null;
const artwork = assets.artwork ? await loadImage(Buffer.from(assets.artwork, 'base64')) : null;
let artwork = assets.artwork ? await loadImage(Buffer.from(assets.artwork, 'base64')) : null;
if (!artwork && input.sampleArt) {
artwork = await sampleArtImage();
}
if (background) {
template.background._image = background;
}
Expand All @@ -47,7 +71,9 @@ async function render() {
showElements: true,
});

const output = isSvg ? canvas.getContent() : canvas.toBuffer('image/png');
const output = isSvg
? canvas.getContent()
: downscale(canvas, width, height, input.outputWidth).toBuffer('image/png');
if (output.length > MAX_OUTPUT_BYTES) {
throw new Error('Generated image exceeds the output size limit.');
}
Expand Down
94 changes: 46 additions & 48 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,52 @@ <h1>make a wrapped</h1>
</p>
<form id="wrapped-form" class="form" autocomplete="off" novalidate>
<label class="form__label" id="username-label" for="username">music username</label>
<div id="navidrome-fields" class="service-fields service-fields--navidrome" hidden>
<div class="service-fields__grid">
<label class="service-fields__field service-fields__field--wide">
<span class="form__label">navidrome server url</span>
<input
id="navidrome-server"
name="navidrome-server"
type="url"
placeholder="https://navidrome.example.com"
autocomplete="url"
inputmode="url"
>
</label>
<label class="service-fields__field">
<span class="form__label">username</span>
<input
id="navidrome-username"
name="navidrome-username"
type="text"
placeholder="e.g. alice"
autocomplete="username"
minlength="2"
>
</label>
<label class="service-fields__field">
<span class="form__label">password or app token</span>
<input
id="navidrome-password"
name="navidrome-password"
type="password"
placeholder="stored locally, never shared"
autocomplete="current-password"
>
</label>
<label class="service-fields__field service-fields__field--wide">
<span class="form__label">stats method</span>
<select id="navidrome-method" name="navidrome-method">
<option value="experimental" selected>Experimental (Native API)</option>
<option value="legacy">Legacy (Subsonic scan)</option>
</select>
</label>
</div>
<p class="service-fields__hint" id="navidrome-method-hint">
experimental pulls real per-play scrobble history via the native api (navidrome 0.64.0+). if it does not work, try legacy.
</p>
</div>
<div class="form__field-group">
<input
id="username"
Expand All @@ -185,15 +231,6 @@ <h1>make a wrapped</h1>
minlength="2"
autocomplete="off"
>
<input
id="navidrome-username"
name="navidrome-username"
type="text"
placeholder="e.g. alice"
autocomplete="username"
minlength="2"
hidden
>
<button id="submit" type="submit" class="btn">generate</button>
</div>
<p id="status-message" class="status-message" role="status" aria-live="polite" hidden></p>
Expand Down Expand Up @@ -425,45 +462,6 @@ <h1>make a wrapped</h1>
</div>
</div>
</div>
<div id="navidrome-fields" class="service-fields service-fields--navidrome" hidden>
<p class="service-fields__description">
connect to your navidrome server directly. everything runs in your browser, credentials never leave this page.
</p>
<label class="form__label" for="navidrome-server">navidrome server url</label>
<div class="form__field-group">
<input
id="navidrome-server"
name="navidrome-server"
type="url"
placeholder="https://navidrome.example.com"
autocomplete="url"
inputmode="url"
>
</div>
<label class="form__label" for="navidrome-password">password or app token</label>
<div class="form__field-group">
<input
id="navidrome-password"
name="navidrome-password"
type="password"
placeholder="stored locally, never shared"
autocomplete="current-password"
>
</div>
<label class="form__label" for="navidrome-method">stats method</label>
<div class="form__field-group">
<select id="navidrome-method" name="navidrome-method">
<option value="experimental" selected>Experimental (Native API)</option>
<option value="legacy">Legacy (Subsonic scan)</option>
</select>
</div>
<p class="service-fields__hint" id="navidrome-method-hint">
experimental pulls real per-play scrobble history via the native api. requires navidrome 0.64.0 or above. if it does not work, try the legacy method.
</p>
<p class="service-fields__hint">
use your normal login or create an app-specific token. nothing leaves your browser.
</p>
</div>
<div class="actions">
<button id="download" type="button" class="btn btn--secondary" aria-disabled="true">
download image
Expand Down
15 changes: 3 additions & 12 deletions static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -385,10 +385,9 @@ function handleServiceChange(nextValue) {
const selectedService = nextValue || getSelectedService();
const isNavidrome = selectedService === 'navidrome';
const copy = getUsernameCopy(selectedService);
const navCopy = getUsernameCopy('navidrome');
if (usernameLabel) {
usernameLabel.textContent = isNavidrome ? navCopy.label : copy.label;
usernameLabel.setAttribute('for', isNavidrome && navidromeUsernameInput ? 'navidrome-username' : 'username');
usernameLabel.textContent = copy.label;
usernameLabel.hidden = isNavidrome;
}
if (usernameField) {
usernameField.hidden = isNavidrome;
Expand All @@ -400,15 +399,7 @@ function handleServiceChange(nextValue) {
}
}
if (navidromeUsernameInput) {
navidromeUsernameInput.hidden = !isNavidrome;
if (typeof navidromeUsernameInput.required === 'boolean') {
navidromeUsernameInput.required = isNavidrome;
} else if (isNavidrome) {
navidromeUsernameInput.setAttribute('required', 'required');
} else {
navidromeUsernameInput.removeAttribute('required');
}
navidromeUsernameInput.placeholder = navCopy.placeholder;
navidromeUsernameInput.required = isNavidrome;
}
if (navidromeFields) {
navidromeFields.hidden = !isNavidrome;
Expand Down
15 changes: 9 additions & 6 deletions static/js/templates/sample-art.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
let sampleArtPromise = null;

function drawSampleArt(seedName) {
const size = 600;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
export function paintSampleArt(ctx, size, seedName) {
const hue = (seedName || 'Make a Wrapped').split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 360;

ctx.save();
Expand Down Expand Up @@ -50,6 +45,14 @@ function drawSampleArt(seedName) {
ctx.arc(cx, cy, size * 0.035, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}

function drawSampleArt(seedName) {
const size = 600;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
paintSampleArt(canvas.getContext('2d'), size, seedName);
return canvas;
}

Expand Down
Loading
Loading