NSFW tagging: make "only dressed outfits ship" an enforced rule instead of a README line
The bodies list had grown to 20+ entries mixing nude anatomical bases, dressed NPCs and assembled outputs with no way to tell them apart — and the house rule "bases stay in the library, only dressed outfits ship into games" existed as exactly one unenforced line in README.md:45. Tagging without a gate would just be labelling; the gate is the point. Follows the convention already in use across the fleet rather than inventing a second one: a '-nsfw' suffix before the extension, as in ~/Documents/FBX (106 of 124 files tagged, with a matching `nsfw` column in _INVENTORY.csv). Filenames beat a sidecar database here because these assets get rsynced between six machines constantly — a tag in the name travels with the file, cannot drift out of sync, and is visible in Finder. · scan() reports `nsfw` per asset; the UI shows a badge, hides bases by default behind a "show bases" toggle, and says how many are hidden rather than silently shortening the list. · POST /api/tag renames an asset to add or remove the suffix. · Export into a live game directory now REFUSES anything nsfw-tagged (403). Verified both ways: a tagged stem is blocked, an untagged one still exports. · An assembled result INHERITS the body's tag. Clearing it automatically because "a garment was added" would be a silent wrong call — a hat does not clothe a nude base — and the failure mode is shipping a nude model into a game, so untagging stays deliberate. Verified in-browser: 1 base hidden with the count shown, badge renders, toggle reveals it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
037f6ef0af
commit
d6c0ba4c0e
53
server.py
53
server.py
@ -75,6 +75,29 @@ def slug(s):
|
||||
return re.sub(r'[^a-z0-9]+', '-', (s or '').lower()).strip('-')[:60] or 'x'
|
||||
|
||||
|
||||
# Asset classification lives in the FILENAME, matching the convention already in use across the
|
||||
# fleet (~/Documents/FBX: a '-nsfw' suffix before the extension, 106 of 124 files tagged, plus an
|
||||
# `nsfw` column in _INVENTORY.csv). Filenames beat a sidecar database here because assets get
|
||||
# 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)
|
||||
|
||||
|
||||
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."""
|
||||
stem, dot, ext = name.rpartition('.')
|
||||
if not dot:
|
||||
stem, ext = name, ''
|
||||
stem = NSFW_RE.sub('', stem).rstrip('-')
|
||||
if nsfw:
|
||||
stem += '-nsfw'
|
||||
return stem + (dot + ext if dot else '')
|
||||
|
||||
|
||||
def allowed(path):
|
||||
p = os.path.realpath(path)
|
||||
roots = list(DIRS.values()) + EXTRA_BODIES + [CACHE, OUTFITS, os.path.join(LIB, 'doll')]
|
||||
@ -98,6 +121,7 @@ def scan():
|
||||
p = os.path.join(dd, f)
|
||||
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
||||
'home': os.path.basename(dd),
|
||||
'nsfw': is_nsfw(f),
|
||||
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'})
|
||||
out[key] = rows
|
||||
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
|
||||
@ -355,6 +379,21 @@ class H(BaseHTTPRequestHandler):
|
||||
|
||||
body = json.loads(raw or b'{}')
|
||||
|
||||
if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset
|
||||
p, nsfw = body.get('path', ''), bool(body.get('nsfw'))
|
||||
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)
|
||||
if new == name:
|
||||
return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'})
|
||||
tgt = os.path.join(d, new)
|
||||
if os.path.exists(tgt):
|
||||
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})
|
||||
|
||||
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
|
||||
# hat placement died on reload. This is the persistence half of that tier.
|
||||
@ -505,6 +544,12 @@ class H(BaseHTTPRequestHandler):
|
||||
bad = [s for s in stems if '_i_' not in s]
|
||||
if bad:
|
||||
return self.j({'error': 'refusing to overwrite generic slot art: ' + bad[0]}, 400)
|
||||
# The house rule "bases stay in the library, only dressed outfits ship into games"
|
||||
# has lived as one unenforced line in the README. This is the enforcement.
|
||||
nsfw = [s for s in stems if is_nsfw(s)]
|
||||
if nsfw:
|
||||
return self.j({'error': f'refusing to export nsfw-tagged asset into a game '
|
||||
f'directory: {nsfw[0]}'}, 403)
|
||||
files = [(s, os.path.join(DOLL.DOLL_DIR, s + '.png')) for s in stems]
|
||||
missing = [s for s, p in files if not os.path.isfile(p)]
|
||||
if missing:
|
||||
@ -558,7 +603,13 @@ class H(BaseHTTPRequestHandler):
|
||||
elif op == 'assemble':
|
||||
b = glb_of(a['body'], jid)
|
||||
gs = [glb_of(g, jid) for g in a['garments']]
|
||||
out = os.path.join(DIRS['out'], slug(a.get('name') or 'outfit') + '.glb')
|
||||
# An assembled result INHERITS the body's nsfw tag. Clearing it automatically
|
||||
# because "a garment was added" would be a silent wrong call — a hat does not
|
||||
# clothe a nude base — and the failure mode is shipping a nude model into a
|
||||
# game. Untagging stays a deliberate act once you've looked at the result.
|
||||
out = os.path.join(DIRS['out'],
|
||||
tagged_name(slug(a.get('name') or 'outfit') + '.glb',
|
||||
is_nsfw(a['body'])))
|
||||
run_blender(['assemble', b, out] + gs, jid)
|
||||
elif op == 'convert':
|
||||
src = a['path']
|
||||
|
||||
@ -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 }
|
||||
.nsfw { background:#7a2233; color:#ffd9e0; border-radius:4px; padding:0 4px; margin-right:5px;
|
||||
font-size:10px; letter-spacing:.5px; font-weight:700 }
|
||||
/* 2D paper-doll stage — takes over the viewport while the doll tab is open */
|
||||
#dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
|
||||
background:#14120e; z-index:3 }
|
||||
@ -70,6 +72,10 @@
|
||||
<div class="col" id="left">
|
||||
<h2>bodies</h2>
|
||||
<input type="file" id="upBody" accept=".glb,.gltf,.fbx,.obj" style="margin-bottom:6px">
|
||||
<div class="row" style="margin:0 0 6px">
|
||||
<label style="width:auto"><input type="checkbox" id="showNsfw" style="width:auto"> show bases</label>
|
||||
<button id="tagBtn" class="ghost" style="margin-left:auto">tag / untag</button>
|
||||
</div>
|
||||
<div id="bodies"></div>
|
||||
<h2>body ops</h2>
|
||||
<div class="row"><label>height m</label><input id="scaleH" value="1.72"><button id="scaleBtn" class="ghost">scale</button></div>
|
||||
@ -221,6 +227,7 @@ const $ = id => document.getElementById(id);
|
||||
let LIB = {}, BODY = null, GARM = null, bodyRoot = null, garmRoot = null, mixer = null, clock = new THREE.Clock();
|
||||
let bones = [], attached = [], picked = new Set(); // picked = fitted garments ticked for assemble
|
||||
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
|
||||
let SHOW_NSFW = false; // anatomical bases hidden by default
|
||||
|
||||
// ---------- stage ----------
|
||||
const stage = $('stage');
|
||||
@ -401,14 +408,23 @@ function rowHtml(it, kind) {
|
||||
const on = (kind === 'bodies' && BODY && BODY.path === it.path) || (kind !== 'bodies' && GARM && GARM.path === it.path);
|
||||
const tick = kind === 'garments' && it.name.endsWith('-fitted.glb')
|
||||
? `<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>' : '';
|
||||
return `<div class="item ${on ? 'on' : ''}" data-k="${kind}" data-p="${it.path}">
|
||||
${tick}<span class="nm">${it.name}</span><span class="meta">${it.kb}kb · ${it.home}</span></div>`;
|
||||
${tick}<span class="nm">${it.name.replace(/-nsfw/i, '')}</span>
|
||||
<span class="meta">${badge}${it.kb}kb · ${it.home}</span></div>`;
|
||||
}
|
||||
function visible(rows) {
|
||||
return (rows || []).filter(r => SHOW_NSFW || !r.nsfw);
|
||||
}
|
||||
function renderLists() {
|
||||
$('bodies').innerHTML = (LIB.bodies || []).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
|
||||
$('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
|
||||
$('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
|
||||
$('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
|
||||
const nb = (LIB.bodies || []).filter(r => r.nsfw).length;
|
||||
$('bodies').innerHTML = visible(LIB.bodies).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
|
||||
if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `<div class="note">${nb} anatomical base/s hidden</div>`);
|
||||
$('garments').innerHTML = visible(LIB.garments).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
|
||||
$('genLib').innerHTML = visible(LIB.gen).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
|
||||
$('outs').innerHTML = visible(LIB.out).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
|
||||
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
|
||||
$('savedFits').innerHTML = (LIB.outfits || []).length
|
||||
? (LIB.outfits || []).map(n => `<button class="ghost fitchip" data-fit="${n}">${n}</button>`).join('')
|
||||
@ -437,6 +453,18 @@ function wireUpload(inputId, to) {
|
||||
}
|
||||
wireUpload('upBody', 'bodies'); wireUpload('upGarm', 'garments');
|
||||
|
||||
$('showNsfw').onchange = e => { SHOW_NSFW = e.target.checked; renderLists(); };
|
||||
$('tagBtn').onclick = async () => {
|
||||
const it = BODY || GARM;
|
||||
if (!it) return toast('select a body or garment first');
|
||||
const now = /-nsfw/i.test(it.name);
|
||||
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());
|
||||
if (r.error) return toast('✗ ' + r.error);
|
||||
toast(now ? 'untagged — this can now be exported to games' : 'tagged nsfw — blocked from game export');
|
||||
BODY = GARM = null; refresh();
|
||||
};
|
||||
|
||||
// ---------- jobs ----------
|
||||
function toast(t) { $('job').textContent = t; }
|
||||
async function runJob(url, payload, label) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user