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/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/package.json b/package.json index 987fcb34..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/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/pages/pageRegistry.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/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 ." 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..759fb2bd 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -13,16 +13,17 @@ 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 type { ProjectedAssetLibraryEntry } from './assetLibraryProjection' +import { useNavStore } from '@shared/stores/navStore' import { ASSET_LIBRARY_SORT_OPTIONS, - buildAssetLibraryOpenRequest, - createAssetLibraryOpenJob, describeAssetLibraryOpenability, filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, isAssetLibraryEntryOpenable, + openAssetLibraryEntry, resolveOpenPanelAfterLibrarySelection, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, @@ -480,6 +481,7 @@ function AssetLibraryPopover({ onOpenSelected, onRefresh, onClose, + onOpenLibraryPage, }: { entries: ProjectedAssetLibraryEntry[] selectedEntryId: string | null @@ -496,6 +498,7 @@ function AssetLibraryPopover({ onOpenSelected: () => void onRefresh: () => void onClose: () => void + onOpenLibraryPage: () => void }) { const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) @@ -524,12 +527,23 @@ function AssetLibraryPopover({ + + + ) + return ( +
+ {button('grid', 'Grid view', ( + + ))} + {button('list', 'List view', ( + + ))} +
+ ) +} + +function EmptyState({ title, detail, action }: { title: string, detail?: string, action?: JSX.Element }): JSX.Element { + return ( +
+
+ +
+

{title}

+ {detail &&

{detail}

} + {action} +
+ ) +} + +export default function LibraryPage(): JSX.Element { + const service = useMemo(() => getDefaultAssetLibraryService(), []) + const setCurrentJob = useAppStore((s) => s.setCurrentJob) + const pushMeshUrl = useAppStore((s) => s.pushMeshUrl) + const navigate = useNavStore((s) => s.navigate) + + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [listError, setListError] = useState(null) + const [query, setQuery] = useState(DEFAULT_LIBRARY_QUERY) + const [view, setView] = useState(readStoredView) + const [selectedId, setSelectedId] = useState(null) + const [openingId, setOpeningId] = useState(null) + const [openError, setOpenError] = useState(null) + const searchRef = useRef(null) + + const load = useCallback(async () => { + setLoading(true) + setListError(null) + try { + const result = await service.list() + if (!result.success) { + setListError(result.error.message) + return + } + setEntries(result.entries) + // Renders missing thumbnails in the background; tiles repaint as each one lands. + void requestLibraryThumbnails(result.entries) + } catch (err) { + setListError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, [service]) + + useEffect(() => { void load() }, [load]) + + // "/" focuses the search, as on the Extensions page. + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key !== '/') return + const active = document.activeElement + if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || active instanceof HTMLSelectElement) return + event.preventDefault() + searchRef.current?.focus() + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + const visible = useMemo(() => selectLibraryEntries(entries, query), [entries, query]) + const scopeOptions = useMemo(() => libraryScopeOptions(entries), [entries]) + const capabilityOptions = useMemo(() => libraryCapabilityOptions(entries), [entries]) + const total = scopeOptions[0].count + const selected = visible.find((entry) => entry.id === selectedId) ?? null + const filtered = query.search.trim() !== '' || query.capability !== 'all' || query.scope !== 'all' + + const updateQuery = (patch: Partial) => setQuery((current) => ({ ...current, ...patch })) + + function changeView(next: LibraryViewMode) { + setView(next) + try { localStorage.setItem(VIEW_STORAGE_KEY, next) } catch { /* private mode / quota */ } + } + + function selectEntry(entryId: string) { + setSelectedId(entryId) + setOpenError(null) + } + + async function openEntry(entry: ProjectedAssetLibraryEntry) { + if (openingId) return + setSelectedId(entry.id) + setOpenError(null) + if (!isAssetLibraryEntryOpenable(entry)) { + setOpenError(describeAssetLibraryOpenability(entry)) + return + } + setOpeningId(entry.id) + try { + const result = await openAssetLibraryEntry(service, entry) + if (!result.success) { + setOpenError(result.message) + return + } + setCurrentJob(result.selection.job) + pushMeshUrl(result.selection.historyUrl) + navigate('generate') + } catch (err) { + setOpenError(err instanceof Error ? err.message : String(err)) + } finally { + setOpeningId(null) + } + } + + return ( +
+ + {/* Head */} +
+
+

Library

+

+ {loading && entries.length === 0 + ? 'Loading workspace assets…' + : filtered + ? <>{visible.length} of {total} assets + : <>{total} {total === 1 ? 'asset' : 'assets'} in your workspace} +

+
+ +
+ + {/* Toolbar */} +
+ + + {/* Source scope */} +
+ {scopeOptions.map((option) => { + const active = query.scope === option.value + return ( + + ) + })} +
+ + + + + + +
+ +
+ + {/* Content */} + {listError && entries.length === 0 ? ( + { void load() }} className="mt-2 text-xs font-medium text-accent-light hover:text-violet-300">Try again} + /> + ) : loading && entries.length === 0 ? ( + + ) : total === 0 ? ( + navigate('generate')} className="mt-2 text-xs font-medium text-accent-light hover:text-violet-300">Go to Generate} + /> + ) : visible.length === 0 ? ( + setQuery((current) => ({ ...DEFAULT_LIBRARY_QUERY, sort: current.sort }))} className="mt-2 text-xs font-medium text-accent-light hover:text-violet-300">Clear filters} + /> + ) : ( +
+ { void openEntry(entry) }} + /> +
+ )} + + {/* Status bar */} +
+ {selected ? ( + <> +
+

+ {selected.displayName} + {selected.workspacePath} +

+

+ {openError ?? describeAssetLibraryOpenability(selected)} +

+
+ + + ) : ( +

+ {listError ? {listError} : 'Double-click an asset to open it in Generate. Arrow keys move, Enter opens.'} +

+ )} +
+
+ ) +} 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) +} 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/components/agent/clientTools.test.ts b/src/shared/components/agent/clientTools.test.ts index 3584d032..b6f29ace 100644 --- a/src/shared/components/agent/clientTools.test.ts +++ b/src/shared/components/agent/clientTools.test.ts @@ -337,7 +337,7 @@ test('navigate changes page and validates it', async () => { const f = fake() assert.equal(await run('navigate', { page: 'models' }, f), 'Opened the Extensions page.') assert.deepEqual(f.calls, [['navigate', 'models']]) - assert.match(await run('navigate', { page: 'home' }, f), /^Error: page must be one of: generate, workflows, models, settings/) + assert.match(await run('navigate', { page: 'home' }, f), /^Error: page must be one of: generate, workflows, library, models, settings/) }) test('a throwing dependency comes back as an error text', async () => { diff --git a/src/shared/pages/pageRegistry.test.ts b/src/shared/pages/pageRegistry.test.ts index 97b7f514..ce65055b 100644 --- a/src/shared/pages/pageRegistry.test.ts +++ b/src/shared/pages/pageRegistry.test.ts @@ -17,11 +17,12 @@ test('page ids are unique and every page has a known group, a label and an icon' } }) -test('the four existing pages keep their ids, labels and sidebar order', () => { +test('the pages keep their ids, labels and sidebar order', () => { assert.deepEqual( pagesByGroup().map((s) => [s.group, s.pages.map((p) => `${p.id}:${p.label}`)]), [ ['create', ['generate:Generate', 'workflows:Workflows']], + ['manage', ['library:Library']], ['system', ['models:Extensions', 'settings:Settings']], ], ) diff --git a/src/shared/pages/pageRegistry.ts b/src/shared/pages/pageRegistry.ts index 405c1395..6428d071 100644 --- a/src/shared/pages/pageRegistry.ts +++ b/src/shared/pages/pageRegistry.ts @@ -52,6 +52,19 @@ export const PAGES = [ load: () => import('@areas/workflows/WorkflowsPage'), wrapperClass: 'flex flex-1 overflow-hidden', }, + { + id: 'library', + label: 'Library', + group: 'manage', + icon: [ + ['rect', { x: 3, y: 3, width: 7.5, height: 7.5, rx: 1.5 }], + ['rect', { x: 13.5, y: 3, width: 7.5, height: 7.5, rx: 1.5 }], + ['rect', { x: 3, y: 13.5, width: 7.5, height: 7.5, rx: 1.5 }], + ['rect', { x: 13.5, y: 13.5, width: 7.5, height: 7.5, rx: 1.5 }], + ], + load: () => import('@areas/library/LibraryPage'), + wrapperClass: 'flex flex-1 overflow-hidden', + }, { id: 'models', label: 'Extensions', diff --git a/src/shared/thumbnails/assetThumbnailQueue.test.ts b/src/shared/thumbnails/assetThumbnailQueue.test.ts new file mode 100644 index 00000000..c39c1240 --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailQueue.test.ts @@ -0,0 +1,129 @@ +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('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({ + 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..6b791922 --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailQueue.ts @@ -0,0 +1,93 @@ +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 + /** 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. */ +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)) + .then((thumbnailUrl) => { + if (thumbnailUrl) deps.onReady?.(workspacePath, thumbnailUrl) + return thumbnailUrl + }) + .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..710e17e7 --- /dev/null +++ b/src/shared/thumbnails/assetThumbnailService.ts @@ -0,0 +1,70 @@ +import { useAppStore } from '@shared/stores/appStore' +import type { AssetLibraryEntry } from '../types/assetLibrary' +import { + createAssetThumbnailQueue, + entriesNeedingThumbnails, + meshUrlForWorkspacePath, + 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 + +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()) }, + onReady: (workspacePath, thumbnailUrl) => useAssetThumbnailStore.getState().setThumbnailUrl(workspacePath, thumbnailUrl), + }) + 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/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 +} 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) + } +} 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 } 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 }>