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'
|
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):
|
def allowed(path):
|
||||||
p = os.path.realpath(path)
|
p = os.path.realpath(path)
|
||||||
roots = list(DIRS.values()) + EXTRA_BODIES + [CACHE, OUTFITS, os.path.join(LIB, 'doll')]
|
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)
|
p = os.path.join(dd, f)
|
||||||
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
|
||||||
'home': os.path.basename(dd),
|
'home': os.path.basename(dd),
|
||||||
|
'nsfw': is_nsfw(f),
|
||||||
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'})
|
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'})
|
||||||
out[key] = rows
|
out[key] = rows
|
||||||
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
|
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'{}')
|
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
|
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
|
||||||
# hat placement died on reload. This is the persistence half of that tier.
|
# 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]
|
bad = [s for s in stems if '_i_' not in s]
|
||||||
if bad:
|
if bad:
|
||||||
return self.j({'error': 'refusing to overwrite generic slot art: ' + bad[0]}, 400)
|
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]
|
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)]
|
missing = [s for s, p in files if not os.path.isfile(p)]
|
||||||
if missing:
|
if missing:
|
||||||
@ -558,7 +603,13 @@ class H(BaseHTTPRequestHandler):
|
|||||||
elif op == 'assemble':
|
elif op == 'assemble':
|
||||||
b = glb_of(a['body'], jid)
|
b = glb_of(a['body'], jid)
|
||||||
gs = [glb_of(g, jid) for g in a['garments']]
|
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)
|
run_blender(['assemble', b, out] + gs, jid)
|
||||||
elif op == 'convert':
|
elif op == 'convert':
|
||||||
src = a['path']
|
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 { 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 }
|
||||||
|
.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 */
|
/* 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;
|
#dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
|
||||||
background:#14120e; z-index:3 }
|
background:#14120e; z-index:3 }
|
||||||
@ -70,6 +72,10 @@
|
|||||||
<div class="col" id="left">
|
<div class="col" id="left">
|
||||||
<h2>bodies</h2>
|
<h2>bodies</h2>
|
||||||
<input type="file" id="upBody" accept=".glb,.gltf,.fbx,.obj" style="margin-bottom:6px">
|
<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>
|
<div id="bodies"></div>
|
||||||
<h2>body ops</h2>
|
<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>
|
<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 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 bones = [], attached = [], picked = new Set(); // picked = fitted garments ticked for assemble
|
||||||
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
|
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
|
||||||
|
let SHOW_NSFW = false; // anatomical bases hidden by default
|
||||||
|
|
||||||
// ---------- stage ----------
|
// ---------- stage ----------
|
||||||
const stage = $('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 on = (kind === 'bodies' && BODY && BODY.path === it.path) || (kind !== 'bodies' && GARM && GARM.path === it.path);
|
||||||
const tick = kind === 'garments' && it.name.endsWith('-fitted.glb')
|
const tick = kind === 'garments' && it.name.endsWith('-fitted.glb')
|
||||||
? `<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
|
||||||
|
// 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}">
|
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() {
|
function renderLists() {
|
||||||
$('bodies').innerHTML = (LIB.bodies || []).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
|
const nb = (LIB.bodies || []).filter(r => r.nsfw).length;
|
||||||
$('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
|
$('bodies').innerHTML = visible(LIB.bodies).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
|
||||||
$('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
|
if (nb && !SHOW_NSFW) $('bodies').insertAdjacentHTML('beforeend', `<div class="note">${nb} anatomical base/s hidden</div>`);
|
||||||
$('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</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
|
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
|
||||||
$('savedFits').innerHTML = (LIB.outfits || []).length
|
$('savedFits').innerHTML = (LIB.outfits || []).length
|
||||||
? (LIB.outfits || []).map(n => `<button class="ghost fitchip" data-fit="${n}">${n}</button>`).join('')
|
? (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');
|
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 ----------
|
// ---------- jobs ----------
|
||||||
function toast(t) { $('job').textContent = t; }
|
function toast(t) { $('job').textContent = t; }
|
||||||
async function runJob(url, payload, label) {
|
async function runJob(url, payload, label) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user