diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bd13da..5f419c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
+- **Environment pickers draw still posters, not the stage assets.** Opening Gear → **Appearance** → **Environments** used to hand the compositor the three bundled GIFs — ~32MB and ~480 frames between them — to play inside 40px boxes, and it loaded all three whether or not one was selected. Built-ins now ship a committed 192×112 poster (`src/assets/environments/thumbs/`, regenerate with `npm run thumbs`), so a GIF is loaded only for the environment actually on stage. Custom-folder tiles get the same treatment at runtime: a poster is generated once per file and cached under Electron `userData/thumbnails/`, alongside avatar portraits. Only the picker changes — the stage still animates. (#22)
+- **A custom environments folder is no longer read into memory up front.** Configuring the folder used to pull every file in it fully into the renderer as a blob URL, before the Custom expander had even been opened; blob URLs pin their bytes, so a folder of large GIFs stayed resident for the session. Files are now read one at a time, only for the environment being selected. The stage holds the current background until the new one is ready rather than blanking, and the read moved off the Electron main process so a large image no longer stalls the window. (#22)
- `npm test` now also runs renderer unit tests (`src/**/*.test.mjs`), starting with the animation catalog lookups. To make them loadable by plain Node, `src/config/animations.js` is split into `vrmaAssets.js` (Vite-resolved `.vrma` imports), `animationLookup.js` (pure lookups), and a composing entry point that re-exports both — the public API is unchanged. See [Contributing → Where tests go](CONTRIBUTING.md#where-tests-go). (#23)
- Remove the README CI status badge; workflow status stays on the Actions tab.
- README badge: replace misleading **BOOTH VRM** (linked to vrm.dev) with **VRM Docs** → https://vrm.dev/en/.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e226182..1063c41 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,13 +54,15 @@ A `src/` module is only testable if nothing in its import graph reaches a Vite-r
Anything needing React, the DOM, or `URL.createObjectURL` has **no** automated coverage today and is verified by hand (`npm run dev:desktop`) — say so in the PR rather than leaving it implied.
-### Bundled avatar thumbnails
+### Bundled picker thumbnails
-The Appearance picker draws avatars as static images, not live VRM previews. Portraits for the bundled avatars are **committed** under `avatar/src/assets/avatars/thumbs/` (`avatar1.png` … `avatar3.png`).
+The Appearance picker draws avatars as static images, not live VRM previews, and environments as still posters, not the animated stage GIFs. Both are **committed**: portraits under `avatar/src/assets/avatars/thumbs/` (`avatar1.png` … `avatar3.png`), environment posters under `avatar/src/assets/environments/thumbs/` (one PNG per id in `src/config/environments.js`).
-If you change a bundled `.vrm`, or add or remove an entry in `src/config/avatars.js`, run `npm run thumbs` and commit the result — the picker will otherwise show a stale or missing portrait. The command opens Electron briefly, renders each avatar with the same renderer the app uses at runtime, writes the PNGs into the source tree, and exits. It is dev-only: the write channel is registered only for that run, so a packaged app cannot write into the source tree.
+Run `npm run thumbs` and commit the result if you change a bundled `.vrm` or `.gif`, or add or remove an entry in `src/config/avatars.js` or `src/config/environments.js` — the picker will otherwise show a stale or missing thumbnail. The command opens Electron briefly, renders both sets with the same code the app uses at runtime, writes the PNGs into the source tree, and exits. It is dev-only: the write channel is registered only for that run, so a packaged app cannot write into the source tree.
-Thumbnails for a user's own custom folder are **not** committed — they are rendered on demand and cached under Electron `userData/thumbnails/`.
+Both sets are globbed rather than imported by name, so a missing file degrades (the avatar picker renders one on the spot; the environment picker falls back to the GIF) instead of breaking the build. That matters because the generator itself imports those config modules — a named import for a poster that does not exist yet would stop the run that was meant to create it.
+
+Thumbnails for a user's own custom folders are **not** committed — they are generated on demand and cached under Electron `userData/thumbnails/`.
---
diff --git a/avatar/electron/main.cjs b/avatar/electron/main.cjs
index dc62087..0c50c2f 100644
--- a/avatar/electron/main.cjs
+++ b/avatar/electron/main.cjs
@@ -31,7 +31,7 @@ const {
} = require('./vroid-oauth-server.cjs');
const {
pathExists,
- readLibraryFile,
+ readLibraryFileAsync,
resolveLibraryPath,
scanAnimations,
scanAvatars,
@@ -597,9 +597,9 @@ ipcMain.handle('library:scan-environments', (event, dirPath) => {
return scanEnvironments(dirPath);
});
-ipcMain.handle('library:read-file', (event, id) => {
+ipcMain.handle('library:read-file', async (event, id) => {
assertTrustedLibrarySender(event);
- const buffer = readLibraryFile(id);
+ const buffer = await readLibraryFileAsync(id);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
});
@@ -615,16 +615,35 @@ ipcMain.handle('thumbnail:get', (event, id) => {
// Dev-only asset generation (npm run thumbs). Registered only for that run, so
// a packaged app has no channel that can write into the source tree at all.
if (!app.isPackaged && process.env.AVATAR_GEN_THUMBS) {
- ipcMain.handle('thumbnail:dev-write', (event, fileName, png) => {
+ // Both the directory and the shape of the name are fixed here rather than
+ // taken from the renderer, so this channel can only ever write generated
+ // artwork into the two directories that hold it.
+ const DEV_THUMB_TARGETS = {
+ avatars: { dir: '../src/assets/avatars/thumbs', pattern: /^avatar\d+\.png$/ },
+ environments: {
+ dir: '../src/assets/environments/thumbs',
+ // Environment ids are author-defined, so this bounds the shape instead of
+ // listing them: no separators and no dots means no path to escape into.
+ pattern: /^[a-z][a-z0-9-]{0,31}\.png$/,
+ },
+ };
+
+ ipcMain.handle('thumbnail:dev-write', (event, kind, fileName, png) => {
assertTrustedLibrarySender(event);
- if (!/^avatar\d+\.png$/.test(String(fileName))) {
+ // hasOwn, not a bare lookup: `__proto__` and friends would otherwise sail
+ // past this guard with something truthy off Object.prototype.
+ if (!Object.hasOwn(DEV_THUMB_TARGETS, String(kind))) {
+ throw new Error(`Refusing to write thumbnails for unknown kind: ${kind}`);
+ }
+ const target = DEV_THUMB_TARGETS[String(kind)];
+ if (!target.pattern.test(String(fileName))) {
throw new Error(`Refusing to write unexpected thumbnail name: ${fileName}`);
}
- const dir = path.join(__dirname, '../src/assets/avatars/thumbs');
+ const dir = path.join(__dirname, target.dir);
fs.mkdirSync(dir, { recursive: true });
- const target = path.join(dir, fileName);
- fs.writeFileSync(target, Buffer.from(png));
- return target;
+ const filePath = path.join(dir, fileName);
+ fs.writeFileSync(filePath, Buffer.from(png));
+ return filePath;
});
}
diff --git a/avatar/electron/preload.cjs b/avatar/electron/preload.cjs
index c2ba67d..ff82efe 100644
--- a/avatar/electron/preload.cjs
+++ b/avatar/electron/preload.cjs
@@ -37,8 +37,8 @@ contextBridge.exposeInMainWorld('voxDesktop', {
getThumbnail: (id) => ipcRenderer.invoke('thumbnail:get', id),
putThumbnail: (id, png) => ipcRenderer.invoke('thumbnail:put', id, png),
// Only answered during `npm run thumbs`; rejects in any normal run.
- devWriteThumbnail: (fileName, png) =>
- ipcRenderer.invoke('thumbnail:dev-write', fileName, png),
+ devWriteThumbnail: (kind, fileName, png) =>
+ ipcRenderer.invoke('thumbnail:dev-write', kind, fileName, png),
});
contextBridge.exposeInMainWorld('voxVroidHub', {
diff --git a/avatar/electron/thumbnails.cjs b/avatar/electron/thumbnails.cjs
index cc9323c..23e3e7c 100644
--- a/avatar/electron/thumbnails.cjs
+++ b/avatar/electron/thumbnails.cjs
@@ -5,8 +5,9 @@ const path = require('node:path');
const crypto = require('node:crypto');
const CACHE_DIR_NAME = 'thumbnails';
-// A 112x112 RGBA PNG of a character portrait; anything far past this is not a
-// thumbnail we wrote, so refuse to serve or store it.
+// A 112x112 character portrait or a 192x112 environment poster, both RGBA PNG;
+// anything far past this is not a thumbnail we wrote, so refuse to serve or
+// store it.
const MAX_THUMBNAIL_BYTES = 512 * 1024;
/** @type {string | null} */
@@ -29,8 +30,11 @@ function configureThumbnailCache(userDataPath) {
/**
* Two files can share a name across folders, and the same file can be replaced
* in place, so the identity of a thumbnail is the path *and* what the file
- * looked like when we rendered it. Swapping in a new .vrm under the same name
- * changes mtime and size, which misses the cache and regenerates.
+ * looked like when we rendered it. Swapping in a new .vrm or .gif under the
+ * same name changes mtime and size, which misses the cache and regenerates.
+ *
+ * Nothing here is specific to avatars: keying on the absolute path is what lets
+ * avatar portraits and environment posters share one cache without colliding.
*
* @param {string} absolutePath
* @returns {string | null}
diff --git a/avatar/electron/user-library.cjs b/avatar/electron/user-library.cjs
index 23e7750..d8eeb27 100644
--- a/avatar/electron/user-library.cjs
+++ b/avatar/electron/user-library.cjs
@@ -108,7 +108,7 @@ function scanEnvironments(dirPath) {
* @param {unknown} id
* @returns {Buffer}
*/
-function readLibraryFile(id) {
+function resolveReadablePath(id) {
if (typeof id !== 'string' || id.trim() === '') {
throw new Error('Missing library file id.');
}
@@ -116,6 +116,11 @@ function readLibraryFile(id) {
if (!absolutePath) {
throw new Error('Unknown library file. Refresh the directory and try again.');
}
+ return absolutePath;
+}
+
+function readLibraryFile(id) {
+ const absolutePath = resolveReadablePath(id);
// Ensure the indexed path still exists and is a regular file.
const stat = fs.statSync(absolutePath);
if (!stat.isFile()) {
@@ -124,6 +129,23 @@ function readLibraryFile(id) {
return fs.readFileSync(absolutePath);
}
+/**
+ * The same read off the main thread. Environment images are fetched while the
+ * user is watching — a synchronous read of a 20MB gif blocks the main process,
+ * and with it the window, for as long as the disk takes.
+ *
+ * @param {unknown} id
+ * @returns {Promise}
+ */
+async function readLibraryFileAsync(id) {
+ const absolutePath = resolveReadablePath(id);
+ const stat = await fs.promises.stat(absolutePath);
+ if (!stat.isFile()) {
+ throw new Error('Library path is not a file.');
+ }
+ return fs.promises.readFile(absolutePath);
+}
+
/**
* @param {unknown} dirPath
*/
@@ -147,6 +169,7 @@ module.exports = {
scanAnimations,
scanEnvironments,
readLibraryFile,
+ readLibraryFileAsync,
resolveLibraryPath,
pathExists,
normalizeDir,
diff --git a/avatar/electron/user-library.test.cjs b/avatar/electron/user-library.test.cjs
index 49e12ba..4d54ccc 100644
--- a/avatar/electron/user-library.test.cjs
+++ b/avatar/electron/user-library.test.cjs
@@ -7,6 +7,7 @@ const path = require("node:path");
const test = require("node:test");
const {
readLibraryFile,
+ readLibraryFileAsync,
scanAnimations,
scanAvatars,
scanEnvironments,
@@ -111,3 +112,18 @@ test("reading a clip that vanished after the scan throws", (context) => {
assert.throws(() => readLibraryFile(entry.id));
assert.throws(() => readLibraryFile("lib-anim-never-scanned-000000000000"));
});
+
+// The IPC channel reads asynchronously so a large environment image cannot
+// block the main process, and with it the window. Same contract as the sync
+// read, or the renderer's error handling would only hold for one of them.
+test("the async read matches the sync one, including its failures", async (context) => {
+ const root = fixture(context, { "clip.vrma": "animation-bytes" });
+ const [entry] = scanAnimations(root);
+
+ assert.equal((await readLibraryFileAsync(entry.id)).toString(), "animation-bytes");
+
+ fs.rmSync(path.join(root, "clip.vrma"));
+
+ await assert.rejects(() => readLibraryFileAsync(entry.id));
+ await assert.rejects(() => readLibraryFileAsync("lib-anim-never-scanned-000000000000"));
+});
diff --git a/avatar/scripts/custom-envs.test.mjs b/avatar/scripts/custom-envs.test.mjs
index 536b818..1d54a88 100644
--- a/avatar/scripts/custom-envs.test.mjs
+++ b/avatar/scripts/custom-envs.test.mjs
@@ -51,7 +51,12 @@ test('a production build strips the custom/ glob', (context) => {
assert.ok(result, 'expected the plugin to rewrite environments.js');
assert.match(result.code, /const customModules = \{\};/);
- assert.doesNotMatch(result.code, /import\.meta\.glob/);
+ // What must not survive is any glob that can reach into custom/. Asserting
+ // on the path rather than on `import.meta.glob` in general: environments.js
+ // also globs the committed thumbs/ posters, which are build assets that are
+ // meant to ship, and that glob has to stay.
+ assert.doesNotMatch(result.code, /assets\/environments\/custom/);
+ assert.match(result.code, /import\.meta\.glob\('\.\.\/assets\/environments\/thumbs/);
});
test('the dev server keeps the custom/ glob', (context) => {
diff --git a/avatar/src/assets/environments/thumbs/bloom.png b/avatar/src/assets/environments/thumbs/bloom.png
new file mode 100644
index 0000000..dedb4b8
Binary files /dev/null and b/avatar/src/assets/environments/thumbs/bloom.png differ
diff --git a/avatar/src/assets/environments/thumbs/code.png b/avatar/src/assets/environments/thumbs/code.png
new file mode 100644
index 0000000..28d4d79
Binary files /dev/null and b/avatar/src/assets/environments/thumbs/code.png differ
diff --git a/avatar/src/assets/environments/thumbs/stars.png b/avatar/src/assets/environments/thumbs/stars.png
new file mode 100644
index 0000000..d8afb60
Binary files /dev/null and b/avatar/src/assets/environments/thumbs/stars.png differ
diff --git a/avatar/src/components/AvatarStage.jsx b/avatar/src/components/AvatarStage.jsx
index ba24d70..8a0bddb 100644
--- a/avatar/src/components/AvatarStage.jsx
+++ b/avatar/src/components/AvatarStage.jsx
@@ -28,6 +28,7 @@ import {
import { useAudioSource } from '../hooks/useAudioSource';
import { getDesktopApi, getLibraryApi, isDesktopMode } from '../lib/desktopMode';
import {
+ loadEnvironmentSource,
loadLibraryAnimations,
loadLibraryAvatars,
loadLibraryEnvironments,
@@ -37,6 +38,7 @@ import {
} from '../lib/userLibrary';
import { loadUserSettings, resetUserSettings, saveUserSettings } from '../lib/userSettingsStore';
import { revokeThumbnailUrls } from '../lib/thumbnails';
+import { revokeEnvironmentThumbnailUrls } from '../lib/environmentThumbnails';
import { VrmAvatar } from './avatar/VrmAvatar';
import { AvatarStageShell } from './avatar/AvatarStageShell';
import { CameraController } from './ui/CameraController';
@@ -93,6 +95,8 @@ export function AvatarStage() {
const libraryAvatarsRef = useRef([]);
const libraryAnimationsRef = useRef([]);
const libraryEnvironmentsRef = useRef([]);
+ /** Id of the environment whose full-size image is currently being read. */
+ const environmentSourceLoadRef = useRef(null);
const panelRef = useRef(null);
const drawerRef = useRef(null);
const commandMenuRef = useRef(null);
@@ -440,6 +444,10 @@ export function AvatarStage() {
// screen — but a remount in that window loaded nothing. Hold the old list
// and drop it only once its replacement is in state (#39).
const previous = libraryEnvironmentsRef.current;
+ // Posters are keyed by path, so a rescan has to drop them or an image
+ // replaced in place keeps serving its old one: the disk cache would catch
+ // the new mtime, but this session's url cache answers first.
+ revokeEnvironmentThumbnailUrls();
const useLibrary =
desktopMode &&
directories.environments.mode === 'custom' &&
@@ -510,12 +518,78 @@ export function AvatarStage() {
};
}, [directories.environments.mode, directories.environments.path]);
+ useEffect(() => {
+ // Bytes are held for the environment on stage and, while one is being read,
+ // for the outgoing one the stage is still showing — never for the whole
+ // folder, which is what loadLibraryEnvironments used to do the moment the
+ // folder was configured. The picker draws posters instead.
+ const selectedId = selectedBg.type === 'env' ? selectedBg.id : null;
+
+ // The registry backs getEnvironmentById, which the holo field and the
+ // chrome tone both read during render, so it has to move with the state
+ // rather than trail it by an effect.
+ const publish = (next) => {
+ setLibraryCustomEnvironments(next);
+ setLibraryEnvironments(next);
+ };
+
+ const target = selectedId
+ ? libraryEnvironments.find((entry) => entry.id === selectedId)
+ : null;
+
+ // The stage holds the previous background until the incoming one is ready,
+ // so its url is still on screen and cannot be released yet.
+ if (!target || target.src) {
+ const stale = libraryEnvironments.filter((entry) => entry.src && entry.id !== selectedId);
+ if (stale.length > 0) {
+ revokeEnvironmentBlobUrls(stale);
+ publish(
+ libraryEnvironments.map((entry) =>
+ entry.src && entry.id !== selectedId ? { ...entry, src: null } : entry,
+ ),
+ );
+ }
+ return undefined;
+ }
+
+ // Publishing below re-runs this effect, and rapid switching can re-enter it
+ // for an id already being read. One marker cannot cover every interleaving,
+ // so the read itself checks again before it publishes.
+ if (environmentSourceLoadRef.current === target.id) return undefined;
+ environmentSourceLoadRef.current = target.id;
+
+ void (async () => {
+ try {
+ const url = await loadEnvironmentSource(target);
+ if (!url) return;
+ const current = libraryEnvironmentsRef.current;
+ const entry = current.find((candidate) => candidate.id === target.id);
+ // Gone in a rescan, or another read of the same file got there first.
+ if (!entry || entry.src) {
+ URL.revokeObjectURL(url);
+ return;
+ }
+ // Published even if the selection has moved on: the pass above releases
+ // it on the next run, which is cheaper than losing the read and having
+ // to redo it if the user comes back.
+ publish(current.map((item) => (item.id === target.id ? { ...item, src: url } : item)));
+ } finally {
+ if (environmentSourceLoadRef.current === target.id) {
+ environmentSourceLoadRef.current = null;
+ }
+ }
+ })();
+
+ return undefined;
+ }, [selectedBg, libraryEnvironments]);
+
useEffect(() => {
return () => {
revokeAvatarBlobUrls(libraryAvatarsRef.current);
revokeThumbnailUrls();
revokeAnimationBlobUrls(libraryAnimationsRef.current);
revokeEnvironmentBlobUrls(libraryEnvironmentsRef.current);
+ revokeEnvironmentThumbnailUrls();
setLibraryCustomEnvironments([]);
};
}, []);
diff --git a/avatar/src/components/avatar/AvatarStageShell.jsx b/avatar/src/components/avatar/AvatarStageShell.jsx
index 8399b58..360361f 100644
--- a/avatar/src/components/avatar/AvatarStageShell.jsx
+++ b/avatar/src/components/avatar/AvatarStageShell.jsx
@@ -1,8 +1,13 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Cog } from 'lucide-react';
import { STAGE } from '../../config/defaults';
import { resolveChromeTone, resolveChromeToneSync, toneFromLuma } from '../../lib/chromeTone';
-import { getHoloFieldStyle, isHoloFieldHidden } from '../../lib/holoField';
+import {
+ getHoloFieldStyle,
+ getHoloImageUrl,
+ isHoloFieldHidden,
+ isHoloFieldPending,
+} from '../../lib/holoField';
import { getDesktopApi, isDesktopMode } from '../../lib/desktopMode';
import { BarCommandMenu } from '../ui/BarCommandMenu';
import { WindowScaleMenu } from '../ui/WindowScaleMenu';
@@ -30,7 +35,19 @@ export function AvatarStageShell({
}) {
const { barHeight, canvasOverflowTop, canvasOverflowSide } = STAGE;
const desktopMode = isDesktopMode();
- const holoStyle = getHoloFieldStyle(environmentSelection);
+ // A custom-folder environment is read from disk only once selected, so its
+ // image arrives a moment after the selection does. Rather than blank the
+ // stage for that moment, hold the last background that was ready — glow
+ // included, so the whole field changes at once instead of in two steps.
+ const holoPending = isHoloFieldPending(environmentSelection);
+ const lastReadyHoloStyle = useRef(null);
+ const resolvedHoloStyle = getHoloFieldStyle(environmentSelection);
+ if (!holoPending) lastReadyHoloStyle.current = resolvedHoloStyle;
+ const holoStyle = holoPending ? (lastReadyHoloStyle.current ?? resolvedHoloStyle) : resolvedHoloStyle;
+
+ // Tone is sampled from the image, which resolves after the selection for the
+ // same reason — so the selection alone is not enough to know when to sample.
+ const holoImageUrl = getHoloImageUrl(environmentSelection);
const [chromeTone, setChromeTone] = useState(() => resolveChromeToneSync(environmentSelection));
useEffect(() => {
@@ -59,7 +76,10 @@ export function AvatarStageShell({
}
if (!useDesktopSample) {
- void applyEnvironmentTone();
+ // Hold the current tone while the image is still being read: there is
+ // nothing to sample yet, and resolving now would flip the bar to its
+ // default and back a moment later.
+ if (!holoPending) void applyEnvironmentTone();
return () => {
cancelled = true;
};
@@ -83,7 +103,7 @@ export function AvatarStageShell({
unsubSettled?.();
window.removeEventListener('focus', onFocus);
};
- }, [environmentSelection, desktopMode, overlayMode]);
+ }, [environmentSelection, holoImageUrl, holoPending, desktopMode, overlayMode]);
function toggleMenu(event) {
event.stopPropagation();
diff --git a/avatar/src/components/panels/MiniEnvironment.jsx b/avatar/src/components/panels/MiniEnvironment.jsx
new file mode 100644
index 0000000..438342e
--- /dev/null
+++ b/avatar/src/components/panels/MiniEnvironment.jsx
@@ -0,0 +1,49 @@
+import { useEffect, useState } from 'react';
+import { getEnvironmentThumbnail } from '../../lib/environmentThumbnails';
+
+/**
+ * A custom-folder environment tile.
+ *
+ * The picker used to point straight at the full-size image, which for a folder
+ * of gifs meant decoding and animating every one of them inside a 40px box.
+ * Each tile now draws a poster that is generated once and cached to disk, the
+ * same arrangement MiniAvatar uses for portraits. Only the environment actually
+ * selected loads its real bytes, and only on the stage.
+ */
+export function MiniEnvironment({ entry, selected, onClick }) {
+ const [src, setSrc] = useState(null);
+ const { id, fileName } = entry;
+
+ useEffect(() => {
+ let cancelled = false;
+ setSrc(null);
+
+ // Deliberately not keyed on `entry`: selecting an environment replaces that
+ // object to carry its loaded source, and re-running here would drop the
+ // tile back to its placeholder — a flicker in the picker, of all places.
+ void getEnvironmentThumbnail({ id, fileName }).then((url) => {
+ if (!cancelled) setSrc(url);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [id, fileName]);
+
+ return (
+
+ );
+}
diff --git a/avatar/src/components/panels/PalettePanel.jsx b/avatar/src/components/panels/PalettePanel.jsx
index 7fd3885..06995e3 100644
--- a/avatar/src/components/panels/PalettePanel.jsx
+++ b/avatar/src/components/panels/PalettePanel.jsx
@@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { environments, defaultColor } from '../../config/environments';
import { MiniAvatar } from '../avatar/MiniAvatar';
+import { MiniEnvironment } from './MiniEnvironment';
import { AccordionSection, Divider } from '../ui/PanelPrimitives';
import { VroidHubPanel } from './VroidHubPanel';
@@ -136,7 +137,8 @@ export function PalettePanel({
onClick={() => setSelectedBg({ type: 'env', id: env.id })}
title={env.label}
>
-
+ {/* Poster, not the GIF: the stage owns the animation. */}
+ {env.label}
))}
@@ -177,22 +179,33 @@ export function PalettePanel({
Close Custom to show Stars / Code / Bloom / None again.
- {customEnvironmentList.map((env) => (
-
- ))}
+ {customEnvironmentList.map((env) =>
+ // A user's folder resolves a cached poster; contributor
+ // custom/ media is a build asset and already has a url.
+ env.library ? (
+ setSelectedBg({ type: 'env', id: env.id })}
+ />
+ ) : (
+
+ ),
+ )}
>
)}
diff --git a/avatar/src/config/environments.js b/avatar/src/config/environments.js
index 25325ef..5cbc031 100644
--- a/avatar/src/config/environments.js
+++ b/avatar/src/config/environments.js
@@ -2,6 +2,25 @@ import starsGif from '../assets/environments/stars.gif';
import codeGif from '../assets/environments/code.gif';
import bloomGif from '../assets/environments/bloom.gif';
+// Still posters generated ahead of time (see npm run thumbs), so the picker
+// never plays a full stage GIF inside a 40px box. Globbed rather than imported
+// by name because the generator imports this module: a named import for a
+// poster that does not exist yet would fail to resolve, and the run that would
+// have produced it could never start. A missing file is not an error — the
+// picker falls back to the animated source.
+const thumbnailModules = import.meta.glob('../assets/environments/thumbs/*.png', {
+ eager: true,
+ import: 'default',
+});
+
+/** @param {string} id */
+function bundledEnvThumb(id) {
+ const match = Object.entries(thumbnailModules).find(([modulePath]) =>
+ modulePath.endsWith(`/${id}.png`),
+ );
+ return match ? /** @type {string} */ (match[1]) : null;
+}
+
/** @typedef {{ strong: string, soft: string, highlight: string }} HoloGlow */
/** Default lavender fade used for Color mode. */
@@ -23,9 +42,12 @@ const customGlow = {
* @typedef {Object} EnvironmentEntry
* @property {string} id
* @property {string} label
- * @property {string} src Bundled GIF URL (Vite-resolved)
+ * @property {string} src Bundled GIF URL (Vite-resolved), used by the stage
+ * @property {string | null} [thumb] Still poster for the picker; falls back to `src`
* @property {HoloGlow} glow
* @property {boolean} [custom]
+ * @property {boolean} [library] From a user-picked folder, so `src` is read on
+ * demand rather than resolved by Vite — see lib/userLibrary.js
*/
/** @type {EnvironmentEntry[]} */
@@ -34,6 +56,7 @@ export const environments = [
id: 'stars',
label: 'Stars',
src: starsGif,
+ thumb: bundledEnvThumb('stars'),
glow: {
strong: 'rgba(130, 150, 230, 0.58)',
soft: 'rgba(85, 105, 190, 0.26)',
@@ -44,6 +67,7 @@ export const environments = [
id: 'code',
label: 'Code',
src: codeGif,
+ thumb: bundledEnvThumb('code'),
glow: {
strong: 'rgba(100, 220, 160, 0.45)',
soft: 'rgba(40, 120, 80, 0.22)',
@@ -54,6 +78,7 @@ export const environments = [
id: 'bloom',
label: 'Bloom',
src: bloomGif,
+ thumb: bundledEnvThumb('bloom'),
glow: {
strong: 'rgba(255, 190, 230, 0.58)',
soft: 'rgba(210, 160, 245, 0.28)',
@@ -122,20 +147,29 @@ export function getEnvironmentById(id) {
/**
* @param {EnvironmentSelection} selection
- * @returns {{ glow: HoloGlow | null, imageUrl: string | null, hidden: boolean }}
+ * @returns {{ glow: HoloGlow | null, imageUrl: string | null, hidden: boolean, pending: boolean }}
*/
export function resolveHoloTheme(selection) {
if (selection.type === 'none') {
- return { glow: null, imageUrl: null, hidden: true };
+ return { glow: null, imageUrl: null, hidden: true, pending: false };
}
if (selection.type === 'env') {
const env = getEnvironmentById(selection.id);
- return { glow: env.glow, imageUrl: env.src, hidden: false };
+ // A custom-folder environment reads its bytes only once selected, which
+ // takes as long as the file is big. `pending` separates that from the cases
+ // that legitimately have no image, so the stage can hold what it is already
+ // showing instead of blanking — see AvatarStageShell.
+ return {
+ glow: env.glow,
+ imageUrl: env.src ?? null,
+ hidden: false,
+ pending: Boolean(env.library) && !env.src,
+ };
}
const hex = selection.value.length === 4 ? defaultColor : selection.value;
- return { glow: glowFromHex(hex), imageUrl: null, hidden: false };
+ return { glow: glowFromHex(hex), imageUrl: null, hidden: false, pending: false };
}
/** @param {string} hex */
diff --git a/avatar/src/lib/environmentThumbnails.js b/avatar/src/lib/environmentThumbnails.js
new file mode 100644
index 0000000..6ea7802
--- /dev/null
+++ b/avatar/src/lib/environmentThumbnails.js
@@ -0,0 +1,222 @@
+/**
+ * Still posters for environment pickers.
+ *
+ * The picker used to point at the same asset as the stage, so opening
+ * Appearance handed the compositor three full animated GIFs — ~32MB and ~480
+ * frames between them — to play inside 40px boxes. A poster is what the picker
+ * actually needs; the stage keeps the animation.
+ *
+ * Bundled environments carry a committed poster (npm run thumbs). A user's own
+ * folder cannot, so those are generated here on demand and cached to disk, the
+ * same arrangement avatar portraits use — see lib/thumbnails.js.
+ *
+ * Nothing here touches three.js: unlike an avatar portrait, an environment
+ * thumbnail is a decode and a downscale, both of which run off the main thread.
+ */
+import { getLibraryApi } from './desktopMode';
+import { imageMime } from './userLibrary';
+
+// Wider than tall because the picker lays these out in a 3-up grid at 40px
+// high, and 2x covers HiDPI. `object-fit: cover` still does the final crop, so
+// this only has to be big enough, not exactly the displayed shape.
+const THUMB_W = 192;
+const THUMB_H = 112;
+
+/**
+ * GIFs commonly fade in, and frame 0 of a fade is a black rectangle that tells
+ * the user nothing about the environment. Sampling a little way in costs one
+ * extra decoded frame and avoids that.
+ */
+const PREFERRED_FRAME = 8;
+
+/**
+ * @param {Blob} source
+ * @returns {Promise<{ frame: ImageBitmap | VideoFrame, close: () => void }>}
+ */
+async function decodeStill(source, frameIndex) {
+ // ImageDecoder is the only way to ask a GIF for a specific frame;
+ // createImageBitmap always hands back frame 0.
+ if (source.type === 'image/gif' && typeof ImageDecoder === 'function') {
+ const decoder = new ImageDecoder({ data: await source.arrayBuffer(), type: source.type });
+ try {
+ await decoder.tracks.ready;
+ // frameCount climbs as the track is parsed and reads 0 before it starts,
+ // so clamping against it too early would silently ask for frame 0 — the
+ // fade-in frame this is here to skip. The whole file is already in hand,
+ // so waiting for the true count costs nothing.
+ await decoder.completed;
+ const available = decoder.tracks.selectedTrack?.frameCount ?? 0;
+ const wanted = available > 0 ? Math.min(frameIndex, available - 1) : frameIndex;
+ const { image } = await decoder.decode({ frameIndex: wanted });
+ return { frame: image, close: () => image.close() };
+ } finally {
+ decoder.close();
+ }
+ }
+
+ // Stills, and the fallback if ImageDecoder is unavailable. Resizing at decode
+ // time is what keeps a 6000x4000 photo dropped into a custom folder from ever
+ // materialising at full size.
+ let bitmap = await createImageBitmap(source, {
+ resizeHeight: THUMB_H,
+ resizeQuality: 'high',
+ });
+
+ // Height alone leaves anything narrower than the box short on width, and the
+ // cover scale below would then stretch a source that had pixels to spare.
+ // Which axis needs constraining is only knowable once one decode has run.
+ if (bitmap.width < THUMB_W) {
+ const byWidth = await createImageBitmap(source, {
+ resizeWidth: THUMB_W,
+ resizeQuality: 'high',
+ });
+ bitmap.close();
+ bitmap = byWidth;
+ }
+
+ return { frame: bitmap, close: () => bitmap.close() };
+}
+
+/**
+ * Render a still poster for an environment image.
+ *
+ * @param {Blob} source A `.gif` / `.png` / `.jpg` blob.
+ * @param {{ frameIndex?: number }} [options]
+ * @returns {Promise} A PNG sized {@link THUMB_W}x{@link THUMB_H}.
+ */
+export async function renderEnvironmentThumbnailBlob(source, options = {}) {
+ const { frame, close } = await decodeStill(source, options.frameIndex ?? PREFERRED_FRAME);
+
+ try {
+ const width = frame.displayWidth ?? frame.width;
+ const height = frame.displayHeight ?? frame.height;
+ if (!width || !height) throw new Error('Environment image has no dimensions.');
+
+ const canvas = new OffscreenCanvas(THUMB_W, THUMB_H);
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Could not get a 2d context for the thumbnail.');
+
+ // `cover`: fill the box on both axes and centre the overflow, matching how
+ // the picker's CSS crops these.
+ const scale = Math.max(THUMB_W / width, THUMB_H / height);
+ const drawW = width * scale;
+ const drawH = height * scale;
+ ctx.drawImage(frame, (THUMB_W - drawW) / 2, (THUMB_H - drawH) / 2, drawW, drawH);
+
+ return await canvas.convertToBlob({ type: 'image/png' });
+ } finally {
+ close();
+ }
+}
+
+/** @type {Map} library entry id → object URL, for this session. */
+const urlCache = new Map();
+/** @type {Map>} in-flight work, so two mounts share one decode. */
+const pending = new Map();
+
+/**
+ * Unlike a VRM portrait, which blocks the main thread for a second and so has
+ * to be generated strictly one at a time, this is decode work that Chromium
+ * runs off-thread. The cap is here to bound how many source files are held in
+ * memory at once, not to protect the frame rate.
+ */
+const MAX_CONCURRENT = 3;
+let active = 0;
+/** @type {(() => void)[]} */
+const waiting = [];
+
+/**
+ * @template T
+ * @param {() => Promise} job
+ * @returns {Promise}
+ */
+async function runBounded(job) {
+ if (active >= MAX_CONCURRENT) {
+ await new Promise((resolve) => waiting.push(resolve));
+ }
+ active += 1;
+ try {
+ return await job();
+ } finally {
+ active -= 1;
+ waiting.shift()?.();
+ }
+}
+
+/**
+ * @param {{ id: string, fileName: string }} entry
+ * @returns {Promise}
+ */
+async function generateThumbnail(entry) {
+ const api = getLibraryApi();
+ if (!api) throw new Error('Library API unavailable.');
+
+ const buffer = await api.readFile(entry.id);
+ // Deliberately not a blob url: those pin their bytes until revoked, and the
+ // whole point of this path is that the full-size file does not stay resident
+ // once the poster exists. A Blob is collectable as soon as this returns.
+ const source = new Blob([buffer], { type: imageMime(entry.fileName) });
+ return renderEnvironmentThumbnailBlob(source);
+}
+
+/**
+ * A cached poster for a user-library environment, generating and storing one if
+ * this is the first time we have seen the file.
+ *
+ * @param {{ id: string, fileName: string }} entry
+ * @returns {Promise} object URL, or null if unavailable
+ */
+export function getEnvironmentThumbnail(entry) {
+ const cached = urlCache.get(entry.id);
+ if (cached) return Promise.resolve(cached);
+
+ const inFlight = pending.get(entry.id);
+ if (inFlight) return inFlight;
+
+ const work = (async () => {
+ const api = getLibraryApi();
+ if (!api) return null;
+
+ try {
+ const stored = await api.getThumbnail(entry.id);
+ if (stored) {
+ const url = URL.createObjectURL(new Blob([stored], { type: 'image/png' }));
+ urlCache.set(entry.id, url);
+ return url;
+ }
+
+ const blob = await runBounded(() => generateThumbnail(entry));
+
+ // Best-effort, and it has to actually behave that way: a cache that
+ // refuses the write must not cost us the poster we just generated.
+ try {
+ await api.putThumbnail(entry.id, await blob.arrayBuffer());
+ } catch {
+ // Falls back to regenerating next launch.
+ }
+
+ const url = URL.createObjectURL(blob);
+ urlCache.set(entry.id, url);
+ return url;
+ } catch {
+ return null;
+ } finally {
+ pending.delete(entry.id);
+ }
+ })();
+
+ pending.set(entry.id, work);
+ return work;
+}
+
+/**
+ * Drop this session's poster urls. Must be called whenever the environment
+ * library is rescanned: ids are derived from the file path alone, so without
+ * this an image replaced in place keeps serving its old poster for the rest of
+ * the session — urlCache would answer before the disk cache's mtime/size check
+ * ever got a chance to miss.
+ */
+export function revokeEnvironmentThumbnailUrls() {
+ for (const url of urlCache.values()) URL.revokeObjectURL(url);
+ urlCache.clear();
+}
diff --git a/avatar/src/lib/generateBundledThumbnails.js b/avatar/src/lib/generateBundledThumbnails.js
index ad4ddcc..03a24e4 100644
--- a/avatar/src/lib/generateBundledThumbnails.js
+++ b/avatar/src/lib/generateBundledThumbnails.js
@@ -1,11 +1,12 @@
/**
- * Dev-only: renders a portrait for each bundled avatar and writes it into
- * `src/assets/avatars/thumbs/`, to be committed.
+ * Dev-only: renders the picker artwork for each bundled avatar and environment
+ * and writes it into `src/assets/avatars/thumbs/` and
+ * `src/assets/environments/thumbs/`, to be committed.
*
- * This deliberately reuses the same renderer the app uses at runtime rather
+ * This deliberately reuses the same renderers the app uses at runtime rather
* than standing up a headless-GL or Puppeteer pipeline just for build assets —
* one code path means the committed thumbnails cannot drift from the ones
- * generated for a user's own .vrm files.
+ * generated for a user's own .vrm files or custom environment folder.
*
* Run with: npm run thumbs
*
@@ -13,28 +14,69 @@
* guard at its only call site.
*/
import { avatars } from '../config/avatars';
+import { environments } from '../config/environments';
import { getDesktopApi } from './desktopMode';
import { renderThumbnailBlob } from './thumbnails';
+import { renderEnvironmentThumbnailBlob } from './environmentThumbnails';
-export async function generateBundledThumbnails() {
- const api = getDesktopApi();
- if (!api?.devWriteThumbnail) {
- console.error('[thumbs] dev write channel unavailable — run via npm run thumbs');
- return;
- }
+/**
+ * Frame to sample per bundled environment, where the default lands on a fade-in
+ * or an otherwise unrepresentative frame. Keyed by environment id.
+ * @type {Record}
+ */
+const ENVIRONMENT_FRAME_OVERRIDES = {};
+async function generateAvatarThumbnails(api) {
for (const entry of avatars) {
const modelPath = entry.skins.find((skin) => skin.id === 'default')?.path ?? entry.skins[0]?.path;
if (!modelPath) continue;
try {
const blob = await renderThumbnailBlob(modelPath);
- const written = await api.devWriteThumbnail(`${entry.id}.png`, await blob.arrayBuffer());
+ const written = await api.devWriteThumbnail(
+ 'avatars',
+ `${entry.id}.png`,
+ await blob.arrayBuffer(),
+ );
console.log(`[thumbs] ${entry.id} -> ${written}`);
} catch (error) {
console.error(`[thumbs] ${entry.id} failed`, error);
}
}
+}
+
+async function generateEnvironmentThumbnails(api) {
+ for (const entry of environments) {
+ try {
+ // The bundled GIFs are Vite-resolved urls, so this is the same fetch the
+ // stage does — no filesystem access from the renderer.
+ const response = await fetch(entry.src);
+ if (!response.ok) throw new Error(`fetch ${entry.src} -> ${response.status}`);
+
+ const blob = await renderEnvironmentThumbnailBlob(await response.blob(), {
+ frameIndex: ENVIRONMENT_FRAME_OVERRIDES[entry.id],
+ });
+ const written = await api.devWriteThumbnail(
+ 'environments',
+ `${entry.id}.png`,
+ await blob.arrayBuffer(),
+ );
+ console.log(`[thumbs] env ${entry.id} -> ${written}`);
+ } catch (error) {
+ console.error(`[thumbs] env ${entry.id} failed`, error);
+ }
+ }
+}
+
+export async function generateBundledThumbnails() {
+ const api = getDesktopApi();
+ if (!api?.devWriteThumbnail) {
+ console.error('[thumbs] dev write channel unavailable — run via npm run thumbs');
+ return;
+ }
+
+ await generateAvatarThumbnails(api);
+ await generateEnvironmentThumbnails(api);
console.log('[thumbs] done');
api.closeWindow?.();
diff --git a/avatar/src/lib/holoField.js b/avatar/src/lib/holoField.js
index 960f120..540f7f9 100644
--- a/avatar/src/lib/holoField.js
+++ b/avatar/src/lib/holoField.js
@@ -22,3 +22,29 @@ export function getHoloFieldStyle(selection) {
export function isHoloFieldHidden(selection) {
return selection.type === 'none';
}
+
+/**
+ * The image url a selection currently resolves to, or null.
+ *
+ * A custom-folder environment loads its bytes only once it is the one on stage,
+ * so the same selection resolves to null and then to a url. Anything deriving
+ * from the image rather than from the selection has to watch this instead.
+ *
+ * @param {import('../config/environments').EnvironmentSelection} selection
+ * @returns {string | null}
+ */
+export function getHoloImageUrl(selection) {
+ return resolveHoloTheme(selection).imageUrl;
+}
+
+/**
+ * Whether this selection is waiting on an image that is still being read, as
+ * opposed to one that has no image at all. Callers hold what they are already
+ * showing while this is true: swapping to a blank stage and back reads as a
+ * fault, and bridging with the low-resolution poster reads as one too.
+ *
+ * @param {import('../config/environments').EnvironmentSelection} selection
+ */
+export function isHoloFieldPending(selection) {
+ return resolveHoloTheme(selection).pending;
+}
diff --git a/avatar/src/lib/userLibrary.js b/avatar/src/lib/userLibrary.js
index 80f4e93..9683c96 100644
--- a/avatar/src/lib/userLibrary.js
+++ b/avatar/src/lib/userLibrary.js
@@ -17,7 +17,7 @@ function toBlobUrl(mime, buffer) {
/**
* @param {string} fileName
*/
-function imageMime(fileName) {
+export function imageMime(fileName) {
const lower = fileName.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
@@ -147,6 +147,16 @@ export async function loadLibraryAnimations(dirPath) {
/**
* Load image entries from a user folder into EnvironmentEntry-shaped objects.
+ *
+ * This reads no image data. It used to pull every file in the folder fully into
+ * renderer memory as a blob url the moment the folder was configured — before
+ * the Custom expander had even been opened — and blob urls pin their bytes,
+ * so Chromium could not discard and refetch them the way it can an ordinary
+ * image. A folder of large gifs therefore held hundreds of MB resident for the
+ * session. Entries now start with `src: null`; the picker draws a cached poster
+ * (lib/environmentThumbnails.js) and only the environment actually selected
+ * gets its bytes read, via loadEnvironmentSource.
+ *
* @param {string} dirPath
* @returns {Promise<{ environments: object[], error: string | null }>}
*/
@@ -163,18 +173,20 @@ export async function loadLibraryEnvironments(dirPath) {
};
}
const scanned = await api.scanEnvironments(dirPath);
- const environments = [];
- for (const entry of scanned) {
- const buffer = await api.readFile(entry.id);
- const blobUrl = toBlobUrl(imageMime(entry.fileName), buffer);
- environments.push({
- id: entry.id,
- label: entry.label,
- src: blobUrl,
- glow: customEnvGlow,
- custom: true,
- });
- }
+ const environments = scanned.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ // Kept so the poster and the on-demand source can both work out the mime
+ // type without going back to the scan.
+ fileName: entry.fileName,
+ src: null,
+ glow: customEnvGlow,
+ custom: true,
+ // Distinguishes these from bundled custom/ media, which carries a url from
+ // the start. `src` cannot do that job: it fills in once this entry is the
+ // one on stage, and the picker must not start showing the full image then.
+ library: true,
+ }));
return { environments, error: null };
} catch (error) {
return {
@@ -184,6 +196,28 @@ export async function loadLibraryEnvironments(dirPath) {
}
}
+/**
+ * Read one environment image and mint a blob url for it. Only ever called for
+ * the environment currently on the stage: the picker uses posters instead, so
+ * at most one full-size image is resident at a time.
+ *
+ * @param {{ id: string, fileName: string }} entry
+ * @returns {Promise}
+ */
+export async function loadEnvironmentSource(entry) {
+ const api = getLibraryApi();
+ if (!api) return null;
+
+ try {
+ const buffer = await api.readFile(entry.id);
+ return toBlobUrl(imageMime(entry.fileName), buffer);
+ } catch {
+ // The file went away between the scan and here; the stage keeps its glow
+ // and simply shows no image.
+ return null;
+ }
+}
+
/**
* @param {object[]} avatars
*/
diff --git a/avatar/src/styles/app.css b/avatar/src/styles/app.css
index cac829b..0ffd775 100644
--- a/avatar/src/styles/app.css
+++ b/avatar/src/styles/app.css
@@ -1072,7 +1072,8 @@
min-height: 58px;
}
-.environment-custom__grid .background-thumb img {
+.environment-custom__grid .background-thumb img,
+.environment-custom__grid .background-thumb__pending {
height: 40px;
}
@@ -1195,7 +1196,8 @@
}
@media (prefers-reduced-motion: reduce) {
- .mini-avatar__pending {
+ .mini-avatar__pending,
+ .background-thumb__pending {
animation: none;
}
}
@@ -1236,13 +1238,20 @@
}
.background-thumb img,
-.background-thumb__default {
+.background-thumb__default,
+.background-thumb__pending {
width: 100%;
height: 52px;
object-fit: cover;
display: block;
}
+/* Shown while a custom environment's poster is being generated. */
+.background-thumb__pending {
+ background: linear-gradient(135deg, #efeaf8, #dcd3f0);
+ animation: mini-avatar-pulse 1.4s ease-in-out infinite;
+}
+
.background-thumb__label {
display: block;
font-size: 10px;
diff --git a/avatar/vite.config.js b/avatar/vite.config.js
index b568260..e6f6155 100644
--- a/avatar/vite.config.js
+++ b/avatar/vite.config.js
@@ -42,12 +42,12 @@ export default defineConfig(({ command }) => {
plugins: [react(), stripCustomEnvs(includeCustom)],
server: {
watch: {
- // `npm run thumbs` writes generated portraits into this directory while
- // the dev server is running. Watching it means each write invalidates the
- // module graph, reloads the generator, and starts the whole run again —
- // an endless loop that also interrupts in-flight VRM loads. These are
+ // `npm run thumbs` writes generated artwork into these directories while
+ // the dev server is running. Watching them means each write invalidates
+ // the module graph, reloads the generator, and starts the whole run again
+ // — an endless loop that also interrupts in-flight VRM loads. These are
// build assets; they never need hot reload.
- ignored: ['**/src/assets/avatars/thumbs/**'],
+ ignored: ['**/src/assets/avatars/thumbs/**', '**/src/assets/environments/thumbs/**'],
},
},
};
diff --git a/docs/avatars.md b/docs/avatars.md
index 19eaad8..e04d239 100644
--- a/docs/avatars.md
+++ b/docs/avatars.md
@@ -12,7 +12,7 @@ Path: `avatar/src/assets/avatars/`.
Gear → **Appearance** → **Avatars**. Selection persists as `avatarId` in [config.yaml](user-settings.md).
-Picker cards use **static thumbnails** (not live 3D previews), so Appearance opens quickly. Bundled portraits live under `src/assets/avatars/thumbs/` (regenerate with `npm run thumbs` when a bundled `.vrm` or the catalog changes — see [Contributing](../CONTRIBUTING.md#bundled-avatar-thumbnails)). Custom-folder portraits are rendered once and cached under Electron `userData/thumbnails/` (keyed by path, mtime, and size).
+Picker cards use **static thumbnails** (not live 3D previews), so Appearance opens quickly. Bundled portraits live under `src/assets/avatars/thumbs/` (regenerate with `npm run thumbs` when a bundled `.vrm` or the catalog changes — see [Contributing](../CONTRIBUTING.md#bundled-picker-thumbnails)). Custom-folder portraits are rendered once and cached under Electron `userData/thumbnails/` (keyed by path, mtime, and size).
diff --git a/docs/development/project-layout.md b/docs/development/project-layout.md
index 64a721d..7173df8 100644
--- a/docs/development/project-layout.md
+++ b/docs/development/project-layout.md
@@ -44,7 +44,7 @@ End users personalize via **Settings → Directories** and **VRoid Hub** (see [U
| `npm run build` | CI / web | Production Vite bundle |
| `npm run lint` | Contributors / CI | ESLint (`src/`, `electron/**/*.cjs`, scripts; `--max-warnings=0`) |
| `npm test` | Contributors / CI | Electron unit tests (`electron/*.test.cjs`), renderer unit tests (`src/**/*.test.mjs`), and build-tooling tests (`scripts/*.test.mjs`, which run three real `vite build`s, so the suite takes ~15s) |
-| `npm run thumbs` | Contributors | Re-render committed avatar portraits into `src/assets/avatars/thumbs/` |
+| `npm run thumbs` | Contributors | Re-render committed picker thumbnails into `src/assets/avatars/thumbs/` and `src/assets/environments/thumbs/` |
### Continuous integration
@@ -52,4 +52,4 @@ Pull requests and pushes to `main` run [`.github/workflows/ci.yml`](../../.githu
Maintainer version bumps and GitHub Releases: [Release checklist](release-checklist.md).
-Run `npm run thumbs` whenever a bundled `.vrm` or the `config/avatars.js` catalog changes, and commit the PNGs — the Appearance picker reads those files rather than rendering a live preview. Custom-folder avatars are cached at runtime under Electron `userData/thumbnails/` instead. See [Contributing](../../CONTRIBUTING.md#bundled-avatar-thumbnails).
+Run `npm run thumbs` whenever a bundled `.vrm` or `.gif`, or the `config/avatars.js` / `config/environments.js` catalog, changes, and commit the PNGs — the Appearance picker reads those files rather than rendering a live preview or playing the stage GIF. Custom-folder assets are cached at runtime under Electron `userData/thumbnails/` instead. See [Contributing](../../CONTRIBUTING.md#bundled-picker-thumbnails).
diff --git a/docs/environments.md b/docs/environments.md
index b181ee4..3ca3c7b 100644
--- a/docs/environments.md
+++ b/docs/environments.md
@@ -23,6 +23,8 @@ Open Gear → **Appearance** → **Environments**.
Credits for the three GIFs (GIPHY): see [Assets & credits](assets-and-credits.md#built-in-environment-gifs).
+The picker shows a **still poster** for each of these (committed under `src/assets/environments/thumbs/`), not the GIF — only the environment you actually select animates, and only on the stage. Regenerate the posters with `npm run thumbs` if you change a bundled GIF or the catalog; see [Contributing](../CONTRIBUTING.md#bundled-picker-thumbnails).
+
@@ -54,6 +56,10 @@ The Color fade row stays pinned under the environment list (**Use color** + **Re
Built-ins are **never** replaced by a custom env directory (unlike avatars).
+Tiles in the Custom grid are **posters**, generated once per file and cached under Electron `userData/thumbnails/` (keyed by path, mtime, and size — the same cache avatar portraits use). A tile pulses while its poster is being made, and an animated file only ever animates on the stage. Nothing in the folder is read into memory until you select it, so folder size costs you disk, not RAM.
+
+Because the file is read at selection time, a large image takes a moment to appear. The stage keeps showing the current background until the new one is ready, then changes over in one step — so a slow read looks like a delay, never a blank stage.
+