Repositories openvibers openvibe.meme official
openvibers/openvibe.meme
Open source meme database: running jokes cited to the moment they happened, meme of the week, and a caption generator.
Get it
Get this repository
A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.
git clone https://git.openvibe.codes/openvibers/openvibe.meme.git
OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.
Clone to my computerA Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.
Open in CodesREADME.md
View fileOpenVibe.Meme
The open meme database, rebuilt with AI.
Live site: https://openvibe.meme · API docs: https://openvibe.services · For AI: https://openvibe.meme/llms.txt
Run it: npm ci && npm run dev (Node 22 or newer) · Tests: npm test
Status: v1 written and tested (see [STATUS.json](STATUS.json)).
Domain: openvibe.meme · Port: 5120 · Service id: meme · Env prefix: MEME
License: AGPL-3.0-only (same as every OpenVibe service).
OpenVibe.Meme is an open encyclopedia of internet memes and a maker attached to each entry. Pages explain the format,
cite its origin and spread, connect its variations and related memes, and link to licensed templates. Members can
make editable captions and submit examples for moderation. Known memes may show an OpenVibe.Live appearance when a
transcript names that meme; OpenVibe-only catchphrase pages redirect to the homepage. Every public entry is also a
Search Fabric document.
Purpose
- Entries, cited. Every entry has one origin citation and any number of later uses; the quote is verbatim,
the URL is canonical (OpenVibe.Video's?t=redirect is followed, e.g./v/6026?t=864→/v/6025?t=1541),
andquote_hashis its sha256. A summary is stored as written (L24 has no looks filter); only an empty summary
falls back to the title. Verbatim quotes are never rewritten. - Live references stay attached to known memes.
server/importer.jsreads public Chat/Live history and
transcripts, then attaches a dated use citation only when the text names an existing public internet-meme entry.
It never creates a meme from a stream catchphrase; the Chat global timeline is a paraphrase and is not used as a source. - A judge writes the entry.
server/ai/judge.jsasks OpenVibe.AI to score each candidate from its own
citations — with the chat lines and transcript lines around each quote — and to write the entry from that material
only: a title in natural case, a short summary, where it started, how people use it and two or three example uses.
The score and the reason are stored with the entry. - Only what clears the bar is published. Score 0.75 or more, used at least three times across at least two days
or two channels, nothing denied, nothing about anyone's looks, the entry complete and a live origin citation.
Everything else stays hidden for staff, with its score, onGET /api/v1/staff/candidates. Every decision is a
status event. - The encyclopedia. The well-known internet memes, seeded from Wikipedia's lists (CC BY-SA) and Wikidata (CC0)
with a Commons picture only when its licence allows it, each an entry of kindclassicwith its origin, its
spread, its sources and its attribution. - The citation is checked again. A daily job reads every public citation URL; a 404/410 marks it
gone, a
401/403 marks ithidden, and an entry whose origin is gone is hidden (revision +1, Search tombstone). - Meme of the week. People vote once per ISO week; a second vote changes the choice, never adds a ballot.
- The encyclopedia (M2). An entry has a
scope(openvibefor legacy OpenVibe-only phrases, which no longer
have public meme pages,internetfor the encyclopedia), an origin with a public citation, spread points (date, place, URL, count), named variations,
Media examples, meaning and usage, L1 labels (nudity,language,graphic,18+,flashing,spoilers),
related memes and a moderated revision history. A signed-in person proposes an entry or an edit; it stays
pending, and staff approve or reject it. Every public entry is still one Search Fabric document, now with
scopeandlabelsfacets and the origin as provenance. - Trends (M3). Reddit's official API (OAuth2 client credentials, the four permitted subreddits, no scraping)
records a score sample per post;/api/v1/trends?window=day|weekranks posts by upvote velocity, lists the
templates a repeated perceptual hash reveals, and the public entries used in the same window. The importer is
off whenMEME_REDDIT_CLIENT_ID/MEME_REDDIT_CLIENT_SECRETare missing. - Analyse (M4).
POST /api/v1/analyze { url | media_id }sends the picture to OpenVibe.AI's vision call and
one structured extraction. It returns the template, the matched entries, origin guesses with a confidence,
meaning and usage, L1 labels, and for a Reddit URL its spread and the comments' tone. The daily AI budget is
enforced before any call. - Deep dives (M5).
POST /api/v1/research { topic }starts a job that gathers sources with OpenVibe.AI's
research.gatherand writes a long read in which every paragraph cites a numbered source. A read that cites
nothing is failed, not published.GET /api/v1/research/:idpolls it, and the author may save it as a public
deep-dive entry. - The generator. Pick a template, type 1–4 lines of at most 120 characters each and preview them as HTML text
over the image; a caption is stored as text lines, never as a rendered image. - The generator (M6).
POST /api/v1/generate { template_slug | topic, lines? }researches how the format is
used right now, writes captions in that voice, and renders a real PNG server-side with the template's own text
boxes, bundled DejaVu fonts, outline and wrap. The PNG goes to OpenVibe.Media and the caption keeps its labels
and Media id. The older caption page still previews text in HTML over the template.
Owns
| Table | Holds | Migration |
|---|---|---|
| meme_entries | mem_ + ULID, unique slug (/m/<slug>), title, normalised title_key (lowercase, quotes/punctuation stripped, whitespace collapsed; unique per kind for public rows), kind joke\|catchphrase\|bit\|meme\|saga\|classic\|format\|event\|person\|phrase\|deep_dive, scope openvibe\|internet, AI summary, meaning/usage, L1 labels, status + status_since, first/last seen, visibility public\|unlisted\|hidden, submitted_by for a proposal, revision (+1 on every change), timestamps, the picture and its licence, the attribution, the Wikidata id | migrations/0001_initial.sql, 0002_judge_and_encyclopedia.sql, 0003_encyclopedia.sql, 0009_entry_title_key.sql |
| meme_citations | entry, role origin\|use\|example, source service/type/id, stream/VOD/clip/message ids, canonical VOD offset_seconds, verbatim quote_text, quote_hash, canonical url, observed_at, source_status live\|hidden\|gone; the open web is a source too (wikipedia, wikidata, commons) | 0001_initial.sql, 0002_judge_and_encyclopedia.sql |
| meme_judgements | one row per entry: the score, the reason, the title/summary/origin/spread/examples the AI wrote, whether the text names looks or carries a denied word, the model and the run | 0002_judge_and_encyclopedia.sql |
| meme_status_events | every status change: from, to, reason, who, when; a judge decision adds decision, from_visibility, to_visibility and score | 0001_initial.sql, 0002_judge_and_encyclopedia.sql |
| meme_origins | an internet entry's public origin: source web\|reddit\|media\|openvibe, URL, title, publisher, author, published/retrieved time, quote | 0003_encyclopedia.sql |
| meme_spread_points | entry, date, place, optional URL and count, note — one sighting per (entry, date, place, URL) | 0003_encyclopedia.sql |
| meme_variations | entry, name, description, optional med_ Media id, source URL | 0003_encyclopedia.sql |
| meme_examples | entry, med_ Media id, caption, source URL — reposted pictures keep their source link | 0003_encyclopedia.sql |
| meme_entry_related | directed related pairs; the API returns both sides' public entries | 0003_encyclopedia.sql |
| meme_entry_revisions | entry, revision, author, change jsonb, field-by-field diff, moderation state pending\|approved\|rejected, reviewer and note | 0003_encyclopedia.sql |
| meme_entry_redirects | a merged entry's old slug/id → the kept entry; both answer 301, and a redirect wins over a hidden row that reused the slug | 0009_entry_title_key.sql, 0011_merge_hidden_duplicates.sql |
| meme_votes | (week YYYY-Www, subject) → entry; one vote a week | 0001_initial.sql |
| meme_templates | id, name, image_url (openvibe.media, a local data-free placeholder or a Wikimedia Commons file), box_count, source, license, credit, source page, attribution; three placeholders seeded | 0001_initial.sql, 0002_judge_and_encyclopedia.sql |
| meme_captions | mcp_ + ULID, subject, template, lines jsonb (1–4 plain-text lines), visibility, created | 0001_initial.sql |
| meme_reports | mrp_ + ULID, entry, subject, kind takedown\|correction, reason, note, status | 0001_initial.sql |
| meme_reddit_posts | one Reddit post: id t3_…, subreddit, title, permalink, source URL, author, score, comments, over-18, dHash, Media id, created/last seen | 0004_trends.sql |
| meme_reddit_samples | one score observation per post per run; velocity is computed from these, never from one score | 0004_trends.sql |
| meme_reddit_templates | a dHash cluster repeated across posts: label, representative picture, Media id, post count, first/last seen | 0004_trends.sql |
| meme_reddit_template_posts | which posts belong to a template and their hash distance | 0004_trends.sql |
| meme_ai_usage | per UTC day and kind (analyze/research/generate): AI calls reserved and the cost the run stated | 0005_analyze.sql |
| meme_analyses | anl_ + ULID, subject (or null for a public analysis), input URL/Media id, status, the finished result jsonb and its cost | 0005_analyze.sql |
| meme_research | rsc_ + ULID, subject, topic, status, title/summary, the Markdown read, numbered sources/facts/citations, cost, error, saved entry slug, times | 0006_research.sql |
| meme_deep_dives | entry, research job, the full Markdown read and its numbered sources/citations | 0006_research.sql |
| meme_research_usage | per UTC day: research calls reserved and the cost the runs stated | 0006_research.sql |
| meme_templates | id, name, image_url (openvibe.media or a local data-free placeholder), box_count, source, license, layout jsonb (text boxes, font, size, colour, outline, align, wrap); three placeholders seeded | 0001_initial.sql, 0007_generate.sql |
| meme_captions | mcp_ + ULID, subject, template, lines jsonb (1–4 plain-text lines), visibility, created, L1 labels, rendered media_id | 0001_initial.sql, 0007_generate.sql |
| meme_reports | mrp_ + ULID, entry, subject, kind takedown\|correction, reason, note, status | 0001_initial.sql |
| meme_generate_usage | per UTC day: generation calls reserved and the cost the runs stated | 0007_generate.sql |
| meme_outbox | Today's outbox shape: fixed evt_ id, topic, JSON payload, attempts, last error, claim/backoff lease — the Search document queue | 0001_initial.sql |
| account_data_events | openvibe-sdk/account-data's export/deletion receipts | 0001_initial.sql |
| telemetry_samples | aggregated request telemetry, pruned after 14 days | 0001_initial.sql |
Does not own
- Chat, Live and the VODs — OpenVibe.Chat and OpenVibe.Live. Meme reads their public history, Rundown and
transcripts. It never reads a private VOD, and it never cites VOD 5709 or clip 483 (the fixed deny list).
The one service grant Meme holds is OpenVibe.AI'sai.run.create, used by the judge and the encyclopedia. - Identity, roles and accounts — OpenVibe.Network. Meme verifies session tokens against its JWKS; a staff
route asks forglobal_modor above. - Search — OpenVibe.Search. Meme produces
search.index-document@1documents in its own outbox, and
openvibe-publishing/search-relaydelivers them to Search's owner API with Meme'ssearch.document.writegrant
(MEME_SEARCH_URL; unset, they wait). - Events, the host and the database — OpenVibe.Events, OpenVibe.Host.
Depends on
Environment variable names are in [.env.example](.env.example); no value is committed.
- OpenVibe.Network — sign-in (OAuth client
meme, PKCE S256, cookiesmeme_at/meme_rt/meme_oauth), JWKS,
token exchange/refresh/revoke, account export/deletion, staff roles:OV_NETWORK_URL,
OV_NETWORK_INTERNAL_URL,OV_NETWORK_ISSUER,OV_OAUTH_CLIENT_ID,OV_OAUTH_CLIENT_SECRET,
OV_OAUTH_REDIRECT_URI,OV_SESSION_AUDIENCE,MEME_AUDIENCE. - OpenVibe.Chat / Live / Video — the public reads:
OV_CHAT_URL,OV_LIVE_URL,OV_VIDEO_URL,
MEME_UPSTREAM_TIMEOUT_MS. - OpenVibe.AI — the judge and the encyclopedia's writing pass:
OV_AI_INTERNAL_URL(default
http://127.0.0.1:4700),MEME_AI_AUDIENCE(openvibe.ai),MEME_AI_SCOPE(ai.run.create),
MEME_AI_WAIT_MS,MEME_AI_TIMEOUT_MS,MEME_AI_POLL_MS,MEME_AI_MAX_TOKENS. Meme callsai.generateas
the servicememewith a Network client-credentials token; Network grantsai.run.createfor theai.generate
namespace. Meme never names a provider — AI's routing decides, and the run reports what answered. - Wikipedia, Wikidata and Wikimedia Commons — the encyclopedia:
MEME_WIKIPEDIA_URL,MEME_WIKIDATA_URL,
MEME_COMMONS_URL,MEME_WIKI_USER_AGENT,MEME_WIKI_TIMEOUT_MS,MEME_WIKI_MIN_INTERVAL_MS,
MEME_ENCYCLOPEDIA_LIMIT,MEME_ENCYCLOPEDIA_LISTS,MEME_ENCYCLOPEDIA_CATEGORIES,
MEME_ENCYCLOPEDIA_MIN_SCORE,MEME_TEMPLATES_LIMIT. - OpenVibe.Events — account deliveries:
MEME_EVENTS_SECRET,MEME_EVENTS_URL,MEME_EVENTS_ENDPOINT. - PostgreSQL —
DATABASE_URL(through PgBouncer) andDATABASE_DIRECT_URL(migrations); embedded PGlite in
MEME_PGLITE_DIRfor development. - Valkey — optional
VALKEY_URL,VALKEY_PREFIX(readiness lists it; the limits in v1 count in-process). - Packages —
openvibe-contractsv0.229.0,openvibe-sdkv0.74.0,openvibe-sharedv4.179.0, the same
tags OpenVibe.Today pins.
API
Everything is JSON under /api/v1; errors are application/problem+json with a code. A person is the meme_at
session cookie or Authorization: Bearer <Network user token>, verified offline with the Network JWKS. A write
made with the session cookie must come from https://openvibe.meme (403 meme.cross_site). A signed-in account
also gets per-actor minute/hour limits (MEME_LIMITS_MINUTE, MEME_LIMITS_HOUR).
| Route | What it does |
|---|---|
| GET /entries?scope=&q=&status=&label=&kind=&sort=&window=&channel=&limit=&cursor= | public entries; scope=openvibe\|internet; q matches title/summary/meaning/usage/slug; label is one L1 label; sort=top&window=day\|week\|month ranks by live uses (a cursor only on newest); channel=<stream_id> is Live's per-channel Memes block; Cache-Control: public, max-age=60 |
| GET /memes?scope=internet&q=&sort=top&window=week | internet-only meme-library alias; defaults to scope=internet |
| GET /memes/:slug | one public internet meme with origin, spread, variations, examples, related memes, and revisions |
| GET /entries/:slug | one entry with origin (a citation, or the web origin), web_origin, citations, timeline, examples, spread, variations, related and approved revisions, and the judge's notes (where it started, how people use it, the examples); a hidden proposal is 404 unless its author or staff |
| GET /entries/:slug/revisions | the approved revision history; staff also see pending and rejected ones |
| GET /entries/:slug/series?days=30 | uses per UTC day, zero-filled, 1–365 days |
| POST /entries | signed in, same-origin: a proposal (title, kind, scope, summary, meaning, usage, labels, origin, spread, variations, examples, related) — born hidden with revision 1 pending (202); staff proposals are approved and public (201) |
| POST /entries/:slug/revisions | signed in: a proposed edit, stored as a revision with a field-by-field diff; a person's waits for review (202), staff's applies at once (201) |
| POST /entries/:slug/revisions/:revision/review | staff: { state: "approved"\|"rejected", note? }; approving copies the change onto the entry, bumps its revision and re-indexes it |
| GET /moderation/revisions?limit= | staff: the pending revision queue with each entry's slug and title |
| GET /trending?limit= | public entries, rising first, then peaked, classic, dead; last use newest first |
| GET /trends?window=day\|week&limit= | rising Reddit posts with upvote velocity (from score samples), new template clusters and the public entries used in the window; Cache-Control: public, max-age=120 |
| POST /research | signed in; { topic } starts a deep-dive job (202 + Location); 503 without AI, 429 meme.ai_budget, 400 for a topic outside 2–300 characters |
| GET /research/:id | the job: queued/running/failed is visible to its author and staff; succeeded is public. Carries the source pack, facts, citations and the Markdown read |
| POST /research/:id/entry | the author or staff save a succeeded read as a public deep-dive entry (201, 200 when already saved) |
| GET /entries/:slug/deep-dive | the saved deep dive's full Markdown, numbered sources and citations |
| 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 /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?, 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 |
| GET /staff/candidates | staff: the hidden candidates with the judge's score, its reason, the uses, the days and the channels |
| GET /staff/candidates/:slug | staff: one candidate — its judgement, its citations and its decision history |
| POST /entries/:slug/publish | staff: hidden → public; 409 meme.origin_gone when the origin is not live |
| POST /entries/:slug/hide | staff: public → hidden; Search tombstone |
| PATCH /entries/:slug | staff: { title, kind, status, reason? }; a status change writes meme_status_events and the edit is kept as an approved revision |
Validation refusals are 400 { code: "meme.invalid", message }; a blocked caption is
400 { code: "meme.blocked_words" }; an edit that changes nothing is 409 meme.no_change and a revision
already reviewed is 409 meme.revision_reviewed. Other codes: meme.sign_in_required, meme.staff_required,
meme.cross_site, meme.not_found, meme.template_not_found, meme.origin_gone, meme.rate_limited.
The judge
server/jobs/judge.js runs every 30 minutes (MEME_JUDGE_INTERVAL_MS), off under MEME_DRILL; tests call
runOnce().
For every candidate nobody has judged yet it:
- reads the entry's live citations and opens each one up — the chat lines around a chat message, the transcript
lines around a VOD second (server/ai/context.js), because a joke only makes sense with its neighbours; - asks OpenVibe.AI to score it from 0 to 1 on whether it is a real meme, catchphrase or inside joke rather than
everyday words that recur, and to write the entry from that material only; - stores the score, the reason and the written entry in
meme_judgements; - applies the publish rule (
server/ai/gate.js): score ≥MEME_JUDGE_MIN_SCORE(0.75), at least
MEME_JUDGE_MIN_USES(3) uses across at leastMEME_JUDGE_MIN_DAYS(2) days orMEME_JUDGE_MIN_CHANNELS(2)
channels, nothing denied, nothing that names looks, the entry complete, and a live origin citation; - publishes what cleared the bar and leaves the rest hidden, logging both as a status event with the score and
the reasons.
The AI's text is never taken at its word: a title that names looks is dropped (the entry keeps the name it had),
every other field is repaired word by word with noLooks, and a field that still names looks — or that carries a
word from the deny list — keeps the entry hidden.
node scripts/rejudge.js runs one round over every hidden candidate and prints what was published and why;
--dry-run judges and reports without publishing, --slug <slug> judges only those.
The encyclopedia
node scripts/seed-classics.js seeds the well-known internet memes; node scripts/seed-templates.js seeds the
caption templates. Neither starts the server.
The classics are read from Wikipedia's lists of Internet phenomena and memes (CC BY-SA) and from the categories
behind them, and every entry is finished from Wikidata (CC0): the Q id, the inception date and the picture. A
Commons picture is used only when its licence allows it (public domain, CC0, CC), and each entry keeps its credit,
its licence, its file page and the attribution of the text it was written from. The same AI pass writes the entry
— the title in natural case, the summary, where it started, how it spread, two or three example uses — and the
same publish rule decides what becomes public: an entry the AI scored under MEME_ENCYCLOPEDIA_MIN_SCORE (0.75),
or whose text names looks or carries a denied word, stays hidden for staff with its score.
Nothing is scraped from a site whose terms forbid it: Wikipedia, Wikidata and Wikimedia Commons only, each with
its User-Agent, paced per host.
Trends (M3)
server/reddit.js reads oauth.reddit.com with this app's own OAuth2 client-credentials token and a User-Agent
that names it. The official API is the only Reddit path: no page, login or captcha wall is touched. Each run
reads /r/<sub>/top?t=day for the subreddits in MEME_REDDIT_SUBREDDITS (default r/memes, r/dankmemes,
r/MemeEconomy and r/wholesomememes), upserts the posts, appends a score sample, and dHashes the picture with
sharp (9×8 grayscale, 64-bit). Equal-enough hashes are grouped; a group of at least
MEME_REDDIT_MIN_TEMPLATE_POSTS distinct posts becomes a meme_reddit_templates row, and the picture is
uploaded to OpenVibe.Media when OV_MEDIA_INTERNAL_URL and the Network client secret are set. Without the Reddit
credentials the importer is off, and the read still serves whatever was recorded before.
server/source-registration.js creates the registry row in OpenVibe.Sources (meme-reddit, disabled, with its
terms and robots notes) once when MEME_SOURCES_REGISTER=1 and OV_SOURCES_INTERNAL_URL are set; it never
overwrites an existing row. The row documents where the data comes from; Meme's importer owns the OAuth exchange.
Analyse (M4)
POST /api/v1/analyze accepts exactly one of url (a public http(s) URL) or media_id (a med_… object). For a
Reddit URL it reads the post and its top comments through the official API (server/reddit-read.js); any other URL
is treated as the picture, and a Media id is resolved to https://openvibe.media/o/<id>.
It then makes two OpenVibe.AI runs: pics.describe_image (vision: alt, caption, tags) and ai.extract (one
structured answer: template, meaning, usage, labels, origin guesses with confidence, similar formats, comment
tone). Meme matches the answer against its own templates and public entries and never lets the model write an
entry. MEME_AI_DAILY_CALLS (200) and MEME_AI_DAILY_BUDGET_USD (5) cap the day; the calls are reserved before
the first run and the run's stated cost is recorded in meme_ai_usage. Reddit credentials are the same
MEME_REDDIT_CLIENT_ID/MEME_REDDIT_CLIENT_SECRET as the trends importer.
Deep dives (M5)
POST /api/v1/research requires a signed-in person and a topic. The job runs research.gather (Wikipedia, the
topic's Fandom wiki, Wikidata, DeepSeek search, Commons/Openverse, as OpenVibe.AI implements it) and then
ai.generate with the numbered evidence pack. The prompt requires a 700–1400 word Markdown read whose every
paragraph ends with its source numbers. groundedIssues() checks that every body paragraph of at least ten words
has a [n] and that every n names a gathered source; a read that fails is stored failed with
meme.research_ungrounded, never published. The read is public once succeeded. Saving it creates a public entry
and meme_deep_dives keeps its full text for GET /entries/:slug/deep-dive.
Two calls per job are reserved against MEME_RESEARCH_DAILY_CALLS (40) and MEME_RESEARCH_DAILY_BUDGET_USD (5);
the calls and the runs' stated cost go in meme_research_usage. The job is in-process: a restart loses a queued
job (it stays running for the operator to see; there is no retry button yet).
The generator (M6)
POST /api/v1/generate takes a template (template_slug, which is the id, slug or name) or a topic. It first runs
research.gather on how the format is actually captioned right now, then ai.extract for the lines and the L1
labels; with lines supplied the person's lines are used verbatim and only the labels come from the model. When a
topic alone is given, the model picks one of the configured templates.
server/render-png.js loads the template's layout (or a vertical fallback for a template without one), wraps
each line to its box, shrinks the font until it fits, converts the text to outlines with opentype.js (bundled
DejaVu Sans and DejaVu Sans Bold under server/assets/fonts, Bitstream Vera licence included), strokes the
outline and fills the text, then composites the SVG over the template artwork with sharp. The PNG is uploaded to
OpenVibe.Media as a public object; without Media the route answers 503 rather than returning an unstored picture.
The saved caption carries the labels and the med_… id. Two calls per request are reserved against
MEME_GENERATE_DAILY_CALLS (60) and MEME_GENERATE_DAILY_BUDGET_USD (5) in meme_generate_usage.
The importer
server/importer.js runs every 30 minutes (MEME_IMPORTER_INTERVAL_MS), off under MEME_DRILL; tests call
runOnce(). It reads public sources only:
- Chat global history — verbatim lines with ids and timestamps, each a usable citation.
- Rundown topics and segments — every quote is verbatim speech with
vod_idand a VOD second, withsaid_at. - VOD transcripts — a segment is used only when the public VOD row gives a date; source part ids are
canonicalised through OpenVibe.Video before a citation is stored.
The linker matches a transcript against public internet-meme titles, attaches matched moments as use citations,
and extends the entry's first/last-use range. It does not create entries or rewrite the researched origin. The
source URL remains canonical, uses naming the deny list are dropped, and repeated reads are deduplicated by the
citation store. MEME_IMPORTER_MAX_KNOWN_MEMES limits how many public entries are checked per run.
Citations and re-verification
server/reverify.js runs daily (MEME_REVERIFY_INTERVAL_MS), off under MEME_DRILL. Each citation is read again:
- a Chat line is looked up in
GET /api/chat/global/history?before=<timestamp+1m>&limit=500; a missing id (or a
deleted message) isgone/hidden; - every other citation's canonical URL is fetched; 404/410 →
gone, 401/403 →hidden, 2xx/3xx →live; - a redirect to a joined VOD updates
vod_id,offset_secondsandurl; - an entry whose origin is no longer
liveis hidden (revision+1) and a Search tombstone is queued.
A transient 5xx is recorded and changes nothing: a hiccup must not erase a real citation.
Search
server/search.js builds one search.index-document@1 for each public entry: owner meme, type entry, id
mem_…, revision = the entry's revision, canonical page ${BASE_URL}/m/<slug>, title/summary/body (the
verbatim quotes as plain text), facets kind/status/source, authorship ai_assisted, and one provenance
reference per live citation. Publishing or editing queues meme.index_document.upserted; hiding, deleting or
losing the origin queues meme.index_document.deleted (a tombstone wins over an older revision). Rows live in
meme_outbox in the same transaction as the entry change; openvibe-publishing/search-relay (v1.5.0) sends them to
Search's owner API a few seconds after the change and every minute, oldest first, superseding a document's older rows. The search-feed recipe (Work, Help, Rent, Quest) is followed: a document from one
row, a sequencer revision in the row, a tombstone when the page is no longer public.
Pages
All server-rendered through the OpenVibe Frame (openvibe-shared/shell), readable without JavaScript, with semantic
HTML and stable mm- ids/classes. Public pages are public, max-age=60 with Vary: Cookie; a page drawn for a
signed-in person is private, no-store.
| Page | What it is |
|---|---|
| / | trending, meme of the week, the generator teaser |
| /browse | searchable public encyclopedia with collection, type, and usage filters (/search alias) |
| /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 | 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 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
scripts/seed-proof.js reads the live public APIs and creates one hidden entry:
- slug
cozmo-offline, titleCozmo is offline, kindjoke, statusclassic, visibilityhidden; - origin: Chat message 86957 (2026-10-28 00:11:53, “your Cozmo ain’t online yet”);
- use: VOD 6025 at 1541 s — the canonical target of
/v/6026?t=864(source part 6026 at 864.08 s), quote
“but yeah and Cosmo is broken now…”; - summary AI-written and stored as written (no looks filter);
first_seen_at/last_seen_atfrom the two citations.
It never publishes. A re-run leaves the existing entry and adds only a citation that is not already there. VOD
5709 and clip 483 are refused before anything is written.
Run and test
npm install
npm test # PGlite; every test file in its own process, no network needed
npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
OV_ALLOW_SUITE=1 npm test
npm start # PORT 5120, loopback; DATABASE_URL or an embedded PGlite in development
node scripts/seed-proof.js # the first proof, hidden, from the live public APIs
node scripts/rejudge.js # one judge round over every hidden candidate; prints what was published
node scripts/seed-classics.js # the encyclopedia, from Wikipedia and Wikidata
node scripts/seed-templates.js # the caption templates, from Wikimedia Commons
The tests boot the app with a mock Network, an OpenVibe.AI stand-in, a Chat/Live/Video fixture server, and a
temporary database: the migration, the importer's candidate and its canonical redirect, the deny list, L24 text
(no looks filter), the one-vote-a-week rule, the caption limits, staff publish/patch/hide, the Search document (validated against
search.index-document@1), the citation 404 → gone → hidden path, sign-in, the pages and cache headers, and the
account export/deletion over votes, captions and reports. The judge, the publish rule, the encyclopedia's
attribution and the templates' licences have their own files: test/judge.test.js, test/encyclopedia.test.js
and test/templates.test.js.
Deploy
deploy/systemd/openvibe-meme.service (127.0.0.1:5120, /etc/openvibe/meme.env) and
deploy/nginx/openvibe.meme.conf (TLS, www → apex, /auth/me on the API zone, /internal and /metrics 404, a
forms zone for the POST routes). Not deployed by this change.
License
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.419.0
- openvibe-sdk: v0.161.0
- openvibe-shared: v4.237.0
- openvibe-publishing: v1.7.0
<!-- versions:end -->