diff --git a/nonpacks/library/migrations/0010_version_skins_manifest.py b/nonpacks/library/migrations/0010_version_skins_manifest.py new file mode 100644 index 0000000..8424884 --- /dev/null +++ b/nonpacks/library/migrations/0010_version_skins_manifest.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-08-05 14:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0009_version_plugins_manifest'), + ] + + operations = [ + migrations.AddField( + model_name='version', + name='skins_manifest', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/nonpacks/library/models.py b/nonpacks/library/models.py index ffbacfc..4378d6d 100644 --- a/nonpacks/library/models.py +++ b/nonpacks/library/models.py @@ -210,6 +210,7 @@ class Version(models.Model): pack_description = models.TextField(blank=True, default='') mods_manifest = models.JSONField(default=dict, blank=True) plugins_manifest = models.JSONField(default=dict, blank=True) + skins_manifest = models.JSONField(default=dict, blank=True) animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True) animation_manifest = models.JSONField(default=dict, blank=True) models_manifest = models.JSONField(default=dict, blank=True) diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index c971d6a..f809a15 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -42,6 +42,7 @@ from .zips import ( read_pack_meta, read_pack_mcmeta, read_plugins_manifest, + read_skins_manifest, ) @@ -333,6 +334,7 @@ def project_detail(request, slug): logical_manifest = None mods_manifest = None plugins_manifest = None + skins_manifest = None anim_stats = None latest = versions.first() if project.category == 'guide' and latest is not None: @@ -343,6 +345,7 @@ def project_detail(request, slug): logical_manifest = latest.logical_manifest or None mods_manifest = latest.mods_manifest or None plugins_manifest = latest.plugins_manifest or None + skins_manifest = latest.skins_manifest or None if animation_manifest: anims = animation_manifest.get('animations') or [] entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player') @@ -376,6 +379,7 @@ def project_detail(request, slug): 'logical_manifest': logical_manifest, 'mods_manifest': mods_manifest, 'plugins_manifest': plugins_manifest, + 'skins_manifest': skins_manifest, 'autoload_plugins_json': autoload_plugins_json, 'anim_stats': anim_stats, } @@ -652,6 +656,7 @@ def _reparse_version(version): version.models_manifest = read_models_manifest(path, version.project.category) or {} version.logical_manifest = read_logical_manifest(path) or {} version.plugins_manifest = read_plugins_manifest(path) or {} if version.project.category == 'model' else {} + version.skins_manifest = read_skins_manifest(path) or {} if version.project.category == 'skin' else {} manifest = parse_mods_manifest(path, release.file.original_filename) version.mods_manifest = manifest if manifest.get('files') else {} fmt, desc = read_pack_mcmeta(path) @@ -674,6 +679,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): pack_description = '' mods_manifest = {} plugins_manifest = None + skins_manifest = None animation_id = '' animation_manifest = None models_manifest = None @@ -718,6 +724,8 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): mods_manifest = manifest if plugins_manifest is None and project.category == 'model': plugins_manifest = read_plugins_manifest(path) + if skins_manifest is None and project.category == 'skin': + skins_manifest = read_skins_manifest(path) update_fields = [] if pack_format is not None: @@ -730,6 +738,9 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor): if plugins_manifest is not None: version.plugins_manifest = plugins_manifest update_fields.append('plugins_manifest') + if skins_manifest is not None: + version.skins_manifest = skins_manifest + update_fields.append('skins_manifest') if animation_id: version.animation_id = animation_id update_fields.append('animation_id') diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py index cf786db..98521cd 100644 --- a/nonpacks/library/zips.py +++ b/nonpacks/library/zips.py @@ -7,6 +7,7 @@ adopted file (under MEDIA_ROOT) and return plain dicts/values. import json import os +import struct import zipfile INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'} @@ -16,6 +17,48 @@ PROBLEM_TAGS = {'broken', 'bugged', 'borked'} MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb') +def _png_size(head): + """(width, height) from a PNG IHDR header, or None when not a PNG.""" + if len(head) < 24 or head[:8] != b'\x89PNG\r\n\x1a\n': + return None + return (struct.unpack('>I', head[16:20])[0], struct.unpack('>I', head[20:24])[0]) + + +SKIN_SIZES = {(64, 32), (64, 64), (128, 128)} + + +def read_skins_manifest(path): + """Scan the archive for Minecraft skin PNGs (64x32, 64x64 or 128x128). + + Returns None when no skins are found; otherwise {skins: [{name, member, + width, height}]} where ``member`` is the zip member served via pack_asset. + """ + zf = _open_zip(path) + if zf is None: + return None + try: + skins = [] + for name in sorted(n for n in set(zf.namelist()) if n.lower().endswith('.png')): + try: + with zf.open(name) as fh: + head = fh.read(24) + except (KeyError, OSError, zipfile.BadZipFile): + continue + size = _png_size(head) + if size and size in SKIN_SIZES: + skins.append({ + 'name': name.rsplit('/', 1)[-1], + 'member': name, + 'width': size[0], + 'height': size[1], + }) + if not skins: + return None + return {'skins': skins} + finally: + zf.close() + + def _in_plugins_dir(name): """True when a member lives inside a plugins/ folder (any nesting).""" parts = name.split('/') diff --git a/nonpacks/static/css/style.css b/nonpacks/static/css/style.css index cb7ab8b..6da4ff7 100644 --- a/nonpacks/static/css/style.css +++ b/nonpacks/static/css/style.css @@ -3484,3 +3484,73 @@ a.deletelink { padding: 0.1rem 0.5rem; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; } .plugins-size { color: var(--md-sys-color-on-surface-variant, #6b7280); font-size: 0.78rem; } + +/* Skins tab */ +.skin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 1rem; +} +.skin-card { + background: var(--md-sys-color-surface-variant, #45475a); + border: 1px solid var(--md-sys-color-outline-variant, #3a3f4d); + border-radius: 10px; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.skin-preview { + width: 100%; + aspect-ratio: 1; + object-fit: contain; + image-rendering: pixelated; + background: repeating-conic-gradient(#2a2e3a 0 25%, #232732 0 50%) 0 0 / 24px 24px; + border-radius: 6px; +} +.skin-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + font-size: 0.82rem; + color: var(--md-sys-color-on-surface, #cdd6f4); +} +.skin-card-head strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.skin-size-badge { + border-radius: 999px; + background: #313244; + color: #a6adc8; + padding: 0.1rem 0.5rem; + font-size: 0.68rem; + font-weight: 600; + flex: 0 0 auto; +} +.skin-card-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: auto; +} +.skin-model-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + font-size: 0.72rem; + color: var(--md-sys-color-on-surface-variant, #a6adc8); +} +.skin-model-select { + background: var(--md-sys-color-surface, #1e1e2e); + color: var(--md-sys-color-on-surface, #cdd6f4); + border: 1px solid var(--md-sys-color-outline-variant, #45475a); + border-radius: 6px; + padding: 0.15rem 0.3rem; + font-size: 0.75rem; +} +.skin-render-btn { width: 100%; } + diff --git a/nonpacks/static/js/packs_preview.js b/nonpacks/static/js/packs_preview.js index c0f92ec..3958ed6 100644 --- a/nonpacks/static/js/packs_preview.js +++ b/nonpacks/static/js/packs_preview.js @@ -173,15 +173,9 @@ return baseTextures.concat(featureTextures); } - async function open(btn) { - const member = btn.dataset.member; - const name = btn.dataset.name || member; - const modelFormat = btn.dataset.modelFormat || '.geo.json'; - const slug = btn.dataset.projectSlug || ''; - const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null }; - if (!member || !opts.baseUrl || busy) return; - busy = true; - + // Create (or reset) the full-screen Blockbench overlay + fresh iframe and + // wait for the app to be ready. Returns false on failure. + async function prepareOverlay(name) { const overlayEl = ensureOverlay(); overlayEl.hidden = false; document.querySelector('#bb-overlay-title').textContent = name; @@ -209,12 +203,20 @@ }; window.addEventListener('message', onMsg); }); + if (!ready) setStatus('Blockbench failed to start.'); + return ready; + } - if (!ready) { - setStatus('Blockbench failed to start.'); - busy = false; - return; - } + async function open(btn) { + const member = btn.dataset.member; + const name = btn.dataset.name || member; + const modelFormat = btn.dataset.modelFormat || '.geo.json'; + const slug = btn.dataset.projectSlug || ''; + const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null }; + if (!member || !opts.baseUrl || busy) return; + busy = true; + + if (!(await prepareOverlay(name))) { busy = false; return; } setStatus('Loading model…'); try { @@ -254,6 +256,34 @@ busy = false; } + // Load a Minecraft skin into Blockbench's built-in "Minecraft Skin" project. + async function openSkin(btn) { + const member = btn.dataset.member; + const name = btn.dataset.name || member; + const opts = { baseUrl: btn.dataset.baseUrl }; + if (!member || !opts.baseUrl || busy) return; + busy = true; + + if (!(await prepareOverlay(name))) { busy = false; return; } + + setStatus('Loading skin…'); + try { + const select = btn.closest('.skin-card')?.querySelector('.skin-model-select'); + const model = select ? select.value : 'steve'; + const img = await loadImage(assetUrl(opts.baseUrl, member)); + iframe.contentWindow.postMessage({ + type: 'packs-open-skin', + name: name, + model: model, + dataUrl: imageDataUrl(img), + }, window.location.origin); + setStatus('Applying…'); + } catch (e) { + setStatus('Could not load skin: ' + e.message); + } + busy = false; + } + window.addEventListener('message', (e) => { if (e.origin !== window.location.origin) return; if (!e.data) return; @@ -266,5 +296,5 @@ } }); - window.PacksPreview = { open: open, close: close }; + window.PacksPreview = { open: open, openSkin: openSkin, close: close }; })(); diff --git a/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js b/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js index 8ed99f4..9d6c88e 100644 --- a/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js +++ b/nonpacks/static/vendor/blockbench/plugins/packs_bootstrap.js @@ -124,8 +124,45 @@ } } + // Open a Minecraft Skin project (Steve/Alex player model) with the skin. + async function openSkin(msg) { + await waitFor(() => typeof Formats !== 'undefined' && !!Formats.skin, 15000); + const model = msg.model === 'alex' ? 'alex' : 'steve'; + const dialog = Formats.skin.setup_dialog; + dialog.show(); + await dialog.setFormValues({ + model: model, + texture_source: 'upload_texture', + texture_file: { content: msg.dataUrl, name: 'skin.png', path: 'skin.png' }, + layer_template: true, + }); + dialog.confirm(); + // Reveal every cube (incl. the outer layer) and land in Pose mode. + await new Promise((r) => setTimeout(r, 700)); + try { + (typeof Cube !== 'undefined' && Cube.all || []).forEach((c) => { c.visibility = true; }); + if (typeof Canvas !== 'undefined' && Canvas.updateAll) Canvas.updateAll(); + const pose = Modes && Modes.options && Modes.options.pose; + if (pose && typeof pose.select === 'function') pose.select(); + } catch (e) { + console.error('packs skin post-load', e); + } + notify('model-open', { name: msg.name || 'skin' }); + } + window.addEventListener('message', (e) => { - if (!e.data || e.data.type !== 'packs-open-model') return; + if (!e.data) return; + if (e.data.type === 'packs-open-skin') { + openSkin(e.data).then( + () => {}, + (err) => { + console.error('packs-open-skin failed', err); + notify('model-error', { name: e.data.name || '', error: String(err) }); + } + ); + return; + } + if (e.data.type !== 'packs-open-model') return; openModel(e.data).then( () => {}, (err) => { diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index eacb440..8093ef2 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -74,6 +74,7 @@ {% if logical_manifest %}{% endif %} {% if mods_manifest %}{% endif %} {% if plugins_manifest %}{% endif %} + {% if skins_manifest %}{% endif %} @@ -421,6 +422,37 @@ {% endif %} +{% if skins_manifest %} +
+
+

Skins

+
+ {% for skin in skins_manifest.skins %} +
+ {{ skin.name }} +
+ {{ skin.name }} + {{ skin.width }}x{{ skin.height }} +
+
+ + +
+
+ {% endfor %} +
+
+
+{% endif %} +