Tags
The tag taxonomy, how auto-tagging decides what to apply, and how to work the backlog by hand.
Tagging labels source clips by sex position and camera framing so you can pull them up in batches — "all the doggy clips" — instead of hunting for them one at a time. Auto-tagging from caption text and subreddit membership only ever reaches a minority of the library, because most captions never name a position at all; hand-correcting the rest, mostly through the rapid tagger, is the long-term workflow this feature exists for.
Implementation detail lives in docs/architecture/media-tagging.md; the persisted document shapes are in docs/architecture/data-model.md, and the original design rationale in docs/superpowers/specs/2026-08-02-media-tagging-system-design.md.
Taxonomy model
A tag key is namespace:slug, e.g. sex_position:missionary.
Selecting more than one tag on /media matches any of them, including across namespaces. Selecting doggy, cowgirl, and pov returns every clip carrying any one of the three — so every POV clip comes back too, whether or not it has a position tag. There is no intersection filter on the screen: to narrow rather than widen, pick one tag at a time. (?tagMatch=all in the URL does switch it to "must have all of these", but no control on the page sets it.)
| Namespace | Multi-value | What it holds |
|---|---|---|
sex_position |
yes | Position and act as one flat list — the primary namespace. A clip can carry more than one value (it transitions from blowjob to doggy), which is deliberate: batching by position is the entire point. |
camera |
yes | Framing and POV. Seeded with pov only, and pov comes entirely from subreddit rules — it ships with no caption aliases, so captioning alone will never produce it. |
review |
no | A human's verdict, not a description. Its one value, no-position, records "I looked and there is nothing here to tag". Manual-only — it has no aliases and can never be auto-applied. |
The seeded sex_position values are doggy, blowjob (which absorbs the deepthroat cluster), cowgirl, reverse-cowgirl, 69, missionary, masturbation, anal, cunnilingus, handjob, titjob, standing-sex, cumshot, and facial. Every one was measured against real caption text before being seeded, and each uses qualified aliases rather than bare words — see "Alias traps" below for why that distinction does real work. You can add more from /tags without a deploy.
penetration was measured and deliberately left out. Its natural aliases (thrusting, penetrating, sexual intercourse) are everyday phrasing for this captioner, so it matched 1,143 clips spanning every position at once. Accurate, but a bucket holding every penetrative act of any position is not a batch you can caption consistently against, which is the whole point of the taxonomy.
review:no-position sits in its own namespace rather than being a sex_position value on purpose. Pinning freezes a namespace, so sex_position:none would stop a clip ever picking up a real position later if the alias table improves. Keeping the verdict separate means marking a clip as having nothing to tag never closes the door on tagging it properly.
How auto-tagging decides
Two signals feed the same resolver:
- Subreddit rules — a clip's source key is matched against the rule table on
/tags, and a hit applies that rule's tags at that rule's own confidence. Case and trailing slashes do not matter:redgifsamateur/andredgifsamateurare the same rule. Every rule in the table today is a subreddit name, so in practice this signal only reaches Reddit clips — Instagram and Telegram media pick up caption-derived tags only. - Caption text matching — each tag definition's aliases (case-insensitive regexes) are tested against a fixed, ordered list of the clip's structured caption fields. A
negativeAliaseshit on the same text suppresses the match, which is what stopscowgirlfrom also firing on "reverse cowgirl" text.
Confidence is assigned by which caption field matched, not by how strong the match looks textually:
| Field | Confidence |
|---|---|
video_nsfw.sex_position |
0.9 |
video_nsfw.action |
0.85 |
video_nsfw.nsfw.summary |
0.8 |
video_sfw.action |
0.7 |
video_nsfw.nsfw.frames[].description |
0.6 |
Subreddit-rule confidence is set per rule instead — e.g. 0.95 for an unambiguous sub like missionaryfucking.
What that number then controls:
>= 0.8— high confidence: the tier/media's High confidence filter selects. The filter starts on All, so until you change it you are seeing every tier at once.0.5to0.8— needs review: written and filterable, but meant to be swept separately. This band is exactly what the rapid tagger's Needs review mode serves you.- below
0.5— never written at all - manual — always
1.0, always pinned
Both seeded namespaces allow multiple values, so every candidate above the floor is kept independently. If two candidates tie on confidence within one namespace, manual beats subreddit rule beats caption field.
Auto-tagging fires at three moments:
- Right after a caption is written — automatic, and the case that keeps up with new media. Best-effort: a tagging failure never fails an already-succeeded caption job. This runs as a call from the caption executor back into the app, so it depends on that service being configured to make it — it was silently not happening for the whole of September 2026, and the visible symptom was simply that new clips had no tags.
- During a
npm run backfill:tagssweep — the bulk catch-up over media captioned before a rule or alias existed. - When you press Re-run tagging on
/tags— a bounded pass for narrowing the gap right after an edit, without waiting on a sweep.
Re-running over unchanged input is idempotent: the same tags come out, and anything whose key, source, and confidence have not changed keeps its original applied-at timestamp.
Manual pinning
This is the one rule to know before you touch anything.
Adding a tag by hand — from the media detail view, the gallery bulk-select bar, or the rapid tagger — writes it with source manual, confidence 1, and pinned: true. A pinned tag tells the resolver to stop generating auto candidates for that entire namespace on that document, not just to protect the one tag you added. Concretely: correct a clip's sex_position to missionary and no future backfill or re-run will ever add or remove a sex_position:* tag on that clip again, even if its caption text changes or new aliases are added. The whole namespace is frozen by the correction, and stays frozen until someone removes the tag by hand.
That is what you want while working the backlog — your corrections can never be trampled by a later sweep. It is also why a careless bulk apply is expensive: it does not just add a wrong tag, it takes those clips out of automatic tagging permanently.
Where you tag things
/media — filter and quick-correct
The tag filter lives inside the gallery's consolidated Filters panel — tap "Filters" to open it. Tags render as one horizontally-scrolling row per active namespace, the same way Subreddit and Channel already do. A separate Confidence section (High / Needs review / All) sits alongside Duration, Aspect, and Sort. Every selected tag and confidence tier shows up as its own removable pill in the active-filter row above the grid.
Per-card quick chips show a clip's top three tags by confidence in the card footer with a "+N" overflow count; tapping a chip removes that tag without leaving the grid. Quick chips can only remove what is already applied — they are a shortcut, not an editor.
Bulk-select is the volume tool here. Entering multi-select mode surfaces a bottom action bar, grouped by namespace, that applies or removes a tag across every selected item in one call. Selection survives filter and pagination changes within a session and shows a single "N selected (M not in view)" indicator; it clears only on Cancel or after a successful bulk action. Remember what "apply" means here — see "Manual pinning" above.
The media detail view — one clip, carefully
The tag editor on a media's detail page groups applied and available tags by namespace, shows each applied tag's source and confidence, and adds or removes one at a time. Use it for individual corrections where you want to see the provenance first; it is not built for volume.
/tags — the vocabulary itself
This is where namespaces, tag definitions, and subreddit rules are edited, with live per-tag media counts. You can edit aliases and negative aliases (one pattern per line, matched as a case-insensitive regex) and Match priority. Match priority is not what its name suggests: it orders how tags come out, lower first — it does not decide which definition wins when two could match the same text. Negative aliases do that. A malformed regex is rejected when you save rather than silently degrading to "never matches", and creating a definition whose slug already exists is rejected rather than overwriting the existing definition's aliases and priority.
Nothing here is ever hard-deleted. Deactivate is the only removal path, which keeps historical evidence and provenance intact.
Re-run tagging is bounded on purpose: it processes up to 500 candidate media per click and reports progress, because a full-corpus rewrite would time out a serverless function. Its scope is "untagged". Every document it processes gets stamped as swept — including one that legitimately resolves to zero tags, so the same handful of documents cannot monopolize every click. The consequence to remember: a document that swept to zero tags will not be reconsidered by a later untagged-scope run or by npm run backfill:tags --resume. After editing aliases or rules, a plain npm run backfill:tags with no --resume is what reconsiders them. Pinned tags are always preserved across a re-run.
As of 2026-09-12 every media document has been swept at least once, so Re-run tagging and --resume both have nothing left to find. The full no---resume run is the only thing that will pick up a taxonomy edit.
The rapid tagger
Get to it with Open Rapid Tagger at the top of /tags (/tags/rapid). This is the screen that does the actual work on the backlog: one video at a time, autoplaying, with the next clip prefetched so playback starts instantly when you advance.
Tapping a tag chip applies it and advances immediately. The save runs in the background rather than holding you up, so a slow or failing write cannot stall the queue — a failure surfaces as a toast and does not rewind you to the previous clip. On desktop, number keys 1–9 apply the Nth visible tag, counting in the same left-to-right, top-to-bottom order shown on screen, and Right Arrow skips.
Two filters sit at the top:
- Mode —
Untagged(zero tags at all) orNeeds review(at least one tag in the 0.5–0.8 confidence band). - Source — All / Reddit / Instagram / Telegram.
The queue is video media only, and a Sort control picks the order: Newest (ingest time descending, the default) or Random — a fresh server-side draw across the whole filtered set on every fetch, not a shuffle of the page you're already looking at. Reach for Random when a working session would otherwise be one subreddit's recent uploads back to back; a spread surfaces which subreddits need rules faster than a strictly-newest queue does, and — if these tags are ever used as training signal — draws from across the library rather than skewing toward whatever synced most recently.
Skip is session-local, not recorded. Skipping writes nothing to the database; the skip is remembered only for as long as the page stays mounted, and navigating away or reloading forgets every one. If you skip your way to the end of a batch you get "N clips in this batch were already skipped this session" and a Show skipped clips again button, which puts them back in front of you without a reload.
No position is the permanent counterpart. The dashed full-width button below the tag chips (or n on desktop) writes review:no-position and advances, with the same immediate, background-saved behaviour as applying a tag. The clip now has a tag, so it leaves the untagged queue for good — unlike Skip, it survives a reload.
Reach for it constantly. Most of what remains has no position to record: of the untagged clips that do have captions, 10,243 captions explicitly describe no act at all — posing, cuddling, selfies — and Instagram alone accounts for around 10,000 of the backlog. Without marking those, the queue can never converge, because they come back every time you open the screen.
Skip means "not now". No position means "never — there is nothing here".
Queue for generation marks the clip you're looking at for ComfyUI. It is the one control here that does not advance — mark first, then tag, and the tag moves you on. Marks accumulate across the batch of 25 and are sent together, either when you reach the end of the batch or when you press Dispatch N.
That deferral is deliberate. Generation picks its LoRAs from the clip's tags, and a clip with no tags gets none — so dispatching the moment you press the button would generate the un-LoRA'd version of every clip, which is the opposite of what tagging them is for. Waiting until the tag is applied is what makes a marked clip worth generating. It also means the GPU gets one burst per batch instead of a job every few seconds while you work.
Marks are dropped if you Skip (the clip is still untagged) or Archive (it shouldn't generate at all), and they live only for the current batch — leaving the screen discards them, which is why Dispatch is there to fire early.
Roughly one clip in eleven has no caption yet and cannot go straight to ComfyUI. Those still get a job; it enters the normal caption-then-generate path, and the summary says so rather than claiming it was dispatched. A clip that already has generation in flight is skipped rather than queued twice.
No face is not a tagging verdict at all. The third button in that row (or f on desktop) writes review:no-face and advances like the others, but its purpose is different: it records that the clip has no visible face, as a label on a decision the ingest pipeline already made. Nothing filters on it — no gallery, no dispatch, no caption path reads review:no-face, and it is not meant to be used as a media filter.
But it does retire the clip. Applying review:no-face anywhere — this screen, the bulk selection bar on Media or Jobs, the media detail editor — flags the media isArchived and archives every generation job for it. Tag a pending job "No face" and both the job and the source media go away in one action. That is deliberate: no face means the clip cannot produce a usable swap, so there is nothing to keep it queued for.
It is one-way. Removing the tag does not bring anything back — restore the clip from its media detail page. And it fires wherever the tag is applied by hand, so applying it to a 40-clip selection archives all forty. Nothing auto-applies it: review:no-face has no aliases, and the auto-tagger does not route through the code path that archives.
Every clip in the rapid tagger is one the ingest-time face gate already passed. Flagging one is therefore telling the system it was wrong, and those disagreements are the only way to find out whether the gate's thresholds — which have never been checked against a human — are anywhere near right. Flagging also fires an on-demand re-check so the label has a machine score sitting beside it, which is what makes it usable for tuning. If that check fails, the label is still saved; only the score is lost.
Face review
/tags/face-review is the other half of that exercise, and it is worth understanding why it exists before using it.
The face gate runs at ingest and permanently discards anything it calls faceless — 1,185 clips so far. It samples five frames spread across each clip and keeps the clip if any of them has a face, so a clip that was thrown away was faceless in all five, not merely unlucky in one. The rapid tagger can only ever show you clips it kept, so flagging there produces one kind of mistake (a faceless clip that got through) and can never produce the other (a clip with a face that got thrown away). Tuning the gate on one of those alone can only push it stricter, with no visibility into what that costs. This screen supplies the missing half.
These labels measure the detector, not the threshold. Every rejected row carries no score at all — measured across all 1,185 of them — because the detector reports a confidence only when it finds a face. A reject is not "scored low", it is "nothing found", and that is a property of the model as exported rather than something a small change can recover. So FACE_GATE_MIN_CONFIDENCE can only ever be tuned from the rapid tagger's side, where scores exist; what this screen tells you is how often the detector misses a face that is plainly there. Both are worth knowing, but they are different questions and a label here cannot answer the first one.
For the same reason the queue is ordered newest first rather than by score.
Labelling does not restore a clip. A frame marked "Has a face" stays skipped and is not re-ingested — clearing that today would just feed it back to the same gate that rejected it. Recovery, if it happens, follows a threshold change rather than fighting it, and is done in bulk afterwards.
When there are labels on both sides, npm run analyze:face-labels sweeps candidate thresholds and prints what each would have got right and wrong. It deliberately refuses to recommend one off a handful of rows.
Alias traps
All measured against production rather than hypothetical. They are why every seeded value uses a qualified alias rather than the obvious bare word — copy these patterns when you add anything from /tags.
facialis 92% noise as a bare word. It appears in 1,909 captions, but 1,755 of those are the phrase "facial expression" — this captioner's boilerplate. It is seeded, but only with qualified aliases (cum on face,facial cumshot), which is why it lands at 39 clips instead of 1,909. Never reintroduce the bare word.analover-matches as a bare word. Only 144 of 235 raw hits actually describe anal sex. Seeded withanal sex/anal penetration/ass fuckfor the same reason.reverse cowgirlcontainscowgirl. Handled in data:cowgirl's definition carriesreverse[ _-]?cowgirlas a negative alias, so the two can never both come from the same text. Copy that pattern for any new tag whose name is a substring of another.blowjob's generic oral aliases cannot tell whose genitals. Its alias list includesoral sexand bareoral, which name no recipient. Measured: 631 clips match a penis-specific alias, but another 802 match only the generic terms — and 88 of those also match cunnilingus, so they were taggedblowjobfrom the first backfill until this was fixed.blowjobnow carries the cunnilingus terms as negative aliases. The ~740 genuinely ambiguous clips, where the caption says "oral sex" and nothing more, stay onblowjob— the right call for this library.
Negative aliases only reach within a single caption field. The matcher walks the caption field by field, so a negative in one field cannot suppress a match in another. 82 clips still carry both blowjob and cunnilingus for exactly this reason: one field says "performing oral sex" (no cunnilingus term present, so the negative stays silent) while another says "cunnilingus". Some of those genuinely show both acts; others are the captioner describing one act two ways. If you add a tag whose negative aliases matter, expect them to work per-field, not per-clip.
Commands
npm run seed:tags— idempotent. Upserts the namespaces, tag definitions, and subreddit rules; a repeat run changes nothing.npm run backfill:tags -- --dry-run— prints the would-be tag distribution and writes nothing.npm run backfill:tags -- --resume— sweeps, skipping documents an earlier sweep already stamped.npm run backfill:tags -- --limit=500— caps how many documents get processed.npm run create:caption-indexes— provisions the database indexes tag filtering and the backfill cursor need. Run it once; tag filters are slow without it.
Always run --dry-run before a real backfill. Two separate defects during implementation would each have made a real run silently write nothing, and comparing the dry run's printed distribution against the live per-tag counts on /tags is the guard against a repeat. Expect the dry-run numbers to come in lower than the live ones: the dry run reflects caption-derived tags only, while the live totals also include subreddit-rule assignments — camera:pov, for instance, is entirely subreddit rules, since pov ships with no caption aliases at all.
Drop --resume when you want a sweep to reconsider documents an earlier run already stamped — for example after editing aliases or adding a subreddit rule. See docs/architecture/media-tagging.md for the cursor mechanics behind that.