SHRINKGOD v1: local GLB optimizer — synced compare viewer, rig-protected simplify, texture crunch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
ab8f5ee383
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
public/test-*.glb
|
||||
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# 🗜️ SHRINKGOD — local GLB optimizer
|
||||
|
||||
Drop in a heavy GLB, pick a target, compare original vs optimized side-by-side (synced camera, animation playback, wireframe), export. **Everything runs client-side in the browser** — gltf-transform + meshoptimizer WASM. No uploads, files never leave the machine.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run build # static build in dist/ — host anywhere, still fully local
|
||||
```
|
||||
|
||||
Dev test hook: `?test=<file-in-public>.glb&run=1` auto-loads and optimizes.
|
||||
|
||||
## Pipeline
|
||||
|
||||
dedup → flatten+join (static models only) → weld → per-primitive meshopt simplify → animation resample → prune → texture resize/re-encode → quantize → (optional) meshopt compression.
|
||||
|
||||
### Rig protection (automatic)
|
||||
- Skinned meshes never go below **50% kept geometry** and error is capped at 0.5% — vertex collapse near joints wrecks skin weights in motion.
|
||||
- Morph-target (blendshape) primitives are left untouched.
|
||||
- Mesh joining is disabled for rigged/animated models (would break node animation).
|
||||
- Best results: **optimize before rigging** — static geometry can be crunched far harder.
|
||||
|
||||
### Textures
|
||||
- Longest side clamped to the chosen max (canvas re-encode).
|
||||
- Color/emissive maps optionally converted to WebP (quality slider); normal/roughness/occlusion maps always stay lossless PNG.
|
||||
- Re-encode is skipped if it wouldn't shrink the file.
|
||||
|
||||
### Presets
|
||||
| | geometry kept | error | max texture | webp |
|
||||
|---|---|---|---|---|
|
||||
| Light | 50% | 0.5% | 2048 | no |
|
||||
| Balanced | 25% | 1% | 1024 | yes |
|
||||
| Crunch | 10% | 5% | 512 | yes |
|
||||
|
||||
## Known limits
|
||||
- Draco-compressed input not supported yet (friendly error; re-export without Draco).
|
||||
- Meshopt compression output (`EXT_meshopt_compression`) reads fine in three.js but **not in Blender** — leave it off for assets going back into a DCC.
|
||||
- KTX2/basis textures pass through untouched.
|
||||
|
||||
## Bench (M5 MacBook Pro, Balanced preset)
|
||||
- 15.8 MB photogrammetry room scan (151k tris, 13 MB textures) → **1.5 MB** (−90%) in 5 s.
|
||||
- 3.5 MB rigged character (18.6k tris, 20 clips) → **2.7 MB** (−24%), clips + skinning intact (rig clamp limits geometry reduction by design).
|
||||
106
index.html
Normal file
106
index.html
Normal file
@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>SHRINKGOD — local GLB optimizer</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗜️</text></svg>" />
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">🗜️ <b>SHRINKGOD</b><span class="sub">GLB optimizer</span></div>
|
||||
<div class="local-badge" title="Everything runs in this browser tab. No uploads.">🔒 100% local — files never leave this machine</div>
|
||||
</header>
|
||||
|
||||
<div id="layout">
|
||||
<aside id="sidebar">
|
||||
<section id="file-info" class="panel hidden">
|
||||
<div id="file-name" class="mono"></div>
|
||||
<div id="file-meta" class="dim"></div>
|
||||
<div id="rig-badge" class="badge hidden">🦴 rig detected — bones & skin weights protected</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Target</h3>
|
||||
<div class="preset-row">
|
||||
<button class="preset" data-preset="light">Light</button>
|
||||
<button class="preset active" data-preset="balanced">Balanced</button>
|
||||
<button class="preset" data-preset="crunch">Crunch</button>
|
||||
</div>
|
||||
<label class="slider-label">Geometry kept <span id="ratio-val" class="mono accent">25%</span>
|
||||
<input id="ratio" type="range" min="2" max="100" value="25" />
|
||||
</label>
|
||||
<label class="slider-label">Error tolerance <span id="error-val" class="mono accent">1.0%</span>
|
||||
<input id="error" type="range" min="1" max="100" value="32" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Textures</h3>
|
||||
<label class="row">Max size
|
||||
<select id="max-tex">
|
||||
<option value="99999">Original</option>
|
||||
<option value="2048">2048</option>
|
||||
<option value="1024" selected>1024</option>
|
||||
<option value="512">512</option>
|
||||
<option value="256">256</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row check"><input id="webp" type="checkbox" checked /> Convert color maps to WebP</label>
|
||||
<label class="slider-label">Quality <span id="quality-val" class="mono accent">85</span>
|
||||
<input id="quality" type="range" min="40" max="100" value="85" />
|
||||
</label>
|
||||
<div class="dim tiny">Normal / roughness maps stay lossless PNG.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3>Structure</h3>
|
||||
<label class="row check"><input id="join" type="checkbox" checked /> Join meshes <span class="dim tiny">(static only)</span></label>
|
||||
<label class="row check"><input id="quantize" type="checkbox" checked /> Quantize vertex data</label>
|
||||
<label class="row check"><input id="meshopt" type="checkbox" /> Meshopt compression <span class="dim tiny">(web loaders only — Blender can't read it)</span></label>
|
||||
</section>
|
||||
|
||||
<button id="optimize" class="big-btn" disabled>Optimize</button>
|
||||
<button id="export" class="big-btn secondary" disabled>Export GLB</button>
|
||||
<div id="notes" class="panel hidden"></div>
|
||||
</aside>
|
||||
|
||||
<main id="main">
|
||||
<div id="dropzone">
|
||||
<div class="dz-inner">
|
||||
<div class="dz-icon">⬇︎</div>
|
||||
<div>Drop a <b>.glb</b> here<br /><span class="dim">or</span></div>
|
||||
<button id="browse">Choose file</button>
|
||||
<input id="file-input" type="file" accept=".glb,model/gltf-binary" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewer-ui" class="hidden">
|
||||
<div id="toolbar">
|
||||
<label class="row check"><input id="wireframe" type="checkbox" /> Wireframe</label>
|
||||
<label class="row" id="anim-row" hidden>Clip
|
||||
<select id="anim-select"></select>
|
||||
<label class="row check"><input id="anim-play" type="checkbox" checked /> Play</label>
|
||||
</label>
|
||||
<div id="status" class="dim"></div>
|
||||
</div>
|
||||
<div id="viewers">
|
||||
<div class="pane-wrap">
|
||||
<div class="pane-label">ORIGINAL</div>
|
||||
<div id="pane-left" class="pane"></div>
|
||||
<div id="stats-left" class="stats"></div>
|
||||
</div>
|
||||
<div class="pane-wrap">
|
||||
<div class="pane-label">OPTIMIZED</div>
|
||||
<div id="pane-right" class="pane"><div id="right-empty" class="pane-empty">hit <b>Optimize</b></div></div>
|
||||
<div id="stats-right" class="stats"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1601
package-lock.json
generated
Normal file
1601
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "shrinkgod",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.4.2",
|
||||
"@gltf-transform/extensions": "^4.4.2",
|
||||
"@gltf-transform/functions": "^4.4.2",
|
||||
"meshoptimizer": "^1.2.0",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
239
src/main.js
Normal file
239
src/main.js
Normal file
@ -0,0 +1,239 @@
|
||||
import { CompareViewer } from './viewer.js';
|
||||
import { analyzeBytes, optimize, PRESETS } from './optimize.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const state = {
|
||||
name: null,
|
||||
origBytes: null,
|
||||
origReport: null,
|
||||
optBytes: null,
|
||||
optReport: null,
|
||||
viewer: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
// ---- error slider is log-scale: 0.01% .. 10% of mesh radius ----
|
||||
const sliderToError = (s) => Math.pow(10, -4 + 3 * (s / 100));
|
||||
const errorToSlider = (e) => Math.round(((Math.log10(e) + 4) / 3) * 100);
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB';
|
||||
return n + ' B';
|
||||
}
|
||||
const fmtInt = (n) => n.toLocaleString('en-US');
|
||||
|
||||
function updateLabels() {
|
||||
$('ratio-val').textContent = $('ratio').value + '%';
|
||||
const e = sliderToError(+$('error').value);
|
||||
$('error-val').textContent = (e * 100).toPrecision(2) + '%';
|
||||
$('quality-val').textContent = $('quality').value;
|
||||
}
|
||||
|
||||
function applyPreset(name) {
|
||||
const p = PRESETS[name];
|
||||
$('ratio').value = Math.round(p.ratio * 100);
|
||||
$('error').value = errorToSlider(p.error);
|
||||
$('max-tex').value = String(p.maxTex <= 4096 ? p.maxTex : 99999);
|
||||
$('webp').checked = p.webp;
|
||||
document.querySelectorAll('.preset').forEach((b) =>
|
||||
b.classList.toggle('active', b.dataset.preset === name)
|
||||
);
|
||||
updateLabels();
|
||||
}
|
||||
|
||||
function currentOpts() {
|
||||
return {
|
||||
ratio: +$('ratio').value / 100,
|
||||
error: sliderToError(+$('error').value),
|
||||
maxTex: +$('max-tex').value,
|
||||
webp: $('webp').checked,
|
||||
texQuality: +$('quality').value / 100,
|
||||
join: $('join').checked,
|
||||
quantize: $('quantize').checked,
|
||||
meshopt: $('meshopt').checked,
|
||||
};
|
||||
}
|
||||
|
||||
function statsTable(report, base) {
|
||||
const rows = [
|
||||
['file', fmtBytes(report.bytes), base && base.bytes],
|
||||
['triangles', fmtInt(report.tris), base && base.tris],
|
||||
['vertices', fmtInt(report.verts), base && base.verts],
|
||||
['draw calls', fmtInt(report.drawCalls), base && base.drawCalls],
|
||||
['textures', `${report.textures.length} (${fmtBytes(report.texBytes)})`, base && base.texBytes],
|
||||
];
|
||||
const rawVals = [report.bytes, report.tris, report.verts, report.drawCalls, report.texBytes];
|
||||
let html = '<table>';
|
||||
rows.forEach(([label, val, baseVal], i) => {
|
||||
let delta = '';
|
||||
if (baseVal != null && baseVal > 0) {
|
||||
const pct = ((rawVals[i] - baseVal) / baseVal) * 100;
|
||||
const cls = pct <= 0 ? 'delta-good' : 'delta-bad';
|
||||
delta = `<td class="num ${cls}">${pct > 0 ? '+' : ''}${pct.toFixed(0)}%</td>`;
|
||||
}
|
||||
html += `<tr><td class="dim">${label}</td><td class="num">${val}</td>${delta}</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
const extras = [];
|
||||
if (report.animations.length) extras.push(`${report.animations.length} clip(s)`);
|
||||
if (report.skins) extras.push(`${report.skins} skin(s)`);
|
||||
if (report.morphPrims) extras.push(`${report.morphPrims} morph prim(s)`);
|
||||
if (extras.length) html += `<div class="dim tiny">${extras.join(' · ')}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
$('status').textContent = text;
|
||||
}
|
||||
|
||||
async function loadFile(file) {
|
||||
const bytes = await file.arrayBuffer();
|
||||
setStatus('reading…');
|
||||
let report;
|
||||
try {
|
||||
report = await analyzeBytes(bytes);
|
||||
} catch (e) {
|
||||
if (/draco/i.test(String(e))) {
|
||||
alert('This GLB is Draco-compressed — not supported yet. Re-export without Draco (it will still come out smaller here).');
|
||||
} else {
|
||||
alert('Could not read this file as a GLB:\n' + (e.message || e));
|
||||
}
|
||||
setStatus('');
|
||||
return;
|
||||
}
|
||||
|
||||
state.name = file.name;
|
||||
state.origBytes = bytes;
|
||||
state.origReport = report;
|
||||
state.optBytes = null;
|
||||
state.optReport = null;
|
||||
|
||||
$('dropzone').classList.add('hidden');
|
||||
$('viewer-ui').classList.remove('hidden');
|
||||
$('file-info').classList.remove('hidden');
|
||||
$('file-name').textContent = file.name;
|
||||
$('file-meta').textContent = `${fmtBytes(report.bytes)} · ${fmtInt(report.tris)} tris · ${report.meshes} mesh(es) · ${report.materials} material(s)`;
|
||||
|
||||
const rigged = report.skins > 0 || report.animations.length > 0;
|
||||
$('rig-badge').classList.toggle('hidden', !rigged);
|
||||
$('join').disabled = rigged;
|
||||
if (rigged) $('join').checked = false;
|
||||
|
||||
if (!state.viewer) {
|
||||
state.viewer = new CompareViewer($('pane-left'), $('pane-right'), $('viewers'));
|
||||
}
|
||||
state.viewer.clear('right');
|
||||
$('right-empty').style.display = 'flex';
|
||||
await state.viewer.load('left', bytes.slice(0));
|
||||
|
||||
// animation UI
|
||||
const names = state.viewer.clipNames();
|
||||
$('anim-row').hidden = names.length === 0;
|
||||
if (names.length) {
|
||||
$('anim-select').innerHTML = names
|
||||
.map((n, i) => `<option value="${i}">${n}</option>`)
|
||||
.join('');
|
||||
state.viewer.playClip(0);
|
||||
}
|
||||
|
||||
$('stats-left').innerHTML = statsTable(report, null);
|
||||
$('stats-right').innerHTML = '';
|
||||
$('notes').classList.add('hidden');
|
||||
$('optimize').disabled = false;
|
||||
$('export').disabled = true;
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
async function runOptimize() {
|
||||
if (state.busy || !state.origBytes) return;
|
||||
state.busy = true;
|
||||
$('optimize').disabled = true;
|
||||
$('optimize').textContent = 'Crunching…';
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const { bytes, report, notes } = await optimize(state.origBytes, currentOpts(), (s) =>
|
||||
setStatus(s + '…')
|
||||
);
|
||||
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
state.optBytes = bytes;
|
||||
state.optReport = report;
|
||||
|
||||
$('right-empty').style.display = 'none';
|
||||
await state.viewer.load('right', bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
||||
const sel = +($('anim-select').value || 0);
|
||||
if (state.viewer.clipNames().length) state.viewer.playClip(sel);
|
||||
|
||||
$('stats-right').innerHTML = statsTable(report, state.origReport);
|
||||
const saved = 1 - report.bytes / state.origReport.bytes;
|
||||
setStatus(`done in ${secs}s — ${(saved * 100).toFixed(0)}% smaller`);
|
||||
|
||||
const notesEl = $('notes');
|
||||
notesEl.classList.toggle('hidden', notes.length === 0);
|
||||
notesEl.innerHTML = notes.map((n) => `<div>· ${n}</div>`).join('');
|
||||
|
||||
$('export').disabled = false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Optimize failed:\n' + (e.message || e));
|
||||
setStatus('failed');
|
||||
} finally {
|
||||
state.busy = false;
|
||||
$('optimize').disabled = false;
|
||||
$('optimize').textContent = 'Optimize';
|
||||
}
|
||||
}
|
||||
|
||||
function exportGlb() {
|
||||
if (!state.optBytes) return;
|
||||
const base = state.name.replace(/\.glb$/i, '');
|
||||
const blob = new Blob([state.optBytes], { type: 'model/gltf-binary' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${base}.opt.glb`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// ---- wiring ----
|
||||
document.querySelectorAll('.preset').forEach((b) =>
|
||||
b.addEventListener('click', () => applyPreset(b.dataset.preset))
|
||||
);
|
||||
['ratio', 'error', 'quality'].forEach((id) => $(id).addEventListener('input', updateLabels));
|
||||
$('optimize').addEventListener('click', runOptimize);
|
||||
$('export').addEventListener('click', exportGlb);
|
||||
$('browse').addEventListener('click', () => $('file-input').click());
|
||||
$('file-input').addEventListener('change', (e) => {
|
||||
if (e.target.files[0]) loadFile(e.target.files[0]);
|
||||
});
|
||||
$('wireframe').addEventListener('change', (e) => state.viewer?.setWireframe(e.target.checked));
|
||||
$('anim-select').addEventListener('change', (e) => state.viewer?.playClip(+e.target.value));
|
||||
$('anim-play').addEventListener('change', (e) => state.viewer?.setPlaying(e.target.checked));
|
||||
|
||||
window.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
$('dropzone').classList.add('drag');
|
||||
});
|
||||
window.addEventListener('dragleave', () => $('dropzone').classList.remove('drag'));
|
||||
window.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
$('dropzone').classList.remove('drag');
|
||||
const file = [...(e.dataTransfer?.files || [])].find((f) => /\.glb$/i.test(f.name));
|
||||
if (file) loadFile(file);
|
||||
});
|
||||
|
||||
applyPreset('balanced');
|
||||
|
||||
// ---- dev test hook: ?test=file.glb&run=1 loads from /public and optimizes ----
|
||||
const params = new URLSearchParams(location.search);
|
||||
const testFile = params.get('test');
|
||||
if (testFile) {
|
||||
(async () => {
|
||||
const res = await fetch('/' + testFile);
|
||||
const buf = await res.arrayBuffer();
|
||||
await loadFile(new File([buf], testFile));
|
||||
if (params.get('run')) await runOptimize();
|
||||
window.__testDone = true;
|
||||
})();
|
||||
}
|
||||
274
src/optimize.js
Normal file
274
src/optimize.js
Normal file
@ -0,0 +1,274 @@
|
||||
import { WebIO } from '@gltf-transform/core';
|
||||
import {
|
||||
ALL_EXTENSIONS,
|
||||
EXTTextureWebP,
|
||||
EXTMeshoptCompression,
|
||||
} from '@gltf-transform/extensions';
|
||||
import {
|
||||
dedup,
|
||||
prune,
|
||||
weld,
|
||||
weldPrimitive,
|
||||
simplifyPrimitive,
|
||||
resample,
|
||||
flatten,
|
||||
join,
|
||||
quantize,
|
||||
listTextureSlots,
|
||||
} from '@gltf-transform/functions';
|
||||
import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
|
||||
|
||||
const TRIANGLES = 4;
|
||||
|
||||
let _io = null;
|
||||
async function getIO() {
|
||||
if (!_io) {
|
||||
await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]);
|
||||
_io = new WebIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({
|
||||
'meshopt.decoder': MeshoptDecoder,
|
||||
'meshopt.encoder': MeshoptEncoder,
|
||||
});
|
||||
}
|
||||
return _io;
|
||||
}
|
||||
|
||||
export const PRESETS = {
|
||||
light: { ratio: 0.5, error: 0.005, maxTex: 2048, webp: false },
|
||||
balanced: { ratio: 0.25, error: 0.01, maxTex: 1024, webp: true },
|
||||
crunch: { ratio: 0.1, error: 0.05, maxTex: 512, webp: true },
|
||||
};
|
||||
|
||||
// Skinned meshes never go below this keep-ratio, and error is capped —
|
||||
// collapsing verts near joints wrecks skin weights and silhouettes in motion.
|
||||
const SKINNED_MIN_RATIO = 0.5;
|
||||
const SKINNED_MAX_ERROR = 0.005;
|
||||
|
||||
function skinnedMeshSet(root) {
|
||||
const set = new Set();
|
||||
for (const node of root.listNodes()) {
|
||||
if (node.getSkin() && node.getMesh()) set.add(node.getMesh());
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
export function reportDoc(doc, byteLength) {
|
||||
const root = doc.getRoot();
|
||||
let tris = 0;
|
||||
let verts = 0;
|
||||
let prims = 0;
|
||||
let morphPrims = 0;
|
||||
for (const mesh of root.listMeshes()) {
|
||||
for (const prim of mesh.listPrimitives()) {
|
||||
prims++;
|
||||
if (prim.listTargets().length > 0) morphPrims++;
|
||||
const pos = prim.getAttribute('POSITION');
|
||||
if (!pos) continue;
|
||||
verts += pos.getCount();
|
||||
const idx = prim.getIndices();
|
||||
const count = idx ? idx.getCount() : pos.getCount();
|
||||
if (prim.getMode() === TRIANGLES) tris += Math.floor(count / 3);
|
||||
}
|
||||
}
|
||||
let drawCalls = 0;
|
||||
for (const node of root.listNodes()) {
|
||||
const mesh = node.getMesh();
|
||||
if (mesh) drawCalls += mesh.listPrimitives().length;
|
||||
}
|
||||
const textures = root.listTextures().map((tex) => {
|
||||
let size = null;
|
||||
try { size = tex.getSize(); } catch { /* unknown mime */ }
|
||||
return {
|
||||
name: tex.getName() || '',
|
||||
mime: tex.getMimeType(),
|
||||
bytes: tex.getImage() ? tex.getImage().byteLength : 0,
|
||||
size,
|
||||
};
|
||||
});
|
||||
return {
|
||||
bytes: byteLength,
|
||||
tris,
|
||||
verts,
|
||||
prims,
|
||||
morphPrims,
|
||||
drawCalls,
|
||||
meshes: root.listMeshes().length,
|
||||
materials: root.listMaterials().length,
|
||||
textures,
|
||||
texBytes: textures.reduce((s, t) => s + t.bytes, 0),
|
||||
animations: root.listAnimations().map((a) => a.getName() || 'clip'),
|
||||
skins: root.listSkins().length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function analyzeBytes(bytes) {
|
||||
const io = await getIO();
|
||||
const doc = await io.readBinary(new Uint8Array(bytes));
|
||||
return reportDoc(doc, bytes.byteLength);
|
||||
}
|
||||
|
||||
function isColorSlot(slot) {
|
||||
return /base|diffuse|emissive|sheenColor|specularColor/i.test(slot);
|
||||
}
|
||||
|
||||
async function processTextures(doc, opts, notes) {
|
||||
const textures = doc.getRoot().listTextures();
|
||||
let touched = 0;
|
||||
for (const tex of textures) {
|
||||
const image = tex.getImage();
|
||||
if (!image) continue;
|
||||
const mime = tex.getMimeType();
|
||||
if (!/^image\/(png|jpeg|webp)$/.test(mime)) continue; // ktx2 etc: leave alone
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(new Blob([image], { type: mime }), {
|
||||
colorSpaceConversion: 'none',
|
||||
premultiplyAlpha: 'none',
|
||||
});
|
||||
} catch {
|
||||
notes.push(`could not decode texture "${tex.getName() || mime}" — left as-is`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const longest = Math.max(bitmap.width, bitmap.height);
|
||||
const scale = Math.min(1, opts.maxTex / longest);
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const needsResize = scale < 1;
|
||||
|
||||
// Data textures (normal/ORM/etc) stay lossless; color can go webp/jpeg.
|
||||
const slots = listTextureSlots(tex);
|
||||
const color = slots.length === 0 || slots.some(isColorSlot);
|
||||
let targetMime;
|
||||
if (!color) targetMime = 'image/png';
|
||||
else if (opts.webp) targetMime = 'image/webp';
|
||||
else targetMime = mime === 'image/webp' ? 'image/webp' : mime; // never png->jpeg (alpha)
|
||||
|
||||
if (!needsResize && targetMime === mime && mime === 'image/png') {
|
||||
bitmap.close();
|
||||
continue; // nothing to gain re-encoding png->png at same size
|
||||
}
|
||||
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
const lossy = targetMime !== 'image/png';
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: targetMime,
|
||||
quality: lossy ? opts.texQuality : undefined,
|
||||
});
|
||||
const out = new Uint8Array(await blob.arrayBuffer());
|
||||
|
||||
// Keep the original if we somehow made it bigger without shrinking dims.
|
||||
if (!needsResize && out.byteLength >= image.byteLength) continue;
|
||||
tex.setImage(out).setMimeType(blob.type);
|
||||
touched++;
|
||||
}
|
||||
if (doc.getRoot().listTextures().some((t) => t.getMimeType() === 'image/webp')) {
|
||||
doc.createExtension(EXTTextureWebP).setRequired(true);
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* bytes: ArrayBuffer of a .glb
|
||||
* opts: { ratio, error, maxTex, webp, texQuality, join, quantize, meshopt }
|
||||
*/
|
||||
export async function optimize(bytes, opts, onStatus = () => {}) {
|
||||
const io = await getIO();
|
||||
await MeshoptSimplifier.ready;
|
||||
const notes = [];
|
||||
|
||||
onStatus('parsing');
|
||||
const doc = await io.readBinary(new Uint8Array(bytes.slice(0)));
|
||||
const root = doc.getRoot();
|
||||
const rigged = root.listSkins().length > 0 || root.listAnimations().length > 0;
|
||||
|
||||
onStatus('deduplicating');
|
||||
await doc.transform(dedup());
|
||||
|
||||
if (opts.join && !rigged) {
|
||||
onStatus('joining meshes');
|
||||
try {
|
||||
await doc.transform(flatten(), join());
|
||||
} catch (e) {
|
||||
notes.push(`join skipped: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
onStatus('welding');
|
||||
await doc.transform(weld());
|
||||
|
||||
onStatus('simplifying geometry');
|
||||
const skinned = skinnedMeshSet(root);
|
||||
let skippedMorph = 0;
|
||||
let clampedSkinned = 0;
|
||||
for (const mesh of root.listMeshes()) {
|
||||
const isSkinned = skinned.has(mesh);
|
||||
for (const prim of mesh.listPrimitives()) {
|
||||
if (prim.getMode() !== TRIANGLES) continue;
|
||||
if (prim.listTargets().length > 0) {
|
||||
skippedMorph++;
|
||||
continue;
|
||||
}
|
||||
let { ratio, error } = opts;
|
||||
let lockBorder = false;
|
||||
if (isSkinned) {
|
||||
if (ratio < SKINNED_MIN_RATIO) {
|
||||
ratio = SKINNED_MIN_RATIO;
|
||||
clampedSkinned++;
|
||||
}
|
||||
error = Math.min(error, SKINNED_MAX_ERROR);
|
||||
lockBorder = true;
|
||||
}
|
||||
try {
|
||||
weldPrimitive(prim);
|
||||
simplifyPrimitive(prim, { simplifier: MeshoptSimplifier, ratio, error, lockBorder });
|
||||
} catch (e) {
|
||||
notes.push(`simplify skipped on "${mesh.getName() || 'mesh'}": ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.listAnimations().length > 0) {
|
||||
onStatus('resampling animations');
|
||||
try {
|
||||
await doc.transform(resample());
|
||||
} catch (e) {
|
||||
notes.push(`animation resample skipped: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
onStatus('pruning');
|
||||
await doc.transform(prune());
|
||||
|
||||
onStatus('processing textures');
|
||||
await processTextures(doc, opts, notes);
|
||||
|
||||
if (opts.quantize || opts.meshopt) {
|
||||
onStatus('quantizing');
|
||||
try {
|
||||
await doc.transform(quantize());
|
||||
} catch (e) {
|
||||
notes.push(`quantize skipped: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.meshopt) {
|
||||
doc
|
||||
.createExtension(EXTMeshoptCompression)
|
||||
.setRequired(true)
|
||||
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.FILTER });
|
||||
}
|
||||
|
||||
if (skippedMorph) notes.push(`${skippedMorph} morph-target primitive(s) left untouched (blendshape protection)`);
|
||||
if (clampedSkinned) notes.push(`${clampedSkinned} skinned primitive(s) clamped to keep ≥${SKINNED_MIN_RATIO * 100}% (rig protection)`);
|
||||
|
||||
onStatus('writing glb');
|
||||
const out = await io.writeBinary(doc);
|
||||
const report = reportDoc(doc, out.byteLength);
|
||||
return { bytes: out, report, notes, rigged };
|
||||
}
|
||||
221
src/style.css
Normal file
221
src/style.css
Normal file
@ -0,0 +1,221 @@
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0c0e11;
|
||||
--panel: #14171c;
|
||||
--panel-2: #1a1e24;
|
||||
--border: #262b33;
|
||||
--text: #d7dce3;
|
||||
--dim: #8a93a0;
|
||||
--accent: #7ee787;
|
||||
--accent-dim: #2e5138;
|
||||
--bad: #ff7b72;
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 -apple-system, system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.dim { color: var(--dim); }
|
||||
.tiny { font-size: 11px; }
|
||||
.accent { color: var(--accent); }
|
||||
.hidden, [hidden] { display: none !important; }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand { font-size: 16px; letter-spacing: 0.5px; }
|
||||
.brand .sub { color: var(--dim); font-size: 12px; margin-left: 10px; }
|
||||
.local-badge {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
#layout { display: flex; flex: 1 1 auto; min-height: 0; }
|
||||
|
||||
#sidebar {
|
||||
width: 270px;
|
||||
flex: 0 0 auto;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.panel h3 {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#file-name { font-size: 13px; word-break: break-all; }
|
||||
#file-meta { font-size: 12px; margin-top: 4px; }
|
||||
.badge {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
background: #2a2416;
|
||||
color: #e3b341;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.preset-row { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||
.preset {
|
||||
flex: 1;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.preset.active { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.slider-label { display: block; font-size: 12px; color: var(--dim); margin-top: 8px; }
|
||||
.slider-label span { float: right; }
|
||||
input[type="range"] { width: 100%; accent-color: var(--accent); margin-top: 4px; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-top: 6px; }
|
||||
.row.check { cursor: pointer; }
|
||||
.row select {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
input[type="checkbox"] { accent-color: var(--accent); }
|
||||
|
||||
.big-btn {
|
||||
padding: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #08110a;
|
||||
}
|
||||
.big-btn.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
||||
.big-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
#notes { font-size: 12px; color: var(--dim); }
|
||||
#notes div { margin-top: 4px; }
|
||||
|
||||
#main { flex: 1 1 auto; position: relative; display: flex; min-width: 0; }
|
||||
|
||||
#dropzone {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
}
|
||||
#dropzone.drag { background: rgba(126, 231, 135, 0.06); }
|
||||
.dz-inner {
|
||||
text-align: center;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 60px 80px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.dz-icon { font-size: 42px; margin-bottom: 12px; }
|
||||
#browse {
|
||||
margin-top: 14px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#viewer-ui { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#toolbar .row { margin-top: 0; }
|
||||
#status { margin-left: auto; font-size: 12px; }
|
||||
|
||||
#viewers { flex: 1; display: flex; min-height: 0; }
|
||||
.pane-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.pane-wrap + .pane-wrap { border-left: 1px solid var(--border); }
|
||||
.pane-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--dim);
|
||||
background: rgba(12, 14, 17, 0.7);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pane { flex: 1; min-height: 0; position: relative; }
|
||||
.pane canvas { display: block; width: 100%; height: 100%; }
|
||||
.pane-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dim);
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex: 0 0 auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
min-height: 76px;
|
||||
}
|
||||
.stats table { width: 100%; border-collapse: collapse; }
|
||||
.stats td { padding: 1px 6px 1px 0; }
|
||||
.stats td.num { text-align: right; }
|
||||
.delta-good { color: var(--accent); }
|
||||
.delta-bad { color: var(--bad); }
|
||||
189
src/viewer.js
Normal file
189
src/viewer.js
Normal file
@ -0,0 +1,189 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { MeshoptDecoder } from 'meshoptimizer';
|
||||
|
||||
const BG = new THREE.Color(0x0c0e11);
|
||||
|
||||
function disposeObject(obj) {
|
||||
obj.traverse((child) => {
|
||||
if (child.geometry) child.geometry.dispose();
|
||||
const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
|
||||
for (const m of mats) {
|
||||
for (const key of Object.keys(m)) {
|
||||
if (m[key] && m[key].isTexture) m[key].dispose();
|
||||
}
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class Pane {
|
||||
constructor(el) {
|
||||
this.el = el;
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
el.appendChild(this.renderer.domElement);
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = BG;
|
||||
const pmrem = new THREE.PMREMGenerator(this.renderer);
|
||||
this.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
||||
pmrem.dispose();
|
||||
|
||||
this.grid = new THREE.GridHelper(4, 20, 0x2c3138, 0x1b1f24);
|
||||
this.scene.add(this.grid);
|
||||
|
||||
this.model = null;
|
||||
this.mixer = null;
|
||||
this.clips = [];
|
||||
}
|
||||
|
||||
setModel(gltf) {
|
||||
if (this.model) {
|
||||
this.scene.remove(this.model);
|
||||
disposeObject(this.model);
|
||||
}
|
||||
this.model = gltf.scene;
|
||||
this.clips = gltf.animations || [];
|
||||
this.mixer = this.clips.length ? new THREE.AnimationMixer(this.model) : null;
|
||||
this.scene.add(this.model);
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.el.clientWidth;
|
||||
const h = this.el.clientHeight;
|
||||
if (w && h) this.renderer.setSize(w, h, false);
|
||||
return w / Math.max(1, h);
|
||||
}
|
||||
}
|
||||
|
||||
export class CompareViewer {
|
||||
constructor(leftEl, rightEl, dragEl) {
|
||||
this.left = new Pane(leftEl);
|
||||
this.right = new Pane(rightEl);
|
||||
this.panes = [this.left, this.right];
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
|
||||
this.camera.position.set(2, 1.5, 3);
|
||||
|
||||
// One controls instance on the shared container: both panes stay in sync.
|
||||
this.controls = new OrbitControls(this.camera, dragEl);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.12;
|
||||
|
||||
this.loader = new GLTFLoader();
|
||||
this.loader.setMeshoptDecoder(MeshoptDecoder);
|
||||
|
||||
this.clock = new THREE.Clock();
|
||||
this.playing = true;
|
||||
this.wireframe = false;
|
||||
this.framed = false;
|
||||
|
||||
const onResize = () => {
|
||||
const aspect = this.left.resize();
|
||||
this.right.resize();
|
||||
this.camera.aspect = aspect;
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
new ResizeObserver(onResize).observe(leftEl);
|
||||
onResize();
|
||||
|
||||
this.renderer = this.renderLoop.bind(this);
|
||||
requestAnimationFrame(this.renderer);
|
||||
}
|
||||
|
||||
parse(arrayBuffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.loader.parse(arrayBuffer, '', resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
async load(side, arrayBuffer) {
|
||||
const gltf = await this.parse(arrayBuffer);
|
||||
const pane = side === 'left' ? this.left : this.right;
|
||||
pane.setModel(gltf);
|
||||
this.applyWireframe(pane);
|
||||
if (side === 'left') {
|
||||
this.frame(gltf.scene);
|
||||
}
|
||||
return gltf;
|
||||
}
|
||||
|
||||
clear(side) {
|
||||
const pane = side === 'left' ? this.left : this.right;
|
||||
if (pane.model) {
|
||||
pane.scene.remove(pane.model);
|
||||
disposeObject(pane.model);
|
||||
pane.model = null;
|
||||
pane.mixer = null;
|
||||
pane.clips = [];
|
||||
}
|
||||
}
|
||||
|
||||
frame(object) {
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
if (box.isEmpty()) return;
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const dist = maxDim * 1.6;
|
||||
this.camera.position.set(center.x + dist * 0.7, center.y + dist * 0.45, center.z + dist * 0.85);
|
||||
this.camera.near = maxDim / 100;
|
||||
this.camera.far = maxDim * 100;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.controls.target.copy(center);
|
||||
this.controls.update();
|
||||
for (const pane of this.panes) {
|
||||
pane.grid.position.y = box.min.y;
|
||||
pane.grid.scale.setScalar(Math.max(1, maxDim / 2));
|
||||
}
|
||||
this.framed = true;
|
||||
}
|
||||
|
||||
clipNames() {
|
||||
return this.left.clips.map((c) => c.name || 'clip');
|
||||
}
|
||||
|
||||
playClip(index) {
|
||||
for (const pane of this.panes) {
|
||||
if (!pane.mixer) continue;
|
||||
pane.mixer.stopAllAction();
|
||||
if (index < 0) continue;
|
||||
// Match by name first (optimized file may reorder), fall back to index.
|
||||
const want = this.left.clips[index];
|
||||
const clip =
|
||||
(want && pane.clips.find((c) => c.name === want.name)) || pane.clips[index];
|
||||
if (clip) pane.mixer.clipAction(clip).reset().play();
|
||||
}
|
||||
}
|
||||
|
||||
setPlaying(v) {
|
||||
this.playing = v;
|
||||
}
|
||||
|
||||
applyWireframe(pane) {
|
||||
if (!pane.model) return;
|
||||
pane.model.traverse((child) => {
|
||||
const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
|
||||
for (const m of mats) if ('wireframe' in m) m.wireframe = this.wireframe;
|
||||
});
|
||||
}
|
||||
|
||||
setWireframe(v) {
|
||||
this.wireframe = v;
|
||||
this.panes.forEach((p) => this.applyWireframe(p));
|
||||
}
|
||||
|
||||
renderLoop() {
|
||||
const delta = this.clock.getDelta();
|
||||
this.controls.update();
|
||||
for (const pane of this.panes) {
|
||||
if (pane.mixer && this.playing) pane.mixer.update(delta);
|
||||
pane.renderer.render(pane.scene, this.camera);
|
||||
}
|
||||
requestAnimationFrame(this.renderer);
|
||||
}
|
||||
}
|
||||
5
vite.config.js
Normal file
5
vite.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
server: { port: 5173, strictPort: true },
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user