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:
parent
2854182cf9
commit
2c03d6f3fd
38
server.py
38
server.py
@ -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
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user