content tiers: add -swim between safe and nsfw

Swimwear/underwear is clothed (so not nsfw) but still wrong for a shopfront
NPC or a kids scene. Filename suffix like -nsfw, mutually exclusive with it,
surfaced as rating: safe|swim|nsfw on every library row. Tag button now
cycles safe -> swim -> nsfw -> safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 19:00:39 +10:00
parent 2854182cf9
commit 2c03d6f3fd
2 changed files with 46 additions and 14 deletions

View File

@ -134,20 +134,37 @@ def slug(s):
# rsynced between six machines constantly — a tag in the name travels with the file and can't
# drift out of sync, and it's visible in Finder.
NSFW_RE = re.compile(r'-nsfw(?=[.\s(]|$)', re.I)
# -swim is the middle tier: swimwear/underwear/lingerie. Clothed, so it isn't
# nsfw, but a bikini body is still wrong for a shopfront NPC or a kids' scene,
# so consumers that ask for "dressed" shouldn't silently get one.
SWIM_RE = re.compile(r'-swim(?=[.\s(]|$)', re.I)
def is_nsfw(path):
return bool(NSFW_RE.search(os.path.basename(path)))
def tagged_name(name, nsfw):
"""Add or remove the -nsfw suffix, preserving the extension."""
def is_swim(path):
return bool(SWIM_RE.search(os.path.basename(path)))
def rating(path):
"""Strictest tier that applies: nsfw > swim > safe."""
return 'nsfw' if is_nsfw(path) else 'swim' if is_swim(path) else 'safe'
def tagged_name(name, nsfw, swim=False):
"""Set the content-tier suffix, preserving the extension. Both tags are
stripped first, so this moves an asset between tiers rather than stacking
suffixes. nsfw wins if both are asked for."""
stem, dot, ext = name.rpartition('.')
if not dot:
stem, ext = name, ''
stem = NSFW_RE.sub('', stem).rstrip('-')
stem = SWIM_RE.sub('', NSFW_RE.sub('', stem)).rstrip('-')
if nsfw:
stem += '-nsfw'
elif swim:
stem += '-swim'
return stem + (dot + ext if dot else '')
@ -174,6 +191,7 @@ def scan():
p = os.path.join(dd, f)
row = {'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
'home': os.path.basename(dd), 'nsfw': is_nsfw(f),
'swim': is_swim(f), 'rating': rating(f),
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'}
if key == 'garments':
row.update(manifest_for(p, f))
@ -257,6 +275,8 @@ def manifest_for(path, name):
rec.setdefault('layer', LAYER.get(rec.get('slot'), 30))
rec.setdefault('tags', [])
rec['nsfw'] = is_nsfw(name)
rec['swim'] = is_swim(name)
rec['rating'] = rating(name)
return rec
@ -293,6 +313,8 @@ def doll_catalogue():
it['tags'] = rec.get('tags', [])
it['title'] = rec.get('title') or it['key'].replace('_', ' ')
it['nsfw'] = is_nsfw(it['path'])
it['swim'] = is_swim(it['path'])
it['rating'] = rating(it['path'])
it['tier'] = '2d'
return cat
@ -649,7 +671,7 @@ class H(BaseHTTPRequestHandler):
def fx(jid):
out = os.path.join(DIRS['bodies'],
tagged_name(slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigged.glb',
is_nsfw(p)))
is_nsfw(p), is_swim(p)))
cmd = [MIRPAMO, 'rig', p, '-o', out]
if body.get('tris'):
cmd += ['--tris', str(int(body['tris']))]
@ -686,12 +708,13 @@ class H(BaseHTTPRequestHandler):
save_manifest(m)
return self.j({'ok': True, 'rec': rec})
if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset
if u.path == '/api/tag': # set the content tier on an asset
p, nsfw = body.get('path', ''), bool(body.get('nsfw'))
swim = bool(body.get('swim'))
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library'}, 403)
d, name = os.path.split(p)
new = tagged_name(name, nsfw)
new = tagged_name(name, nsfw, swim)
if new == name:
return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'})
tgt = os.path.join(d, new)
@ -699,7 +722,8 @@ class H(BaseHTTPRequestHandler):
return self.j({'error': 'a file with that name already exists'}, 409)
os.rename(p, tgt)
# the convert cache is keyed on the source path, so a rename orphans its entry
return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw})
return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw,
'swim': swim and not nsfw, 'rating': rating(tgt)})
if u.path == '/api/outfit': # save a dress-up: body + attachments + clip + tints
# The rigid-attach sliders used to live only in browser memory ("preview only") — every

