From 90d678709824506a630bfc805dc32384ee013fda Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:01:30 +0100 Subject: [PATCH 1/8] feat(library): cache asset thumbnails per workspace path and file version Thumbnails live in a Modly-owned folder keyed by a hash of workspace dir + path, with the file's mtime and size in the name, so an edited mesh misses the cache. Library entries carry thumbnailUrl when the current version is cached, and pruning drops thumbnails of deleted or changed meshes. --- electron/main/artifact-registry-service.ts | 25 +- electron/main/asset-thumbnail-cache.test.ts | 151 +++++++++++ electron/main/asset-thumbnail-cache.ts | 262 ++++++++++++++++++++ package.json | 2 +- src/shared/types/assetLibrary.ts | 23 ++ 5 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 electron/main/asset-thumbnail-cache.test.ts create mode 100644 electron/main/asset-thumbnail-cache.ts diff --git a/electron/main/artifact-registry-service.ts b/electron/main/artifact-registry-service.ts index 2acb657f..9a4a1473 100644 --- a/electron/main/artifact-registry-service.ts +++ b/electron/main/artifact-registry-service.ts @@ -14,6 +14,7 @@ import type { AssetLibrarySourceScope, } from '../../src/shared/types/assetLibrary' import type { ArtifactProvenance } from '../../src/shared/types/artifacts' +import { findCachedThumbnailUrl, pruneThumbnails } from './asset-thumbnail-cache' const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/ const ENCODED_ESCAPE_PATTERN = /%2e|%2f|%5c/i @@ -44,6 +45,8 @@ export interface NormalizedWorkspaceAssetPath { export interface WorkspaceAssetLibraryRequest { workspaceDir: string + /** Modly-owned thumbnail cache; when set, renderable entries carry their cached `thumbnailUrl`. */ + thumbnailsDir?: string } export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryRequest { @@ -67,6 +70,7 @@ export interface IpcMainLike { export interface WorkspaceAssetLibraryIpcDeps { ipcMain: IpcMainLike getWorkspaceDir: () => string + thumbnailsDir?: string } function libraryError(code: AssetLibraryError['code'], message: string): AssetLibraryError { @@ -223,11 +227,14 @@ async function hasRigMetadata(absolutePath: string): Promise { } } -async function buildEntry(workspaceDir: string, workspacePath: string): Promise { +async function buildEntry(workspaceDir: string, workspacePath: string, thumbnailsDir?: string): Promise { const { absolutePath } = normalizeWorkspaceAssetPath(workspaceDir, workspacePath) const stats = await stat(absolutePath) const classification = classifyAssetLibraryCandidate({ workspacePath, hasRigMetadata: await hasRigMetadata(absolutePath) }) const metadata = await readMetadata(workspaceDir, workspacePath, absolutePath) + const thumbnailUrl = thumbnailsDir && classification.previewKind === '3d-model' + ? await findCachedThumbnailUrl({ workspaceDir, thumbnailsDir }, workspacePath, stats) + : undefined const manifestCapability = metadata.manifestWorkspacePath ? manifestCapabilityFor(metadata.manifestWorkspacePath) : undefined return { id: `library:${workspacePath}`, @@ -247,6 +254,7 @@ async function buildEntry(workspaceDir: string, workspacePath: string): Promise< nonOpenableReason: classification.nonOpenableReason, createdAt: (stats.birthtime.getTime() > 0 ? stats.birthtime : stats.mtime).toISOString(), updatedAt: stats.mtime.toISOString(), + thumbnailUrl, } } @@ -278,7 +286,7 @@ async function collectFiles(workspaceDir: string, rootName: typeof ALLOWED_ROOTS export async function listWorkspaceAssetLibrary(request: WorkspaceAssetLibraryRequest): Promise { try { const workspacePaths = (await Promise.all(ALLOWED_ROOTS.map((root) => collectFiles(request.workspaceDir, root)))).flat().sort() - const entries = await Promise.all(workspacePaths.map((workspacePath) => buildEntry(request.workspaceDir, workspacePath))) + const entries = await Promise.all(workspacePaths.map((workspacePath) => buildEntry(request.workspaceDir, workspacePath, request.thumbnailsDir))) return { success: true, entries: entries.filter((entry) => entry.state !== 'unsupported') } } catch (error) { return { success: false, error: libraryError('list-failed', error instanceof Error ? error.message : String(error)) } @@ -304,7 +312,7 @@ export async function readWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibr const normalized = normalizeWorkspaceAssetPath(request.workspaceDir, request.workspacePath) const sourceValidation = await validateRequestedSource(request.workspaceDir, normalized.workspacePath, request.sourceWorkspacePath) if (!sourceValidation.success) return { success: false, error: sourceValidation.error } - const entry = await buildEntry(request.workspaceDir, normalized.workspacePath) + const entry = await buildEntry(request.workspaceDir, normalized.workspacePath, request.thumbnailsDir) return { success: true, entry, preview: await previewEntry(normalized.absolutePath, entry) } } catch (error) { return { success: false, error: libraryError('unsafe-path', error instanceof Error ? error.message : String(error)) } @@ -353,15 +361,20 @@ function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceW } export function registerWorkspaceAssetLibraryIpcHandlers(deps: WorkspaceAssetLibraryIpcDeps): void { - deps.ipcMain.handle('workspace:library:list', async () => listWorkspaceAssetLibrary({ workspaceDir: deps.getWorkspaceDir() })) + deps.ipcMain.handle('workspace:library:list', async () => { + const result = await listWorkspaceAssetLibrary({ workspaceDir: deps.getWorkspaceDir(), thumbnailsDir: deps.thumbnailsDir }) + // Listing is the natural sweep point: drop thumbnails of deleted or changed meshes in the background. + if (deps.thumbnailsDir) void pruneThumbnails(deps.thumbnailsDir).catch(() => undefined) + return result + }) deps.ipcMain.handle('workspace:library:read', async (_event, payload) => { const { workspacePath, sourceWorkspacePath } = readPayloadRequest(payload) if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } - return readWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) + return readWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), thumbnailsDir: deps.thumbnailsDir, workspacePath, sourceWorkspacePath }) }) deps.ipcMain.handle('workspace:library:open', async (_event, payload) => { const { workspacePath, sourceWorkspacePath } = readPayloadRequest(payload) if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } - return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) + return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), thumbnailsDir: deps.thumbnailsDir, workspacePath, sourceWorkspacePath }) }) } diff --git a/electron/main/asset-thumbnail-cache.test.ts b/electron/main/asset-thumbnail-cache.test.ts new file mode 100644 index 00000000..d746d64a --- /dev/null +++ b/electron/main/asset-thumbnail-cache.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + getThumbnailStatus, + normalizeThumbnailSourcePath, + pruneThumbnails, + removeThumbnails, + resolveThumbnailUrl, + saveThumbnail, + thumbnailPathKey, + thumbnailVersion, +} from './asset-thumbnail-cache.ts' +import { listWorkspaceAssetLibrary } from './artifact-registry-service.ts' + +const WEBP = new Uint8Array([...Buffer.from('RIFF'), 4, 0, 0, 0, ...Buffer.from('WEBPVP8 ')]) + +async function withCache(run: (context: { workspaceDir: string, thumbnailsDir: string }) => Promise) { + const root = await mkdtemp(path.join(tmpdir(), 'modly-thumbs-')) + try { + const context = { workspaceDir: path.join(root, 'workspace'), thumbnailsDir: path.join(root, 'thumbnails') } + await mkdir(path.join(context.workspaceDir, 'Workflows'), { recursive: true }) + await run(context) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +async function writeMesh(workspaceDir: string, workspacePath: string, content = 'glb', mtime = new Date('2026-01-01T00:00:00Z')) { + const absolute = path.join(workspaceDir, ...workspacePath.split('/')) + await mkdir(path.dirname(absolute), { recursive: true }) + await writeFile(absolute, content) + await utimes(absolute, mtime, mtime) +} + +test('cache key depends on workspace dir and path; version on mtime and size', () => { + assert.equal(thumbnailPathKey('/a', 'Workflows/hero.glb'), thumbnailPathKey('/a', 'Workflows/hero.glb')) + assert.notEqual(thumbnailPathKey('/a', 'Workflows/hero.glb'), thumbnailPathKey('/b', 'Workflows/hero.glb')) + assert.notEqual(thumbnailPathKey('/a', 'Workflows/hero.glb'), thumbnailPathKey('/a', 'Workflows/other.glb')) + assert.match(thumbnailPathKey('/a', 'Workflows/hero.glb'), /^[0-9a-f]{32}$/) + assert.equal(thumbnailVersion({ mtimeMs: 1000.7, size: 10 }), thumbnailVersion({ mtimeMs: 1000.2, size: 10 })) + assert.notEqual(thumbnailVersion({ mtimeMs: 1000, size: 10 }), thumbnailVersion({ mtimeMs: 2000, size: 10 })) + assert.notEqual(thumbnailVersion({ mtimeMs: 1000, size: 10 }), thumbnailVersion({ mtimeMs: 1000, size: 11 })) +}) + +test('only safe workspace-relative .glb/.gltf paths are thumbnail sources', () => { + assert.equal(normalizeThumbnailSourcePath('/ws', 'Default\\hero.glb').workspacePath, 'Default/hero.glb') + assert.equal(normalizeThumbnailSourcePath('/ws', 'Workflows/./a/hero.GLTF').workspacePath, 'Workflows/a/hero.GLTF') + assert.throws(() => normalizeThumbnailSourcePath('/ws', '../hero.glb')) + assert.throws(() => normalizeThumbnailSourcePath('/ws', '/tmp/hero.glb')) + assert.throws(() => normalizeThumbnailSourcePath('/ws', 'C:\\hero.glb')) + assert.throws(() => normalizeThumbnailSourcePath('/ws', 'Workflows/%2e%2e/hero.glb')) + assert.throws(() => normalizeThumbnailSourcePath('/ws', 'Workflows/notes.json')) +}) + +test('thumbnail URLs resolve only to cache file names', () => { + const name = `${'a'.repeat(32)}.abc-1f-r1.webp` + assert.equal(resolveThumbnailUrl('/cache', `modly-thumb://thumbnails/${name}`), path.join('/cache', name)) + assert.equal(resolveThumbnailUrl('/cache', 'modly-thumb://thumbnails/../secret.webp'), null) + assert.equal(resolveThumbnailUrl('/cache', `modly-thumb://other/${name}`), null) + assert.equal(resolveThumbnailUrl('/cache', `file:///cache/${name}`), null) + assert.equal(resolveThumbnailUrl('/cache', 'modly-thumb://thumbnails/x.json'), null) +}) + +test('a saved thumbnail is served for its version and goes stale when the file changes', () => withCache(async (context) => { + await writeMesh(context.workspaceDir, 'Workflows/hero.glb') + const before = await getThumbnailStatus(context, 'Workflows/hero.glb') + assert.ok(before.success) + assert.equal(before.thumbnailUrl, undefined) + + const saved = await saveThumbnail(context, { workspacePath: 'Workflows/hero.glb', version: before.version, image: WEBP }) + assert.ok(saved.success) + assert.ok(resolveThumbnailUrl(context.thumbnailsDir, saved.thumbnailUrl)) + const cached = await getThumbnailStatus(context, 'Workflows/hero.glb') + assert.ok(cached.success) + assert.equal(cached.thumbnailUrl, saved.thumbnailUrl) + + await writeMesh(context.workspaceDir, 'Workflows/hero.glb', 'edited glb', new Date('2026-02-01T00:00:00Z')) + const edited = await getThumbnailStatus(context, 'Workflows/hero.glb') + assert.ok(edited.success) + assert.equal(edited.thumbnailUrl, undefined) + assert.notEqual(edited.version, before.version) + + // A render of the old version is refused; the new version replaces the old file. + const stale = await saveThumbnail(context, { workspacePath: 'Workflows/hero.glb', version: before.version, image: WEBP }) + assert.equal(!stale.success && stale.error.code, 'stale') + const resaved = await saveThumbnail(context, { workspacePath: 'Workflows/hero.glb', version: edited.version, image: WEBP }) + assert.ok(resaved.success) + const webps = (await readdir(context.thumbnailsDir)).filter((name) => name.endsWith('.webp')) + assert.equal(webps.length, 1) +})) + +test('save rejects non-WebP payloads and unsafe paths', () => withCache(async (context) => { + await writeMesh(context.workspaceDir, 'Workflows/hero.glb') + const status = await getThumbnailStatus(context, 'Workflows/hero.glb') + assert.ok(status.success) + const png = await saveThumbnail(context, { workspacePath: 'Workflows/hero.glb', version: status.version, image: new Uint8Array([0x89, 0x50, 0x4e, 0x47]) }) + assert.equal(!png.success && png.error.code, 'invalid-image') + const notBytes = await saveThumbnail(context, { workspacePath: 'Workflows/hero.glb', version: status.version, image: 'RIFF' }) + assert.equal(!notBytes.success && notBytes.error.code, 'invalid-image') + const unsafe = await saveThumbnail(context, { workspacePath: '../hero.glb', version: status.version, image: WEBP }) + assert.equal(!unsafe.success && unsafe.error.code, 'unsafe-path') + const missing = await getThumbnailStatus(context, 'Workflows/missing.glb') + assert.equal(!missing.success && missing.error.code, 'not-found') +})) + +test('removeThumbnails and pruning drop thumbnails of deleted assets', () => withCache(async (context) => { + await writeMesh(context.workspaceDir, 'Workflows/kept.glb') + await writeMesh(context.workspaceDir, 'Workflows/deleted.glb') + await writeMesh(context.workspaceDir, 'Default/removed.glb') + for (const workspacePath of ['Workflows/kept.glb', 'Workflows/deleted.glb', 'Default/removed.glb']) { + const status = await getThumbnailStatus(context, workspacePath) + assert.ok(status.success) + assert.ok((await saveThumbnail(context, { workspacePath, version: status.version, image: WEBP })).success) + } + + await removeThumbnails(context, 'Default/removed.glb') + assert.equal((await readdir(context.thumbnailsDir)).length, 4) + + await rm(path.join(context.workspaceDir, 'Workflows', 'deleted.glb')) + await writeFile(path.join(context.thumbnailsDir, `${'b'.repeat(32)}.abc-1-r1.webp`), WEBP) + await pruneThumbnails(context.thumbnailsDir) + const remaining = await readdir(context.thumbnailsDir) + assert.equal(remaining.length, 2) + const kept = await getThumbnailStatus(context, 'Workflows/kept.glb') + assert.ok(kept.success && kept.thumbnailUrl) +})) + +test('library entries expose thumbnailUrl only for cached renders of the current version', () => withCache(async (context) => { + await writeMesh(context.workspaceDir, 'Workflows/hero.glb') + await writeMesh(context.workspaceDir, 'Workflows/other.glb') + await writeFile(path.join(context.workspaceDir, 'Workflows', 'hero.world.json'), '{}') + const status = await getThumbnailStatus(context, 'Workflows/hero.glb') + assert.ok(status.success) + const saved = await saveThumbnail(context, { workspacePath: 'Workflows/hero.glb', version: status.version, image: WEBP }) + assert.ok(saved.success) + + const listed = await listWorkspaceAssetLibrary(context) + assert.ok(listed.success) + const byPath = new Map(listed.entries.map((entry) => [entry.workspacePath, entry])) + assert.equal(byPath.get('Workflows/hero.glb')?.thumbnailUrl, saved.thumbnailUrl) + assert.equal(byPath.get('Workflows/other.glb')?.thumbnailUrl, undefined) + assert.equal(byPath.get('Workflows/hero.world.json')?.thumbnailUrl, undefined) + + const withoutCache = await listWorkspaceAssetLibrary({ workspaceDir: context.workspaceDir }) + assert.ok(withoutCache.success) + assert.ok(withoutCache.entries.every((entry) => entry.thumbnailUrl === undefined)) +})) diff --git a/electron/main/asset-thumbnail-cache.ts b/electron/main/asset-thumbnail-cache.ts new file mode 100644 index 00000000..1c931963 --- /dev/null +++ b/electron/main/asset-thumbnail-cache.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto' +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve } from 'node:path' + +import type { + AssetThumbnailError, + AssetThumbnailSaveResult, + AssetThumbnailStatusResult, +} from '../../src/shared/types/assetLibrary' + +// Thumbnails are rendered by the renderer (offscreen three.js, same rig as +// Viewer3D) and cached here, in a Modly-owned folder outside the workspace. +// Layout, flat in `thumbnailsDir`: +// ..webp — the render of one file version +// .json — { workspaceDir, workspacePath } so pruning can +// find the source again from a hashed name +// pathKey hashes workspace dir + workspace path; version encodes the file's +// mtime and size, so an edited file misses the cache and is re-rendered. + +export const THUMBNAIL_PROTOCOL = 'modly-thumb' +const THUMBNAIL_HOST = 'thumbnails' +/** Bump when the render rig changes so every cached thumbnail goes stale. */ +export const THUMBNAIL_RENDER_REVISION = 1 +const MAX_THUMBNAIL_BYTES = 1024 * 1024 +const PATH_KEY_PATTERN = /^[0-9a-f]{32}$/ +const THUMBNAIL_FILE_PATTERN = /^([0-9a-f]{32})\.([0-9a-z]+-[0-9a-z]+-r[0-9]+)\.webp$/ +const ENCODED_ESCAPE_PATTERN = /%2e|%2f|%5c/i + +export interface FileVersionStats { + mtimeMs: number + size: number +} + +export interface ThumbnailCacheContext { + workspaceDir: string + thumbnailsDir: string +} + +interface ThumbnailSidecar { + workspaceDir: string + workspacePath: string +} + +function thumbnailError(code: AssetThumbnailError['code'], message: string): AssetThumbnailError { + return { code, message } +} + +/** Validates a workspace-relative .glb/.gltf path and resolves it inside the workspace. */ +export function normalizeThumbnailSourcePath(workspaceDir: string, workspacePath: string): { workspacePath: string, absolutePath: string } { + const normalized = workspacePath.trim().replace(/\\/g, '/') + if (!normalized || ENCODED_ESCAPE_PATTERN.test(normalized)) throw new Error('Thumbnail source path is not a safe workspace path') + if (isAbsolute(normalized) || /^[a-zA-Z]:[\\/]/.test(workspacePath) || workspacePath.startsWith('\\\\')) { + throw new Error('Thumbnail source path must be workspace-relative') + } + const segments = normalized.split('/').filter((segment) => segment && segment !== '.') + if (segments.length === 0 || segments.includes('..')) throw new Error('Thumbnail source path must not contain traversal segments') + const safePath = segments.join('/') + if (!/\.(glb|gltf)$/i.test(safePath)) throw new Error('Only .glb/.gltf meshes have thumbnails') + const root = resolve(workspaceDir) + const absolutePath = resolve(root, ...segments) + const back = relative(root, absolutePath) + if (!back || back.startsWith('..') || isAbsolute(back)) throw new Error('Thumbnail source path escapes the workspace root') + return { workspacePath: safePath, absolutePath } +} + +export function thumbnailPathKey(workspaceDir: string, workspacePath: string): string { + return createHash('sha256').update(`${resolve(workspaceDir)}\n${workspacePath}`).digest('hex').slice(0, 32) +} + +export function thumbnailVersion(stats: FileVersionStats): string { + return `${Math.trunc(stats.mtimeMs).toString(36)}-${stats.size.toString(36)}-r${THUMBNAIL_RENDER_REVISION}` +} + +export function thumbnailFileName(pathKey: string, version: string): string { + return `${pathKey}.${version}.webp` +} + +export function thumbnailUrlFor(fileName: string): string { + return `${THUMBNAIL_PROTOCOL}://${THUMBNAIL_HOST}/${fileName}` +} + +/** Maps a modly-thumb:// URL back to a cached file, or null for anything that is not one. */ +export function resolveThumbnailUrl(thumbnailsDir: string, url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + if (parsed.protocol !== `${THUMBNAIL_PROTOCOL}:` || parsed.hostname !== THUMBNAIL_HOST) return null + const fileName = parsed.pathname.replace(/^\//, '') + if (!THUMBNAIL_FILE_PATTERN.test(fileName)) return null + return join(thumbnailsDir, fileName) +} + +export function isWebpImage(bytes: Uint8Array): boolean { + const ascii = (start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end)) + return bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP' +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** URL of the cached thumbnail for this exact file version, if it was rendered already. */ +export async function findCachedThumbnailUrl(context: ThumbnailCacheContext, workspacePath: string, stats: FileVersionStats): Promise { + const fileName = thumbnailFileName(thumbnailPathKey(context.workspaceDir, workspacePath), thumbnailVersion(stats)) + return await exists(join(context.thumbnailsDir, fileName)) ? thumbnailUrlFor(fileName) : undefined +} + +export async function getThumbnailStatus(context: ThumbnailCacheContext, workspacePath: string): Promise { + let source: { workspacePath: string, absolutePath: string } + try { + source = normalizeThumbnailSourcePath(context.workspaceDir, workspacePath) + } catch (error) { + return { success: false, error: thumbnailError('unsafe-path', error instanceof Error ? error.message : String(error)) } + } + let stats: FileVersionStats + try { + stats = await stat(source.absolutePath) + } catch { + return { success: false, error: thumbnailError('not-found', 'Workspace mesh was not found.') } + } + return { + success: true, + workspacePath: source.workspacePath, + version: thumbnailVersion(stats), + thumbnailUrl: await findCachedThumbnailUrl(context, source.workspacePath, stats), + } +} + +async function cachedFilesFor(thumbnailsDir: string, pathKey: string): Promise { + let names: string[] + try { + names = await readdir(thumbnailsDir) + } catch { + return [] + } + return names.filter((name) => THUMBNAIL_FILE_PATTERN.exec(name)?.[1] === pathKey) +} + +/** + * Stores a render under the version the renderer was given. A file edited + * since then is refused as stale, so a thumbnail never claims a newer version + * than the one it shows; older versions of the same path are dropped. + */ +export async function saveThumbnail(context: ThumbnailCacheContext, request: { workspacePath: string, version: string, image: unknown }): Promise { + if (!(request.image instanceof Uint8Array) || request.image.length > MAX_THUMBNAIL_BYTES || !isWebpImage(request.image)) { + return { success: false, error: thumbnailError('invalid-image', 'Thumbnail must be a WebP image under 1 MB.') } + } + const status = await getThumbnailStatus(context, request.workspacePath) + if (!status.success) return status + if (status.version !== request.version) { + return { success: false, error: thumbnailError('stale', 'Workspace mesh changed while its thumbnail was rendering.') } + } + + const pathKey = thumbnailPathKey(context.workspaceDir, status.workspacePath) + const fileName = thumbnailFileName(pathKey, status.version) + const target = join(context.thumbnailsDir, fileName) + try { + await mkdir(context.thumbnailsDir, { recursive: true }) + const sidecar: ThumbnailSidecar = { workspaceDir: resolve(context.workspaceDir), workspacePath: status.workspacePath } + await writeFile(join(context.thumbnailsDir, `${pathKey}.json`), JSON.stringify(sidecar), 'utf8') + const temp = `${target}.${process.pid}.tmp` + await writeFile(temp, request.image) + await rename(temp, target) + const outdated = (await cachedFilesFor(context.thumbnailsDir, pathKey)).filter((name) => name !== fileName) + await Promise.all(outdated.map((name) => rm(join(context.thumbnailsDir, name), { force: true }))) + } catch (error) { + return { success: false, error: thumbnailError('write-failed', error instanceof Error ? error.message : String(error)) } + } + return { success: true, thumbnailUrl: thumbnailUrlFor(fileName) } +} + +async function removePathKey(thumbnailsDir: string, pathKey: string): Promise { + const files = [...await cachedFilesFor(thumbnailsDir, pathKey), `${pathKey}.json`] + await Promise.all(files.map((name) => rm(join(thumbnailsDir, name), { force: true }))) +} + +/** Drops every cached thumbnail of a workspace path (used when the asset is deleted). */ +export async function removeThumbnails(context: ThumbnailCacheContext, workspacePath: string): Promise { + const normalized = workspacePath.trim().replace(/\\/g, '/').split('/').filter((segment) => segment && segment !== '.').join('/') + await removePathKey(context.thumbnailsDir, thumbnailPathKey(context.workspaceDir, normalized)) +} + +async function readSidecar(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as Partial + return typeof parsed.workspaceDir === 'string' && typeof parsed.workspacePath === 'string' + ? { workspaceDir: parsed.workspaceDir, workspacePath: parsed.workspacePath } + : null + } catch { + return null + } +} + +/** + * Garbage-collects the cache: removes thumbnails whose source mesh is gone + * (however it was deleted), versions that no longer match the file on disk, + * and renders left without a sidecar. + */ +export async function pruneThumbnails(thumbnailsDir: string): Promise { + let names: string[] + try { + names = await readdir(thumbnailsDir) + } catch { + return + } + const sidecarKeys = new Set(names.filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -'.json'.length)).filter((key) => PATH_KEY_PATTERN.test(key))) + + for (const pathKey of sidecarKeys) { + const sidecar = await readSidecar(join(thumbnailsDir, `${pathKey}.json`)) + let current: string | null = null + if (sidecar) { + try { + const { absolutePath } = normalizeThumbnailSourcePath(sidecar.workspaceDir, sidecar.workspacePath) + current = thumbnailFileName(pathKey, thumbnailVersion(await stat(absolutePath))) + } catch { + current = null + } + } + if (!current) { + await removePathKey(thumbnailsDir, pathKey) + continue + } + const outdated = names.filter((name) => THUMBNAIL_FILE_PATTERN.exec(name)?.[1] === pathKey && name !== current) + await Promise.all(outdated.map((name) => rm(join(thumbnailsDir, name), { force: true }))) + } + + const orphans = names.filter((name) => { + const key = THUMBNAIL_FILE_PATTERN.exec(name)?.[1] + return (key !== undefined && !sidecarKeys.has(key)) || name.endsWith('.tmp') + }) + await Promise.all(orphans.map((name) => rm(join(thumbnailsDir, name), { force: true }))) +} + +export interface AssetThumbnailIpcDeps { + ipcMain: { handle(channel: string, handler: (event: unknown, payload?: unknown) => Promise): void } + getWorkspaceDir: () => string + thumbnailsDir: string +} + +export function registerAssetThumbnailIpcHandlers(deps: AssetThumbnailIpcDeps): void { + const context = (): ThumbnailCacheContext => ({ workspaceDir: deps.getWorkspaceDir(), thumbnailsDir: deps.thumbnailsDir }) + deps.ipcMain.handle('workspace:thumbnails:status', async (_event, payload) => { + const workspacePath = (payload as { workspacePath?: unknown } | undefined)?.workspacePath + if (typeof workspacePath !== 'string') return { success: false, error: thumbnailError('unsafe-path', 'workspacePath is required.') } + return getThumbnailStatus(context(), workspacePath) + }) + deps.ipcMain.handle('workspace:thumbnails:save', async (_event, payload) => { + const values = (payload ?? {}) as { workspacePath?: unknown, version?: unknown, image?: unknown } + if (typeof values.workspacePath !== 'string' || typeof values.version !== 'string') { + return { success: false, error: thumbnailError('unsafe-path', 'workspacePath and version are required.') } + } + return saveThumbnail(context(), { workspacePath: values.workspacePath, version: values.version, image: values.image }) + }) +} diff --git a/package.json b/package.json index dd05c25e..e017a0b7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/shared/types/assetLibrary.ts b/src/shared/types/assetLibrary.ts index fb5a602d..618096e7 100644 --- a/src/shared/types/assetLibrary.ts +++ b/src/shared/types/assetLibrary.ts @@ -43,6 +43,8 @@ export interface AssetLibraryEntry { nonOpenableReason?: string createdAt?: string updatedAt?: string + /** Cached render of the current file version (modly-thumb:// URL); absent until rendered. */ + thumbnailUrl?: string } export interface AssetLibrarySourceLink { @@ -81,3 +83,24 @@ export type AssetLibraryReadResult = export type AssetLibraryOpenResult = | { success: true, entry: AssetLibraryEntry } | { success: false, error: AssetLibraryError } + +export interface AssetThumbnailError { + code: 'unsafe-path' | 'not-found' | 'unsupported' | 'stale' | 'invalid-image' | 'write-failed' + message: string +} + +/** A workspace mesh's thumbnail state: `version` identifies the file version a new render must be saved under. */ +export type AssetThumbnailStatusResult = + | { success: true, workspacePath: string, version: string, thumbnailUrl?: string } + | { success: false, error: AssetThumbnailError } + +export interface AssetThumbnailSaveRequest { + workspacePath: string + version: string + /** Encoded WebP image bytes. */ + image: Uint8Array +} + +export type AssetThumbnailSaveResult = + | { success: true, thumbnailUrl: string } + | { success: false, error: AssetThumbnailError } From 44071c85103cc9c80bca5f09914466dd396aa071 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:01:31 +0100 Subject: [PATCH 2/8] feat(library): serve thumbnails over modly-thumb:// and expose status/save IPC A privileged custom scheme works for both the dev-server and packaged renderer origins, and only resolves file names from the cache. Deleting a job also drops its thumbnail. --- electron/main/asset-thumbnail-protocol.ts | 42 +++++++++++++++++++ electron/main/index.ts | 4 ++ electron/main/ipc-handlers.ts | 12 ++++++ .../preload/artifact-registry-preload.test.ts | 22 ++++++++++ electron/preload/electron-api.ts | 7 ++++ src/index.html | 2 +- src/shared/types/electron.d.ts | 7 ++++ 7 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 electron/main/asset-thumbnail-protocol.ts diff --git a/electron/main/asset-thumbnail-protocol.ts b/electron/main/asset-thumbnail-protocol.ts new file mode 100644 index 00000000..a2c521ee --- /dev/null +++ b/electron/main/asset-thumbnail-protocol.ts @@ -0,0 +1,42 @@ +import { app, protocol } from 'electron' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { resolveThumbnailUrl, THUMBNAIL_PROTOCOL } from './asset-thumbnail-cache' + +/** Dedicated folder: userData/cache collides with Chromium's own disk cache on Windows. */ +export function getThumbnailsDir(): string { + return join(app.getPath('userData'), 'thumbnails') +} + +/** Must run before `app.whenReady()` resolves. */ +export function registerThumbnailScheme(): void { + protocol.registerSchemesAsPrivileged([ + { scheme: THUMBNAIL_PROTOCOL, privileges: { standard: true, secure: true } }, + ]) +} + +/** + * Serves cached thumbnails as `modly-thumb://thumbnails/.webp`. Works for + * both the dev server (http) and packaged (file://) renderer origins, which a + * plain file:// URL would not. Only names matching the cache's own pattern + * resolve, so the scheme cannot reach anything else on disk. + */ +export function handleThumbnailProtocol(): void { + protocol.handle(THUMBNAIL_PROTOCOL, async (request) => { + const filePath = resolveThumbnailUrl(getThumbnailsDir(), request.url) + if (!filePath) return new Response(null, { status: 400 }) + try { + const body = await readFile(filePath) + return new Response(body, { + headers: { + 'content-type': 'image/webp', + // File names embed the source version, so a URL's content never changes. + 'cache-control': 'public, max-age=31536000, immutable', + }, + }) + } catch { + return new Response(null, { status: 404 }) + } + }) +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 2f3f5b83..24c68642 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -8,6 +8,7 @@ import { initAutoUpdater } from './updater' import { syncBuiltinExtensions } from './builtin-sync' import { reconcileInterruptedExtensionInstalls } from './extension-install-recovery' import { getSettings } from './settings-store' +import { handleThumbnailProtocol, registerThumbnailScheme } from './asset-thumbnail-protocol' let mainWindow: BrowserWindow | null = null let pythonBridge: PythonBridge | null = null @@ -76,6 +77,7 @@ function createWindow(): void { } app.setName('Modly') +registerThumbnailScheme() process.on('uncaughtException', (err) => { if ((err as NodeJS.ErrnoException).code === 'EPIPE') return @@ -101,6 +103,8 @@ app.whenReady().then(async () => { optimizer.watchWindowShortcuts(window) }) + handleThumbnailProtocol() + // Sync built-in extensions from app resources to userData syncBuiltinExtensions() diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 03970578..0632620f 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -91,6 +91,8 @@ import { validateExtensionDestinationRegistration, } from './extension-install-recovery' import { registerWorkspaceAssetLibraryIpcHandlers } from './artifact-registry-service' +import { getThumbnailsDir } from './asset-thumbnail-protocol' +import { registerAssetThumbnailIpcHandlers, removeThumbnails } from './asset-thumbnail-cache' import { updatesSupported } from './updater' import { ModelWeightOperations } from './model-weight-operations' import { readLocalFileBase64 } from './bounded-file-reader' @@ -995,6 +997,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe registerWorkspaceAssetLibraryIpcHandlers({ ipcMain, getWorkspaceDir: () => getSettings(app.getPath('userData')).workspaceDir, + thumbnailsDir: getThumbnailsDir(), + }) + registerAssetThumbnailIpcHandlers({ + ipcMain, + getWorkspaceDir: () => getSettings(app.getPath('userData')).workspaceDir, + thumbnailsDir: getThumbnailsDir(), }) ipcMain.handle('workspace:listCollections', async () => { @@ -1035,6 +1043,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('workspace:deleteJob', async (_, { collection, filename }: { collection: string; filename: string }) => { await rmAsync(workspacePath(collection, filename), { force: true }) await rmAsync(workspacePath(collection, filename.replace(/\.glb$/, '.meta.json')), { force: true }) + await removeThumbnails( + { workspaceDir: getSettings(app.getPath('userData')).workspaceDir, thumbnailsDir: getThumbnailsDir() }, + `${collection}/${filename}`, + ).catch(() => undefined) }) // Directory utilities for settings diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 9dec46fd..2f78c2e2 100644 --- a/electron/preload/artifact-registry-preload.test.ts +++ b/electron/preload/artifact-registry-preload.test.ts @@ -44,6 +44,28 @@ test('preload exposes scoped workspace library list/read/open methods', async () ]) }) +test('preload exposes workspace thumbnail status/save methods', async () => { + const calls: Array<{ channel: string, payload?: unknown }> = [] + const api = createElectronApi({ + invoke: async (channel: string, payload?: unknown) => { + calls.push({ channel, payload }) + return { success: true } + }, + send: () => undefined, + on: () => undefined, + removeAllListeners: () => undefined, + }, { setZoomFactor: () => undefined }, { getPathForFile: () => '' }) + const image = new Uint8Array([1, 2, 3]) + + await api.workspace.thumbnails.status('Workflows/hero.glb') + await api.workspace.thumbnails.save({ workspacePath: 'Workflows/hero.glb', version: 'v1', image }) + + assert.deepEqual(calls, [ + { channel: 'workspace:thumbnails:status', payload: { workspacePath: 'Workflows/hero.glb' } }, + { channel: 'workspace:thumbnails:save', payload: { workspacePath: 'Workflows/hero.glb', version: 'v1', image } }, + ]) +}) + test('preload resolves dropped file paths through Electron webUtils', () => { const files: unknown[] = [] const calls: string[] = [] diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index d11863be..364ba965 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -4,6 +4,9 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetThumbnailSaveRequest, + AssetThumbnailSaveResult, + AssetThumbnailStatusResult, } from '../../src/shared/types/assetLibrary' import type { Conversation as AgentConversation, @@ -249,6 +252,10 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra read: (request: AssetLibraryReadRequest): Promise => ipcRenderer.invoke('workspace:library:read', request) as Promise, open: (request: AssetLibraryOpenRequest): Promise => ipcRenderer.invoke('workspace:library:open', request) as Promise, }, + thumbnails: { + status: (workspacePath: string): Promise => ipcRenderer.invoke('workspace:thumbnails:status', { workspacePath }) as Promise, + save: (request: AssetThumbnailSaveRequest): Promise => ipcRenderer.invoke('workspace:thumbnails:save', request) as Promise, + }, }, // Extensions diff --git a/src/index.html b/src/index.html index b1c8a532..03ed408e 100644 --- a/src/index.html +++ b/src/index.html @@ -3,7 +3,7 @@ - + Modly diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 675827f6..57ec8f3a 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -7,6 +7,9 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetThumbnailSaveRequest, + AssetThumbnailSaveResult, + AssetThumbnailStatusResult, } from './assetLibrary' import type { Conversation, ConversationSummary } from '../utils/agentConversations' @@ -313,6 +316,10 @@ declare global { read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise } + thumbnails: { + status: (workspacePath: string) => Promise + save: (request: AssetThumbnailSaveRequest) => Promise + } } setup: { check: () => Promise<{ needed: boolean; defaultDataDir: string; platform: string; arch: string }> From 4cf073d36d76465ef9218954af9383e292a9596a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:01:32 +0100 Subject: [PATCH 3/8] feat(library): render thumbnails offscreen and backfill the library The renderer draws each mesh with Viewer3D's default rig in a single offscreen WebGL context, one at a time, whenever a mesh reaches the viewer (generation, import, edits, library opens) and for library entries still missing one. three.js is loaded lazily so it stays out of the entry chunk. --- package.json | 2 +- src/App.tsx | 3 + src/areas/generate/GeneratePage.tsx | 2 + .../thumbnails/assetThumbnailQueue.test.ts | 113 ++++++++++++++++++ src/shared/thumbnails/assetThumbnailQueue.ts | 87 ++++++++++++++ .../thumbnails/assetThumbnailService.ts | 68 +++++++++++ src/shared/thumbnails/renderMeshThumbnail.ts | 106 ++++++++++++++++ 7 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/shared/thumbnails/assetThumbnailQueue.test.ts create mode 100644 src/shared/thumbnails/assetThumbnailQueue.ts create mode 100644 src/shared/thumbnails/assetThumbnailService.ts create mode 100644 src/shared/thumbnails/renderMeshThumbnail.ts diff --git a/package.json b/package.json index e017a0b7..0d8700a5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/App.tsx b/src/App.tsx index 2de17224..66f21092 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import MainLayout from '@shared/components/layout/MainLayout' import { UpdateModal } from '@shared/components/ui/UpdateModal' import { ErrorModal } from '@shared/components/ui/ErrorModal' import { Toast } from '@shared/components/ui/Toast' +import { startAssetThumbnailService } from '@shared/thumbnails/assetThumbnailService' const UI_SCALE_FACTORS: Record = { small: 0.875, medium: 1, large: 1.25, 'very-large': 1.5 } @@ -37,6 +38,8 @@ export default function App(): JSX.Element { window.electron.ui.setZoomFactor(UI_SCALE_FACTORS[uiScale]) }, [useAtkinsonFont, uiScale]) + useEffect(() => startAssetThumbnailService(), []) + useEffect(() => { if (setupStatus === 'done') initApp() // eslint-disable-next-line react-hooks/exhaustive-deps -- react to setup transition only; initApp is stable diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 9f77ef10..82cbf53c 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -13,6 +13,7 @@ import { meshPathFromUrl } from './meshEditPanel' import { commitSculpt, useSculptStore } from './sculpt/sculptStore' import { sculptKeyAction } from './sculpt/sculptLogic' import { getDefaultAssetLibraryService } from './assetLibraryService' +import { requestLibraryThumbnails } from '@shared/thumbnails/assetThumbnailService' import { buildOrcaSlicerDeepLink, canOpenInOrcaSlicer } from './orcaSlicerLink' import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' import { @@ -886,6 +887,7 @@ export default function GeneratePage(): JSX.Element { return } setLibraryEntries(result.entries) + void requestLibraryThumbnails(result.entries) setLibrarySelectedEntryId((current) => current && result.entries.some((entry) => entry.id === current) ? current : result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null) diff --git a/src/shared/thumbnails/assetThumbnailQueue.test.ts b/src/shared/thumbnails/assetThumbnailQueue.test.ts new file mode 100644 index 00000000..e7d2619f --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailQueue.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { AssetLibraryEntry, AssetThumbnailSaveResult } from '../types/assetLibrary' +import { + createAssetThumbnailQueue, + entriesNeedingThumbnails, + meshUrlForWorkspacePath, + workspacePathFromMeshUrl, +} from './assetThumbnailQueue.ts' + +const IMAGE = new Uint8Array([1, 2, 3]) + +function fakeDeps(options: { cached?: Set, version?: () => string, save?: () => AssetThumbnailSaveResult, renderFails?: boolean } = {}) { + const renders: string[] = [] + const saves: Array<{ workspacePath: string, version: string }> = [] + let active = 0 + let maxActive = 0 + let idle = 0 + const deps = { + status: async (workspacePath: string) => ({ + success: true as const, + workspacePath, + version: options.version?.() ?? 'v1', + thumbnailUrl: options.cached?.has(workspacePath) ? `modly-thumb://thumbnails/${workspacePath}` : undefined, + }), + save: async (request: { workspacePath: string, version: string }) => { + saves.push({ workspacePath: request.workspacePath, version: request.version }) + return options.save?.() ?? { success: true as const, thumbnailUrl: `modly-thumb://thumbnails/${request.workspacePath}` } + }, + render: async (workspacePath: string) => { + active += 1 + maxActive = Math.max(maxActive, active) + renders.push(workspacePath) + await new Promise((resolve) => setTimeout(resolve, 5)) + active -= 1 + if (options.renderFails) throw new Error('bad mesh') + return IMAGE + }, + onIdle: () => { idle += 1 }, + } + return { deps, renders, saves, stats: () => ({ maxActive, idle }) } +} + +test('mesh URLs map to workspace paths for glb/gltf only', () => { + assert.equal(workspacePathFromMeshUrl('/workspace/Default/hero.glb'), 'Default/hero.glb') + assert.equal(workspacePathFromMeshUrl('/workspace/My%20Coll/hero.gltf?v=2'), 'My Coll/hero.gltf') + assert.equal(workspacePathFromMeshUrl('/workspace/Default/part.stl'), null) + assert.equal(workspacePathFromMeshUrl('/optimize/serve-file?path=C%3A%2Fhero.glb'), null) + assert.equal(workspacePathFromMeshUrl('/workspace/%E0%A4%A.glb'), null) + assert.equal(meshUrlForWorkspacePath('http://127.0.0.1:8765/', 'My Coll/hero #1.glb'), 'http://127.0.0.1:8765/workspace/My%20Coll/hero%20%231.glb') +}) + +test('only ready, openable 3D entries without a thumbnail need one', () => { + const base: AssetLibraryEntry = { + id: 'a', workspacePath: 'Workflows/a.glb', displayName: 'a.glb', sourceScope: 'workflows', + state: 'ready', previewKind: '3d-model', warnings: [], openable: true, + } + const entries: AssetLibraryEntry[] = [ + base, + { ...base, id: 'b', thumbnailUrl: 'modly-thumb://thumbnails/b' }, + { ...base, id: 'c', previewKind: 'text', openable: false }, + { ...base, id: 'd', state: 'unsafe' }, + ] + assert.deepEqual(entriesNeedingThumbnails(entries).map((entry) => entry.id), ['a']) +}) + +test('renders serially, dedupes in-flight paths and skips cached versions', async () => { + const { deps, renders, saves, stats } = fakeDeps({ cached: new Set(['Workflows/cached.glb']) }) + const queue = createAssetThumbnailQueue(deps) + const results = await Promise.all([ + queue.ensure('Workflows/a.glb'), + queue.ensure('Workflows/a.glb'), + queue.ensure('Workflows/b.glb'), + queue.ensure('Workflows/cached.glb'), + ]) + assert.deepEqual(results, [ + 'modly-thumb://thumbnails/Workflows/a.glb', + 'modly-thumb://thumbnails/Workflows/a.glb', + 'modly-thumb://thumbnails/Workflows/b.glb', + 'modly-thumb://thumbnails/Workflows/cached.glb', + ]) + assert.deepEqual(renders, ['Workflows/a.glb', 'Workflows/b.glb']) + assert.deepEqual(saves, [{ workspacePath: 'Workflows/a.glb', version: 'v1' }, { workspacePath: 'Workflows/b.glb', version: 'v1' }]) + assert.equal(stats().maxActive, 1) + assert.equal(stats().idle, 1) +}) + +test('a version that failed to render is not retried until the file changes', async () => { + let version = 'v1' + const { deps, renders } = fakeDeps({ renderFails: true, version: () => version }) + const queue = createAssetThumbnailQueue(deps) + assert.equal(await queue.ensure('Workflows/broken.glb'), null) + assert.equal(await queue.ensure('Workflows/broken.glb'), null) + assert.equal(renders.length, 1) + version = 'v2' + await queue.ensure('Workflows/broken.glb') + assert.equal(renders.length, 2) +}) + +test('a stale save is retried on the next request', async () => { + let stale = true + const { deps, renders } = fakeDeps({ + save: () => stale + ? { success: false, error: { code: 'stale', message: 'changed' } } + : { success: true, thumbnailUrl: 'modly-thumb://thumbnails/x' }, + }) + const queue = createAssetThumbnailQueue(deps) + assert.equal(await queue.ensure('Workflows/a.glb'), null) + stale = false + assert.equal(await queue.ensure('Workflows/a.glb'), 'modly-thumb://thumbnails/x') + assert.equal(renders.length, 2) +}) diff --git a/src/shared/thumbnails/assetThumbnailQueue.ts b/src/shared/thumbnails/assetThumbnailQueue.ts new file mode 100644 index 00000000..1d66ed36 --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailQueue.ts @@ -0,0 +1,87 @@ +import type { + AssetLibraryEntry, + AssetThumbnailSaveRequest, + AssetThumbnailSaveResult, + AssetThumbnailStatusResult, +} from '../types/assetLibrary' + +export interface AssetThumbnailQueueDeps { + status: (workspacePath: string) => Promise + save: (request: AssetThumbnailSaveRequest) => Promise + /** Renders the mesh at this workspace path and returns encoded WebP bytes. */ + render: (workspacePath: string) => Promise + /** Called whenever the queue drains, e.g. to release the offscreen GL context. */ + onIdle?: () => void +} + +/** `/workspace/` mesh URLs (what generation, import and edits push) → workspace path, for .glb/.gltf only. */ +export function workspacePathFromMeshUrl(url: string): string | null { + if (!url.startsWith('/workspace/')) return null + let workspacePath: string + try { + workspacePath = decodeURIComponent(url.slice('/workspace/'.length).split('?')[0] ?? '') + } catch { + return null + } + return /\.(glb|gltf)$/i.test(workspacePath) ? workspacePath : null +} + +export function meshUrlForWorkspacePath(apiUrl: string, workspacePath: string): string { + return `${apiUrl.replace(/\/$/, '')}/workspace/${workspacePath.split('/').map(encodeURIComponent).join('/')}` +} + +/** Library entries that can be rendered but have no thumbnail for their current file version yet. */ +export function entriesNeedingThumbnails(entries: AssetLibraryEntry[]): AssetLibraryEntry[] { + return entries.filter((entry) => entry.state === 'ready' && entry.openable && entry.previewKind === '3d-model' && !entry.thumbnailUrl) +} + +/** + * Serial render queue: one offscreen render at a time, each workspace path at + * most once in flight, and a file version that failed to render is not retried + * until the file changes. The main process is the source of truth for whether + * a version is already cached, so a thumbnail is rendered at most once per + * file version even across sessions. + */ +export function createAssetThumbnailQueue(deps: AssetThumbnailQueueDeps) { + const inFlight = new Map>() + const failedVersions = new Set() + let tail: Promise = Promise.resolve() + + async function run(workspacePath: string): Promise { + const status = await deps.status(workspacePath) + if (!status.success) return null + if (status.thumbnailUrl) return status.thumbnailUrl + const versionKey = `${status.workspacePath}@${status.version}` + if (failedVersions.has(versionKey)) return null + try { + const image = await deps.render(status.workspacePath) + const saved = await deps.save({ workspacePath: status.workspacePath, version: status.version, image }) + if (saved.success) return saved.thumbnailUrl + // A stale save means the file changed mid-render: the next request renders the new version. + if (saved.error.code !== 'stale') failedVersions.add(versionKey) + return null + } catch { + failedVersions.add(versionKey) + return null + } + } + + function ensure(workspacePath: string): Promise { + const existing = inFlight.get(workspacePath) + if (existing) return existing + const job = tail + .then(() => run(workspacePath)) + .catch(() => null) + .finally(() => { + inFlight.delete(workspacePath) + if (inFlight.size === 0) deps.onIdle?.() + }) + inFlight.set(workspacePath, job) + tail = job + return job + } + + return { ensure } +} + +export type AssetThumbnailQueue = ReturnType diff --git a/src/shared/thumbnails/assetThumbnailService.ts b/src/shared/thumbnails/assetThumbnailService.ts new file mode 100644 index 00000000..7e4e448e --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailService.ts @@ -0,0 +1,68 @@ +import { useAppStore } from '@shared/stores/appStore' +import type { AssetLibraryEntry } from '../types/assetLibrary' +import { + createAssetThumbnailQueue, + entriesNeedingThumbnails, + meshUrlForWorkspacePath, + workspacePathFromMeshUrl, + type AssetThumbnailQueue, +} from './assetThumbnailQueue' + +// Pause between renders so a large backfill never monopolises the UI thread. +const RENDER_SPACING_MS = 100 + +let queue: AssetThumbnailQueue | null = null +// three.js is loaded on first render so it stays out of the app's entry chunk. +let renderModule: Promise | null = null +const loadRenderModule = () => (renderModule ??= import('./renderMeshThumbnail')) + +function getQueue(): AssetThumbnailQueue { + if (queue) return queue + const thumbnails = window.electron.workspace.thumbnails + queue = createAssetThumbnailQueue({ + status: (workspacePath) => thumbnails.status(workspacePath), + save: (request) => thumbnails.save(request), + render: async (workspacePath) => { + await new Promise((resolve) => setTimeout(resolve, RENDER_SPACING_MS)) + const { renderMeshThumbnail } = await loadRenderModule() + return renderMeshThumbnail(meshUrlForWorkspacePath(useAppStore.getState().apiUrl, workspacePath)) + }, + onIdle: () => { void renderModule?.then((module) => module.releaseThumbnailRenderer()) }, + }) + return queue +} + +/** Queues thumbnails for library entries that lack one; resolves once they are all processed. */ +export async function requestLibraryThumbnails(entries: AssetLibraryEntry[]): Promise { + await Promise.all(entriesNeedingThumbnails(entries).map((entry) => getQueue().ensure(entry.workspacePath))) +} + +async function backfillLibraryThumbnails(): Promise { + try { + const result = await window.electron.workspace.library.list() + if (result.success) await requestLibraryThumbnails(result.entries) + } catch { + // Backfill is best effort; the next library listing retries. + } +} + +function viewerMeshUrl(state: ReturnType): string | undefined { + return state.currentJob?.status === 'done' ? state.currentJob.outputUrl : undefined +} + +/** + * Renders a thumbnail for every mesh that reaches the viewer (generation, + * import, mesh edits, agent edits, library opens) and backfills the library + * once the backend is up. Meshes already cached cost one status call. Returns + * an unsubscribe function. + */ +export function startAssetThumbnailService(): () => void { + if (useAppStore.getState().backendStatus === 'ready') void backfillLibraryThumbnails() + return useAppStore.subscribe((state, previous) => { + if (state.backendStatus === 'ready' && previous.backendStatus !== 'ready') void backfillLibraryThumbnails() + const url = viewerMeshUrl(state) + if (!url || url === viewerMeshUrl(previous)) return + const workspacePath = workspacePathFromMeshUrl(url) + if (workspacePath) void getQueue().ensure(workspacePath) + }) +} diff --git a/src/shared/thumbnails/renderMeshThumbnail.ts b/src/shared/thumbnails/renderMeshThumbnail.ts new file mode 100644 index 00000000..c56e57d7 --- /dev/null +++ b/src/shared/thumbnails/renderMeshThumbnail.ts @@ -0,0 +1,106 @@ +import * as THREE from 'three' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' + +export const THUMBNAIL_SIZE = 256 + +// Same look as Viewer3D's default view: its background, default light rig, +// fov and three-quarter camera direction, R3F's ACES tone mapping and sRGB +// output, DoubleSide materials. The user's live lighting tweaks are left out +// on purpose so every thumbnail is rendered the same way. +const BACKGROUND = '#18181b' +const CAMERA_FOV = 45 +const CAMERA_DIRECTION = new THREE.Vector3(0, 1.5, 4).normalize() + +let renderer: THREE.WebGLRenderer | null = null + +function getRenderer(): THREE.WebGLRenderer { + if (renderer) return renderer + const canvas = document.createElement('canvas') + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }) + renderer.setPixelRatio(1) + renderer.setSize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, false) + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.ACESFilmicToneMapping + return renderer +} + +/** Frees the offscreen WebGL context; the next render recreates it. */ +export function releaseThumbnailRenderer(): void { + if (!renderer) return + renderer.dispose() + renderer.forceContextLoss() + renderer = null +} + +function disposeObject(root: THREE.Object3D): void { + root.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return + child.geometry.dispose() + const materials: THREE.Material[] = Array.isArray(child.material) ? child.material : [child.material] + for (const material of materials) { + for (const value of Object.values(material)) { + if (value instanceof THREE.Texture) value.dispose() + } + material.dispose() + } + }) +} + +function buildScene(model: THREE.Object3D): { scene: THREE.Scene, camera: THREE.PerspectiveCamera } { + model.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return + const materials: THREE.Material[] = Array.isArray(child.material) ? child.material : [child.material] + materials.forEach((material) => { material.side = THREE.DoubleSide }) + }) + + // Rest the mesh on the ground at the origin, as Viewer3D does. + const box = new THREE.Box3().setFromObject(model) + const center = box.getCenter(new THREE.Vector3()) + model.position.set(-center.x, -box.min.y, -center.z) + model.updateMatrixWorld(true) + + const scene = new THREE.Scene() + scene.background = new THREE.Color(BACKGROUND) + scene.add(new THREE.AmbientLight(0xffffff, DEFAULT_LIGHT_SETTINGS.ambientIntensity)) + const main = new THREE.DirectionalLight(DEFAULT_LIGHT_SETTINGS.mainColor, DEFAULT_LIGHT_SETTINGS.mainIntensity) + main.position.set(5, 8, 5) + const fill = new THREE.DirectionalLight(DEFAULT_LIGHT_SETTINGS.fillColor, DEFAULT_LIGHT_SETTINGS.fillIntensity) + fill.position.set(-4, 2, -4) + scene.add(main, fill, model) + + // Frame the bounding sphere from Viewer3D's default camera direction. + const sphere = new THREE.Box3().setFromObject(model).getBoundingSphere(new THREE.Sphere()) + const radius = Math.max(sphere.radius, 1e-3) + const distance = (radius / Math.sin(THREE.MathUtils.degToRad(CAMERA_FOV) / 2)) * 1.05 + const camera = new THREE.PerspectiveCamera(CAMERA_FOV, 1, distance / 100, distance * 10) + camera.position.copy(sphere.center).addScaledVector(CAMERA_DIRECTION, distance) + camera.lookAt(sphere.center) + return { scene, camera } +} + +function canvasToWebp(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('Thumbnail encoding failed')) + return + } + blob.arrayBuffer().then((buffer) => resolve(new Uint8Array(buffer)), reject) + }, 'image/webp', 0.85) + }) +} + +/** Loads a .glb/.gltf from `url` and renders a square WebP thumbnail of it. */ +export async function renderMeshThumbnail(url: string): Promise { + const gltf = await new GLTFLoader().loadAsync(url) + const model = gltf.scene + try { + const gl = getRenderer() + const { scene, camera } = buildScene(model) + gl.render(scene, camera) + return await canvasToWebp(gl.domElement) + } finally { + disposeObject(model) + } +} From 4bb794fc871f0e6f2e7c3f4da7683c99c5f8c84a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:21:38 +0100 Subject: [PATCH 4/8] feat(library): publish finished thumbnails to a store so open views repaint The render queue reports each thumbnail it ends a request with; the service records it by workspace path in a small zustand store. A tile reads its own key, so a background render repaints that tile without re-listing the library. --- package.json | 2 +- .../thumbnails/assetThumbnailQueue.test.ts | 16 ++++++++++++++ src/shared/thumbnails/assetThumbnailQueue.ts | 6 +++++ .../thumbnails/assetThumbnailService.ts | 2 ++ .../thumbnails/assetThumbnailStore.test.ts | 18 +++++++++++++++ src/shared/thumbnails/assetThumbnailStore.ts | 22 +++++++++++++++++++ 6 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/shared/thumbnails/assetThumbnailStore.test.ts create mode 100644 src/shared/thumbnails/assetThumbnailStore.ts diff --git a/package.json b/package.json index 58db5c46..f3210b09 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs src/shared/pages/pageRegistry.test.ts", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/thumbnails/assetThumbnailStore.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs src/shared/pages/pageRegistry.test.ts", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/shared/thumbnails/assetThumbnailQueue.test.ts b/src/shared/thumbnails/assetThumbnailQueue.test.ts index e7d2619f..c39c1240 100644 --- a/src/shared/thumbnails/assetThumbnailQueue.test.ts +++ b/src/shared/thumbnails/assetThumbnailQueue.test.ts @@ -98,6 +98,22 @@ test('a version that failed to render is not retried until the file changes', as assert.equal(renders.length, 2) }) +test('reports each thumbnail once per processed request, and nothing for failures', async () => { + const { deps } = fakeDeps({ cached: new Set(['Workflows/cached.glb']) }) + const ready: Array<[string, string]> = [] + const queue = createAssetThumbnailQueue({ ...deps, onReady: (path, url) => { ready.push([path, url]) } }) + await Promise.all([queue.ensure('Workflows/a.glb'), queue.ensure('Workflows/a.glb'), queue.ensure('Workflows/cached.glb')]) + assert.deepEqual(ready, [ + ['Workflows/a.glb', 'modly-thumb://thumbnails/Workflows/a.glb'], + ['Workflows/cached.glb', 'modly-thumb://thumbnails/Workflows/cached.glb'], + ]) + + const failing = fakeDeps({ renderFails: true }) + const silent: string[] = [] + await createAssetThumbnailQueue({ ...failing.deps, onReady: (path) => { silent.push(path) } }).ensure('Workflows/broken.glb') + assert.deepEqual(silent, []) +}) + test('a stale save is retried on the next request', async () => { let stale = true const { deps, renders } = fakeDeps({ diff --git a/src/shared/thumbnails/assetThumbnailQueue.ts b/src/shared/thumbnails/assetThumbnailQueue.ts index 1d66ed36..6b791922 100644 --- a/src/shared/thumbnails/assetThumbnailQueue.ts +++ b/src/shared/thumbnails/assetThumbnailQueue.ts @@ -12,6 +12,8 @@ export interface AssetThumbnailQueueDeps { render: (workspacePath: string) => Promise /** Called whenever the queue drains, e.g. to release the offscreen GL context. */ onIdle?: () => void + /** Called once per processed request that ends with a thumbnail, so open views can show it. */ + onReady?: (workspacePath: string, thumbnailUrl: string) => void } /** `/workspace/` mesh URLs (what generation, import and edits push) → workspace path, for .glb/.gltf only. */ @@ -71,6 +73,10 @@ export function createAssetThumbnailQueue(deps: AssetThumbnailQueueDeps) { if (existing) return existing const job = tail .then(() => run(workspacePath)) + .then((thumbnailUrl) => { + if (thumbnailUrl) deps.onReady?.(workspacePath, thumbnailUrl) + return thumbnailUrl + }) .catch(() => null) .finally(() => { inFlight.delete(workspacePath) diff --git a/src/shared/thumbnails/assetThumbnailService.ts b/src/shared/thumbnails/assetThumbnailService.ts index 7e4e448e..710e17e7 100644 --- a/src/shared/thumbnails/assetThumbnailService.ts +++ b/src/shared/thumbnails/assetThumbnailService.ts @@ -7,6 +7,7 @@ import { workspacePathFromMeshUrl, type AssetThumbnailQueue, } from './assetThumbnailQueue' +import { useAssetThumbnailStore } from './assetThumbnailStore' // Pause between renders so a large backfill never monopolises the UI thread. const RENDER_SPACING_MS = 100 @@ -28,6 +29,7 @@ function getQueue(): AssetThumbnailQueue { return renderMeshThumbnail(meshUrlForWorkspacePath(useAppStore.getState().apiUrl, workspacePath)) }, onIdle: () => { void renderModule?.then((module) => module.releaseThumbnailRenderer()) }, + onReady: (workspacePath, thumbnailUrl) => useAssetThumbnailStore.getState().setThumbnailUrl(workspacePath, thumbnailUrl), }) return queue } diff --git a/src/shared/thumbnails/assetThumbnailStore.test.ts b/src/shared/thumbnails/assetThumbnailStore.test.ts new file mode 100644 index 00000000..15f7da9f --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailStore.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { useAssetThumbnailStore } from './assetThumbnailStore' + +test('records rendered thumbnails by workspace path and ignores repeats', () => { + const { setThumbnailUrl } = useAssetThumbnailStore.getState() + setThumbnailUrl('Workflows/a.glb', 'modly-thumb://a@v1') + const afterFirst = useAssetThumbnailStore.getState().urls + setThumbnailUrl('Workflows/a.glb', 'modly-thumb://a@v1') + assert.equal(useAssetThumbnailStore.getState().urls, afterFirst) + setThumbnailUrl('Workflows/b.glb', 'modly-thumb://b@v1') + setThumbnailUrl('Workflows/a.glb', 'modly-thumb://a@v2') + assert.deepEqual(useAssetThumbnailStore.getState().urls, { + 'Workflows/a.glb': 'modly-thumb://a@v2', + 'Workflows/b.glb': 'modly-thumb://b@v1', + }) +}) diff --git a/src/shared/thumbnails/assetThumbnailStore.ts b/src/shared/thumbnails/assetThumbnailStore.ts new file mode 100644 index 00000000..d1e90d69 --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailStore.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand' + +// Thumbnails rendered since the library was listed, by workspace path. A tile +// reads its own key, so a finished render repaints that tile only, without +// re-listing the library. + +interface AssetThumbnailState { + urls: Readonly> + setThumbnailUrl: (workspacePath: string, thumbnailUrl: string) => void +} + +export const useAssetThumbnailStore = create((set) => ({ + urls: {}, + setThumbnailUrl: (workspacePath, thumbnailUrl) => set((state) => ( + state.urls[workspacePath] === thumbnailUrl ? state : { urls: { ...state.urls, [workspacePath]: thumbnailUrl } } + )), +})) + +/** The freshest thumbnail for a library entry: a render from this session, else the one it was listed with. */ +export function useAssetThumbnailUrl(entry: { workspacePath: string, thumbnailUrl?: string }): string | undefined { + return useAssetThumbnailStore((state) => state.urls[entry.workspacePath]) ?? entry.thumbnailUrl +} From 5a46e070a6e2158d0e20ddd4b71d4af0c7d65733 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:26:37 +0100 Subject: [PATCH 5/8] feat(library): filtering, sorting and grid windowing for the Library page selectLibraryEntries reuses the Generate picker's visibility, search and sort rules and adds capability and source-scope filters, as one flat list. gridWindow holds the windowing math (columns, visible rows, reveal on keyboard moves). openAssetLibraryEntry factors the picker's open flow so another page can open an entry in Generate. --- package.json | 2 +- src/areas/generate/assetLibraryUi.test.ts | 35 ++++++++++ src/areas/generate/assetLibraryUi.ts | 29 +++++++- src/areas/library/gridWindow.test.ts | 60 +++++++++++++++++ src/areas/library/gridWindow.ts | 79 ++++++++++++++++++++++ src/areas/library/libraryView.test.ts | 64 ++++++++++++++++++ src/areas/library/libraryView.ts | 81 +++++++++++++++++++++++ 7 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 src/areas/library/gridWindow.test.ts create mode 100644 src/areas/library/gridWindow.ts create mode 100644 src/areas/library/libraryView.test.ts create mode 100644 src/areas/library/libraryView.ts diff --git a/package.json b/package.json index f3210b09..0d3d2baf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/thumbnails/assetThumbnailStore.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs src/shared/pages/pageRegistry.test.ts", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/library/libraryView.test.ts src/areas/library/gridWindow.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/thumbnails/assetThumbnailStore.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs src/shared/pages/pageRegistry.test.ts", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/generate/assetLibraryUi.test.ts b/src/areas/generate/assetLibraryUi.test.ts index e6b9fbca..98942fd6 100644 --- a/src/areas/generate/assetLibraryUi.test.ts +++ b/src/areas/generate/assetLibraryUi.test.ts @@ -8,6 +8,7 @@ import { filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, isAssetLibraryEntryOpenable, + openAssetLibraryEntry, resolveOpenPanelAfterLibrarySelection, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, @@ -123,3 +124,37 @@ test('builds linked-source open requests and import jobs for safe sidecars', () assert.equal(selection?.historyUrl, '/workspace/Workflows/run/hero.glb') assert.equal(selection?.job.outputUrl, '/workspace/Workflows/run/hero.glb') }) + +test('openAssetLibraryEntry opens through the service and builds the viewer job', async () => { + const requests: unknown[] = [] + const service = { + open: async (request: { workspacePath: string, sourceWorkspacePath?: string }) => { + requests.push(request) + return { success: true as const, entry: entry() } + }, + } + const opened = await openAssetLibraryEntry(service, entry(), 42) + assert.deepEqual(requests, [{ workspacePath: 'Workflows/hero.glb' }]) + assert.equal(opened.success, true) + if (!opened.success) throw new Error('expected success') + assert.equal(opened.selection.historyUrl, '/workspace/Workflows/hero.glb') + assert.equal(opened.selection.job.id, 'library-42') +}) + +test('openAssetLibraryEntry refuses unopenable entries without calling the service and relays service errors', async () => { + let calls = 0 + const failing = { + open: async () => { + calls += 1 + return { success: false as const, error: { code: 'not-found' as const, message: 'Gone.' } } + }, + } + const unsafe = entry({ workspacePath: 'C:/outside/hero.glb' }) + assert.deepEqual(await openAssetLibraryEntry(failing, unsafe), { + success: false, + message: describeAssetLibraryOpenability(unsafe), + }) + assert.equal(calls, 0) + assert.deepEqual(await openAssetLibraryEntry(failing, entry()), { success: false, message: 'Gone.' }) + assert.equal(calls, 1) +}) \ No newline at end of file diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts index 5e83e65a..a5dae40e 100644 --- a/src/areas/generate/assetLibraryUi.ts +++ b/src/areas/generate/assetLibraryUi.ts @@ -1,6 +1,7 @@ import type { GenerationJob } from '../../shared/stores/appStore' import type { AssetLibraryOpenRequest } from '../../shared/types/assetLibrary' import { resolveAssetLibraryOpenTarget, type AssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' +import type { ProjectedAssetLibraryOpenResult } from './assetLibraryService' export type GenerateOpenPanel = 'export' | 'decimate' | 'smooth' | 'import' | 'library' | 'light' | null export type AssetLibrarySortMode = 'type' | 'name' | 'date' @@ -24,7 +25,7 @@ export interface AssetLibraryOpenSelection { job: GenerationJob } -const ASSET_LIBRARY_CAPABILITY_SECTIONS = [ +export const ASSET_LIBRARY_CAPABILITY_SECTIONS = [ { capability: 'mesh', label: 'Mesh' }, { capability: 'rigged-mesh', label: 'Rigged mesh' }, { capability: 'animation-motion', label: 'Animations/motions' }, @@ -33,7 +34,7 @@ const ASSET_LIBRARY_CAPABILITY_SECTIONS = [ { capability: 'scene-manifest', label: 'Scene manifests' }, ] as const satisfies ReadonlyArray<{ capability: NonNullable, label: string }> -const ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS = [ +export const ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS = [ { sourceScope: 'workflows', label: 'Workflows' }, { sourceScope: 'exports', label: 'Exports' }, ] as const satisfies ReadonlyArray<{ sourceScope: ProjectedAssetLibraryEntry['sourceScope'], label: string }> @@ -164,11 +165,33 @@ export function createAssetLibraryOpenJob( } } +export type AssetLibraryOpenOutcome = + | { success: true, entry: ProjectedAssetLibraryEntry, selection: AssetLibraryOpenSelection } + | { success: false, message: string } + +/** + * Opens a library entry for the Generate viewer: checks it is openable, asks + * the main process to open it, and builds the viewer job. The caller applies + * `selection` (setCurrentJob + pushMeshUrl), from Generate or another page. + */ +export async function openAssetLibraryEntry( + service: { open: (request: AssetLibraryOpenRequest) => Promise }, + entry: ProjectedAssetLibraryEntry, + now = Date.now(), +): Promise { + if (!isAssetLibraryEntryOpenable(entry)) return { success: false, message: describeAssetLibraryOpenability(entry) } + const result = await service.open(buildAssetLibraryOpenRequest(entry)) + if (!result.success) return { success: false, message: result.error.message } + const selection = createAssetLibraryOpenJob(result.entry, resolveAssetLibraryOpenTarget(result.entry), now) + if (!selection) return { success: false, message: describeAssetLibraryOpenability(result.entry) } + return { success: true, entry: result.entry, selection } +} + export function resolveOpenPanelAfterLibrarySelection(currentPanel: GenerateOpenPanel): GenerateOpenPanel { return currentPanel === 'library' ? 'library' : currentPanel } -function sortAssetLibraryEntries( +export function sortAssetLibraryEntries( entries: ProjectedAssetLibraryEntry[], sortMode: AssetLibrarySortMode, ): ProjectedAssetLibraryEntry[] { diff --git a/src/areas/library/gridWindow.test.ts b/src/areas/library/gridWindow.test.ts new file mode 100644 index 00000000..58d55881 --- /dev/null +++ b/src/areas/library/gridWindow.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { computeGridLayout, moveGridIndex, scrollTopToReveal, visibleRowRange } from './gridWindow' + +test('grid columns stretch to fill the width and rows are square tiles plus the label', () => { + const layout = computeGridLayout({ width: 1000, itemCount: 500, gap: 16, minColumnWidth: 160, labelHeight: 40 }) + assert.equal(layout.columns, 5) + assert.equal(layout.columnWidth, 187) + assert.equal(layout.rowHeight, 227) + assert.equal(layout.rowStride, 243) + assert.equal(layout.rowCount, 100) + assert.equal(layout.totalHeight, 100 * 227 + 99 * 16) +}) + +test('grid layout degrades gracefully: one column when narrow, nothing when empty', () => { + const narrow = computeGridLayout({ width: 100, itemCount: 3, gap: 16, minColumnWidth: 160, labelHeight: 40 }) + assert.equal(narrow.columns, 1) + assert.equal(narrow.rowCount, 3) + const empty = computeGridLayout({ width: 0, itemCount: 0, gap: 16, minColumnWidth: 160 }) + assert.equal(empty.rowCount, 0) + assert.equal(empty.totalHeight, 0) +}) + +test('a fixed row height gives a list layout', () => { + const list = computeGridLayout({ width: 800, itemCount: 10, gap: 2, minColumnWidth: Number.MAX_SAFE_INTEGER, rowHeight: 52 }) + assert.equal(list.columns, 1) + assert.equal(list.columnWidth, 800) + assert.equal(list.rowStride, 54) + assert.equal(list.totalHeight, 10 * 52 + 9 * 2) +}) + +test('only rows near the viewport are rendered, whatever the library size', () => { + const layout = { rowStride: 100, rowCount: 1000 } + assert.deepEqual(visibleRowRange(layout, 0, 350, 2), { start: 0, end: 6 }) + assert.deepEqual(visibleRowRange(layout, 5000, 350, 2), { start: 48, end: 56 }) + assert.deepEqual(visibleRowRange(layout, 99_950, 350, 2), { start: 997, end: 1000 }) + assert.deepEqual(visibleRowRange({ rowStride: 100, rowCount: 0 }, 0, 350), { start: 0, end: 0 }) + assert.deepEqual(visibleRowRange(layout, -40, 0, 0), { start: 0, end: 1 }) +}) + +test('scrollTopToReveal scrolls the least needed to show a row', () => { + const layout = { rowStride: 110, rowHeight: 100 } + assert.equal(scrollTopToReveal(layout, 2, 0, 400), null) + assert.equal(scrollTopToReveal(layout, 5, 0, 400), 550 + 100 - 400) + assert.equal(scrollTopToReveal(layout, 1, 500, 400), 110) +}) + +test('arrow keys move through the grid and stay in bounds', () => { + assert.equal(moveGridIndex(0, 'ArrowRight', 4, 10), 1) + assert.equal(moveGridIndex(0, 'ArrowLeft', 4, 10), 0) + assert.equal(moveGridIndex(1, 'ArrowDown', 4, 10), 5) + assert.equal(moveGridIndex(8, 'ArrowDown', 4, 10), 9) + assert.equal(moveGridIndex(5, 'ArrowUp', 4, 10), 1) + assert.equal(moveGridIndex(5, 'End', 4, 10), 9) + assert.equal(moveGridIndex(5, 'Home', 4, 10), 0) + assert.equal(moveGridIndex(-1, 'ArrowDown', 4, 10), 0) + assert.equal(moveGridIndex(3, 'a', 4, 10), null) + assert.equal(moveGridIndex(0, 'ArrowDown', 4, 0), null) +}) diff --git a/src/areas/library/gridWindow.ts b/src/areas/library/gridWindow.ts new file mode 100644 index 00000000..bd5c48ca --- /dev/null +++ b/src/areas/library/gridWindow.ts @@ -0,0 +1,79 @@ +// Windowing math for the Library grid: only the rows in (or near) the viewport +// are rendered, so a large library scrolls at a constant cost. + +export interface GridLayoutOptions { + /** Content width available to the rows, in px. */ + width: number + itemCount: number + gap: number + /** Narrowest a column may get; columns stretch to fill the width. */ + minColumnWidth: number + /** Fixed row height; when absent rows are square tiles plus `labelHeight`. */ + rowHeight?: number + labelHeight?: number +} + +export interface GridLayout { + columns: number + columnWidth: number + rowHeight: number + /** Row height plus the gap below it. */ + rowStride: number + rowCount: number + totalHeight: number +} + +export function computeGridLayout(options: GridLayoutOptions): GridLayout { + const width = Math.max(0, options.width) + const gap = Math.max(0, options.gap) + const columns = Math.max(1, Math.floor((width + gap) / (Math.max(1, options.minColumnWidth) + gap))) + const columnWidth = Math.max(0, Math.floor((width - gap * (columns - 1)) / columns)) + const rowHeight = options.rowHeight ?? columnWidth + (options.labelHeight ?? 0) + const rowCount = Math.ceil(Math.max(0, options.itemCount) / columns) + const rowStride = rowHeight + gap + const totalHeight = rowCount === 0 ? 0 : rowCount * rowHeight + (rowCount - 1) * gap + return { columns, columnWidth, rowHeight, rowStride, rowCount, totalHeight } +} + +export interface RowRange { + /** First row to render. */ + start: number + /** One past the last row to render. */ + end: number +} + +/** Rows intersecting the viewport, plus `overscan` rows on each side. */ +export function visibleRowRange(layout: Pick, scrollTop: number, viewportHeight: number, overscan = 2): RowRange { + if (layout.rowCount === 0 || layout.rowStride <= 0) return { start: 0, end: 0 } + const top = Math.max(0, scrollTop) + const first = Math.min(layout.rowCount - 1, Math.floor(top / layout.rowStride)) + const last = Math.ceil((top + Math.max(0, viewportHeight)) / layout.rowStride) + return { + start: Math.max(0, first - overscan), + end: Math.min(layout.rowCount, Math.max(first + 1, last) + overscan), + } +} + +/** The scrollTop that brings a row fully into view, or null when it already is. */ +export function scrollTopToReveal(layout: Pick, row: number, scrollTop: number, viewportHeight: number): number | null { + const top = row * layout.rowStride + const bottom = top + layout.rowHeight + if (top < scrollTop) return top + if (bottom > scrollTop + viewportHeight) return Math.max(0, bottom - viewportHeight) + return null +} + +/** Arrow-key movement through a grid of `count` items laid out in `columns`. */ +export function moveGridIndex(index: number, key: string, columns: number, count: number): number | null { + if (count === 0) return null + if (index < 0) return 0 + const next = key === 'ArrowRight' ? index + 1 + : key === 'ArrowLeft' ? index - 1 + : key === 'ArrowDown' ? index + columns + : key === 'ArrowUp' ? index - columns + : key === 'Home' ? 0 + : key === 'End' ? count - 1 + : null + if (next === null) return null + return Math.min(count - 1, Math.max(0, next)) +} diff --git a/src/areas/library/libraryView.test.ts b/src/areas/library/libraryView.test.ts new file mode 100644 index 00000000..2589ecd5 --- /dev/null +++ b/src/areas/library/libraryView.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { AssetLibraryEntry } from '../../shared/types/assetLibrary' +import { projectAssetLibraryEntry, type ProjectedAssetLibraryEntry } from '../generate/assetLibraryProjection' +import { + DEFAULT_LIBRARY_QUERY, + capabilityLabel, + libraryCapabilityOptions, + libraryScopeOptions, + selectLibraryEntries, + type LibraryQuery, +} from './libraryView' + +function entry(name: string, overrides: Partial = {}): ProjectedAssetLibraryEntry { + return projectAssetLibraryEntry({ + id: `library:${name}`, + workspacePath: `Workflows/run/${name}`, + displayName: name, + sourceScope: 'workflows', + capability: 'mesh', + state: 'ready', + previewKind: '3d-model', + warnings: [], + openable: true, + ...overrides, + }) +} + +const ENTRIES = [ + entry('zebra.glb', { createdAt: '2026-01-01T00:00:00.000Z' }), + entry('apple.glb', { createdAt: '2026-03-01T00:00:00.000Z' }), + entry('rig.glb', { capability: 'rigged-mesh', createdAt: '2026-02-01T00:00:00.000Z' }), + entry('export.glb', { sourceScope: 'exports', workspacePath: 'Exports/export.glb', createdAt: '2025-12-01T00:00:00.000Z' }), + entry('hidden.glb', { workspacePath: 'Workflows/run/tmp/hidden.glb' }), + entry('unsupported.bin', { state: 'unsupported' }), + entry('loose.glb', { capability: undefined }), +] + +const names = (entries: ProjectedAssetLibraryEntry[]) => entries.map((e) => e.displayName) +const query = (overrides: Partial): LibraryQuery => ({ ...DEFAULT_LIBRARY_QUERY, ...overrides }) + +test('sorts globally by date (newest first) or name, hiding entries the picker hides', () => { + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ sort: 'date' }))), ['apple.glb', 'rig.glb', 'zebra.glb', 'export.glb']) + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ sort: 'name' }))), ['apple.glb', 'export.glb', 'rig.glb', 'zebra.glb']) +}) + +test('type sort keeps the picker order: scope, then capability, then name', () => { + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ sort: 'type' }))), ['apple.glb', 'zebra.glb', 'rig.glb', 'export.glb']) +}) + +test('filters by capability, source scope and search together', () => { + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ capability: 'rigged-mesh' }))), ['rig.glb']) + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ scope: 'exports' }))), ['export.glb']) + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ search: 'APP' }))), ['apple.glb']) + assert.deepEqual(names(selectLibraryEntries(ENTRIES, query({ search: 'apple', capability: 'rigged-mesh' }))), []) +}) + +test('filter options count what each choice would show', () => { + assert.deepEqual(libraryScopeOptions(ENTRIES).map((o) => [o.value, o.count]), [['all', 4], ['workflows', 3], ['exports', 1]]) + assert.deepEqual(libraryCapabilityOptions(ENTRIES).map((o) => [o.value, o.count]), [['all', 4], ['mesh', 3], ['rigged-mesh', 1]]) + assert.equal(capabilityLabel('rigged-mesh'), 'Rigged mesh') + assert.equal(capabilityLabel(undefined), 'Asset') +}) diff --git a/src/areas/library/libraryView.ts b/src/areas/library/libraryView.ts new file mode 100644 index 00000000..0c92235f --- /dev/null +++ b/src/areas/library/libraryView.ts @@ -0,0 +1,81 @@ +import type { AssetCapability, AssetLibrarySourceScope } from '../../shared/types/assetLibrary' +import type { ProjectedAssetLibraryEntry } from '../generate/assetLibraryProjection' +import { + ASSET_LIBRARY_CAPABILITY_SECTIONS, + ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS, + filterAssetLibraryScopeGroups, + filterVisibleAssetLibraryEntries, + sortAssetLibraryEntries, + type AssetLibrarySortMode, +} from '../generate/assetLibraryUi' + +// What the Library page shows: the Generate picker's visibility, search and +// sort rules, plus capability and source-scope filters, as one flat list for +// the grid. + +export type LibraryCapabilityFilter = AssetCapability | 'all' +export type LibraryScopeFilter = AssetLibrarySourceScope | 'all' +export type LibraryViewMode = 'grid' | 'list' + +export interface LibraryQuery { + search: string + capability: LibraryCapabilityFilter + scope: LibraryScopeFilter + sort: AssetLibrarySortMode +} + +export interface LibraryFilterOption { + value: T + label: string + count: number +} + +export const DEFAULT_LIBRARY_QUERY: LibraryQuery = { search: '', capability: 'all', scope: 'all', sort: 'date' } + +const CAPABILITY_LABELS = new Map(ASSET_LIBRARY_CAPABILITY_SECTIONS.map((s) => [s.capability, s.label])) + +export function capabilityLabel(capability: AssetCapability | undefined): string { + return (capability && CAPABILITY_LABELS.get(capability)) ?? 'Asset' +} + +/** The entries to show, filtered and sorted. Type sort keeps the picker's scope → capability order. */ +export function selectLibraryEntries(entries: ProjectedAssetLibraryEntry[], query: LibraryQuery): ProjectedAssetLibraryEntry[] { + const inFilters = entries.filter((entry) => ( + (query.scope === 'all' || entry.sourceScope === query.scope) + && (query.capability === 'all' || entry.capability === query.capability) + )) + const matches = filterAssetLibraryScopeGroups(inFilters, query.search, query.sort) + .flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries)) + return query.sort === 'type' ? matches : sortAssetLibraryEntries(matches, query.sort) +} + +/** Scope filter options with the number of visible entries in each (ignoring the other filters). */ +export function libraryScopeOptions(entries: ProjectedAssetLibraryEntry[]): LibraryFilterOption[] { + const visible = visibleWithCapability(entries) + return [ + { value: 'all', label: 'All', count: visible.length }, + ...ASSET_LIBRARY_SOURCE_SCOPE_SECTIONS.map((section) => ({ + value: section.sourceScope, + label: section.label, + count: visible.filter((entry) => entry.sourceScope === section.sourceScope).length, + })), + ] +} + +/** Capability filter options: "All", then each capability present in the library. */ +export function libraryCapabilityOptions(entries: ProjectedAssetLibraryEntry[]): LibraryFilterOption[] { + const visible = visibleWithCapability(entries) + const present = ASSET_LIBRARY_CAPABILITY_SECTIONS + .map((section) => ({ + value: section.capability as LibraryCapabilityFilter, + label: section.label, + count: visible.filter((entry) => entry.capability === section.capability).length, + })) + .filter((option) => option.count > 0) + return [{ value: 'all', label: 'All types', count: visible.length }, ...present] +} + +// The picker only lists entries with a known capability; count the same set. +function visibleWithCapability(entries: ProjectedAssetLibraryEntry[]): ProjectedAssetLibraryEntry[] { + return filterVisibleAssetLibraryEntries(entries).filter((entry) => entry.capability !== undefined) +} From be2f22621515d2704595ba2acd8e003d86a7020a Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:26:38 +0100 Subject: [PATCH 6/8] test: run the page registry tests under the extensionless TS loader The test imports ./pageRegistry without an extension, so the plain node --test pass could not resolve it. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0d3d2baf..74c026e6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/library/libraryView.test.ts src/areas/library/gridWindow.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/thumbnails/assetThumbnailStore.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs src/shared/pages/pageRegistry.test.ts", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/library/libraryView.test.ts src/areas/library/gridWindow.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/thumbnails/assetThumbnailQueue.test.ts src/shared/thumbnails/assetThumbnailStore.test.ts src/shared/components/agent/clientTools.test.ts src/shared/components/agent/agentContext.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts src/shared/utils/agentConversations.test.ts electron/main/artifact-registry-service.test.ts electron/main/asset-thumbnail-cache.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/main/agent-conversations.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts src/areas/generate/meshEditPanel.test.ts src/areas/generate/sculpt/SculptMesh.test.ts src/areas/generate/sculpt/SculptMesh.perf.test.ts src/areas/generate/sculpt/sculptLogic.test.ts src/shared/pages/pageRegistry.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." From bb435ed525404731916b21c8ec0349bc21c90277 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Fri, 13 Nov 2026 10:27:48 +0100 Subject: [PATCH 7/8] feat(library): Library page with a virtualized thumbnail grid New Library page (group manage) listing workspace assets as square thumbnail tiles or a compact list, with search, source-scope and type filters, sort, and a grid/list toggle. Only the rows near the viewport are mounted, so large workspaces scroll at constant cost. Tiles without a thumbnail show their capability icon and repaint when a background render lands; unopenable entries stay dimmed with their reason. Double-click (or Enter, or the status-bar button) opens the asset in Generate. --- src/areas/library/LibraryGrid.tsx | 300 ++++++++++++++++ src/areas/library/LibraryPage.tsx | 338 ++++++++++++++++++ .../components/agent/clientTools.test.ts | 2 +- src/shared/pages/pageRegistry.test.ts | 3 +- src/shared/pages/pageRegistry.ts | 13 + 5 files changed, 654 insertions(+), 2 deletions(-) create mode 100644 src/areas/library/LibraryGrid.tsx create mode 100644 src/areas/library/LibraryPage.tsx diff --git a/src/areas/library/LibraryGrid.tsx b/src/areas/library/LibraryGrid.tsx new file mode 100644 index 00000000..b6d8f10f --- /dev/null +++ b/src/areas/library/LibraryGrid.tsx @@ -0,0 +1,300 @@ +import { createElement, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from 'react' +import type { IconShape } from '@shared/pages/pageRegistry' +import type { AssetCapability } from '@shared/types/assetLibrary' +import { useAssetThumbnailUrl } from '@shared/thumbnails/assetThumbnailStore' +import type { ProjectedAssetLibraryEntry } from '@areas/generate/assetLibraryProjection' +import { describeAssetLibraryOpenability, isAssetLibraryEntryOpenable } from '@areas/generate/assetLibraryUi' +import { computeGridLayout, moveGridIndex, scrollTopToReveal, visibleRowRange, type GridLayout } from './gridWindow' +import { capabilityLabel, type LibraryViewMode } from './libraryView' + +// Side padding of the scroll area (matches the page's px-7). +const PADDING_X = 28 +const PADDING_Y = 4 +const GRID = { gap: 16, minColumnWidth: 164, labelHeight: 46 } +const LIST = { gap: 2, rowHeight: 56 } +const OVERSCAN_ROWS = 3 + +const CAPABILITY_ICONS: Record = { + mesh: [ + ['path', { d: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' }], + ['polyline', { points: '3.27 6.96 12 12.01 20.73 6.96' }], + ['line', { x1: 12, y1: 22.08, x2: 12, y2: 12 }], + ], + 'rigged-mesh': [ + ['circle', { cx: 12, cy: 4.5, r: 2 }], + ['path', { d: 'M12 6.5v7M12 13.5l-4 7M12 13.5l4 7M5.5 10h13' }], + ], + 'animation-motion': [ + ['circle', { cx: 12, cy: 12, r: 9 }], + ['path', { d: 'M10 8.5v7l6-3.5-6-3.5z' }], + ], + 'landmarks-sidecar': [ + ['path', { d: 'M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z' }], + ['circle', { cx: 12, cy: 9.5, r: 2.5 }], + ], + 'generated-world': [ + ['circle', { cx: 12, cy: 12, r: 9 }], + ['path', { d: 'M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18' }], + ], + 'scene-manifest': [ + ['path', { d: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z' }], + ['polyline', { points: '14 3 14 9 20 9' }], + ['line', { x1: 8, y1: 13, x2: 16, y2: 13 }], + ['line', { x1: 8, y1: 17, x2: 13, y2: 17 }], + ], + unknown: [['rect', { x: 4, y: 4, width: 16, height: 16, rx: 2 }]], +} + +export function CapabilityIcon({ capability, size = 18, className }: { capability?: AssetCapability, size?: number, className?: string }): JSX.Element { + return ( + + ) +} + +const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) + +export function formatEntryDate(entry: ProjectedAssetLibraryEntry): string | null { + const value = entry.createdAt ?? entry.updatedAt + const time = value ? Date.parse(value) : Number.NaN + return Number.isFinite(time) ? DATE_FORMAT.format(time) : null +} + +/** The asset's thumbnail, or its capability icon until one is rendered (or if it fails to load). */ +function Thumbnail({ entry, iconSize }: { entry: ProjectedAssetLibraryEntry, iconSize: number }): JSX.Element { + const url = useAssetThumbnailUrl(entry) + const [failedUrl, setFailedUrl] = useState(null) + if (url && url !== failedUrl) { + return ( + setFailedUrl(url)} + className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.04]" + /> + ) + } + return ( +
+ +
+ ) +} + +function LockBadge(): JSX.Element { + return ( + + + + ) +} + +function Spinner(): JSX.Element { + return