openvibers/openvibe.meme Commits

Meme maker slice 2: editor, uploads, AI captions and exports (#138)

OP OpenVibers committed 4 days ago · 750d883ed7 · 17 files, +610 −91

* Meme maker slice 2: editor, template uploads, caption ideas, GIF/MP4 exports

* Entry page: show the recorded origin source and its quote

* Maker design: your words first, templates as a rail, the canvas always in view

The lines come first; the templates are one sideways rail with the search and an Upload your own button above it; style controls stay a compact grid; the canvas is sticky beside the form on a computer and first on a phone. Fieldsets may shrink (a rail inside one widened the phone page to 585 px).

* Merge remote-tracking branch 'origin/main' into meme/maker-slice2

# Conflicts:
#	README.md
#	STATUS.json
#	public/css/meme.css

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex c01dfe4..6df1184 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -9,6 +9,8 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
syntax-dirs: server test scripts
++ # The maker's GIF and MP4 exports call the system ffmpeg.
++ apt-packages: ffmpeg
# ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
# scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex febd494..a618b60 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ What changed in OpenVibe.Meme, newest first.
## Unreleased
++- **The maker, second slice.** `/make` is a phone-first editor: template search, draggable and resizable caption
++ boxes, crop and type/outline controls, emoji stickers, optional signed-out caption ideas from OpenVibe.AI's cheapest
++ route, and PNG/GIF/MP4 downloads. Captions save their editor state as text and geometry, never as a rendered image.
++ Member-uploaded templates go to OpenVibe.Media and stay hidden until staff approve them; migration 0010 adds the
++ moderation and editor-state columns above 0009. Public templates, entries and the render request are checked against
++ `meme.template@1`, `meme.entry@1` and `meme.render@1` (contracts v0.373.0). Saved captions gain copy/native share,
++ Community, Pics and Chat links, and a render endpoint.
- **One title, one public entry.** `meme_entries.title_key` is the title lowercased with quotes/punctuation stripped
and whitespace collapsed; a trigger keeps it current and a partial unique index on `(kind, title_key)` stops two
public entries sharing a title. The importer upserts on the key instead of the slug, so a second phrase that the
ddiff --git a/README.md b/README.md
iindex c0e8f37..85afd9f 100644
---- a/README.md
++++ b/README.md
@@@ -162,9 +162,13 @@ also gets per-actor minute/hour limits (`MEME_LIMITS_MINUTE`, `MEME_LIMITS_HOUR`
| `GET /week` | this ISO week's candidates (public entries with a live use that week) and their vote counts |
| `POST /week/vote` | signed in, same-origin; `{ entry_id }`; upsert per (`week`, subject) — one vote a week |
| `GET /templates` | the caption templates with their caption counts and `layout` |
--| `POST /render` | signed in; `{ template_id, lines }` returns a PNG from the local renderer without an AI call |
++| `POST /caption-ideas` | signed out or signed in; `{ template_id, topic }` returns short ideas using OpenVibe.AI's cheapest route by default (8 requests/minute/address) |
++| `POST /templates/uploads` | signed in; `{ name, image }` accepts PNG/JPEG/WebP up to 6 MB and sends it to Media in the pending review queue |
++| `GET /staff/templates/uploads`, `POST /staff/templates/:id/review` | staff-only template moderation queue and approval/rejection |
++| `POST /render` | signed in; `{ template_id, lines, format?, editor? }` returns PNG, GIF, or MP4 from the local renderer without an AI call |
++| `GET /captions/:id/render?format=png\|gif\|mp4` | render a public saved caption in the requested format; private captions require the owner or staff |
| `POST /generate` | signed in; `{ template_slug \| topic, lines? }`; researches current uses, writes captions, renders the template's layout as a PNG, stores it in Media and saves the caption. 201 returns `media_id`, `media_url`, `captions`, `template`, `labels`, `caption`; 503 `meme.ai_unavailable`/`meme.media_unavailable`; 429 `meme.ai_budget` |
--| `POST /captions` | signed in; `{ template_id, lines, visibility? }`, 1–4 lines × 120 chars, blocked words refused |
++| `POST /captions` | signed in; `{ template_id, lines, visibility?, editor? }`, 1–4 lines × 120 chars, blocked words refused; safe editor geometry is saved with the text |
| `GET /captions/:id` | one caption; a public read never returns the author's subject |
| `POST /entries/:slug/report` | signed in; `{ kind: "takedown"\|"correction", reason, note? }`, stored for staff |
| `GET /reports` | staff: the open reports |
@@@ -325,14 +329,15 @@ signed-in person is `private, no-store`.
| `/memes` | permanent redirect to the internet-meme collection |
| `/m/:slug` | one entry: origin with its citation link, the use timeline, examples, and a report form when signed in |
| `/week` | the ballot (`POST /week/vote`) |
--| `/make` | the generator form: template, 1–4 lines, a server-rendered preview (`GET /make`), save (`POST /make` → 303 `/c/:id`) |
--| `/c/:id` | one saved caption, the lines over the template image |
++| `/make` | phone-first editor: searchable templates, movable/resizable text, crop, type/outline style, stickers, AI ideas, and PNG/GIF/MP4 export; saves captions as text and editor state |
++| `/c/:id` | one saved caption, native share/copy, Community draft, Pics/Chat links, and downloadable exports |
| `/developers` | the API, the Search document, the importer's sources |
| 404 | the not-found page |
The pages remain server-rendered and readable without JavaScript; `public/js/meme.js` makes the caption preview
--update as the user types. The templates' images are local data-free SVG placeholders (or an
--`openvibe.media` object when staff add a real one). `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list
++update as the user types and adds touch dragging/resizing. Template uploads go to OpenVibe.Media and remain hidden
++until a staff member approves them. GIF and MP4 exports are short loops of the rendered image. The templates' images
++are local data-free SVG placeholders (or a licensed Commons / member `openvibe.media` object). `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list
the public pages and every public entry.
## The first proof
ddiff --git a/migrations/0010_editor_uploads.sql b/migrations/0010_editor_uploads.sql
nnew file mode 100644
iindex 0000000..f394938
---- /dev/null
++++ b/migrations/0010_editor_uploads.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- The next maker slice: persist safe editor geometry and hold member-uploaded templates for review.
++
++ALTER TABLE meme_templates ADD COLUMN moderation_status text NOT NULL DEFAULT 'approved';
++ALTER TABLE meme_templates ADD COLUMN uploaded_by text;
++ALTER TABLE meme_templates ADD CONSTRAINT meme_templates_moderation_status CHECK (moderation_status IN ('pending', 'approved', 'rejected'));
++ALTER TABLE meme_templates ADD CONSTRAINT meme_templates_uploaded_by CHECK (uploaded_by IS NULL OR length(uploaded_by) BETWEEN 1 AND 180);
++
++ALTER TABLE meme_captions ADD COLUMN editor_state jsonb NOT NULL DEFAULT '{}'::jsonb;
++ALTER TABLE meme_captions ADD CONSTRAINT meme_captions_editor_state CHECK (jsonb_typeof(editor_state) = 'object' AND octet_length(editor_state::text) <= 8192);
ddiff --git a/public/css/meme.css b/public/css/meme.css
iindex d541c43..1c0232f 100644
---- a/public/css/meme.css
++++ b/public/css/meme.css
@@@ -143,6 +143,24 @@ html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) bo
.mm-template small { color: var(--text-muted, #7386a3); font-size: .74rem; }
.mm-template:has(input:checked), .mm-template.is-chosen { border-color: var(--room-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 18%, transparent); }
.mm-template:has(input:focus-visible) { outline: 2px solid var(--room-accent); outline-offset: 2px; }
++.mm-editor-tools { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px; }
++.mm-editor-tools legend { padding: 0 6px; font-weight: 750; }
++.mm-editor-tools .mm-field { min-width: 0; }
++.mm-form .mm-field[hidden] { display: none; }
++.mm-editor-tools input[type=color] { width: 100%; min-height: 44px; padding: 4px; }
++.mm-editor-stage { position: relative; width: 100%; aspect-ratio: 11 / 7; overflow: hidden; border-radius: 14px; background: #121827; touch-action: none; }
++.mm-editor-stage > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
++.mm-editor-box { position: absolute; display: grid; place-items: center; min-width: 28px; min-height: 26px; padding: 2px; border: 1px dashed transparent; border-radius: 5px; text-align: center; line-height: 1.05; font-weight: 950; text-transform: uppercase; overflow-wrap: anywhere; cursor: move; user-select: none; touch-action: none; paint-order: stroke fill; -webkit-text-stroke-linejoin: round; text-shadow: 0 1px 1px #0008; }
++.mm-editor-box:focus, .mm-editor-box:hover { border-color: color-mix(in srgb, var(--room-accent) 75%, white); }
++.mm-font-impact { font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif; letter-spacing: .015em; }
++.mm-font-modern { font-family: Inter, ui-sans-serif, system-ui, sans-serif; text-transform: none; font-weight: 800; }
++.mm-font-soft { font-family: ui-rounded, system-ui, sans-serif; text-transform: none; font-weight: 750; }
++.mm-resize-handle { position: absolute; right: -5px; bottom: -5px; width: 15px; height: 15px; border: 2px solid #fff; border-radius: 50%; background: var(--room-accent); cursor: nwse-resize; }
++.mm-editor-sticker { position: absolute; z-index: 2; transform: translate(-50%,-50%); font-size: clamp(22px, 7vw, 44px); filter: drop-shadow(0 2px 2px #000a); }
++.mm-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 180px), 1fr)); gap: 12px; }
++.mm-gallery figure { margin: 0; padding: 10px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #0f1729); }
++.mm-gallery img { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 9px; }
++.mm-template-thumb { display: block; width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 9px; }
.mm-preview { position: sticky; top: 76px; }
.mm-preview h2 { margin-bottom: 12px; }
.mm-caption-card { position: relative; margin: 0; overflow: hidden; border-radius: 16px; background: #111; container-type: inline-size; box-shadow: 0 14px 34px rgba(0, 0, 0, .3); }
@@@ -183,6 +201,7 @@ html.ov-motion .mm-demo-bars i { animation: mm-bar .9s cubic-bezier(.2,.7,.2,1)
.mm-make-grid > .mm-form { padding: 14px; }
.mm-templates { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.mm-template { min-height: 44px; }
++ .mm-editor-tools { grid-template-columns: minmax(0, 1fr); }
#mm-caption-form .mm-actions { position: static; padding: 8px 0; border: 0; background: transparent; }
#mm-caption-form .mm-actions .mm-btn { min-height: 48px; flex: 1; }
}
@@@ -211,4 +230,29 @@ html[data-ov-motion=reduced] .mm-tree-lines path, html[data-ov-motion=reduced] .
.table-wrap > table:has(thead th:nth-child(3)):has(td[data-label]) td:empty { display: none; }
}
++/* The maker (Opus's design pass, 11-09): the words come first, the templates are one rail you scroll sideways with the
++ search and the upload above it, the style controls are a compact grid, and the canvas stays in view (sticky beside the
++ form on a computer, first on a phone). */
++.mm-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++.mm-words { margin: 0; padding: 0; border: 0; display: grid; gap: 10px; }
++.mm-words legend, .mm-templates legend { margin-bottom: 8px; font-size: .95rem; font-weight: 800; color: var(--text-primary, #e6edf7); }
++.mm-tpl-h { margin: 8px 0 -2px; font-size: .95rem; font-weight: 800; color: var(--text-primary, #e6edf7); }
++.mm-tpl-bar { display: flex; gap: 8px; align-items: stretch; }
++.mm-tpl-find { flex: 1; }
++.mm-upload { position: relative; display: inline-flex; align-items: center; flex: none; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px dashed var(--border, #1f2d47); color: var(--text-primary, #e6edf7); font-size: .85rem; font-weight: 700; cursor: pointer; }
++.mm-upload:hover, .mm-upload:focus-within { border-color: var(--room-accent); }
++.mm-upload input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
++.mm-tpl-note { margin: -2px 0 0; }
++fieldset.mm-templates, fieldset.mm-words, fieldset.mm-editor-tools { min-inline-size: 0; min-width: 0; }
++.mm-make-grid > * { min-width: 0; }
++.mm-templates { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x proximity; padding: 2px 2px 10px; scrollbar-width: thin; }
++.mm-templates .mm-template { flex: 0 0 150px; scroll-snap-align: start; }
++@media (min-width: 900px) {
++ .mm-make-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); }
++ .mm-make-grid > .mm-preview { position: sticky; top: calc(var(--ovnav-h, 56px) + 16px); }
++}
++@media (max-width: 899.98px) {
++ .mm-make-grid { grid-template-columns: minmax(0, 1fr); }
++ .mm-make-grid > .mm-preview { order: -1; }
++}
.mm-pics-source { max-width:100%; height:auto; display:block; margin-bottom:20px; }
ddiff --git a/public/js/meme.js b/public/js/meme.js
iindex a873969..f28a476 100644
---- a/public/js/meme.js
++++ b/public/js/meme.js
@@@ -7,26 +7,17 @@
var aside = document.getElementById('mm-preview');
if (!form || !aside || form.dataset.ready) return;
form.dataset.ready = '1';
-- var fig = aside.querySelector('.mm-caption-card');
-- if (!fig) {
-- aside.querySelectorAll('.mm-muted').forEach(function (p) { p.remove(); });
-- fig = document.createElement('figure'); fig.className = 'mm-caption-card';
-- fig.appendChild(document.createElement('img')); fig.appendChild(document.createElement('figcaption'));
-- aside.appendChild(fig);
-- }
-- var img = fig.querySelector('img'), cap = fig.querySelector('figcaption');
-- img.alt = '';
++ var stage = aside.querySelector('[data-stage]'), canvasImg = stage && stage.querySelector('[data-canvas-image]');
++ var boxLayer = stage && stage.querySelector('[data-caption-boxes]'), stickers = stage && stage.querySelector('[data-stickers]');
++ var editorStatus = aside.querySelector('[data-editor-status]');
++ var boxPositions = [[5, 7, 90, 24], [5, 68, 90, 24], [5, 37, 90, 20], [5, 43, 90, 18]];
++ var stickerList = [];
++ var activeTemplate = null;
++ function field(name) { return form.querySelector('[name=' + name + ']'); }
function draw() {
var chosen = form.querySelector('input[name=template_id]:checked');
form.querySelectorAll('.mm-template').forEach(function (l) { l.classList.toggle('is-chosen', !!l.querySelector('input:checked')); });
-- if (chosen) { var t = chosen.closest('.mm-template').querySelector('img'); if (t && img.src !== t.src) img.src = t.src; }
-- cap.replaceChildren();
-- for (var i = 1; i <= 4; i++) {
-- var input = form.querySelector('[name=line' + i + ']');
-- var span = document.createElement('span');
-- span.textContent = input ? input.value : '';
-- cap.appendChild(span);
-- }
++ drawCanvas(chosen);
}
form.addEventListener('input', draw);
form.addEventListener('change', draw);
@@@ -37,6 +28,96 @@
card.hidden = query && !card.textContent.toLocaleLowerCase().includes(query);
});
});
++ function drawCanvas(chosen) {
++ var selected = chosen || form.querySelector('input[name=template_id]:checked');
++ if (selected) {
++ var card = selected.closest('.mm-template'), picture = card && card.querySelector('img');
++ if (picture && canvasImg && canvasImg.src !== picture.src) canvasImg.src = picture.src;
++ if (card && activeTemplate !== selected.value) {
++ activeTemplate = selected.value;
++ try {
++ var layout = JSON.parse(card.dataset.layout || '{}'), source = Array.isArray(layout.boxes) ? layout.boxes : [], w = Number(layout.width) || 440, h = Number(layout.height) || 280;
++ var count = Math.max(1, Math.min(4, Number(card && card.dataset.boxes) || 4));
++ if (source.length) boxPositions = source.slice(0, 4).map(function (b) { return [Number(b.x) / w * 100, Number(b.y) / h * 100, Number(b.w) / w * 100, Number(b.h) / h * 100]; });
++ else boxPositions = [[5, 7, 90, 24], [5, 68, 90, 24], [5, 37, 90, 20], [5, 43, 90, 18]].slice(0, count);
++ } catch (_) { /* Keep the editor's default positions for malformed optional layout data. */ }
++ }
++ }
++ var font = field('font_style') && field('font_style').value || 'impact';
++ var size = Number(field('font_size') && field('font_size').value || 30);
++ var color = field('text_color') && field('text_color').value || '#fff';
++ var outline = field('outline_color') && field('outline_color').value || '#000';
++ var width = Number(field('outline_width') && field('outline_width').value || 0);
++ var cropX = Number(field('crop_x') && field('crop_x').value || 50), cropY = Number(field('crop_y') && field('crop_y').value || 50);
++ if (canvasImg) canvasImg.style.objectPosition = cropX + '% ' + cropY + '%';
++ for (var n = 1; n <= 4; n++) { var lineField = field('line' + n); var lineLabel = lineField && lineField.closest('.mm-field'); if (lineLabel) lineLabel.hidden = n > boxPositions.length; }
++ if (boxLayer) {
++ boxLayer.replaceChildren();
++ for (var i = 1; i <= boxPositions.length; i++) {
++ var input = field('line' + i), box = document.createElement('div');
++ box.className = 'mm-editor-box mm-font-' + font; box.dataset.box = String(i - 1);
++ var pos = boxPositions[i - 1]; box.style.left = pos[0] + '%'; box.style.top = pos[1] + '%'; box.style.width = pos[2] + '%'; box.style.height = pos[3] + '%';
++ box.style.fontSize = 'clamp(12px, ' + (size / 5) + 'vw, ' + size + 'px)'; box.style.color = color;
++ box.style.webkitTextStroke = width + 'px ' + outline; box.textContent = input ? input.value : '';
++ var handle = document.createElement('i'); handle.className = 'mm-resize-handle'; handle.setAttribute('aria-hidden', 'true'); box.appendChild(handle); boxLayer.appendChild(box);
++ }
++ }
++ if (stickers) { stickers.replaceChildren(); stickerList.forEach(function (s, i) { var n = document.createElement('span'); n.className = 'mm-editor-sticker'; n.textContent = s.emoji; n.style.left = s.x + '%'; n.style.top = s.y + '%'; n.dataset.sticker = String(i); stickers.appendChild(n); }); }
++ var state = field('editor_state');
++ if (state) state.value = JSON.stringify({ boxes: boxPositions.map(function (p) { return { x: p[0], y: p[1], w: p[2], h: p[3] }; }), stickers: stickerList, font_style: font, font_size: size, color: color, outline: outline, outline_width: width, crop_x: cropX, crop_y: cropY });
++ }
++ if (stage) {
++ var drag = null;
++ stage.addEventListener('pointerdown', function (event) {
++ var box = event.target.closest('.mm-editor-box'); if (!box) return;
++ var pos = boxPositions[Number(box.dataset.box)]; drag = { id: event.pointerId, box: Number(box.dataset.box), x: event.clientX, y: event.clientY, pos: pos.slice(), resize: event.target.classList.contains('mm-resize-handle'), rect: stage.getBoundingClientRect() };
++ stage.setPointerCapture(event.pointerId); event.preventDefault();
++ });
++ stage.addEventListener('pointermove', function (event) {
++ if (!drag || drag.id !== event.pointerId) return;
++ var dx = (event.clientX - drag.x) / drag.rect.width * 100, dy = (event.clientY - drag.y) / drag.rect.height * 100, p = drag.pos;
++ if (drag.resize) { p[2] = Math.max(15, Math.min(98 - p[0], p[2] + dx)); p[3] = Math.max(8, Math.min(98 - p[1], p[3] + dy)); }
++ else { p[0] = Math.max(0, Math.min(95, p[0] + dx)); p[1] = Math.max(0, Math.min(95, p[1] + dy)); }
++ boxPositions[drag.box] = p;
++ var active = boxLayer.querySelector('[data-box="' + drag.box + '"]');
++ if (active) { active.style.left = p[0] + '%'; active.style.top = p[1] + '%'; active.style.width = p[2] + '%'; active.style.height = p[3] + '%'; }
++ });
++ stage.addEventListener('pointerup', function () { drag = null; drawCanvas(); });
++ stage.addEventListener('pointercancel', function () { drag = null; drawCanvas(); });
++ }
++ var addSticker = form.querySelector('[data-add-sticker]');
++ if (addSticker) addSticker.addEventListener('click', function () { stickerList.push({ emoji: ['✨','😂','🔥','💀','👀'][stickerList.length % 5], x: 42 + stickerList.length * 4, y: 48 }); drawCanvas(); });
++ var ideaButton = form.querySelector('[data-caption-ideas]'), ideaStatus = form.querySelector('[data-ai-status]');
++ if (ideaButton) ideaButton.addEventListener('click', async function () {
++ var topic = field('idea_topic').value.trim(), selected = form.querySelector('input[name=template_id]:checked');
++ if (!topic || !selected) { ideaStatus.textContent = 'Choose a format and enter a topic first.'; return; }
++ ideaButton.disabled = true; ideaStatus.textContent = 'Thinking up a few options…';
++ try {
++ var response = await fetch('/api/v1/caption-ideas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ template_id: selected.value, topic: topic }) });
++ var result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Could not get caption ideas.');
++ var list = form.querySelector('[data-idea-list]'); if (!list) { list = document.createElement('div'); list.dataset.ideaList = '1'; list.className = 'mm-ai-ideas'; ideaButton.parentNode.appendChild(list); }
++ list.replaceChildren();
++ result.ideas.forEach(function (idea) {
++ var use = document.createElement('button'); use.type = 'button'; use.className = 'mm-btn mm-btn-quiet'; use.textContent = idea.join(' / ');
++ use.addEventListener('click', function () { for (var i = 1; i <= 4; i++) { var input = field('line' + i); if (input) input.value = idea[i - 1] || ''; } draw(); field('line1').focus(); });
++ list.appendChild(use);
++ });
++ ideaStatus.textContent = 'Choose an idea to put it on your canvas.';
++ } catch (error) { ideaStatus.textContent = error.message || 'Could not get caption ideas.'; }
++ finally { ideaButton.disabled = false; }
++ });
++ var upload = document.getElementById('mm-template-upload');
++ if (upload) upload.addEventListener('change', async function () {
++ var file = upload.files && upload.files[0]; if (!file) return;
++ if (file.size > 6 * 1024 * 1024) { editorStatus.textContent = 'Choose an image under 6 MB.'; return; }
++ editorStatus.textContent = 'Uploading for review…';
++ try {
++ var data = await new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve(reader.result); }; reader.onerror = reject; reader.readAsDataURL(file); });
++ var response = await fetch('/api/v1/templates/uploads', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: file.name.replace(/\.[^.]+$/, '').slice(0, 80), image: data }) });
++ var result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Upload could not be submitted.');
++ editorStatus.textContent = 'Submitted for review. You can keep making this meme while it is checked.'; upload.value = '';
++ } catch (error) { editorStatus.textContent = error.message || 'Upload failed.'; }
++ });
var download = aside.querySelector('[data-render-png]');
var status = aside.querySelector('[data-render-status]');
if (download) download.addEventListener('click', async function () {
@@@ -46,20 +127,21 @@
status.textContent = 'Rendering your PNG…';
try {
var lines = [];
-- for (var i = 1; i <= 4; i++) {
++ for (var i = 1; i <= boxPositions.length; i++) {
var input = form.querySelector('[name=line' + i + ']');
if (input && input.value.trim()) lines.push(input.value.trim());
}
if (!lines.length) throw new Error('Add a caption before exporting.');
++ var format = aside.querySelector('[data-export-format]') && aside.querySelector('[data-export-format]').value || 'png';
var response = await fetch('/api/v1/render', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'Accept': 'image/png' },
-- body: JSON.stringify({ template_id: chosen.value, lines: lines })
++ body: JSON.stringify({ template_id: chosen.value, lines: lines, format: format, editor: { boxes: boxPositions.map(function (p) { return { x: p[0], y: p[1], w: p[2], h: p[3] }; }), stickers: stickerList, font_style: field('font_style').value, font_size: Number(field('font_size').value), color: field('text_color').value, outline: field('outline_color').value, outline_width: Number(field('outline_width').value), crop_x: Number(field('crop_x').value), crop_y: Number(field('crop_y').value) } })
});
if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to export a PNG.' : 'Could not render this caption. Try again.');
var url = URL.createObjectURL(await response.blob());
var link = document.createElement('a');
-- link.href = url; link.download = 'openvibe-meme.png'; link.click();
++ link.href = url; link.download = 'openvibe-meme.' + format; link.click();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
status.textContent = 'PNG downloaded.';
} catch (error) { status.textContent = error && error.message ? error.message : 'Could not render this caption.'; }
@@@ -67,6 +149,23 @@
});
draw();
}
-- function run() { maker(); }
++ function shareTools() {
++ document.querySelectorAll('[data-copy-link]').forEach(function (button) {
++ if (button.dataset.ready) return; button.dataset.ready = '1';
++ button.addEventListener('click', async function () {
++ try { await navigator.clipboard.writeText(button.dataset.copyLink); button.textContent = 'Link copied'; }
++ catch (_) { button.textContent = button.dataset.copyLink; }
++ });
++ });
++ document.querySelectorAll('[data-native-share]').forEach(function (button) {
++ if (button.dataset.ready) return; button.dataset.ready = '1';
++ button.addEventListener('click', async function () {
++ var data = { title: button.dataset.shareTitle || 'My meme', text: button.dataset.shareText || '', url: button.dataset.nativeShare };
++ try { if (navigator.share) await navigator.share(data); else if (navigator.clipboard) { await navigator.clipboard.writeText(data.text + ' ' + data.url); button.textContent = 'Link copied'; } }
++ catch (_) { /* The person closed the share sheet. */ }
++ });
++ });
++ }
++ function run() { maker(); shareTools(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
})();
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 7d3fe28..7427cc7 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -21,13 +21,13 @@ function createAgentPages(ctx) {
async function home() {
const { week } = currentWeek(s.now());
-- const [trendResult, weekOut, c] = await Promise.all([entries.list({ limit: 8, scope: 'internet', sort: 'top', window: 'week' }), entries.week({ week }), counts()]);
++ const [trendResult, weekOut, c, templatesToday] = await Promise.all([entries.list({ limit: 8, scope: 'internet', sort: 'top', window: 'week' }), entries.week({ week }), counts(), entries.trendingTemplates(6)]);
const trending = trendResult.entries || [];
return page('/', {
index: true, title: null,
-- description: 'Explore an open meme encyclopedia: learn what memes mean, trace their origins and variations, and make your own with reusable templates.',
++ description: 'Explore an open meme encyclopedia with cited origins and remixes; make a meme with draggable text, stickers, AI caption ideas, and PNG/GIF/MP4 downloads.',
jsonLd: homeJsonLd(config),
-- body: raw(views.home({ trending, week: weekOut, counts: c })),
++ body: raw(views.home({ trending, week: weekOut, counts: c, templatesToday })),
});
}
@@@ -35,12 +35,13 @@ function createAgentPages(ctx) {
const templates = await entries.templates();
const values = { template_id: query.get('template_id') || '', visibility: query.get('visibility') || 'public' };
for (const i of [1, 2, 3, 4]) values[`line${i}`] = String(query.get(`line${i}`) || '').slice(0, 120);
-- const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;
++ const initial = templates.find((t) => t.id === 'tpl_classic') || templates.find((t) => Number(t.box_count) >= 2) || templates[0] || null;
++ const chosen = templates.find((t) => t.id === values.template_id) || initial;
const previewLines = [1, 2, 3, 4].map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page('/make', {
index: true, title: 'Make a meme',
-- description: 'Choose a reusable meme template, write a caption, preview as you type, and save an editable link or export a PNG.',
++ description: 'Make a meme with movable text, crop and style controls, emoji stickers, optional AI caption ideas, and PNG/GIF/MP4 export.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: false, problem: null })),
});
}
@@@ -55,7 +56,7 @@ function createAgentPages(ctx) {
const result = await entries.list({ ...filters, limit: 50 });
const title = filters.q ? `Search: ${filters.q}` : 'Explore internet memes';
return page('/browse', { index: !filters.q && !result.error, title,
-- description: 'Search an open meme encyclopedia with researched origins, spread, variations, and reusable templates.',
++ description: 'Search an open meme encyclopedia with researched origins, spread, variations, community examples, and reusable templates.',
jsonLd: [seo.jsonLd.webPage({ name: title, url: `${base()}/browse`, description: 'Explore researched internet memes and formats.', siteUrl: base() })],
body: raw(views.browse({ entries: result.error ? [] : result.entries, filters, error: result.error || null })) });
}
@@@ -83,8 +84,8 @@ function createAgentPages(ctx) {
try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }
const row = await entries.bySlug(slug);
if (!row || row.visibility !== 'public' || row.scope !== 'internet') return null;
-- const citations = await entries.citations(row.id);
-- const e = entries.wireEntry(row);
++ const e = await entries.detail(row);
++ const citations = e.citations;
return page(p, {
index: true, title: e.title,
description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),
ddiff --git a/server/analyze.js b/server/analyze.js
iindex 70ae2e5..0a832a0 100644
---- a/server/analyze.js
++++ b/server/analyze.js
@@@ -78,7 +78,7 @@ function createAnalyzer({ config, s, ai, reddit = null, fetchImpl = globalThis.f
async function matchTemplate(name) {
const wanted = tokensOf(name);
if (!wanted.length) return null;
-- const rows = await s.db.many('SELECT * FROM meme_templates ORDER BY box_count ASC, name ASC');
++ const rows = await s.db.many("SELECT * FROM meme_templates WHERE moderation_status = 'approved' ORDER BY box_count ASC, name ASC");
let best = null;
let bestHits = 0;
for (const row of rows) {
@@@ -132,7 +132,7 @@ function createAnalyzer({ config, s, ai, reddit = null, fetchImpl = globalThis.f
}
const matched = await matchEntries(title || (inputUrl || '').replace(/[^a-zA-Z0-9]+/g, ' '));
-- const known = await s.db.many('SELECT name FROM meme_templates ORDER BY box_count ASC, name ASC LIMIT 20');
++ const known = await s.db.many("SELECT name FROM meme_templates WHERE moderation_status = 'approved' ORDER BY box_count ASC, name ASC LIMIT 20");
// Two calls: vision, then one structured extraction. Reserve both before the first, so a full day refuses
// the request before any cost is incurred.
ddiff --git a/server/generate.js b/server/generate.js
iindex bf47d53..c623941 100644
---- a/server/generate.js
++++ b/server/generate.js
@@@ -33,7 +33,7 @@ function createGenerator({ config, s, entries, ai, media, fetchImpl = globalThis
async function findTemplate(value) {
const wanted = String(value || '').trim().toLowerCase();
if (!wanted) return null;
-- const rows = await s.db.many('SELECT * FROM meme_templates ORDER BY box_count ASC, name ASC');
++ const rows = await s.db.many("SELECT * FROM meme_templates WHERE moderation_status = 'approved' ORDER BY box_count ASC, name ASC");
return rows.find((r) => r.id.toLowerCase() === wanted || slugify(r.name) === wanted || r.name.toLowerCase() === wanted) || null;
}
@@@ -81,7 +81,7 @@ function createGenerator({ config, s, entries, ai, media, fetchImpl = globalThis
].filter(Boolean).join(' ');
const context = [
`Topic: ${subjectText || chosen.name}`,
-- chosen ? `Template: ${chosen.name} (${chosen.box_count} boxes)` : `Templates: ${(await s.db.many('SELECT id, name FROM meme_templates ORDER BY box_count ASC')).map((t) => `${t.id} ${t.name}`).join('; ')}`,
++ chosen ? `Template: ${chosen.name} (${chosen.box_count} boxes)` : `Templates: ${(await s.db.many("SELECT id, name FROM meme_templates WHERE moderation_status = 'approved' ORDER BY box_count ASC")).map((t) => `${t.id} ${t.name}`).join('; ')}`,
evidence ? `Current uses and caption examples:\n${evidence}` : 'No current examples were found; keep the caption plain and format-native.',
given ? `The person's lines: ${JSON.stringify(given)}` : '',
].filter(Boolean).join('\n').slice(0, 24_000);
@@@ -90,7 +90,7 @@ function createGenerator({ config, s, entries, ai, media, fetchImpl = globalThis
const data = extracted.output.data || {};
if (!chosen) chosen = await findTemplate(data.template);
if (!chosen) {
-- const first = await s.db.maybe('SELECT * FROM meme_templates ORDER BY box_count ASC, name ASC LIMIT 1');
++ const first = await s.db.maybe("SELECT * FROM meme_templates WHERE moderation_status = 'approved' ORDER BY box_count ASC, name ASC LIMIT 1");
if (!first) return { ok: false, status: 503, code: 'meme.template_not_found', message: 'No meme templates are configured.' };
chosen = first;
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ee4aadf..164755b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -11,9 +11,14 @@
* GET /week this ISO week's candidates and vote counts
* POST /week/vote one vote a week (signed in, same-origin)
* GET /templates caption templates
-- * POST /render render text lines to PNG without an AI call
-- * POST /captions save a caption (signed in; text only)
++ * POST /caption-ideas cheap OpenVibe.AI caption suggestions (signed out, rate-limited)
++ * POST /templates/uploads submit a member template to Media for review
++ * GET /staff/templates/uploads list pending member template uploads (staff)
++ * POST /staff/templates/:id/review approve or reject a pending member template (staff)
++ * POST /render render a caption as PNG, GIF, or MP4 without an AI call
++ * POST /captions save text and editor state (signed in)
* GET /captions/:id one saved caption
++ * GET /captions/:id/render render a saved caption as PNG, GIF, or MP4
* POST /entries/:slug/report a takedown/correction report (signed in)
* GET /reports open reports (staff)
* GET /staff/candidates the hidden candidates with the judge's score (staff)
@@@ -31,15 +36,48 @@ const { sameOrigin } = require('./principal');
const { isStaff } = require('../identity/staff');
const { currentWeek } = require('../week');
const { KINDS, STATUSES, SCOPES, LABELS, captionLines } = require('../store');
--const { renderMeme } = require('../render-png');
++const { renderMeme, fallbackLayout } = require('../render-png');
++const { renderStillVideo } = require('../render/video');
++const sharp = require('sharp');
++
++function renderOptions(template, editor) {
++ if (!editor || typeof editor !== 'object') return { ok: true, layout: null, stickers: [], crop: null };
++ const hex = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || '')) ? String(value) : fallback;
++ const size = Math.min(64, Math.max(10, Number(editor.font_size) || 30));
++ const outlineWidth = Math.min(8, Math.max(0, Number(editor.outline_width) || 0));
++ const style = ['impact', 'modern', 'soft'].includes(editor.font_style) ? editor.font_style : 'impact';
++ const permitted = new Set(['✨', '😂', '🔥', '💀', '👀', '❤️', '🙌']);
++ const stickers = Array.isArray(editor.stickers) ? editor.stickers.slice(0, 8).filter((x) => x && permitted.has(x.emoji) && Number.isFinite(Number(x.x)) && Number.isFinite(Number(x.y)) && Number(x.x) >= 0 && Number(x.x) <= 100 && Number(x.y) >= 0 && Number(x.y) <= 100).map((x) => ({ emoji: x.emoji, x: Number(x.x), y: Number(x.y), size: 34 })) : [];
++ let crop = null;
++ if (editor.crop_x != null || editor.crop_y != null) {
++ crop = { x: editor.crop_x == null ? 50 : Number(editor.crop_x), y: editor.crop_y == null ? 50 : Number(editor.crop_y) };
++ if (!Number.isFinite(crop.x) || crop.x < 0 || crop.x > 100 || !Number.isFinite(crop.y) || crop.y < 0 || crop.y > 100) return { ok: false, message: 'crop position must be from 0 to 100' };
++ }
++ const inputBoxes = Array.isArray(editor.boxes) ? editor.boxes : [];
++ // Style, stickers and crop alone are a valid edit of the template's own layout.
++ if (!inputBoxes.length) return { ok: true, layout: null, stickers, crop };
++ if (inputBoxes.length > 4) return { ok: false, message: 'editor layout is invalid for this template' };
++ const base = template.layout && typeof template.layout === 'object' && Array.isArray(template.layout.boxes) && template.layout.boxes.length
++ ? template.layout : fallbackLayout(template.box_count);
++ const width = Number(base.width) || 440, height = Number(base.height) || 280;
++ const boxes = inputBoxes.map((box, index) => {
++ const p = ['x', 'y', 'w', 'h'].map((key) => Number(box && box[key]));
++ if (p.some((n) => !Number.isFinite(n)) || p[0] < 0 || p[1] < 0 || p[2] < 12 || p[3] < 8 || p[0] + p[2] > 100 || p[1] + p[3] > 100) return null;
++ return { ...(base.boxes[index] || base.boxes[base.boxes.length - 1]), x: p[0] / 100 * width, y: p[1] / 100 * height, w: p[2] / 100 * width, h: p[3] / 100 * height,
++ font: 'DejaVuSans-Bold', size, color: hex(editor.color, '#ffffff'), outline: hex(editor.outline, '#000000'), outline_width: outlineWidth,
++ uppercase: style === 'impact', align: 'center', valign: 'middle', wrap: true };
++ });
++ if (boxes.some((box) => !box)) return { ok: false, message: 'editor text boxes must stay within the canvas' };
++ return { ok: true, layout: { width, height, boxes }, stickers, crop };
++}
function createApi(ctx) {
-- const { config, s, principal, entries, trends, analyzer, research, generator } = ctx;
++ const { config, s, principal, entries, trends, analyzer, research, generator, generateAi, media } = ctx;
const r = asyncRouter();
// Each person's daily share of the AI features (analyze, research, generate), on top of each one's budget for all.
const personCap = require('../person-cap').createPersonCap({ config, s });
-- r.use(express.json({ limit: '64kb' }));
++ r.use(express.json({ limit: '9mb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
@@@ -221,7 +259,55 @@ function createApi(ctx) {
return res.json({ count: counted.length, templates: counted });
});
-- // Render user-supplied lines as PNG with the same licensed template and bundled fonts as the maker.
++ r.post('/caption-ideas', async (req, res) => {
++ const ip = String(req.ip || 'unknown').slice(0, 80);
++ if (!windowCount(`caption-ideas-day:${ip}`, 86_400_000, 60)) return problem(res, 429, 'meme.rate_limited', 'The daily caption-idea limit is used up; try again tomorrow.');
++ if (!windowCount(`caption-ideas:${ip}`, 60_000, 8)) return problem(res, 429, 'meme.rate_limited', 'Too many caption requests. Try again in a minute.');
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const template = typeof b.template_id === 'string' ? await entries.templateById(b.template_id) : null;
++ const format = String((template && template.name) || b.format || '').trim().slice(0, 120);
++ const topic = String(b.topic || '').trim().slice(0, 300);
++ if (!format || !topic) return invalid(res, 'choose a template and enter a topic or situation');
++ if (!generateAi || !generateAi.run) return problem(res, 503, 'meme.ai_unavailable', 'OpenVibe.AI caption ideas are not configured.');
++ const result = await generateAi.run('meme.caption', { format, topic }, { calls: 1 });
++ if (!result.ok) return problem(res, result.status || 502, result.code || 'meme.ai_failed', result.message || 'Caption ideas are unavailable.');
++ const raw = result.output && (result.output.ideas || result.output.output && result.output.output.ideas);
++ const ideas = (Array.isArray(raw) ? raw : []).slice(0, 5).map((idea) => String(idea || '').split(/\r?\n/).map((line) => line.replace(/\s+/g, ' ').trim().slice(0, 120)).filter(Boolean).slice(0, 4)).filter((idea) => idea.length && captionLines(idea).ok);
++ if (!ideas.length) return problem(res, 502, 'meme.ai_bad_answer', 'OpenVibe.AI returned no usable captions.');
++ res.set('Cache-Control', 'private, no-store');
++ return res.json({ ideas, cost_usd: Number(result.costUsd) || 0, objective: 'cheapest' });
++ });
++
++ r.post('/templates/uploads', requirePerson, async (req, res) => {
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const match = typeof b.image === 'string' && b.image.match(/^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=]+)$/);
++ if (!match) return invalid(res, 'image must be a PNG, JPEG, or WebP data URL');
++ const bytes = Buffer.from(match[2], 'base64');
++ if (!bytes.length || bytes.length > 6 * 1024 * 1024 || bytes.toString('base64').replace(/=+$/, '') !== match[2].replace(/=+$/, '')) return invalid(res, 'image must be at most 6 MB');
++ let meta;
++ try { meta = await sharp(bytes, { limitInputPixels: 16_000_000 }).metadata(); } catch { return invalid(res, 'image file could not be decoded'); }
++ if (meta.format !== match[1].slice(6) || !meta.width || !meta.height || meta.width < 128 || meta.height < 96) return invalid(res, 'image must be at least 128 by 96 pixels and match its file type');
++ if (!media || !media.upload) return problem(res, 503, 'meme.media_unavailable', 'OpenVibe.Media uploads are not configured.');
++ const uploaded = await media.upload(bytes, { mimeType: match[1], filename: 'member-meme-template', metadata: { purpose: 'meme-template-review' } });
++ if (!uploaded || !uploaded.media_id) return problem(res, 503, 'meme.media_unavailable', 'The image could not be stored in OpenVibe.Media.');
++ const out = await entries.submitTemplateUpload({ id: `tpl_upload_${s.newId('mcp').slice(-26)}`, name: typeof b.name === 'string' ? b.name : 'Member template', media_id: uploaded.media_id, media_url: uploaded.url, subject: req.principal.subject });
++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'Upload could not be submitted.');
++ return res.status(202).json({ template: out.template, review_status: 'pending', message: 'Your template is in the moderation queue.' });
++ });
++
++ r.get('/staff/templates/uploads', requirePerson, async (req, res) => {
++ if (!isStaff(req.principal)) return problem(res, 403, 'meme.forbidden', 'Staff access is required.');
++ return res.json({ templates: await entries.pendingTemplateUploads() });
++ });
++ r.post('/staff/templates/:id/review', requirePerson, async (req, res) => {
++ if (!isStaff(req.principal)) return problem(res, 403, 'meme.forbidden', 'Staff access is required.');
++ const decision = req.body && req.body.decision;
++ const out = await entries.reviewTemplateUpload(String(req.params.id || ''), decision);
++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'Template could not be reviewed.');
++ return res.json(out);
++ });
++
++ // Render user-supplied lines as PNG, GIF, or MP4 with the same licensed template and bundled fonts as the maker.
// This path is deterministic and does not spend the AI research/generation budget.
const sameSiteWrite = (req, res, next) => sameOrigin(req, config.baseUrl) ? next() : problem(res, 403, 'meme.cross_site', 'A write must come from OpenVibe.Meme.');
r.get('/pics/:slug/preview', requirePerson, async (req, res) => {
@@@ -243,6 +329,7 @@ function createApi(ctx) {
r.post('/render', requirePerson, async (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const format = ['png', 'gif', 'mp4'].includes(body.format) ? body.format : 'png';
const checked = captionLines(body.lines);
if (!checked.ok) return problem(res, checked.status || 400, checked.code || 'meme.invalid', checked.message || 'Invalid caption lines.');
const templateId = typeof body.template_id === 'string' ? body.template_id.trim() : '';
@@@ -250,14 +337,36 @@ function createApi(ctx) {
const template = await entries.templateById(templateId);
if (!template) return problem(res, 404, 'meme.template_not_found', 'No such template.');
try {
-- const rendered = await renderMeme({ template, lines: checked.lines, maxPixels: 1600 });
++ const opts = renderOptions(template, body.editor);
++ if (!opts.ok) return invalid(res, opts.message);
++ const rendered = await renderMeme({ template, lines: checked.lines, ...opts, maxPixels: 1600 });
++ const bytes = format === 'png' ? rendered.png : await renderStillVideo(rendered.png, format);
++ const type = format === 'png' ? 'image/png' : format === 'gif' ? 'image/gif' : 'video/mp4';
res.set({ 'Cache-Control': 'private, no-store', 'X-Image-Width': String(rendered.width), 'X-Image-Height': String(rendered.height) });
-- return res.type('png').send(rendered.png);
++ return res.type(type).send(bytes);
} catch (err) {
return problem(res, 422, 'meme.render_failed', 'This template could not be rendered right now.');
}
});
++ r.get('/captions/:id/render', async (req, res) => {
++ const row = await entries.captionById(String(req.params.id || ''));
++ if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');
++ const mine = req.principal.kind === 'user' && req.principal.subject === row.subject;
++ if (row.visibility !== 'public' && !mine && !isStaff(req.principal)) return problem(res, 404, 'meme.not_found', 'No such caption.');
++ const format = ['png', 'gif', 'mp4'].includes(req.query.format) ? req.query.format : 'png';
++ const template = await entries.templateById(row.template_id);
++ if (!template) return problem(res, 404, 'meme.template_not_found', 'No such template.');
++ try {
++ const opts = renderOptions(template, row.editor);
++ if (!opts.ok) return problem(res, 422, 'meme.render_failed', 'This caption has invalid editor settings.');
++ const rendered = await renderMeme({ template, lines: row.lines, ...opts, maxPixels: 1600 });
++ const bytes = format === 'png' ? rendered.png : await renderStillVideo(rendered.png, format);
++ res.set('Cache-Control', row.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
++ return res.type(format === 'png' ? 'image/png' : format === 'gif' ? 'image/gif' : 'video/mp4').send(bytes);
++ } catch { return problem(res, 422, 'meme.render_failed', 'This caption could not be rendered right now.'); }
++ });
++
r.get('/captions/:id', async (req, res) => {
const row = await entries.captionById(String(req.params.id || ''));
if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');
@@@ -370,11 +479,14 @@ function createApi(ctx) {
r.post('/captions', requirePerson, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const editor = b.editor && typeof b.editor === 'object' && !Array.isArray(b.editor) ? b.editor : {};
++ if (Buffer.byteLength(JSON.stringify(editor)) > 8192) return invalid(res, 'editor state is too large');
const out = await entries.createCaption({
subject: req.principal.subject,
templateId: b.template_id,
lines: b.lines,
visibility: b.visibility == null ? 'public' : String(b.visibility),
++ editor,
});
if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid caption');
res.set('Location', `/api/v1/captions/${out.caption.id}`);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 98bf756..ca05958 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -47,18 +47,18 @@ function createPageRoutes(ctx) {
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
const { week } = currentWeek(s.now());
-- const [trendResult, weekOut, c] = await Promise.all([
++ const [trendResult, weekOut, c, templatesToday] = await Promise.all([
entries.list({ limit: 8, scope: 'internet', sort: 'top', window: 'week' }),
-- entries.week({ week }), counts(),
++ entries.week({ week }), counts(), entries.trendingTemplates(6),
]);
const trending = trendResult.entries || [];
const feature = null;
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
-- description: 'Explore an open meme encyclopedia: learn what memes mean, trace their origins and variations, and make your own with reusable templates.',
++ description: 'Explore an open meme encyclopedia with cited origins and remixes; make a meme with draggable text, stickers, AI caption ideas, and PNG/GIF/MP4 downloads.',
jsonLd: homeJsonLd(config),
-- body: raw(views.home({ trending, week: weekOut, counts: c, feature })),
++ body: raw(views.home({ trending, week: weekOut, counts: c, feature, templatesToday })),
});
});
@@@ -117,22 +117,25 @@ function createPageRoutes(ctx) {
if (!row || (row.visibility === 'hidden' && !staff)) return notFound(req, res);
// OpenVibe-only catchphrases are not encyclopedia entries; retire their old public pages.
if (row.scope === 'openvibe') return res.redirect(301, '/');
-- const citations = await entries.citations(row.id);
-- const e = entries.wireEntry(row);
++ const detail = await entries.detail(row, { staff });
++ const citations = detail.citations;
++ const e = detail;
const description = metaDescription(e, citations);
-- const jsonLd = seo.jsonLd.webPage({
++ const jsonLd = [seo.jsonLd.webPage({
name: e.title,
url: `${base()}/m/${e.slug}`,
description,
siteUrl: base(),
-- });
++ }), { '@context': 'https://schema.org', '@type': 'CreativeWork', name: e.title, description,
++ url: `${base()}/m/${e.slug}`, dateCreated: e.first_seen_at || undefined,
++ keywords: Array.isArray(e.labels) ? e.labels.join(', ') : undefined }];
return page(req, res, {
index: e.visibility === 'public',
agentLayer: e.visibility === 'public',
cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
title: e.title,
description,
-- jsonLd: [jsonLd],
++ jsonLd,
body: raw(views.entry({ e, citations, signedIn: Boolean(signedIn(req)), staff, reported: req.query.reported === '1', problem,
share: { url: `${base()}/m/${e.slug}`, image: social.url('meme') } })),
});
@@@ -181,19 +184,22 @@ function createPageRoutes(ctx) {
// ── The caption generator ───────────────────────────────
const LINES = [1, 2, 3, 4];
function makeValues(src = {}) {
-- const v = { template_id: src.template_id, visibility: src.visibility || 'public' };
++ let editor = {};
++ if (typeof src.editor_state === 'string' && src.editor_state.length <= 8192) { try { editor = JSON.parse(src.editor_state); } catch {} }
++ const v = { template_id: src.template_id, visibility: src.visibility || 'public', editor_state: JSON.stringify(editor && typeof editor === 'object' && !Array.isArray(editor) ? editor : {}) };
for (const i of LINES) v[`line${i}`] = String(src[`line${i}`] == null ? '' : src[`line${i}`]).slice(0, 120);
return v;
}
async function renderMake(req, res, { values = {}, problem = null, status = 200 } = {}) {
const templates = await entries.templates();
-- const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;
++ const initial = templates.find((t) => t.id === 'tpl_classic') || templates.find((t) => Number(t.box_count) >= 2) || templates[0] || null;
++ const chosen = templates.find((t) => t.id === values.template_id) || initial;
const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Make a meme',
-- description: 'Choose a reusable meme template, write a caption, preview as you type, and save an editable link or export a PNG.',
++ description: 'Make a meme with movable text, crop and style controls, emoji stickers, optional AI caption ideas, and PNG/GIF/MP4 export.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
}, status);
}
@@@ -209,7 +215,7 @@ function createPageRoutes(ctx) {
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
const values = makeValues(req.body || {});
const lines = LINES.map((i) => values[`line${i}`]).filter((l) => String(l).trim());
-- const out = await entries.createCaption({ subject: req.viewer.subject, templateId: values.template_id, lines, visibility: values.visibility });
++ const out = await entries.createCaption({ subject: req.viewer.subject, templateId: values.template_id, lines, visibility: values.visibility, editor: JSON.parse(values.editor_state || '{}') });
if (!out.ok) return renderMake(req, res, { values, problem: out.message || 'That caption could not be saved.', status: out.status || 400 });
return res.redirect(303, `/c/${encodeURIComponent(out.caption.id)}`);
});
ddiff --git a/server/render-png.js b/server/render-png.js
iindex e2aa1b0..441a65f 100644
---- a/server/render-png.js
++++ b/server/render-png.js
@@@ -120,8 +120,8 @@ async function backgroundOf(template, { fetchImpl = globalThis.fetch, timeoutMs
}
/** → { png, width, height, lines } */
--async function renderMeme({ template, lines = [], fetchImpl = globalThis.fetch, maxPixels = 1600 } = {}) {
-- const layout = template && template.layout && typeof template.layout === 'object' ? template.layout : fallbackLayout(template ? template.box_count : 1);
++async function renderMeme({ template, lines = [], layout: requestedLayout = null, stickers = [], crop = null, fetchImpl = globalThis.fetch, maxPixels = 1600 } = {}) {
++ const layout = requestedLayout || (template && template.layout && typeof template.layout === 'object' ? template.layout : fallbackLayout(template ? template.box_count : 1));
const width = Math.min(maxPixels, Math.max(64, Number(layout.width) || 440));
const height = Math.max(64, Math.round(width * ((Number(layout.height) || 280) / (Number(layout.width) || 440))));
const scaled = { ...layout, width, height, boxes: (layout.boxes || []).map((b) => ({
@@@ -133,9 +133,26 @@ async function renderMeme({ template, lines = [], fetchImpl = globalThis.fetch,
size: (Number(b.size) || 24) * (width / (Number(layout.width) || 440)),
})) };
const background = await backgroundOf(template, { fetchImpl });
-- const base = await sharp(background, { density: 144 }).resize(width, height, { fit: 'fill' }).png().toBuffer();
-- const overlay = Buffer.from(textSvg(scaled, lines));
-- const png = await sharp(base).composite([{ input: overlay, top: 0, left: 0 }]).png().toBuffer();
++ const source = sharp(background, { density: 144 }), meta = await source.metadata();
++ const aspect = width / height;
++ const cropX = crop && Number.isFinite(Number(crop.x)) ? Math.min(100, Math.max(0, Number(crop.x))) : 50;
++ const cropY = crop && Number.isFinite(Number(crop.y)) ? Math.min(100, Math.max(0, Number(crop.y))) : 50;
++ let left = 0, top = 0, cropWidth = meta.width, cropHeight = meta.height;
++ if (meta.width / meta.height > aspect) {
++ cropWidth = Math.max(1, Math.round(meta.height * aspect));
++ left = Math.round((meta.width - cropWidth) * cropX / 100);
++ } else {
++ cropHeight = Math.max(1, Math.round(meta.width / aspect));
++ top = Math.round((meta.height - cropHeight) * cropY / 100);
++ }
++ const base = await source.extract({ left, top, width: cropWidth, height: cropHeight }).resize(width, height).png().toBuffer();
++ const overlays = [{ input: Buffer.from(textSvg(scaled, lines)), top: 0, left: 0 }];
++ if (Array.isArray(stickers) && stickers.length) {
++ const safe = stickers.slice(0, 8).filter((s) => s && ['✨', '😂', '🔥', '💀', '👀', '❤️', '🙌'].includes(s.emoji) && Number.isFinite(Number(s.x)) && Number.isFinite(Number(s.y)));
++ const marks = safe.map((s) => `<text x="${Math.max(0, Math.min(100, Number(s.x))) * width / 100}" y="${Math.max(0, Math.min(100, Number(s.y))) * height / 100}" font-family="DejaVu Sans" font-size="${Math.max(18, Math.min(56, Number(s.size) || 34))}" text-anchor="middle">${escapeXml(s.emoji)}</text>`).join('');
++ if (marks) overlays.push({ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${marks}</svg>`), top: 0, left: 0 });
++ }
++ const png = await sharp(base).composite(overlays).png().toBuffer();
return { png, width, height, lines: (scaled.boxes || []).length };
}
ddiff --git a/server/render/video.js b/server/render/video.js
nnew file mode 100644
iindex 0000000..c6e1cb5
---- /dev/null
++++ b/server/render/video.js
@@@ -0,0 +1,33 @@
++'use strict';
++
++const { spawn } = require('child_process');
++
++const FORMATS = Object.freeze({
++ gif: ['-f', 'image2pipe', '-framerate', '12', '-loop', '1', '-i', 'pipe:0', '-t', '2', '-an', '-f', 'gif', 'pipe:1'],
++ mp4: ['-f', 'image2pipe', '-framerate', '24', '-loop', '1', '-i', 'pipe:0', '-t', '3', '-an', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', 'frag_keyframe+empty_moov+default_base_moof', '-f', 'mp4', 'pipe:1'],
++});
++
++function renderStillVideo(png, format, { timeoutMs = 15000, maxBytes = 24 * 1024 * 1024, executable = process.env.FFMPEG_PATH || 'ffmpeg' } = {}) {
++ const args = FORMATS[format];
++ if (!args) return Promise.reject(new TypeError('format must be gif or mp4'));
++ return new Promise((resolve, reject) => {
++ let child;
++ try { child = spawn(executable, ['-hide_banner', '-loglevel', 'error', ...args], { stdio: ['pipe', 'pipe', 'ignore'] }); }
++ catch (error) { reject(error); return; }
++ const chunks = [];
++ let size = 0, settled = false;
++ const finish = (err, result) => { if (settled) return; settled = true; clearTimeout(timer); err ? reject(err) : resolve(result); };
++ const timer = setTimeout(() => { child.kill('SIGKILL'); finish(new Error('render timed out')); }, timeoutMs);
++ child.stdout.on('data', (buf) => {
++ size += buf.length;
++ if (size > maxBytes) { child.kill('SIGKILL'); finish(new Error('render output too large')); return; }
++ chunks.push(buf);
++ });
++ child.on('error', (error) => finish(error));
++ child.on('close', (code) => code === 0 && size ? finish(null, Buffer.concat(chunks)) : finish(new Error('ffmpeg could not encode this meme')));
++ child.stdin.on('error', () => {});
++ child.stdin.end(png);
++ });
++}
++
++module.exports = { renderStillVideo, FORMATS };
ddiff --git a/server/store.js b/server/store.js
iindex 6c83395..432be19 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -185,6 +185,7 @@ function wireTemplate(row) {
...(row.source_url ? { source_url: row.source_url } : {}),
...(row.attribution ? { attribution: row.attribution } : {}),
...(row.layout ? { layout: row.layout } : {}),
++ ...(row.moderation_status ? { moderation_status: row.moderation_status } : {}),
};
}
@@@ -199,6 +200,7 @@ function wireCaption(row) {
lines: Array.isArray(lines) ? lines.map(String) : [],
created_at: iso(row.created_at),
visibility: row.visibility,
++ ...(row.editor_state ? { editor: typeof row.editor_state === 'string' ? JSON.parse(row.editor_state) : row.editor_state } : {}),
};
}
@@@ -1168,10 +1170,17 @@ function createStore(s, { search }) {
}
async function templates() {
-- return (await db.many('SELECT * FROM meme_templates ORDER BY box_count ASC, name ASC')).map(wireTemplate);
++ return (await db.many("SELECT * FROM meme_templates WHERE moderation_status = 'approved' ORDER BY box_count ASC, name ASC")).map(wireTemplate);
++ }
++ async function trendingTemplates(limit = 6) {
++ return (await db.many(`SELECT t.*, COUNT(c.id)::int AS uses_today FROM meme_templates t
++ LEFT JOIN meme_captions c ON c.template_id = t.id AND c.visibility = 'public'
++ AND c.created_at >= ((now() AT TIME ZONE 'UTC')::date::timestamp AT TIME ZONE 'UTC')
++ WHERE t.moderation_status = 'approved' GROUP BY t.id ORDER BY uses_today DESC, t.name ASC LIMIT $1`,
++ [Math.min(12, Math.max(1, Number(limit) || 6))])).map((row) => ({ ...wireTemplate(row), uses_today: Number(row.uses_today) || 0 }));
}
async function templateById(id) {
-- return wireTemplate(await db.maybe('SELECT * FROM meme_templates WHERE id = $1', [String(id || '')]));
++ return wireTemplate(await db.maybe("SELECT * FROM meme_templates WHERE id = $1 AND moderation_status = 'approved'", [String(id || '')]));
}
/**
@@@ -1189,7 +1198,29 @@ function createStore(s, { search }) {
return wireTemplate(row || await db.maybe('SELECT * FROM meme_templates WHERE id = $1', [String(id || '')]));
}
-- async function createCaption({ subject, templateId, lines, visibility = 'public' }) {
++ async function submitTemplateUpload({ id, name, media_id, media_url, subject }) {
++ const safeId = String(id || '');
++ if (!/^tpl_upload_[0-9A-HJKMNP-TV-Z]{26}$/.test(safeId)) return refusal(400, 'meme.invalid', 'Invalid template id.');
++ const row = await db.maybe(
++ `INSERT INTO meme_templates (id, name, image_url, box_count, source, license, credit, source_url, attribution, moderation_status, uploaded_by)
++ VALUES ($1, $2, $3, 2, 'member upload', 'Member uploaded; approval required', $4, NULL, NULL, 'pending', $5) RETURNING *`,
++ [safeId, String(name || 'Member template').trim().slice(0, 80) || 'Member template', String(media_url || `https://openvibe.media/o/${encodeURIComponent(String(media_id || ''))}`), String(subject || '').slice(0, 180), subject],
++ );
++ return { ok: true, template: wireTemplate(row) };
++ }
++
++ async function pendingTemplateUploads(limit = 100) {
++ return (await db.many("SELECT * FROM meme_templates WHERE moderation_status = 'pending' ORDER BY created_at ASC LIMIT $1", [Math.min(200, Math.max(1, Number(limit) || 100))])).map(wireTemplate);
++ }
++
++ async function reviewTemplateUpload(id, decision) {
++ if (!['approved', 'rejected'].includes(decision)) return refusal(400, 'meme.invalid', 'decision must be approved or rejected.');
++ const row = await db.maybe("UPDATE meme_templates SET moderation_status = $2 WHERE id = $1 AND moderation_status = 'pending' RETURNING *", [String(id || ''), decision]);
++ if (!row) return refusal(404, 'meme.template_not_found', 'No pending template upload has that id.');
++ return { ok: true, template: wireTemplate(row) };
++ }
++
++ async function createCaption({ subject, templateId, lines, visibility = 'public', editor = {} }) {
if (!CAPTION_VISIBILITIES.includes(visibility)) return refusal(400, 'meme.invalid', `visibility must be one of ${CAPTION_VISIBILITIES.join(', ')}`, 'visibility');
const checked = captionLines(lines);
if (!checked.ok) return checked;
@@@ -1197,9 +1228,9 @@ function createStore(s, { search }) {
if (!tpl) return refusal(404, 'meme.template_not_found', 'No such template.');
const id = s.newId('mcp');
const row = await db.maybe(
-- `INSERT INTO meme_captions (id, subject, template_id, lines, created_at, visibility)
-- VALUES ($1, $2, $3, $4::jsonb, now(), $5) RETURNING *`,
-- [id, subject, tpl.id, JSON.stringify(checked.lines), visibility],
++ `INSERT INTO meme_captions (id, subject, template_id, lines, created_at, visibility, editor_state)
++ VALUES ($1, $2, $3, $4::jsonb, now(), $5, $6::jsonb) RETURNING *`,
++ [id, subject, tpl.id, JSON.stringify(checked.lines), visibility, JSON.stringify(editor && typeof editor === 'object' ? editor : {})],
);
return { ok: true, caption: wireCaption(row) };
}
@@@ -1402,7 +1433,7 @@ function createStore(s, { search }) {
list, trending, internetEntries, week, series, showcase, uniqueSlug,
createCandidate, createEntry, upsertCandidate, addCitations, enrich, reindex, publish, hide, patch,
createProposal, addRevision, reviewRevision, pendingRevisions,
-- templates, templateById, addTemplate, createCaption, captionById, captionCountForTemplate,
++ templates, trendingTemplates, templateById, addTemplate, submitTemplateUpload, pendingTemplateUploads, reviewTemplateUpload, createCaption, captionById, captionCountForTemplate,
castVote, createReport, listOpenReports,
saveJudgement, judgement, applyJudgement, logDecision, decisions, listHidden, unjudged,
KINDS, STATUSES, VISIBILITIES, SCOPES, LABELS, MODERATION_STATES, BLOCKED_WORDS, MAX_LINES, MAX_LINE,
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 61edc79..1a28114 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -103,7 +103,7 @@ function homeRise(feature) {
return `<div class="mm-demo-rise"><span class="mm-status mm-status-${esc(feature.entry.status)}">${esc(STATUS_LABEL[feature.entry.status] || feature.entry.status)}</span><span class="mm-demo-bars">${bars}</span><small>uses per day, last ${rows.length} days · <a href="/m/${esc(feature.entry.slug)}">${esc(feature.entry.title)}</a></small></div>`;
}
--function home({ trending = [], week = null, counts = {}, feature = null } = {}) {
++function home({ trending = [], week = null, counts = {}, feature = null, templatesToday = [] } = {}) {
const candidates = week ? week.candidates.slice(0, 3) : [];
// Real first (the owner, 10-31: empty sections made the rooms look unfinished). Trending and the week's teaser show only
// when they have entries; while neither does, one short "be the first" row sits below the scenes instead.
@@@ -115,19 +115,20 @@ function home({ trending = [], week = null, counts = {}, feature = null } = {})
<div class="mm-head-row"><h2 id="mm-week-teaser-h">Meme of the week</h2><a class="mm-more" href="/week">The ballot${kit.icon('chevron')}</a></div>
<ol class="mm-entries mm-entries-week">${candidates.map(card).join('')}</ol>
</section>` : '';
++ const templateSec = templatesToday.length ? `<section class="mm-section mm-template-today" aria-labelledby="mm-template-today-h"><div class="mm-head-row"><h2 id="mm-template-today-h">Templates to try today</h2><a class="mm-more" href="/make">Make one${kit.icon('chevron')}</a></div><ol class="mm-entries">${templatesToday.slice(0, 6).map((t) => `<li class="mm-entry-card"><a class="mm-entry-link" href="/make?template_id=${encodeURIComponent(t.id)}"><img class="mm-template-thumb" src="${esc(t.image_url)}" alt="" loading="lazy"><span class="mm-entry-title">${esc(t.name)}</span><span class="mm-entry-meta">${Number(t.uses_today) || 0} captions today · ${esc(t.license)}</span></a></li>`).join('')}</ol></section>` : '';
const firsts = [
!trending.length && ['Start with a classic', 'Browse the internet meme encyclopedia and follow a format from its first trace to its latest variations.', '/browse?scope=internet', 'Explore internet memes'],
!candidates.length && ['Make a meme', 'Choose a reusable template, write a caption, and export a PNG or save a remixable link.', '/make', 'Open the meme maker'],
].filter(Boolean);
const beFirst = firsts.length ? `<section class="mm-section mm-first" aria-labelledby="mm-first-h"><h2 id="mm-first-h">Be the first</h2><ul class="mm-first-list">${firsts.map(([title, text, href, label]) => `<li><b>${esc(title)}</b><span>${esc(text)}</span><a href="${esc(href)}">${esc(label)}</a></li>`).join('')}</ul></section>` : '';
-- const live = `${trendingSec}${weekSec}`;
++ const live = `${trendingSec}${weekSec}${templateSec}`;
const room = sc.room({
room: 'meme',
hero: {
title: 'Know the meme. Make it yours.',
lede: 'An open encyclopedia of internet memes: what each one means, where it began, how it spread, and the formats people still remix.',
-- start: { href: '/browse', label: 'Explore the meme library' },
-- chips: [{ label: 'Make a meme', href: '/make' }, ...(trending.length ? [{ label: 'Trending', href: '#mm-trending' }] : []), { label: 'The API', href: '/developers' }],
++ start: { href: '/make', label: 'Make a meme' },
++ chips: [{ label: 'Explore memes', href: '/browse?scope=internet' }, ...(trending.length ? [{ label: 'Trending', href: '#mm-trending' }] : []), { label: 'The API', href: '/developers' }],
proof: Number(counts.entries) ? [{ value: Number(counts.entries), label: Number(counts.entries) === 1 ? 'entry' : 'entries' }] : [],
motif: treeMotif(feature),
},
@@@ -163,6 +164,8 @@ function entry({ e, citations = [], signedIn = false, staff = false, reported =
const uses = citations.filter((c) => c !== origin);
const examples = citations.filter((c) => c.role === 'example');
const others = uses.filter((c) => c.role !== 'example');
++ const liveUses = citations.filter((c) => c.source_service === 'live');
++ const mediaExamples = Array.isArray(e.examples) ? e.examples.filter((x) => x && x.media_id) : [];
return `<div class="mm-wrap" id="mm-entry" data-entry="${esc(e.slug)}">
<article class="mm-entry">
<header class="mm-entry-h">
@@@ -179,7 +182,7 @@ ${e.summary ? `<p class="mm-summary">${esc(e.summary)}${share && share.url ? ' '
<section class="mm-origin" id="mm-origin" aria-labelledby="mm-origin-h">
<h2 id="mm-origin-h">Where it started</h2>
--${origin ? citation(origin) : '<p class="mm-muted">No origin citation is attached yet.</p>'}
++${origin ? citation(origin) : (e.origin && e.origin.url ? `<p class="mm-origin-source"><a href="${esc(e.origin.url)}" rel="nofollow noopener">${esc(e.origin.title || e.origin.url)}</a>${e.origin.publisher ? ` · ${esc(e.origin.publisher)}` : ''}${e.origin.published_at ? ` · <time datetime="${esc(e.origin.published_at)}">${esc(String(e.origin.published_at).slice(0, 10))}</time>` : ''}</p>${e.origin.quote ? `<p class="mm-origin-quote"><q>${esc(e.origin.quote)}</q></p>` : ''}` : '<p class="mm-muted">No origin citation is attached yet.</p>')}
</section>
<section class="mm-timeline-sec" id="mm-timeline" aria-labelledby="mm-timeline-h">
@@@ -188,6 +191,12 @@ ${others.length ? `<ol class="mm-timeline">${others.map((c) => `<li class="mm-ti
</section>
${examples.length ? `<section class="mm-examples" id="mm-examples" aria-labelledby="mm-examples-h"><h2 id="mm-examples-h">Examples</h2>${examples.map((c) => citation(c)).join('')}</section>` : '<section class="mm-examples" id="mm-examples" aria-labelledby="mm-examples-h"><h2 id="mm-examples-h">Examples</h2><p class="mm-muted">No example is attached yet.</p></section>'}
++${mediaExamples.length ? `<section class="mm-example-gallery" aria-labelledby="mm-gallery-h"><h2 id="mm-gallery-h">Remixes from the community</h2><div class="mm-gallery">${mediaExamples.map((x) => `<figure><a href="https://openvibe.media/o/${encodeURIComponent(x.media_id)}"><img src="https://openvibe.media/o/${encodeURIComponent(x.media_id)}" loading="lazy" alt="${esc(x.caption || e.title)}"></a>${x.caption ? `<figcaption>${esc(x.caption)}</figcaption>` : ''}${x.source_url ? `<p><a rel="nofollow noopener" href="${esc(x.source_url)}">Source</a></p>` : ''}</figure>`).join('')}</div></section>` : ''}
++${e.spread && e.spread.length ? `<section class="mm-spread" aria-labelledby="mm-spread-h"><h2 id="mm-spread-h">How it spread</h2><ol class="mm-timeline">${e.spread.map((x) => `<li class="mm-timeline-item"><time class="mm-timeline-when" datetime="${esc(x.observed_at)}">${esc(String(x.observed_at || '').slice(0, 10))}</time><div><b>${esc(x.place)}</b>${x.note ? `<p>${esc(x.note)}</p>` : ''}${x.url ? ` <a rel="nofollow noopener" href="${esc(x.url)}">Source</a>` : ''}</div></li>`).join('')}</ol></section>` : ''}
++${e.variations && e.variations.length ? `<section class="mm-variations" aria-labelledby="mm-variations-h"><h2 id="mm-variations-h">Variations</h2><ol class="mm-entries">${e.variations.map((x) => `<li class="mm-entry-card"><div class="mm-entry-link"><span class="mm-entry-title">${esc(x.name)}</span>${x.description ? `<span class="mm-entry-summary">${esc(x.description)}</span>` : ''}${x.source_url ? `<a href="${esc(x.source_url)}" rel="nofollow noopener">See source</a>` : ''}</div></li>`).join('')}</ol></section>` : ''}
++${e.related && e.related.length ? `<nav class="mm-related" aria-labelledby="mm-related-h"><h2 id="mm-related-h">Related memes</h2><ul class="mm-entries">${e.related.map((x) => `<li class="mm-entry-card"><a class="mm-entry-link" href="/m/${esc(x.slug)}"><span class="mm-entry-title">${esc(x.title)}</span>${x.summary ? `<span class="mm-entry-summary">${esc(x.summary)}</span>` : ''}</a></li>`).join('')}</ul></nav>` : ''}
++${liveUses.length ? `<section class="mm-live-mentions" aria-labelledby="mm-live-h"><h2 id="mm-live-h">Seen on OpenVibe.Live</h2><p class="mm-muted">This meme was named in a stream transcript.</p>${liveUses.map((c) => citation(c)).join('')}</section>` : ''}
++<details class="mm-entry-faq"><summary>How was this page documented?</summary><p>The entry summarizes cited origins, dated appearances, and community supplied examples. To request a correction or removal, use the report form below.</p></details>
<footer class="mm-entry-foot">
${reported ? '<p class="mm-note" role="status">Thank you. Staff will look at the report.</p>' : ''}
@@@ -244,39 +253,83 @@ ${entries.length ? `<ol class="mm-entries">${entries.map((e) => `<li class="mm-e
function make({ templates = [], values = {}, preview = null, signedIn = false, problem = null } = {}) {
const v = { template_id: templates[0] ? templates[0].id : '', visibility: 'public', ...values };
const lines = LINES.map((i) => v[`line${i}`] || '');
++ const chosenTemplate = (preview && preview.template) || templates.find((t) => t.id === v.template_id) || templates[0] || null;
++ const layout = (chosenTemplate && chosenTemplate.layout) || {};
++ const baseBoxes = Array.isArray(layout.boxes) ? layout.boxes : [];
++ const boxCount = baseBoxes.length ? baseBoxes.length : Math.max(1, Math.min(4, Number(chosenTemplate && chosenTemplate.box_count) || 4));
++ const shownLines = lines.slice(0, Math.min(4, boxCount));
++ const stageText = shownLines.map((line, i) => {
++ const b = baseBoxes[i] || { x: 20, y: i * 60 + 8, w: 400, h: 48 };
++ const x = Number(layout.width) ? Number(b.x) / Number(layout.width) * 100 : 5;
++ const y = Number(layout.height) ? Number(b.y) / Number(layout.height) * 100 : i * 20;
++ const w = Number(layout.width) ? Number(b.w) / Number(layout.width) * 100 : 90;
++ const h = Number(layout.height) ? Number(b.h) / Number(layout.height) * 100 : 18;
++ return `<span class="mm-editor-box mm-font-impact" style="left:${x}%;top:${y}%;width:${w}%;height:${h}%;font-size:clamp(12px,6vw,30px);color:#fff;-webkit-text-stroke:3px #000">${esc(line)}</span>`;
++ }).join('');
return `<div class="mm-wrap" id="mm-make">
<header class="mm-page-h"><p class="mm-kicker">Meme maker</p><h1>Make a meme</h1>
--<p class="mm-lede">Choose a reusable template, write up to four lines, and preview as you type. Sign in to save an editable link or export a PNG.</p></header>
++<p class="mm-lede">Pick a format, add text, drag it into place, and download your meme. Uploads are reviewed before anyone else can use them.</p></header>
${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<div class="mm-make-grid">
<form id="mm-caption-form" class="mm-form mm-card" method="post" action="/make">
--<label class="mm-field"><span>Find a template</span><input id="mm-template-filter" type="search" placeholder="Search templates" aria-controls="mm-template-grid" autocomplete="off"></label>
--<fieldset class="mm-templates" id="mm-template-grid"><legend>Choose a template</legend>
--${templates.map((t) => `<label class="mm-template${t.id === v.template_id ? ' is-chosen' : ''}"><input type="radio" name="template_id" value="${esc(t.id)}"${t.id === v.template_id ? ' checked' : ''} required><img src="${esc(t.image_url)}" alt="" width="220" height="140" loading="lazy"><span><b>${esc(t.name)}</b><small>${Number(t.box_count)} ${Number(t.box_count) === 1 ? 'box' : 'boxes'} · ${esc(t.license)}</small></span></label>`).join('')}
++<input type="hidden" name="editor_state" value="${esc(v.editor_state || '')}">
++<fieldset class="mm-words"><legend>Your words</legend>
++${LINES.map((i) => `<label class="mm-field"${i > boxCount ? ' hidden' : ''}><span>Line ${i}</span><input name="line${i}" type="text" maxlength="120" value="${esc(lines[i - 1])}" placeholder="${i === 1 ? 'when you recognize the format' : ''}"></label>`).join('')}
++</fieldset>
++<p class="mm-tpl-h" aria-hidden="true">Template</p>
++<div class="mm-tpl-bar"><label class="mm-field mm-tpl-find"><span class="mm-sr">Find a template</span><input id="mm-template-filter" type="search" placeholder="Find a template" aria-controls="mm-template-grid" autocomplete="off"></label>
++<label class="mm-upload"><input id="mm-template-upload" type="file" accept="image/png,image/jpeg,image/webp"><span>Upload your own</span></label></div>
++<p class="mm-fine mm-tpl-note">PNG, JPEG or WebP up to 6 MB. New uploads go to moderation before others can use them.</p>
++<fieldset class="mm-templates" id="mm-template-grid"><legend class="mm-sr">Template</legend>
++${templates.map((t) => `<label class="mm-template${t.id === v.template_id ? ' is-chosen' : ''}" data-template="${esc(t.id)}" data-boxes="${Number(t.box_count) || 1}" data-layout="${esc(JSON.stringify(t.layout || {}))}"><input type="radio" name="template_id" value="${esc(t.id)}"${t.id === v.template_id ? ' checked' : ''} required><img src="${esc(t.image_url)}" alt="" width="220" height="140" loading="lazy"><span><b>${esc(t.name)}</b><small>${Number(t.box_count)} ${Number(t.box_count) === 1 ? 'box' : 'boxes'} · ${esc(t.license)}</small></span></label>`).join('')}
++</fieldset>
++<fieldset class="mm-editor-tools"><legend>Style and place</legend>
++<label class="mm-field"><span>Type style</span><select name="font_style"><option value="impact">Classic Impact</option><option value="modern">Modern</option><option value="soft">Soft</option></select></label>
++<label class="mm-field"><span>Text size</span><input name="font_size" type="range" min="12" max="64" value="30"></label>
++<label class="mm-field"><span>Text color</span><input name="text_color" type="color" value="#ffffff"></label>
++<label class="mm-field"><span>Outline</span><input name="outline_color" type="color" value="#000000"></label>
++<label class="mm-field"><span>Outline width</span><input name="outline_width" type="range" min="0" max="8" value="3"></label>
++<label class="mm-field"><span>Crop left / right</span><input name="crop_x" type="range" min="0" max="100" value="50"></label>
++<label class="mm-field"><span>Crop top / bottom</span><input name="crop_y" type="range" min="0" max="100" value="50"></label>
++<button class="mm-btn mm-btn-quiet" type="button" data-add-sticker>+ Add emoji sticker</button>
++<label class="mm-field mm-ai-idea"><span>Get caption ideas</span><input name="idea_topic" type="text" maxlength="300" placeholder="what are you making a meme about?"><button class="mm-btn" type="button" data-caption-ideas>Make it with AI</button><small data-ai-status aria-live="polite">Available without signing in · cheapest OpenVibe.AI route by default</small></label>
</fieldset>
--${LINES.map((i) => `<label class="mm-field"><span>Line ${i}</span><input name="line${i}" type="text" maxlength="120" value="${esc(lines[i - 1])}" placeholder="${i === 1 ? 'when you recognize the format' : ''}"></label>`).join('')}
<label class="mm-field"><span>Who can see it?</span><select name="visibility"><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Anyone</option><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
<div class="mm-actions"><button class="mm-btn" type="submit">Save the caption</button><button class="mm-btn mm-btn-quiet" type="submit" formaction="/make" formmethod="get">Preview</button></div>
${signedIn ? '' : '<p class="mm-fine">You can preview without signing in. <a href="/auth/login?next=%2Fmake">Sign in</a> to save a caption.</p>'}
</form>
<aside class="mm-preview" id="mm-preview" aria-labelledby="mm-preview-h"><h2 id="mm-preview-h">Your canvas</h2>
--${preview ? `<figure class="mm-caption-card"><img src="${esc(preview.template.image_url)}" alt=""><figcaption>${preview.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption></figure><p class="mm-fine">Text only, over ${esc(preview.template.name)}. Nothing is saved until you press Save.</p>` : '<p class="mm-muted">Press Preview to see your lines over the template.</p>'}
--${signedIn ? '<button class="mm-btn mm-btn-quiet" type="button" data-render-png>Download PNG</button><p class="mm-fine" data-render-status aria-live="polite"></p>' : '<p class="mm-fine">Sign in to export a PNG. Your preview is still free to use.</p>'}
++<div class="mm-editor-stage mm-caption-card" data-stage tabindex="0" aria-label="Meme canvas. Drag caption boxes to move them."><img data-canvas-image src="${esc(chosenTemplate ? chosenTemplate.image_url : '')}" alt=""><div data-caption-boxes>${stageText}</div><div data-stickers></div></div>
++<p class="mm-fine">Drag text to place it. Use the lower corner to resize. <span data-editor-status aria-live="polite"></span></p>
++${signedIn ? '<label class="mm-field"><span>Export format</span><select data-export-format><option value="png">PNG image</option><option value="gif">GIF loop</option><option value="mp4">MP4 clip</option></select></label><button class="mm-btn mm-btn-quiet" type="button" data-render-png>Download</button><p class="mm-fine" data-render-status aria-live="polite"></p>' : '<p class="mm-fine">Sign in to download PNG, GIF, or MP4. Your preview is free to use.</p>'}
</aside>
</div>
</div>`;
}
function caption({ caption: c, template, mine = false, share = null } = {}) {
++ const editor = c.editor && typeof c.editor === 'object' ? c.editor : {};
++ const fontStyle = ['impact', 'modern', 'soft'].includes(editor.font_style) ? editor.font_style : 'impact';
++ const fontSize = Math.min(64, Math.max(12, Number(editor.font_size) || 30));
++ const color = /^#[0-9a-f]{6}$/i.test(String(editor.color || '')) ? editor.color : '#ffffff';
++ const outline = /^#[0-9a-f]{6}$/i.test(String(editor.outline || '')) ? editor.outline : '#000000';
++ const outlineWidth = Math.min(8, Math.max(0, Number(editor.outline_width) || 0));
++ const boxes = Array.isArray(editor.boxes) ? editor.boxes : [];
++ const boxCount = boxes.length ? boxes.length : Math.max(1, Math.min(4, Number(template && template.box_count) || c.lines.length));
++ const shownLines = c.lines.slice(0, Math.min(4, boxCount));
++ const boxHtml = shownLines.map((line, i) => {
++ const b = boxes[i] || [[5, 7, 90, 24], [5, 68, 90, 24], [5, 37, 90, 20], [5, 43, 90, 18]][i] || [5, 7 + i * 20, 90, 18];
++ const x = Math.min(95, Math.max(0, Number(b.x) || 0)), y = Math.min(95, Math.max(0, Number(b.y) || 0));
++ const w = Math.min(100 - x, Math.max(12, Number(b.w) || 90)), h = Math.min(100 - y, Math.max(8, Number(b.h) || 20));
++ return `<span class="mm-editor-box mm-font-${fontStyle}" style="left:${x}%;top:${y}%;width:${w}%;height:${h}%;font-size:${fontSize}px;color:${color};-webkit-text-stroke:${outlineWidth}px ${outline}">${esc(line)}</span>`;
++ }).join('');
++ const stickerHtml = (Array.isArray(editor.stickers) ? editor.stickers : []).filter((x) => x && ['✨', '😂', '🔥', '💀', '👀', '❤️', '🙌'].includes(x.emoji)).slice(0, 8).map((x) => `<span class="mm-editor-sticker" style="left:${Math.min(100, Math.max(0, Number(x.x) || 50))}%;top:${Math.min(100, Math.max(0, Number(x.y) || 50))}%">${x.emoji}</span>`).join('');
return `<div class="mm-wrap" id="mm-caption" data-caption="${esc(c.id)}">
<header class="mm-page-h"><p class="mm-kicker">A saved caption</p><h1>${esc(template ? template.name : 'Caption')}</h1>
<p class="mm-lede">${mine ? 'Yours. ' : ''}Text over the template image, saved as lines — never as a rendered picture.</p></header>
--<figure class="mm-caption-card mm-caption-large">
--${template ? `<img src="${esc(template.image_url)}" alt="">` : ''}
--<figcaption>${c.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption>
--</figure>
++<div class="mm-editor-stage mm-caption-card mm-caption-large">${template ? `<img src="${esc(template.image_url)}" alt="">` : ''}<div>${boxHtml}${stickerHtml}</div></div>
<dl class="mm-facts"><div><dt>Saved</dt><dd>${time(c.created_at)}</dd></div><div><dt>Visibility</dt><dd>${esc(c.visibility)}</dd></div><div><dt>Caption</dt><dd><code>${esc(c.id)}</code></dd></div></dl>
--<div class="mm-actions">${share && share.url ? kit.share({ title: template ? template.name : 'Caption', url: share.url, image: (template && template.image_url) || null }) : ''}<a class="mm-btn" href="/make">Make another</a><a class="mm-btn mm-btn-quiet" href="/">Back to the memes</a></div>
++<div class="mm-actions">${share && share.url ? `${kit.share({ title: template ? template.name : 'Caption', url: share.url, text: c.lines.join(' / '), image: (template && template.image_url) || null })}<button class="mm-btn mm-btn-quiet" type="button" data-copy-link="${esc(share.url)}">Copy link</button><button class="mm-btn mm-btn-quiet" type="button" data-native-share="${esc(share.url)}" data-share-title="${esc(template ? template.name : 'My meme') }" data-share-text="${esc(c.lines.join(' / '))}">Share…</button><a class="mm-btn mm-btn-quiet" href="https://openvibe.community/forum/discuss?title=${encodeURIComponent(template ? template.name : 'My meme')}&amp;body=${encodeURIComponent(c.lines.join(' / ') + ' — ' + share.url)}" target="_blank" rel="noopener">Post to Feed</a><a class="mm-btn mm-btn-quiet" href="https://openvibe.pics/" target="_blank" rel="noopener">Upload to Pics</a><a class="mm-btn mm-btn-quiet" href="https://openvibe.chat/" target="_blank" rel="noopener">Share in Chat</a><a class="mm-btn mm-btn-quiet" href="/api/v1/captions/${encodeURIComponent(c.id)}/render?format=png" download>Download PNG</a><a class="mm-btn mm-btn-quiet" href="/api/v1/captions/${encodeURIComponent(c.id)}/render?format=gif" download>Download GIF</a><a class="mm-btn mm-btn-quiet" href="/api/v1/captions/${encodeURIComponent(c.id)}/render?format=mp4" download>Download MP4</a>` : ''}<a class="mm-btn" href="/make">Make another</a><a class="mm-btn mm-btn-quiet" href="/">Back to the memes</a></div>
</div>`;
}
@@@ -298,15 +351,20 @@ ${ep('GET', '/api/v1/entries/:slug/series?days=30', 'Uses per UTC day, zero-fill
${ep('GET', '/api/v1/trending', 'Public entries, rising first.')}
${ep('GET', '/api/v1/week', 'This ISO week’s candidates and their vote counts.')}
${ep('GET', '/api/v1/templates', 'Caption templates: image, box count, source and license.')}
--${ep('POST', '/api/v1/render', 'Render a caption to PNG from a licensed template and 1–4 text lines; same contract as /generate.')}
++${ep('POST', '/api/v1/caption-ideas', 'Signed-out safe caption suggestions using the cheapest OpenVibe.AI route by default; at most 8 requests per minute per address.')}
++${ep('POST', '/api/v1/render', 'Render a caption as PNG, GIF or MP4 from a licensed template, 1–4 lines and optional editor geometry.')}
++${ep('GET', '/api/v1/captions/:id/render?format=png|gif|mp4', 'Download the rendered public caption; private captions require their owner or staff.')}
${ep('GET', '/api/v1/captions/:id', 'One saved caption (a hidden one is 404 unless it is yours or you are staff).')}
</dl></section>
<section><h2>Write (signed in)</h2><dl class="mm-eps">
${ep('POST', '/api/v1/week/vote', '<code>{ entry_id }</code>. One vote a week per person; a second call changes the choice. Same-origin.')}
${ep('POST', '/api/v1/captions', '<code>{ template_id, lines, visibility? }</code>: 1–4 lines, each at most 120 characters, text only, blocked words refused.')}
++${ep('POST', '/api/v1/templates/uploads', '<code>{ name, image }</code> (PNG/JPEG/WebP data URL, at most 6 MB), stored in Media and held for staff review.')}
${ep('POST', '/api/v1/entries/:slug/report', '<code>{ kind: "takedown"|"correction", reason, note? }</code> for staff review.')}
</dl></section>
<section><h2>Staff (Network role <code>global_mod</code> or above)</h2><dl class="mm-eps">
++${ep('GET', '/api/v1/staff/templates/uploads', 'List member-uploaded templates waiting for review.')}
++${ep('POST', '/api/v1/staff/templates/:id/review', '<code>{ decision: "approved"|"rejected" }</code>.')}
${ep('POST', '/api/v1/entries/:slug/publish', 'Make a hidden candidate public. Refused when its origin citation is gone (409).')}
${ep('POST', '/api/v1/entries/:slug/hide', 'Remove it from public lists and tombstone its Search document.')}
${ep('PATCH', '/api/v1/entries/:slug', '<code>{ title, kind, status }</code>: change an entry; status history is kept.')}
ddiff --git a/test/encyclopedia-entries.test.js b/test/encyclopedia-entries.test.js
iindex 378b9ef..cb39b35 100644
---- a/test/encyclopedia-entries.test.js
++++ b/test/encyclopedia-entries.test.js
@@@ -44,6 +44,8 @@ const MEDIA = (n) => `med_01JAB2C3D4E5F6G7H8J9K0MNP${n}`;
const d = await t.get('/api/v1/entries/doge');
assert.strictEqual(d.status, 200, d.text);
const e = d.json();
++ const entryContract = contracts.validate('meme.entry@1', e);
++ assert.strictEqual(entryContract.valid, true, JSON.stringify(entryContract.errors));
assert.strictEqual(e.web_origin.url, origin.url);
assert.strictEqual(e.spread.length, 1);
assert.strictEqual(e.spread[0].count, 420);
ddiff --git a/test/maker-enhancements.test.js b/test/maker-enhancements.test.js
nnew file mode 100644
iindex 0000000..11b2935
---- /dev/null
++++ b/test/maker-enhancements.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++
++const assert = require('assert');
++const sharp = require('sharp');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const mediaId = 'med_01ARZ3NDEKTSV4RRFFQ69G5FAV';
++ const calls = [];
++ const t = await boot({
++ media: { async upload(bytes, options) { calls.push({ bytes, options }); return { media_id: mediaId, url: `https://openvibe.media/o/${mediaId}` }; } },
++ generateAi: { async run(workflow, input) { assert.strictEqual(workflow, 'meme.caption'); assert.ok(input.format); assert.ok(input.topic); return { ok: true, output: { ideas: ['when tests pass\nship it', 'when the fixture finally loads'] }, costUsd: 0 }; } },
++ });
++ const alice = t.network.addUser('alice');
++ const mod = t.network.addUser('mod', { role: 'global_mod' });
++ const png = await sharp({ create: { width: 128, height: 96, channels: 3, background: '#39445a' } }).png().toBuffer();
++ const data = `data:image/png;base64,${png.toString('base64')}`;
++ try {
++ await check('member template upload is sent to Media and remains hidden until staff approval', async () => {
++ const sent = await t.get('/api/v1/templates/uploads', { as: alice, json: { name: 'My panel', image: data } });
++ assert.strictEqual(sent.status, 202, sent.text);
++ const id = sent.json().template.id;
++ assert.strictEqual(sent.json().review_status, 'pending');
++ const pendingContract = contracts.validate('meme.template@1', sent.json().template);
++ assert.strictEqual(pendingContract.valid, true, JSON.stringify(pendingContract.errors));
++ assert.strictEqual(calls.length, 1);
++ assert.ok(calls[0].bytes.length > 0);
++ assert.ok(!(await t.get('/api/v1/templates')).json().templates.some((x) => x.id === id));
++ const pending = await t.get('/api/v1/staff/templates/uploads', { as: mod });
++ assert.ok(pending.json().templates.some((x) => x.id === id));
++ assert.strictEqual((await t.get(`/api/v1/staff/templates/${id}/review`, { as: mod, method: 'POST', json: { decision: 'approved' } })).status, 200);
++ const listed = (await t.get('/api/v1/templates')).json().templates;
++ assert.ok(listed.some((x) => x.id === id));
++ for (const template of listed) {
++ const checked = contracts.validate('meme.template@1', template);
++ assert.strictEqual(checked.valid, true, `${template.id}: ${JSON.stringify(checked.errors)}`);
++ }
++ });
++ await check('invalid and oversized member image uploads are rejected before Media', async () => {
++ assert.strictEqual((await t.get('/api/v1/templates/uploads', { as: alice, json: { name: 'Nope', image: 'data:image/svg+xml;base64,PHN2Zy8+' } })).status, 400);
++ assert.strictEqual((await t.get('/api/v1/templates/uploads', { as: alice, json: { name: 'Nope', image: `data:image/png;base64,${Buffer.alloc(6 * 1024 * 1024 + 1).toString('base64')}` } })).status, 400);
++ assert.strictEqual(calls.length, 1);
++ });
++ await check('signed-out AI idea requests select the cheapest task and return short options', async () => {
++ const response = await t.get('/api/v1/caption-ideas', { json: { template_id: 'tpl_classic', topic: 'shipping a fix' } });
++ assert.strictEqual(response.status, 200, response.text);
++ assert.deepStrictEqual(response.json().ideas[0], ['when tests pass', 'ship it']);
++ assert.strictEqual(response.json().objective, 'cheapest');
++ });
++ await check('maker page renders the touch canvas, uploads, editor controls, and signed-out AI helper', async () => {
++ const page = await t.get('/make');
++ assert.strictEqual(page.status, 200);
++ for (const text of ['data-stage', 'mm-template-upload', 'font_style', 'data-add-sticker', 'data-caption-ideas']) assert.ok(page.text.includes(text), text);
++ });
++ await check('custom editor geometry and stickers render, and saved captions retain editable state', async () => {
++ const editor = { boxes: [{ x: 5, y: 7, w: 90, h: 24 }, { x: 5, y: 68, w: 90, h: 24 }, { x: 5, y: 38, w: 90, h: 18 }, { x: 5, y: 44, w: 90, h: 18 }], stickers: [{ emoji: '🔥', x: 52, y: 54 }], font_style: 'impact', font_size: 30, color: '#ffffff', outline: '#000000', outline_width: 3 };
++ const request = { template_id: 'tpl_classic', lines: ['first caption', 'second caption'], editor };
++ assert.strictEqual(contracts.validate('meme.render@1', request).valid, true);
++ assert.strictEqual(contracts.validate('meme.render@1', { ...request, lines: ['1', '2', '3', '4', '5'] }).valid, false);
++ assert.strictEqual(contracts.validate('meme.render@1', { ...request, format: 'webp' }).valid, false);
++ const rendered = await t.get('/api/v1/render', { as: alice, json: request });
++ assert.strictEqual(rendered.status, 200, rendered.text);
++ assert.strictEqual(rendered.headers.get('content-type'), 'image/png');
++ const styleOnly = await t.get('/api/v1/render', { as: alice, json: { template_id: 'tpl_classic', lines: ['style only'], editor: { font_style: 'modern', crop_x: 40, crop_y: 60 } } });
++ assert.strictEqual(styleOnly.status, 200, styleOnly.text);
++ const saved = await t.get('/api/v1/captions', { as: alice, json: { template_id: 'tpl_classic', lines: ['editable', 'later'], editor } });
++ assert.strictEqual(saved.status, 201, saved.text);
++ assert.deepStrictEqual(saved.json().editor, editor);
++ const savedRender = await t.get(`/api/v1/captions/${saved.json().id}/render?format=png`);
++ assert.strictEqual(savedRender.status, 200);
++ const plain = await t.get('/api/v1/captions', { as: alice, json: { template_id: 'tpl_classic', lines: ['no editor'] } });
++ assert.strictEqual(plain.status, 201, plain.text);
++ const plainRender = await t.get(`/api/v1/captions/${plain.json().id}/render`);
++ assert.strictEqual(plainRender.status, 200, plainRender.text);
++ assert.strictEqual(savedRender.headers.get('content-type'), 'image/png');
++ for (const [format, type] of [['gif', 'image/gif'], ['mp4', 'video/mp4']]) {
++ const animation = await t.get(`/api/v1/captions/${saved.json().id}/render?format=${format}`);
++ assert.strictEqual(animation.status, 200, animation.text);
++ assert.strictEqual(animation.headers.get('content-type'), type);
++ assert.ok(animation.buffer.length > 100);
++ }
++ });
++ await check('home highlights the maker and daily template signals', async () => {
++ const home = await t.get('/');
++ assert.strictEqual(home.status, 200);
++ assert.ok(home.text.includes('Templates to try today'));
++ assert.ok(home.text.includes('Make a meme'));
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exitCode = 1; });