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
|
# 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.
|
# drift out of sync, and it's visible in Finder.
|
||||||
NSFW_RE = re.compile(r'-nsfw(?=[.\s(]|$)', re.I)
|
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):
|
def is_nsfw(path):
|
||||||
return bool(NSFW_RE.search(os.path.basename(path)))
|
return bool(NSFW_RE.search(os.path.basename(path)))
|
||||||
|
|
||||||
|
|
||||||
def tagged_name(name, nsfw):
|
def is_swim(path):
|
||||||
"""Add or remove the -nsfw suffix, preserving the extension."""
|
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('.')
|
stem, dot, ext = name.rpartition('.')
|
||||||
if not dot:
|
if not dot:
|
||||||
stem, ext = name, ''
|
stem, ext = name, ''
|
||||||
stem = NSFW_RE.sub('', stem).rstrip('-')
|
stem = SWIM_RE.sub('', NSFW_RE.sub('', stem)).rstrip('-')
|
||||||
if nsfw:
|
if nsfw:
|
||||||
stem += '-nsfw'
|
stem += '-nsfw'
|
||||||
|
elif swim:
|
||||||
|
stem += '-swim'
|
||||||
return stem + (dot + ext if dot else '')
|
return stem + (dot + ext if dot else '')
|
||||||
|
|
||||||
|
|
||||||
@ -174,6 +191,7 @@ def scan():
|
|||||||
p = os.path.join(dd, f)
|
p = os.path.join(dd, f)
|
||||||
row = {'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
row = {'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
||||||
'home': os.path.basename(dd), 'nsfw': is_nsfw(f),
|
'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'}
|
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'}
|
||||||
if key == 'garments':
|
if key == 'garments':
|
||||||
row.update(manifest_for(p, f))
|
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('layer', LAYER.get(rec.get('slot'), 30))
|
||||||
rec.setdefault('tags', [])
|
rec.setdefault('tags', [])
|
||||||
rec['nsfw'] = is_nsfw(name)
|
rec['nsfw'] = is_nsfw(name)
|
||||||
|
rec['swim'] = is_swim(name)
|
||||||
|
rec['rating'] = rating(name)
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
@ -293,6 +313,8 @@ def doll_catalogue():
|
|||||||
it['tags'] = rec.get('tags', [])
|
it['tags'] = rec.get('tags', [])
|
||||||
it['title'] = rec.get('title') or it['key'].replace('_', ' ')
|
it['title'] = rec.get('title') or it['key'].replace('_', ' ')
|
||||||
it['nsfw'] = is_nsfw(it['path'])
|
it['nsfw'] = is_nsfw(it['path'])
|
||||||
|
it['swim'] = is_swim(it['path'])
|
||||||
|
it['rating'] = rating(it['path'])
|
||||||
it['tier'] = '2d'
|
it['tier'] = '2d'
|
||||||
return cat
|
return cat
|
||||||
|
|
||||||
@ -649,7 +671,7 @@ class H(BaseHTTPRequestHandler):
|
|||||||
def fx(jid):
|
def fx(jid):
|
||||||
out = os.path.join(DIRS['bodies'],
|
out = os.path.join(DIRS['bodies'],
|
||||||
tagged_name(slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigged.glb',
|
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]
|
cmd = [MIRPAMO, 'rig', p, '-o', out]
|
||||||
if body.get('tris'):
|
if body.get('tris'):
|
||||||
cmd += ['--tris', str(int(body['tris']))]
|
cmd += ['--tris', str(int(body['tris']))]
|
||||||
@ -686,12 +708,13 @@ class H(BaseHTTPRequestHandler):
|
|||||||
save_manifest(m)
|
save_manifest(m)
|
||||||
return self.j({'ok': True, 'rec': rec})
|
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'))
|
p, nsfw = body.get('path', ''), bool(body.get('nsfw'))
|
||||||
|
swim = bool(body.get('swim'))
|
||||||
if not allowed(p) or not os.path.isfile(p):
|
if not allowed(p) or not os.path.isfile(p):
|
||||||
return self.j({'error': 'path outside library'}, 403)
|
return self.j({'error': 'path outside library'}, 403)
|
||||||
d, name = os.path.split(p)
|
d, name = os.path.split(p)
|
||||||
new = tagged_name(name, nsfw)
|
new = tagged_name(name, nsfw, swim)
|
||||||
if new == name:
|
if new == name:
|
||||||
return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'})
|
return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'})
|
||||||
tgt = os.path.join(d, new)
|
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)
|
return self.j({'error': 'a file with that name already exists'}, 409)
|
||||||
os.rename(p, tgt)
|
os.rename(p, tgt)
|
||||||
# the convert cache is keyed on the source path, so a rename orphans its entry
|
# 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
|
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
|
# 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 { 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 }
|
.sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 }
|
||||||
.fitchip { margin:3px 4px 0 0; font-weight:400 }
|
.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;
|
.nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px;
|
||||||
font-size:10px; letter-spacing:.5px; font-weight:700 }
|
font-size:10px; letter-spacing:.5px; font-weight:700 }
|
||||||
/* dressing-room grid — a wardrobe you browse by looking, not by reading filenames */
|
/* 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()">` : '';
|
? `<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
|
// 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
|
// 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}">
|
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>`;
|
<span class="meta">${badge}${it.kb}kb · ${it.home}</span></div>`;
|
||||||
}
|
}
|
||||||
function visible(rows) {
|
function visible(rows) {
|
||||||
@ -516,8 +519,8 @@ function renderGarmentGrid() {
|
|||||||
const slot = g.slot ? `<span class="slot">${g.slot}${g.slot_inferred ? '?' : ''}</span>` : '';
|
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}">
|
return `<div class="card ${on ? 'on' : ''}" data-p="${g.path}" title="${g.name}">
|
||||||
<img loading="lazy" src="thumb?p=${encodeURIComponent(g.path)}" alt="">
|
<img loading="lazy" src="thumb?p=${encodeURIComponent(g.path)}" alt="">
|
||||||
${slot}${g.nsfw ? '<span class="warn">NSFW</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/i, '')}</span>
|
<span class="cap">${(g.title || g.name).replace(/\.(glb|gltf|fbx|obj|png|jpg)$/i, '').replace(/-(nsfw|swim)/i, '')}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('') || '<div class="note">nothing matches — clear the filter or generate one →</div>';
|
}).join('') || '<div class="note">nothing matches — clear the filter or generate one →</div>';
|
||||||
|
|
||||||
@ -684,11 +687,16 @@ $('bodyTypeSave').onclick = async () => {
|
|||||||
$('tagBtn').onclick = async () => {
|
$('tagBtn').onclick = async () => {
|
||||||
const it = BODY || GARM;
|
const it = BODY || GARM;
|
||||||
if (!it) return toast('select a body or garment first');
|
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' },
|
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);
|
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();
|
BODY = GARM = null; refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user