Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.

---

Expand Down
37 changes: 28 additions & 9 deletions avatar/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const {
} = require('./vroid-oauth-server.cjs');
const {
pathExists,
readLibraryFile,
readLibraryFileAsync,
resolveLibraryPath,
scanAnimations,
scanAvatars,
Expand Down Expand Up @@ -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);
});

Expand All @@ -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;
});
}

Expand Down
4 changes: 2 additions & 2 deletions avatar/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
12 changes: 8 additions & 4 deletions avatar/electron/thumbnails.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand All @@ -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}
Expand Down
25 changes: 24 additions & 1 deletion avatar/electron/user-library.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,19 @@ 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.');
}
const absolutePath = fileIndex.get(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()) {
Expand All @@ -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<Buffer>}
*/
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
*/
Expand All @@ -147,6 +169,7 @@ module.exports = {
scanAnimations,
scanEnvironments,
readLibraryFile,
readLibraryFileAsync,
resolveLibraryPath,
pathExists,
normalizeDir,
Expand Down
16 changes: 16 additions & 0 deletions avatar/electron/user-library.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const path = require("node:path");
const test = require("node:test");
const {
readLibraryFile,
readLibraryFileAsync,
scanAnimations,
scanAvatars,
scanEnvironments,
Expand Down Expand Up @@ -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"));
});
7 changes: 6 additions & 1 deletion avatar/scripts/custom-envs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Binary file added avatar/src/assets/environments/thumbs/bloom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added avatar/src/assets/environments/thumbs/code.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added avatar/src/assets/environments/thumbs/stars.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions avatar/src/components/AvatarStage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { useAudioSource } from '../hooks/useAudioSource';
import { getDesktopApi, getLibraryApi, isDesktopMode } from '../lib/desktopMode';
import {
loadEnvironmentSource,
loadLibraryAnimations,
loadLibraryAvatars,
loadLibraryEnvironments,
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' &&
Expand Down Expand Up @@ -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([]);
};
}, []);
Expand Down
30 changes: 25 additions & 5 deletions avatar/src/components/avatar/AvatarStageShell.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
};
Expand All @@ -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();
Expand Down
Loading