View File

@ -49,6 +49,8 @@
.sw { width:22px; height:22px; border-radius:6px; border:1px solid var(--line); cursor:pointer; padding:0 }
.sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 }
.fitchip { margin:3px 4px 0 0; font-weight:400 }
.swim { background:#1d5566; color:#cdf0ff; border-radius:4px; padding:0 4px; margin-right:5px;
font-size:10px; letter-spacing:.5px; }
.nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px;
font-size:10px; letter-spacing:.5px; font-weight:700 }
/* dressing-room grid — a wardrobe you browse by looking, not by reading filenames */
@ -462,9 +464,10 @@ function rowHtml(it, kind) {
? `<input type="checkbox" data-pick="${it.path}" ${picked.has(it.path) ? 'checked' : ''} onclick="event.stopPropagation()">` : '';
// the -nsfw suffix is the fleet-wide convention (see ~/Documents/FBX); surfacing it here is
// what makes "bases stay in the library" visible rather than a line in a README
const badge = it.nsfw ? '<span class="nsfw" title="anatomical base — blocked from game export">NSFW</span>' : '';
const badge = it.nsfw ? '<span class="nsfw" title="anatomical base — blocked from game export">NSFW</span>'
: it.swim ? '<span class="swim" title="swimwear/underwear — clothed, but not for shopfront or kids scenes">SWIM</span>' : '';
return `<div class="item ${on ? 'on' : ''}" data-k="${kind}" data-p="${it.path}">
${tick}<span class="nm">${it.name.replace(/-nsfw/i, '')}</span>
${tick}<span class="nm">${it.name.replace(/-(nsfw|swim)/i, '')}</span>
<span class="meta">${badge}${it.kb}kb · ${it.home}</span></div>`;
}
function visible(rows) {
@ -516,8 +519,8 @@ function renderGarmentGrid() {
const slot = g.slot ? `<span class="slot">${g.slot}${g.slot_inferred ? '?' : ''}</span>` : '';
return `<div class="card ${on ? 'on' : ''}" data-p="${g.path}" title="${g.name}">
<img loading="lazy" src="thumb?p=${encodeURIComponent(g.path)}" alt="">
${slot}${g.nsfw ? '<span class="warn">NSFW</span>' : ''}
<span class="cap">${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-nsfw/i, '')}</span>
${slot}${g.nsfw ? '<span class="warn">NSFW</span>' : g.swim ? '<span class="swim">SWIM</span>' : ''}
<span class="cap">${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-(nsfw|swim)/i, '')}</span>
</div>`;
}).join('') || '<div class="note">nothing matches — clear the filter or generate one →</div>';
@ -684,11 +687,16 @@ $('bodyTypeSave').onclick = async () => {
$('tagBtn').onclick = async () => {
const it = BODY || GARM;
if (!it) return toast('select a body or garment first');
const now = /-nsfw/i.test(it.name);
// three tiers now, so the button cycles safe -> swim -> nsfw -> safe rather
// than toggling one flag
const cur = /-nsfw/i.test(it.name) ? 'nsfw' : /-swim/i.test(it.name) ? 'swim' : 'safe';
const next = cur === 'safe' ? 'swim' : cur === 'swim' ? 'nsfw' : 'safe';
const r = await fetch('api/tag', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: it.path, nsfw: !now }) }).then(r => r.json());
body: JSON.stringify({ path: it.path, nsfw: next === 'nsfw', swim: next === 'swim' }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
toast(now ? 'untagged — this can now be exported to games' : 'tagged nsfw — blocked from game export');
toast(next === 'safe' ? 'untagged — this can now be exported to games'
: next === 'swim' ? 'tagged swim — clothed, but flagged for shopfront/kids scenes'
: 'tagged nsfw — blocked from game export');
BODY = GARM = null; refresh();
};