diff --git a/api/routers/agent.py b/api/routers/agent.py index 3eeb2a73..055b76bf 100644 --- a/api/routers/agent.py +++ b/api/routers/agent.py @@ -1,12 +1,15 @@ """ Agent chat endpoint — runs an Ollama-powered tool-use loop against Modly's API. """ +import json import re import uuid import httpx from fastapi import APIRouter from pydantic import BaseModel +import services.generator_registry as reg_module + router = APIRouter(prefix="/agent", tags=["agent"]) MODLY_API = "http://localhost:8765" @@ -408,6 +411,60 @@ def _extract_thinking(msg: dict) -> tuple[str, str | None]: return content, thinking +def _read_glb_modly_extras(glb_path) -> dict | None: + """Parse a GLB's leading JSON chunk and return asset.extras.modly, if present. + Used only as a fallback for assets that predate the .tags.json sidecar.""" + try: + with open(glb_path, "rb") as f: + header = f.read(12) + if len(header) < 12 or header[0:4] != b"glTF": + return None + chunk_header = f.read(8) + if len(chunk_header) < 8 or chunk_header[4:8] != b"JSON": + return None + chunk_length = int.from_bytes(chunk_header[0:4], "little") + doc = json.loads(f.read(chunk_length)) + return (doc.get("asset") or {}).get("extras", {}).get("modly") + except Exception: + return None + + +def _load_asset_meta(workspace_rel_path: str) -> dict | None: + """Resolve name/project/tags/lineage for the model currently in the viewer. + + Reads the .tags.json sidecar when present, falling back to the GLB's own + embedded extras.modly for name/project/tags. Lineage (derived_from) is only + ever written to the sidecar, so an asset with no sidecar has none to report. + """ + workspace_dir = reg_module.WORKSPACE_DIR.resolve() + abs_path = (workspace_dir / workspace_rel_path).resolve() + if not str(abs_path).startswith(str(workspace_dir)): + return None # escapes the workspace — refuse to read + + sidecar_path = abs_path.with_suffix(".tags.json") + if sidecar_path.exists(): + try: + data = json.loads(sidecar_path.read_text(encoding="utf-8")) + return { + "name": data.get("name"), + "project": data.get("project"), + "tags": data.get("tags") or [], + "derived_from": data.get("derived_from"), + } + except Exception: + pass # malformed sidecar — fall through to the GLB fallback + + modly = _read_glb_modly_extras(abs_path) + if modly: + return { + "name": modly.get("name"), + "project": modly.get("project"), + "tags": modly.get("tags") or [], + "derived_from": None, + } + return None + + @router.get("/models") async def list_ollama_models(ollama_url: str = "http://localhost:11434"): async with httpx.AsyncClient(timeout=5.0) as client: @@ -431,6 +488,30 @@ async def agent_chat(request: AgentChatRequest): ctx_lines.append(f"Current mesh path: {request.context['currentMeshPath']}") if request.context.get("meshTriangles"): ctx_lines.append(f"Current mesh triangles: {request.context['meshTriangles']:,}") + if request.context.get("currentClipName"): + ctx_lines.append(f"Current animation clip: {request.context['currentClipName']}") + if request.context.get("availableClips"): + ctx_lines.append(f"Available animation clips: {', '.join(request.context['availableClips'])}") + + mesh_path = request.context.get("currentMeshPath") + meta = _load_asset_meta(mesh_path) if mesh_path else None + if meta: + if meta.get("name"): + ctx_lines.append(f"Model name: {meta['name']}") + if meta.get("project"): + ctx_lines.append(f"Project: {meta['project']}") + if meta.get("tags"): + ctx_lines.append(f"Tags: {', '.join(meta['tags'])}") + derived = meta.get("derived_from") + if derived: + parent = derived.get("parent") or {} + root = derived.get("root") or {} + parent_label = parent.get("name") or parent.get("path") + root_label = root.get("name") or root.get("path") + if parent_label == root_label: + ctx_lines.append(f"Derived from: {parent_label}") + else: + ctx_lines.append(f"Derived from: {parent_label} (originally: {root_label})") if ctx_lines: messages.append({ "role": "system", diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts index 882120d4..0fa5f191 100644 --- a/electron/main/artifact-registry-service.test.ts +++ b/electron/main/artifact-registry-service.test.ts @@ -10,6 +10,7 @@ import { normalizeWorkspaceAssetPath, openWorkspaceAssetLibraryEntry, readWorkspaceAssetLibraryEntry, + readWorkspaceAssetLibraryThumbnail, registerWorkspaceAssetLibraryIpcHandlers, } from './artifact-registry-service.ts' @@ -22,7 +23,8 @@ async function withWorkspace(run: (workspaceDir: string) => Promise) { } } -test('normalizes only workspace-relative paths under allowed Workflows and Exports roots', () => withWorkspace(async (workspaceDir) => { +test('normalizes only workspace-relative paths under allowed model roots', () => withWorkspace(async (workspaceDir) => { + assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Default/hero.glb').workspacePath, 'Default/hero.glb') assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Workflows/checkpoints/hero.glb').workspacePath, 'Workflows/checkpoints/hero.glb') assert.equal(normalizeWorkspaceAssetPath(workspaceDir, 'Exports/hero.glb').workspacePath, 'Exports/hero.glb') assert.throws(() => normalizeWorkspaceAssetPath(workspaceDir, '../secret.glb'), /traversal|escape|relative/i) @@ -64,6 +66,49 @@ test('lists Workflows and Exports assets while skipping hidden, cache, and inter assert.equal(result.success && result.entries.find((entry) => entry.workspacePath.endsWith('exported.ply'))?.openable, false) })) +test('indexes generated models and reads project, structural tags, creation time, and lineage from model sidecars', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Default'), { recursive: true }) + await mkdir(path.join(workspaceDir, 'Exports/adventure'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Default/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Default/hero.tags.json'), JSON.stringify({ + name: 'Hero', + project: null, + tags: ['mid-poly'], + created: '2026-06-15T10:00:00.000Z', + })) + await writeFile(path.join(workspaceDir, 'Exports/adventure/hero-rigged.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/adventure/hero-rigged.tags.json'), JSON.stringify({ + name: 'Hero Rigged', + project: 'Adventure', + tags: ['rigged', 'animated', 'clip-hero-walk', 'mid-poly'], + created: '2026-06-16T10:00:00.000Z', + derived_from: { + parent: { path: 'Default/hero.glb', name: 'Hero' }, + root: { path: 'Default/hero.glb', name: 'Hero' }, + }, + })) + + const result = await listWorkspaceAssetLibrary({ workspaceDir }) + assert.equal(result.success, true) + if (!result.success) return + assert.deepEqual(result.entries.map((entry) => entry.workspacePath), [ + 'Default/hero.glb', + 'Exports/adventure/hero-rigged.glb', + ]) + + const root = result.entries[0] + const derived = result.entries[1] + assert.equal(root.sourceScope, 'generated') + assert.equal(root.displayName, 'Hero') + assert.equal(derived.displayName, 'Hero Rigged') + assert.equal(derived.capability, 'rigged-mesh') + assert.equal(derived.createdAt, '2026-06-16T10:00:00.000Z') + assert.equal(derived.semantic?.project, 'Adventure') + assert.deepEqual(derived.semantic?.tags, ['rigged', 'animated', 'clip-hero-walk', 'mid-poly']) + assert.equal(derived.semantic?.derivedFrom?.parent.workspacePath, 'Default/hero.glb') + assert.equal(derived.semantic?.derivedFrom?.root.workspacePath, 'Default/hero.glb') +})) + test('reads and opens only safe GLB/GLTF workspace assets', () => withWorkspace(async (workspaceDir) => { await mkdir(path.join(workspaceDir, 'Workflows/checkpoints'), { recursive: true }) await mkdir(path.join(workspaceDir, 'Exports'), { recursive: true }) @@ -198,6 +243,116 @@ test('fails closed for unsafe, self, missing, and mismatched sourceWorkspacePath assert.equal(!mismatched.success && mismatched.error.code, 'not-openable') })) +test('reads a sibling .thumb.png for GLB assets and stays silent when one is missing', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/no-thumb.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/static.ply'), 'ply') + + const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' }) + assert.equal(withThumb.success, true) + assert.equal(withThumb.success && withThumb.dataUrl, `data:image/png;base64,${Buffer.from('fake-png-bytes').toString('base64')}`) + + const withoutThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/no-thumb.glb' }) + assert.equal(withoutThumb.success, false) + + const nonMesh = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/static.ply' }) + assert.equal(nonMesh.success, false) + + const unsafe = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: '../secret.glb' }) + assert.equal(unsafe.success, false) +})) + +test('attaches preview clip metadata to the thumbnail response when a manifest exists', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.preview-idle.webp'), 'fake-idle-webp') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.preview-walk.webp'), 'fake-walk-webp') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.previews.json'), JSON.stringify([ + { clip: 'idle', file: 'hero.preview-idle.webp', duration: 1.5 }, + { clip: 'walk', file: 'hero.preview-walk.webp', duration: 0.8 }, + ])) + + const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' }) + assert.equal(withThumb.success, true) + assert.deepEqual(withThumb.success && withThumb.previews, [ + { clip: 'idle', duration: 1.5 }, + { clip: 'walk', duration: 0.8 }, + ]) +})) + +test('fetches a specific preview clip WebP by name over the same thumbnail request', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.preview-walk.webp'), 'fake-walk-webp') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.previews.json'), JSON.stringify([ + { clip: 'walk', file: 'hero.preview-walk.webp', duration: 0.8 }, + ])) + + const clip = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'walk' }) + assert.equal(clip.success, true) + assert.equal(clip.success && clip.dataUrl, `data:image/webp;base64,${Buffer.from('fake-walk-webp').toString('base64')}`) + + const unknownClip = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'sprint' }) + assert.equal(unknownClip.success, false) +})) + +test('thumbnail response carries no previews field when no manifest exists, and stays silent on a malformed one', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/broken.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/broken.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/broken.previews.json'), 'not valid json{') + + const noManifest = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' }) + assert.equal(noManifest.success, true) + assert.equal(noManifest.success && noManifest.previews, undefined) + + const malformedManifest = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/broken.glb' }) + assert.equal(malformedManifest.success, true) + assert.equal(malformedManifest.success && malformedManifest.previews, undefined) +})) + +test('rejects a preview manifest entry that tries to escape the asset directory via its file field', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'secret.webp'), 'top-secret-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.previews.json'), JSON.stringify([ + { clip: 'escape-relative', file: '../../secret.webp', duration: 1 }, + { clip: 'escape-absolute', file: '/etc/passwd', duration: 1 }, + { clip: 'wrong-extension', file: 'hero.glb', duration: 1 }, + ])) + + // The manifest lists the clip in its metadata (harmless — it's just a name), + // but every attempt to actually fetch the referenced file must fail closed. + const listed = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' }) + assert.equal(listed.success, true) + assert.equal(listed.success && listed.previews?.length, 3) + + const relative = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'escape-relative' }) + assert.equal(relative.success, false) + const absolute = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'escape-absolute' }) + assert.equal(absolute.success, false) + const wrongExtension = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb', previewClip: 'wrong-extension' }) + assert.equal(wrongExtension.success, false) +})) + +test('IPC thumbnail handler forwards previewClip from payload', async () => { + const handlers = new Map Promise>() + registerWorkspaceAssetLibraryIpcHandlers({ + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + getWorkspaceDir: () => '/tmp/modly-workspace', + }) + + const result = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: 'Exports/hero.glb', previewClip: 'walk' }) + // No such workspace/file in this test, so it must fail closed rather than throw. + assert.equal((result as { success: boolean }).success, false) +}) + test('IPC read and open handlers forward sourceWorkspacePath without trusting malformed payloads', async () => { const handlers = new Map Promise>() registerWorkspaceAssetLibraryIpcHandlers({ @@ -224,7 +379,12 @@ test('registers workspace library IPC handlers with structured results', async ( assert.equal(typeof handlers.get('workspace:library:list'), 'function') assert.equal(typeof handlers.get('workspace:library:read'), 'function') assert.equal(typeof handlers.get('workspace:library:open'), 'function') + assert.equal(typeof handlers.get('workspace:library:thumbnail'), 'function') const result = await handlers.get('workspace:library:read')?.({}, { workspacePath: '../escape.glb' }) assert.equal(typeof (result as { success?: unknown }).success, 'boolean') assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path') + const thumbnail = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: '../escape.glb' }) + assert.equal((thumbnail as { success: boolean }).success, false) + const missingPayload = await handlers.get('workspace:library:thumbnail')?.({}, {}) + assert.equal((missingPayload as { success: boolean }).success, false) }) diff --git a/electron/main/artifact-registry-service.ts b/electron/main/artifact-registry-service.ts index 3c26de73..2d4d9595 100644 --- a/electron/main/artifact-registry-service.ts +++ b/electron/main/artifact-registry-service.ts @@ -8,18 +8,22 @@ import type { AssetLibraryError, AssetLibraryListResult, AssetLibraryOpenResult, + AssetLibraryPreviewClip, AssetLibraryPreviewKind, AssetLibraryPreviewPayload, AssetLibraryReadResult, + AssetLibraryLineageLink, + AssetLibrarySemanticMetadata, AssetLibrarySourceScope, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' import type { ArtifactProvenance } from '../../src/shared/types/artifacts' const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:[\\/]/ const ENCODED_ESCAPE_PATTERN = /%2e|%2f|%5c/i -const ALLOWED_ROOTS = ['Workflows', 'Exports'] as const +const ALLOWED_ROOTS = ['Default', 'Workflows', 'Exports'] as const const SKIPPED_DIRS = new Set(['tmp', 'temp', 'cache']) -const INTERNAL_SUFFIXES = ['.artifact.json', '.rigmeta.json'] as const +const INTERNAL_SUFFIXES = ['.artifact.json', '.rigmeta.json', '.tags.json', '.previews.json', '.thumb.png'] as const const TEXT_EXTENSIONS = new Set(['json', 'txt', 'md']) const INTRINSIC_MOTION_EXTENSIONS = new Set(['bvh', 'npz']) const MESH_EXTENSIONS = new Set(['glb', 'gltf', 'obj', 'stl', 'ply', 'splat']) @@ -51,6 +55,11 @@ export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryR sourceWorkspacePath?: string } +export interface WorkspaceAssetLibraryThumbnailRequest extends WorkspaceAssetLibraryRequest { + workspacePath: string + previewClip?: string +} + interface AssetLibraryMetadata { sourceWorkspacePath?: string manifestWorkspacePath?: string @@ -60,6 +69,11 @@ interface AssetLibraryMetadata { warnings: string[] } +interface AssetLibrarySemanticRead { + semantic?: AssetLibrarySemanticMetadata + warnings: string[] +} + export interface IpcMainLike { handle(channel: string, handler: (event: unknown, payload?: unknown) => Promise): void } @@ -112,7 +126,9 @@ function isGlbOrGltf(workspacePath: string): boolean { } function sourceScopeFor(workspacePath: string): AssetLibrarySourceScope { - return workspacePath.startsWith('Exports/') ? 'exports' : 'workflows' + if (workspacePath.startsWith('Exports/')) return 'exports' + if (workspacePath.startsWith('Workflows/')) return 'workflows' + return 'generated' } export function classifyAssetLibraryCandidate(candidate: AssetLibraryClassificationCandidate): AssetLibraryClassification { @@ -150,6 +166,11 @@ function objectField(value: unknown): Record | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : undefined } +function stringListField(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value.map(stringField).filter((item): item is string => item !== undefined))] +} + function manifestCapabilityFor(workspacePath: string): 'generated-world' | 'scene-manifest' | undefined { if (workspacePath.endsWith('.world.json')) return 'generated-world' if (workspacePath.endsWith('.scene.json')) return 'scene-manifest' @@ -203,12 +224,87 @@ async function readMetadata(workspaceDir: string, workspacePath: string, absolut return metadata } +function semanticSidecarPathFor(absolutePath: string): string { + return absolutePath.replace(/\.[^./\\]+$/, '') + '.tags.json' +} + +function safeSemanticLineageLink( + workspaceDir: string, + value: unknown, +): AssetLibraryLineageLink | undefined { + const candidate = objectField(value) + const rawPath = stringField(candidate?.path ?? candidate?.workspacePath) + if (!rawPath) return undefined + try { + const { workspacePath } = normalizeWorkspaceAssetPath(workspaceDir, rawPath) + return { + workspacePath, + displayName: stringField(candidate?.name ?? candidate?.displayName), + } + } catch { + return undefined + } +} + +async function readSemanticMetadata( + workspaceDir: string, + absolutePath: string, +): Promise { + let parsed: Record + try { + const raw = JSON.parse(await readFile(semanticSidecarPathFor(absolutePath), 'utf8')) as unknown + const object = objectField(raw) + if (!object) return { warnings: ['Asset metadata sidecar is not a JSON object.'] } + parsed = object + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { warnings: [] } + return { warnings: ['Asset metadata sidecar could not be read.'] } + } + + const warnings: string[] = [] + const name = stringField(parsed.name) + const project = stringField(parsed.project) + const tags = stringListField(parsed.tags) + if (parsed.tags !== undefined && !Array.isArray(parsed.tags)) { + warnings.push('Ignored invalid asset tags.') + } + + const createdRaw = stringField(parsed.created) + const created = createdRaw && Number.isFinite(Date.parse(createdRaw)) ? createdRaw : undefined + if (createdRaw && !created) warnings.push('Ignored invalid asset creation date.') + + let derivedFrom: AssetLibrarySemanticMetadata['derivedFrom'] + if (parsed.derived_from !== undefined) { + const rawDerivedFrom = objectField(parsed.derived_from) + const parent = safeSemanticLineageLink(workspaceDir, rawDerivedFrom?.parent) + const root = safeSemanticLineageLink(workspaceDir, rawDerivedFrom?.root) + if (parent && root) { + derivedFrom = { parent, root } + } else { + warnings.push('Ignored unsafe or invalid asset lineage.') + } + } + + return { + semantic: { + ...(name ? { name } : {}), + ...(project ? { project } : {}), + tags, + ...(created ? { created } : {}), + ...(derivedFrom ? { derivedFrom } : {}), + }, + warnings, + } +} + function shouldSkipDirectory(name: string): boolean { return name.startsWith('.') || SKIPPED_DIRS.has(name.toLowerCase()) } function shouldSkipFile(name: string): boolean { - return name.startsWith('.') || INTERNAL_SUFFIXES.some((suffix) => name.endsWith(suffix)) + return name.startsWith('.') + || INTERNAL_SUFFIXES.some((suffix) => name.endsWith(suffix)) + || /\.preview-[^/\\]+\.webp$/i.test(name) } async function hasRigMetadata(absolutePath: string): Promise { @@ -225,13 +321,18 @@ async function hasRigMetadata(absolutePath: string): Promise { async function buildEntry(workspaceDir: string, workspacePath: string): Promise { const { absolutePath } = normalizeWorkspaceAssetPath(workspaceDir, workspacePath) const stats = await stat(absolutePath) - const classification = classifyAssetLibraryCandidate({ workspacePath, hasRigMetadata: await hasRigMetadata(absolutePath) }) + const semanticRead = await readSemanticMetadata(workspaceDir, absolutePath) + const semanticTags = semanticRead.semantic?.tags ?? [] + const classification = classifyAssetLibraryCandidate({ + workspacePath, + hasRigMetadata: semanticTags.includes('rigged') || await hasRigMetadata(absolutePath), + }) const metadata = await readMetadata(workspaceDir, workspacePath, absolutePath) const manifestCapability = metadata.manifestWorkspacePath ? manifestCapabilityFor(metadata.manifestWorkspacePath) : undefined return { id: `library:${workspacePath}`, workspacePath, - displayName: basename(workspacePath), + displayName: semanticRead.semantic?.name ?? basename(workspacePath), sourceScope: sourceScopeFor(workspacePath), capability: classification.capability, state: classification.state, @@ -241,11 +342,12 @@ async function buildEntry(workspaceDir: string, workspacePath: string): Promise< artifactId: metadata.artifactId, versionId: metadata.versionId, provenance: metadata.provenance, - warnings: metadata.warnings, + warnings: [...metadata.warnings, ...semanticRead.warnings], openable: classification.openable, nonOpenableReason: classification.nonOpenableReason, - createdAt: (stats.birthtime.getTime() > 0 ? stats.birthtime : stats.mtime).toISOString(), + createdAt: semanticRead.semantic?.created ?? (stats.birthtime.getTime() > 0 ? stats.birthtime : stats.mtime).toISOString(), updatedAt: stats.mtime.toISOString(), + semantic: semanticRead.semantic, } } @@ -342,12 +444,131 @@ export async function openWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibr return { success: true, entry: read.entry } } -function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceWorkspacePath?: string } { +function previewManifestAbsolutePathFor(absolutePath: string): string { + return absolutePath.replace(/\.(glb|gltf)$/i, '.previews.json') +} + +interface AssetLibraryPreviewManifestEntry { + clip: string + file: string + duration: number +} + +function parsePreviewManifestList(raw: unknown): unknown[] { + if (Array.isArray(raw)) return raw + if (typeof raw === 'object' && raw !== null) { + const container = raw as Record + if (Array.isArray(container.previews)) return container.previews + if (Array.isArray(container.clips)) return container.clips + } + return [] +} + +function parsePreviewManifestEntry(raw: unknown): AssetLibraryPreviewManifestEntry | null { + if (typeof raw !== 'object' || raw === null) return null + const candidate = raw as Record + const clip = stringField(candidate.clip) + const file = stringField(candidate.file) + const duration = typeof candidate.duration === 'number' && Number.isFinite(candidate.duration) ? candidate.duration : undefined + if (!clip || !file || duration === undefined) return null + return { clip, file, duration } +} + +// The preview manifest and its clips are generated by a separate pipeline (not +// thumbs.py) and may not exist yet, or may be malformed mid-write — both are +// normal, silent conditions handled the same way a missing thumbnail is. +async function readPreviewManifestEntries(absolutePath: string): Promise { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(previewManifestAbsolutePathFor(absolutePath), 'utf8')) + } catch { + return [] + } + return parsePreviewManifestList(parsed) + .map(parsePreviewManifestEntry) + .filter((entry): entry is AssetLibraryPreviewManifestEntry => entry !== null) +} + +async function readAssetLibraryPreviewClips(absolutePath: string): Promise { + const entries = await readPreviewManifestEntries(absolutePath) + return entries.map(({ clip, duration }) => ({ clip, duration })) +} + +/** + * Resolves a manifest-listed clip name to a safe absolute WebP path. The + * manifest's `file` is trusted only as a filename — any directory component is + * discarded via `basename` — and the reconstructed sibling workspace path is + * re-validated through the same `normalizeWorkspaceAssetPath` guard every + * other workspace library read uses, so a stale or malformed manifest can + * never point outside the asset's own directory. + */ +async function resolvePreviewClipAbsolutePath( + workspaceDir: string, + workspacePath: string, + absolutePath: string, + previewClip: string, +): Promise { + const entries = await readPreviewManifestEntries(absolutePath) + const match = entries.find((entry) => entry.clip === previewClip) + if (!match) return null + const fileName = basename(match.file) + if (extensionOf(fileName) !== 'webp') return null + const siblingWorkspacePath = [...workspacePath.split('/').slice(0, -1), fileName].join('/') + let normalized: NormalizedWorkspaceAssetPath + try { + normalized = normalizeWorkspaceAssetPath(workspaceDir, siblingWorkspacePath) + } catch { + return null + } + try { + await stat(normalized.absolutePath) + } catch { + return null + } + return normalized.absolutePath +} + +// Thumbnails are pre-rendered beside their source mesh (see thumbs.py): a +// `.glb` asset may have a sibling `.thumb.png`. Missing files are +// a normal, silent condition, never surfaced as an AssetLibraryError. +// +// The same request also serves looping preview clips generated by a separate +// pipeline as `.preview-.webp`, listed in a sibling +// `.previews.json` manifest. Passing `previewClip` fetches that clip's +// WebP instead of the static thumbnail; omitting it fetches the static +// thumbnail as before and, when a manifest exists, attaches the clip list so +// the UI can offer hover-to-animate without a second round trip to discover +// it. No manifest yet (the common case until the preview pipeline lands) is +// indistinguishable from today's behavior. +export async function readWorkspaceAssetLibraryThumbnail(request: WorkspaceAssetLibraryThumbnailRequest): Promise { + try { + const normalized = normalizeWorkspaceAssetPath(request.workspaceDir, request.workspacePath) + if (!isGlbOrGltf(normalized.workspacePath)) return { success: false } + + if (request.previewClip) { + const clipAbsolutePath = await resolvePreviewClipAbsolutePath(request.workspaceDir, normalized.workspacePath, normalized.absolutePath, request.previewClip) + if (!clipAbsolutePath) return { success: false } + const buffer = await readFile(clipAbsolutePath) + return { success: true, dataUrl: `data:image/webp;base64,${buffer.toString('base64')}` } + } + + const thumbnailAbsolutePath = normalized.absolutePath.replace(/\.(glb|gltf)$/i, '.thumb.png') + const buffer = await readFile(thumbnailAbsolutePath) + const dataUrl = `data:image/png;base64,${buffer.toString('base64')}` + const previews = await readAssetLibraryPreviewClips(normalized.absolutePath) + return previews.length > 0 ? { success: true, dataUrl, previews } : { success: true, dataUrl } + } catch { + return { success: false } + } +} + +function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceWorkspacePath?: string, previewClip?: string } { if (typeof payload !== 'object' || payload === null) return {} - const values = payload as { workspacePath?: unknown, sourceWorkspacePath?: unknown } + const values = payload as { workspacePath?: unknown, sourceWorkspacePath?: unknown, previewClip?: unknown } return { workspacePath: typeof values.workspacePath === 'string' ? values.workspacePath : undefined, sourceWorkspacePath: typeof values.sourceWorkspacePath === 'string' ? values.sourceWorkspacePath : undefined, + previewClip: typeof values.previewClip === 'string' ? values.previewClip : undefined, } } @@ -363,4 +584,9 @@ export function registerWorkspaceAssetLibraryIpcHandlers(deps: WorkspaceAssetLib if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) }) + deps.ipcMain.handle('workspace:library:thumbnail', async (_event, payload) => { + const { workspacePath, previewClip } = readPayloadRequest(payload) + if (!workspacePath) return { success: false } + return readWorkspaceAssetLibraryThumbnail({ workspaceDir: deps.getWorkspaceDir(), workspacePath, previewClip }) + }) } diff --git a/electron/main/index.ts b/electron/main/index.ts index 8cf9c810..226713c4 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -16,6 +16,15 @@ let pythonBridge: PythonBridge | null = null process.stdout?.on('error', () => {}) process.stderr?.on('error', () => {}) +// UI zoom: Ctrl/Cmd with + / - / 0 scales the whole window like a browser. +// Chromium claims those chords before the page can see them, so they are caught +// here — but the SIZE is not stored here. It lives in the renderer's saved +// settings, and this only forwards the direction. +// +// It used to be stored in both places. The main process restored the saved zoom +// on load, then the renderer mounted and overwrote it with its own separate +// "interface scale" setting, so the size was silently lost on every restart. + function createWindow(): void { mainWindow = new BrowserWindow({ width: 1280, @@ -57,6 +66,23 @@ function createWindow(): void { event.preventDefault() app.quit() } + + const isZoomChord = input.type === 'keyDown' && (input.control || input.meta) && !input.alt + if (!isZoomChord) return + + const wc = mainWindow?.webContents + if (!wc) return + + if (input.key === '+' || input.key === '=') { + event.preventDefault() + wc.send('ui:zoomStep', 1) + } else if (input.key === '-' || input.key === '_') { + event.preventDefault() + wc.send('ui:zoomStep', -1) + } else if (input.key === '0') { + event.preventDefault() + wc.send('ui:zoomStep', 0) + } }) mainWindow.webContents.setWindowOpenHandler((details) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 14fc984a..6a99e269 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -35,6 +35,8 @@ import { import { validateInstallManifest } from './extension-install-utils' import { registerWorkspaceAssetLibraryIpcHandlers } from './artifact-registry-service' import { updatesSupported } from './updater' +import { TextureWatchService } from './texture-watch-service' +import type { TextureWatchChoice } from '../../src/shared/types/liveTexture' type WindowGetter = () => BrowserWindow | null const pExecFile = promisify(execFile) @@ -276,6 +278,10 @@ const renameWithRetry = (from: string, to: string, label: string): Promise() export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGetter): void { + const textureWatchService = new TextureWatchService() + const textureWatchOwners = new Set() + app.once('before-quit', () => textureWatchService.stopAll()) + // Reconcile leftovers of interrupted installs. No install can be in flight // this early in the app's life, so anything matching is stale: // - staging dirs → discard (never the only copy of anything) @@ -426,6 +432,36 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe return result.canceled ? null : result.filePaths[0] }) + ipcMain.handle('texture:chooseAndWatch', async (event): Promise => { + const win = getWindow() + if (!win) return { cancelled: true } + + const result = await dialog.showOpenDialog(win, { + title: 'Choose a texture to watch', + filters: [{ name: 'PNG image', extensions: ['png'] }], + properties: ['openFile'], + }) + if (result.canceled || !result.filePaths[0]) return { cancelled: true } + + const ownerId = event.sender.id + if (!textureWatchOwners.has(ownerId)) { + textureWatchOwners.add(ownerId) + event.sender.once('destroyed', () => { + textureWatchOwners.delete(ownerId) + textureWatchService.stop(ownerId) + }) + } + + const update = await textureWatchService.start(ownerId, result.filePaths[0], (next) => { + if (!event.sender.isDestroyed()) event.sender.send('texture:changed', next) + }) + return { cancelled: false, update } + }) + + ipcMain.handle('texture:stopWatching', (event) => { + textureWatchService.stop(event.sender.id) + }) + ipcMain.handle('fs:selectMeshFile', async () => { const win = getWindow() if (!win) return null @@ -1395,7 +1431,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe }) // Run a process extension in an isolated worker thread - ipcMain.handle('extensions:runProcess', async (_, extensionId: string, input: { filePath?: string; text?: string; texts?: (string | undefined)[]; nodeId?: string }, params: Record) => { + ipcMain.handle('extensions:runProcess', async (_, extensionId: string, input: { filePath?: string; text?: string; texts?: (string | undefined)[]; nodeId?: string; sourceAssetPath?: string }, params: Record) => { const userData = app.getPath('userData') const { extensionsDir, workspaceDir } = getSettings(userData) diff --git a/electron/main/process-runner.ts b/electron/main/process-runner.ts index b31a62e4..c0f08223 100644 --- a/electron/main/process-runner.ts +++ b/electron/main/process-runner.ts @@ -52,6 +52,9 @@ export interface ProcessInput { /** Per-slot texts for multi-text-input nodes (index = target handle slot). */ texts?: (string | undefined)[] nodeId?: string + /** Absolute path of the existing workspace asset this input was sourced from + * (if any) — threaded through the run so mesh-exporter can record lineage. */ + sourceAssetPath?: string } export interface ProcessResult { diff --git a/electron/main/texture-watch-service.test.ts b/electron/main/texture-watch-service.test.ts new file mode 100644 index 00000000..6f8d42e7 --- /dev/null +++ b/electron/main/texture-watch-service.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +import type { TextureWatchUpdate } from '../../src/shared/types/liveTexture.ts' +import { readPngInfo, TextureWatchService } from './texture-watch-service.ts' + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +async function waitFor( + updates: TextureWatchUpdate[], + predicate: (update: TextureWatchUpdate) => boolean, + timeoutMs = 3_000, +): Promise { + const started = Date.now() + while (Date.now() - started < timeoutMs) { + const match = updates.find(predicate) + if (match) return match + await new Promise((resolve) => setTimeout(resolve, 20)) + } + throw new Error(`Timed out waiting for texture update. Saw: ${JSON.stringify(updates)}`) +} + +test('reads PNG dimensions and rejects a partial save', () => { + assert.deepEqual(readPngInfo(ONE_PIXEL_PNG), { width: 1, height: 1 }) + assert.throws( + () => readPngInfo(ONE_PIXEL_PNG.subarray(0, 24)), + /invalid PNG header|incomplete PNG file/, + ) +}) + +test('reports a malformed save, keeps watching, and recovers on the next valid save', async () => { + const dir = await mkdtemp(join(tmpdir(), 'modly-texture-watch-')) + const filePath = join(dir, 'paint.png') + const updates: TextureWatchUpdate[] = [] + const service = new TextureWatchService() + + try { + await writeFile(filePath, ONE_PIXEL_PNG) + const initial = await service.start(7, filePath, (update) => updates.push(update)) + assert.equal(initial.state, 'ready') + assert.equal(initial.width, 1) + assert.equal(initial.height, 1) + + await writeFile(filePath, Buffer.from('not an image')) + await waitFor(updates, (update) => update.state === 'reading') + const failed = await waitFor(updates, (update) => update.state === 'error') + assert.match(failed.message ?? '', /could not be read/) + + await writeFile(filePath, ONE_PIXEL_PNG) + const recovered = await waitFor( + updates, + (update) => update.state === 'ready' && update.revision > failed.revision, + ) + assert.equal(recovered.width, 1) + assert.equal(recovered.height, 1) + assert.match(recovered.dataUrl ?? '', /^data:image\/png;base64,/) + } finally { + service.stopAll() + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/electron/main/texture-watch-service.ts b/electron/main/texture-watch-service.ts new file mode 100644 index 00000000..77d0af73 --- /dev/null +++ b/electron/main/texture-watch-service.ts @@ -0,0 +1,172 @@ +import { watch as watchDirectory, type FSWatcher } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { basename, dirname, extname } from 'node:path' + +import type { TextureWatchUpdate } from '../../src/shared/types/liveTexture' + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) +const PNG_END = Buffer.from([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]) +const SAVE_SETTLE_MS = 60 +const RETRY_MS = 120 +const MAX_READ_ATTEMPTS = 6 + +interface WatchedTexture { + ownerId: number + filePath: string + fileName: string + revision: number + watcher: FSWatcher + timer: ReturnType | null + send: (update: TextureWatchUpdate) => void +} + +export interface PngInfo { + width: number + height: number +} + +export function readPngInfo(buffer: Buffer): PngInfo { + if ( + buffer.length < 33 + || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) + || buffer.toString('ascii', 12, 16) !== 'IHDR' + || buffer.readUInt32BE(8) !== 13 + ) { + throw new Error('invalid PNG header') + } + + const width = buffer.readUInt32BE(16) + const height = buffer.readUInt32BE(20) + if (width < 1 || height < 1 || !buffer.subarray(-PNG_END.length).equals(PNG_END)) { + throw new Error('incomplete PNG file') + } + + return { width, height } +} + +export class TextureWatchService { + private sessions = new Map() + + async start( + ownerId: number, + filePath: string, + send: (update: TextureWatchUpdate) => void, + ): Promise { + if (extname(filePath).toLowerCase() !== '.png') { + throw new Error('Live texture updates currently support PNG files.') + } + + this.stop(ownerId) + + const fileName = basename(filePath) + const session: WatchedTexture = { + ownerId, + filePath, + fileName, + revision: 0, + watcher: watchDirectory(dirname(filePath), (_eventType, changedName) => { + if (changedName && changedName.toString() !== fileName) return + this.queueRead(session) + }), + timer: null, + send, + } + session.watcher.on('error', () => { + if (this.sessions.get(ownerId) !== session) return + const revision = ++session.revision + send({ + state: 'error', + filePath, + fileName, + revision, + changedAt: Date.now(), + message: 'Texture watching stopped because the file could not be watched. Choose it again to retry.', + }) + this.stop(ownerId) + }) + this.sessions.set(ownerId, session) + + const revision = ++session.revision + return this.readUpdate(session, revision) + } + + stop(ownerId: number): void { + const session = this.sessions.get(ownerId) + if (!session) return + this.sessions.delete(ownerId) + if (session.timer) clearTimeout(session.timer) + session.watcher.close() + } + + stopAll(): void { + for (const ownerId of [...this.sessions.keys()]) this.stop(ownerId) + } + + private queueRead(session: WatchedTexture): void { + if (this.sessions.get(session.ownerId) !== session) return + const revision = ++session.revision + if (session.timer) clearTimeout(session.timer) + session.send({ + state: 'reading', + filePath: session.filePath, + fileName: session.fileName, + revision, + changedAt: Date.now(), + message: 'Checking the saved texture…', + }) + session.timer = setTimeout(() => { + session.timer = null + void this.publishAfterSave(session, revision, 0) + }, SAVE_SETTLE_MS) + } + + private async publishAfterSave( + session: WatchedTexture, + revision: number, + attempt: number, + ): Promise { + if (this.sessions.get(session.ownerId) !== session || session.revision !== revision) return + + const update = await this.readUpdate(session, revision) + if (this.sessions.get(session.ownerId) !== session || session.revision !== revision) return + session.send(update) + + if (update.state === 'error' && attempt + 1 < MAX_READ_ATTEMPTS) { + session.timer = setTimeout(() => { + session.timer = null + void this.publishAfterSave(session, revision, attempt + 1) + }, RETRY_MS) + } + } + + private async readUpdate(session: WatchedTexture, revision: number): Promise { + const changedAt = Date.now() + try { + const [buffer, fileStat] = await Promise.all([ + readFile(session.filePath), + stat(session.filePath), + ]) + const { width, height } = readPngInfo(buffer) + return { + state: 'ready', + filePath: session.filePath, + fileName: session.fileName, + revision, + changedAt, + savedAt: fileStat.mtimeMs, + width, + height, + dataUrl: `data:image/png;base64,${buffer.toString('base64')}`, + } + } catch { + return { + state: 'error', + filePath: session.filePath, + fileName: session.fileName, + revision, + changedAt, + message: 'The saved texture could not be read. It may still be saving or the file may be damaged.', + } + } + } +} diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 511589e4..5f0f66c4 100644 --- a/electron/preload/artifact-registry-preload.test.ts +++ b/electron/preload/artifact-registry-preload.test.ts @@ -24,6 +24,8 @@ test('preload exposes scoped workspace library list/read/open methods', async () workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', }) + await api.workspace.library.thumbnail({ workspacePath: 'Exports/hero.glb' }) + await api.workspace.library.thumbnail({ workspacePath: 'Exports/hero.glb', previewClip: 'walk' }) assert.deepEqual(calls, [ { channel: 'workspace:library:list', payload: undefined }, @@ -41,5 +43,7 @@ test('preload exposes scoped workspace library list/read/open methods', async () sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', }, }, + { channel: 'workspace:library:thumbnail', payload: { workspacePath: 'Exports/hero.glb' } }, + { channel: 'workspace:library:thumbnail', payload: { workspacePath: 'Exports/hero.glb', previewClip: 'walk' } }, ]) }) diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index 4f351b49..d2c89f9b 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -4,7 +4,10 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' +import type { TextureWatchChoice, TextureWatchUpdate } from '../../src/shared/types/liveTexture' export interface IpcRendererLike { invoke(channel: string, ...args: unknown[]): Promise @@ -32,7 +35,16 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra }, // Renderer UI (zoom whole page — scales every px/rem consistently) - ui: { setZoomFactor: (factor: number) => webFrame.setZoomFactor(factor) }, + ui: { + setZoomFactor: (factor: number) => webFrame.setZoomFactor(factor), + // Chromium eats Ctrl/Cmd +/- before the page sees them, so the main + // process catches the chord and forwards only the direction. The size + // itself is the renderer's, so there is exactly one copy of it. + onZoomStep: (cb: (step: number) => void) => { + ipcRenderer.on('ui:zoomStep', (_event, step) => cb(step as number)) + }, + offZoomStep: () => ipcRenderer.removeAllListeners('ui:zoomStep'), + }, // Shell utilities shell: { openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url) }, @@ -87,6 +99,17 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra ipcRenderer.invoke('fs:readScreenshotDataUrl', filename) as Promise, }, + texture: { + chooseAndWatch: (): Promise => + ipcRenderer.invoke('texture:chooseAndWatch') as Promise, + stopWatching: (): Promise => + ipcRenderer.invoke('texture:stopWatching') as Promise, + onChange: (cb: (update: TextureWatchUpdate) => void) => { + ipcRenderer.on('texture:changed', (_event, update) => cb(update as TextureWatchUpdate)) + }, + offChange: () => ipcRenderer.removeAllListeners('texture:changed'), + }, + // Settings settings: { get: (): Promise<{ modelsDir: string; workspaceDir: string; workflowsDir: string; extensionsDir: string; hfToken?: string }> => @@ -189,6 +212,7 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra list: (): Promise => ipcRenderer.invoke('workspace:library:list') as Promise, read: (request: AssetLibraryReadRequest): Promise => ipcRenderer.invoke('workspace:library:read', request) as Promise, open: (request: AssetLibraryOpenRequest): Promise => ipcRenderer.invoke('workspace:library:open', request) as Promise, + thumbnail: (request: AssetLibraryThumbnailRequest): Promise => ipcRenderer.invoke('workspace:library:thumbnail', request) as Promise, }, }, @@ -230,7 +254,7 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra runProcess: ( extensionId: string, - input: { filePath?: string; text?: string; texts?: (string | undefined)[]; nodeId?: string }, + input: { filePath?: string; text?: string; texts?: (string | undefined)[]; nodeId?: string; sourceAssetPath?: string }, params: Record, ): Promise<{ success: boolean; result?: { filePath?: string; text?: string }; error?: string }> => ipcRenderer.invoke('extensions:runProcess', extensionId, input, params) as Promise<{ success: boolean; result?: { filePath?: string; text?: string }; error?: string }>, diff --git a/electron/preload/texture-watch-preload.test.ts b/electron/preload/texture-watch-preload.test.ts new file mode 100644 index 00000000..c417168f --- /dev/null +++ b/electron/preload/texture-watch-preload.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { TextureWatchUpdate } from '../../src/shared/types/liveTexture.ts' +import { createElectronApi } from './electron-api.ts' + +test('preload exposes texture watching without exposing file system internals', async () => { + const invokes: string[] = [] + const listeners = new Map void>() + const api = createElectronApi({ + invoke: async (channel: string) => { + invokes.push(channel) + return channel === 'texture:chooseAndWatch' + ? { cancelled: true } + : undefined + }, + send: () => undefined, + on: (channel, listener) => listeners.set(channel, listener), + removeAllListeners: (channel) => listeners.delete(channel), + }, { setZoomFactor: () => undefined }) + + await api.texture.chooseAndWatch() + await api.texture.stopWatching() + assert.deepEqual(invokes, ['texture:chooseAndWatch', 'texture:stopWatching']) + + let received: TextureWatchUpdate | null = null + api.texture.onChange((update) => { received = update }) + listeners.get('texture:changed')?.({}, { + state: 'reading', + filePath: '/tmp/paint.png', + fileName: 'paint.png', + revision: 2, + changedAt: 1, + }) + assert.equal(received?.state, 'reading') + + api.texture.offChange() + assert.equal(listeners.has('texture:changed'), false) +}) diff --git a/package-lock.json b/package-lock.json index 1f336a2b..944bd257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modly", - "version": "0.3.5", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modly", - "version": "0.3.5", + "version": "0.4.1", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@mkkellogg/gaussian-splats-3d": "^0.4.7", @@ -25,6 +25,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@eslint/js": "^9.39.4", "@types/node": "^22.10.1", "@types/react": "^18.3.17", "@types/react-dom": "^18.3.5", @@ -37,9 +38,12 @@ "electron-builder": "^24.13.3", "electron-vite": "^2.3.0", "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.6.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.7.2", + "typescript-eslint": "^8.61.1", "vite": "^5.4.0" } }, @@ -1193,6 +1197,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", @@ -1206,10 +1223,11 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2470,6 +2488,301 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@use-gesture/core": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", @@ -5137,6 +5450,26 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5165,6 +5498,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5716,10 +6062,11 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -5845,6 +6192,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hls.js": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", @@ -8314,6 +8678,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8392,6 +8769,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9168,6 +9569,29 @@ "node": ">= 10" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", diff --git a/package.json b/package.json index 64ef178f..0c0b56cd 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 electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/preload/artifact-registry-preload.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/components/liveTexture.test.ts src/areas/workflows/mergeNodeText.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/texture-watch-service.test.ts electron/preload/artifact-registry-preload.test.ts electron/preload/texture-watch-preload.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..d6a596e7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,15 @@ import { useEffect, useLayoutEffect, useState } from 'react' -import { useAppStore, type UiScale } from '@shared/stores/appStore' +import { useAppStore } from '@shared/stores/appStore' import FirstRunSetup from '@areas/setup/FirstRunSetup' 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' -const UI_SCALE_FACTORS: Record = { small: 0.875, medium: 1, large: 1.25, 'very-large': 1.5 } export default function App(): JSX.Element { - const { checkSetup, setupStatus, initApp, backendStatus, showError, useAtkinsonFont, uiScale } = useAppStore() + const { checkSetup, setupStatus, initApp, backendStatus, showError, useAtkinsonFont, + uiZoomFactor, setUiZoomFactor } = useAppStore() const [updateVersion, setUpdateVersion] = useState(null) const [currentVersion, setCurrentVersion] = useState('') @@ -34,8 +34,23 @@ export default function App(): JSX.Element { ? "'Atkinson Hyperlegible', system-ui, sans-serif" : "'Inter', system-ui, sans-serif" ) - window.electron.ui.setZoomFactor(UI_SCALE_FACTORS[uiScale]) - }, [useAtkinsonFont, uiScale]) + window.electron.ui.setZoomFactor(uiZoomFactor) + }, [useAtkinsonFont, uiZoomFactor]) + + // Ctrl/Cmd +/-/0 is caught by the main process, because Chromium claims those + // chords before the page sees them. It only tells us WHICH way to go — the + // size itself lives here, in the one place that is saved and re-applied on + // launch. Keeping a second copy in the main process is what used to lose the + // setting on every restart: it restored the saved zoom, then this component + // mounted and overwrote it. + useEffect(() => { + window.electron.ui.onZoomStep((step) => { + if (step === 0) setUiZoomFactor(1) + else setUiZoomFactor(Number((useAppStore.getState().uiZoomFactor + step * 0.1).toFixed(2))) + }) + return () => window.electron.ui.offZoomStep() + // eslint-disable-next-line react-hooks/exhaustive-deps -- store actions are stable + }, []) useEffect(() => { if (setupStatus === 'done') initApp() diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index a6dbe4f3..f9561056 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -1,5 +1,5 @@ -import { useState, useRef, useCallback, useEffect, useMemo } from 'react' -import type { ReactNode } from 'react' +import { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from 'react' +import type { ReactNode, RefObject } from 'react' import { useAppStore, DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' import type { GenerationJob, LightSettings } from '@shared/stores/appStore' import { useApi } from '@shared/hooks/useApi' @@ -9,24 +9,66 @@ import Viewer3D from './components/Viewer3D' import WorkflowPanel from './components/WorkflowPanel' import { getDefaultAssetLibraryService } from './assetLibraryService' import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' +import type { AssetLibraryPreviewClip } from '../../shared/types/assetLibrary' import { + ASSET_LIBRARY_KIND_FILTERS, + ASSET_LIBRARY_POLY_FILTERS, ASSET_LIBRARY_SORT_OPTIONS, + buildAssetLibraryProjectGroups, buildAssetLibraryOpenRequest, + clampAssetLibraryPanelHeight, + clampAssetLibraryPanelWidth, + createDefaultAssetLibraryFilters, createAssetLibraryOpenJob, describeAssetLibraryOpenability, - filterAssetLibraryScopeGroups, + findAssetLibraryMotionClipIndex, + formatAssetLibraryClipName, + getAssetLibraryPanelLayout, getDefaultAssetLibraryCollapsedSectionKeys, + getStoredAssetLibraryPanelHeight, + getStoredAssetLibraryPanelWidth, isAssetLibraryEntryOpenable, + isAssetLibrarySectionExpanded, resolveOpenPanelAfterLibrarySelection, + storeAssetLibraryPanelHeight, + storeAssetLibraryPanelWidth, toggleAssetLibrarySectionKey, + type AssetLibraryFilters, + type AssetLibraryLineageFamily, type AssetLibrarySortMode, + type AssetLibraryViewMode, type GenerateOpenPanel, } from './assetLibraryUi' const MIN_WIDTH = 220 -const MAX_WIDTH = 520 +const MAX_WIDTH = 900 const DEFAULT_WIDTH = 320 +const PANEL_WIDTH_STORAGE_KEY = 'modly-panel-width' + +function clampPanelWidth(width: number): number { + return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)) +} + +/** Reads the user's saved Generate panel width, falling back to the default when unset, invalid, or unreadable. */ +function getStoredPanelWidth(): number { + try { + const raw = Number(localStorage.getItem(PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(raw) && raw > 0 ? clampPanelWidth(raw) : DEFAULT_WIDTH + } catch { + return DEFAULT_WIDTH + } +} + +/** Persists the Generate panel width so it survives app restarts. */ +function storePanelWidth(width: number): void { + try { + localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Ignore quota/private-mode failures — the panel just won't remember its size. + } +} + // --------------------------------------------------------------------------- // Export dropdown // --------------------------------------------------------------------------- @@ -359,6 +401,10 @@ function AssetLibraryToggleButton({ ) } +// A stable empty-array reference for entries with no preview manifest yet, so +// rows don't see a fresh `[]` identity on every parent re-render. +const EMPTY_PREVIEW_CLIPS: AssetLibraryPreviewClip[] = [] + function AssetLibraryPopover({ entries, selectedEntryId, @@ -367,14 +413,28 @@ function AssetLibraryPopover({ error, searchQuery, sortMode, + viewMode, + filters, collapsedSectionKeys, - onSelectEntry, + thumbnails, + previews, + panelWidth, + panelHeight, + anchorRef, + viewerEntryId, + viewerClipName, + onActivateEntry, + onSelectViewerClip, onSearchQueryChange, onSortModeChange, + onViewModeChange, + onFiltersChange, onToggleSection, - onOpenSelected, + onFetchPreviewFrame, onRefresh, onClose, + onResizeMouseDown, + onResizeHeightMouseDown, }: { entries: ProjectedAssetLibraryEntry[] selectedEntryId: string | null @@ -383,70 +443,129 @@ function AssetLibraryPopover({ error: string | null searchQuery: string sortMode: AssetLibrarySortMode + viewMode: AssetLibraryViewMode + filters: AssetLibraryFilters collapsedSectionKeys: string[] - onSelectEntry: (entryId: string) => void + /** Data URLs keyed by workspacePath, populated lazily as thumbnails load. */ + thumbnails: Record + /** Preview clip metadata keyed by workspacePath; empty until a preview manifest exists for that asset. */ + previews: Record + panelWidth: number + panelHeight: number + anchorRef: RefObject + viewerEntryId: string | null + viewerClipName: string | null + /** A row click both selects and, when the entry is openable, opens it immediately — no second confirming click. */ + onActivateEntry: (entryId: string) => void + onSelectViewerClip: (entryId: string, clip: string) => void onSearchQueryChange: (value: string) => void onSortModeChange: (value: AssetLibrarySortMode) => void + onViewModeChange: (value: AssetLibraryViewMode) => void + onFiltersChange: (value: AssetLibraryFilters) => void onToggleSection: (sectionKey: string) => void - onOpenSelected: () => void + onFetchPreviewFrame: (workspacePath: string, clip: string) => Promise onRefresh: () => void onClose: () => void + onResizeMouseDown: (event: React.MouseEvent) => void + onResizeHeightMouseDown: (event: React.MouseEvent) => void }) { - const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) - const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) - const selectedEntry = selectedEntryId && visibleEntryIds.has(selectedEntryId) - ? entries.find((entry) => entry.id === selectedEntryId) ?? null - : null + const projectGroups = buildAssetLibraryProjectGroups(entries, searchQuery, sortMode, filters) const normalizedSearchQuery = searchQuery.trim() - const openDisabled = !selectedEntry || !isAssetLibraryEntryOpenable(selectedEntry) || loading || opening - const selectedMessage = selectedEntry - ? describeAssetLibraryOpenability(selectedEntry) - : scopeGroups.length === 0 && normalizedSearchQuery - ? `No workspace assets match “${normalizedSearchQuery}”.` - : 'Select an asset to open it in Generate.' + const hasActiveFilters = filters.kind !== 'all' || filters.poly !== 'all' || filters.needsAttention + const hasActiveDiscovery = normalizedSearchQuery.length > 0 || hasActiveFilters + const familyCount = projectGroups.reduce((count, group) => count + group.families.length, 0) + const versionCount = projectGroups.reduce((count, group) => count + group.entryCount, 0) + const [layout, setLayout] = useState | null>(null) + + const updateLayout = useCallback(() => { + const anchor = anchorRef.current?.getBoundingClientRect() + if (!anchor) return + setLayout(getAssetLibraryPanelLayout( + anchor, + { width: window.innerWidth, height: window.innerHeight }, + panelWidth, + panelHeight, + )) + }, [anchorRef, panelHeight, panelWidth]) + + useLayoutEffect(() => { + updateLayout() + window.addEventListener('resize', updateLayout) + window.visualViewport?.addEventListener('resize', updateLayout) + const observer = new ResizeObserver(updateLayout) + observer.observe(document.documentElement) + return () => { + window.removeEventListener('resize', updateLayout) + window.visualViewport?.removeEventListener('resize', updateLayout) + observer.disconnect() + } + }, [updateLayout]) return (
-
-
-

Workspace library

-

Select a workspace asset and open the supported source in Generate.

+
+
+

Asset Library

+

Browse models by project, traits, and version family.

+
+
+ +
-
- + {/* Resize handle — drag to widen/narrow the library panel; size is persisted. */} +
+ + {/* Resize handle — drag to grow/shrink the library panel; size is persisted. */} +
-
-
- +
+
+ onSearchQueryChange(event.target.value)} - placeholder="Search by name, path, scope, or capability" - className="bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400" + placeholder="Search names, projects, tags, clips, or locations" + className="appearance-none bg-zinc-800 border border-zinc-700 rounded-lg px-2.5 py-1.5 text-xs text-zinc-200 w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400" />
-
+
onChange(Number(e.target.value))} + className="bg-zinc-800 text-zinc-200 border border-zinc-700 rounded-md px-2 py-1 text-xs focus:outline-none focus:border-accent/60" + > + {clips.map((clip, i) => ( + + ))} + + +
+ ) +} diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index 8cff822f..21eddc71 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -16,6 +16,8 @@ import SplatViewer, { type SplatViewerHandle } from './SplatViewer' import { useGeneration } from '@shared/hooks/useGeneration' import { useAppStore } from '@shared/stores/appStore' import { ViewerToolbar, type ViewMode } from './ViewerToolbar' +import { MotionBar, type MotionClip } from './MotionBar' +import LiveTexturePanel from './LiveTexturePanel' import type { LightSettings } from '@shared/stores/appStore' import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' @@ -142,16 +144,67 @@ interface MeshModelProps { onStats: (stats: { vertices: number; triangles: number }) => void onSelect: () => void onObject: (obj: THREE.Object3D | null) => void + onClips: (clips: MotionClip[]) => void + clipIndex: number } -function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject }: MeshModelProps): JSX.Element { +function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject, onClips, clipIndex }: MeshModelProps): JSX.Element { const extension = url.split('?')[0]?.split('.').pop()?.toLowerCase() - const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject } + const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject, onClips, clipIndex } return extension === 'obj' ? : } function GltfMeshModel(props: MeshModelProps): JSX.Element { - const { scene } = useGLTF(props.url) + const gltf = useGLTF(props.url) + const { scene, animations } = gltf + const mixerRef = useRef(null) + const actionRef = useRef(null) + const { onClips, clipIndex } = props + + // Rigged models arrive with one or more clips; without a mixer they render + // frozen in bind pose, which reads as "the rig failed". The mixer lives for + // as long as this model does — clip switches below reuse it. + useEffect(() => { + if (!animations?.length) { + mixerRef.current = null + actionRef.current = null + return + } + const mixer = new THREE.AnimationMixer(scene) + mixerRef.current = mixer + return () => { + mixer.stopAllAction() + mixer.uncacheRoot(scene) + mixerRef.current = null + actionRef.current = null + } + }, [scene, animations]) + + // Tell the viewer which clips are available so it can render the motion bar. + useEffect(() => { + onClips(animations?.map((clip) => ({ name: clip.name })) ?? []) + // eslint-disable-next-line react-hooks/exhaustive-deps -- onClips is a stable callback + }, [animations]) + + // Switch to the selected clip, cross-fading so playback never hard-cuts. + useEffect(() => { + const mixer = mixerRef.current + if (!mixer || !animations?.length) return + const clip = animations[Math.min(clipIndex, animations.length - 1)] + const next = mixer.clipAction(clip) + next.reset().setLoop(THREE.LoopRepeat, Infinity) + const prev = actionRef.current + if (prev && prev !== next) { + prev.fadeOut(0.25) + next.fadeIn(0.25).play() + } else { + next.play() + } + actionRef.current = next + }, [animations, clipIndex]) + + useFrame((_state, delta) => mixerRef.current?.update(delta)) + return } @@ -815,6 +868,20 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo const [viewMode, setViewMode] = useState('solid') const [autoRotate, setAutoRotate] = useState(false) + const clips = useAppStore((s) => s.motionClips) + const activeClipIndex = useAppStore((s) => s.activeClipIndex) + const setStoreMotionClips = useAppStore((s) => s.setMotionClips) + const setStoreActiveClipIndex = useAppStore((s) => s.setActiveClipIndex) + // Playback and every selector share appStore. Keeping a local mirror here + // made the Library tile and motion bar disagree about the active clip. + const setClips = useCallback((c: MotionClip[]) => { + setStoreMotionClips(c) + const currentIndex = useAppStore.getState().activeClipIndex + if (currentIndex >= c.length) setStoreActiveClipIndex(0) + }, [setStoreActiveClipIndex, setStoreMotionClips]) + const setActiveClipIndex = useCallback((i: number) => { + setStoreActiveClipIndex(i) + }, [setStoreActiveClipIndex]) const selected = useAppStore((s) => s.meshSelected) const setSelected = useAppStore((s) => s.setMeshSelected) const canvasRef = useRef(null) @@ -848,6 +915,8 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo setSelected(false) setViewMode('solid') setStoreMeshStats(null) + setClips([]) + setActiveClipIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the model changes; setters are stable }, [modelUrl]) @@ -997,6 +1066,8 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo onStats={setStoreMeshStats} onSelect={() => setSelected(true)} onObject={setMeshObject} + onClips={setClips} + clipIndex={activeClipIndex} /> @@ -1043,12 +1114,23 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo /> )} - {/* Bottom-left stats overlay */} - {meshStats && ( -
-

- {meshStats.triangles.toLocaleString()} tri • {meshStats.vertices.toLocaleString()} verts -

+ {modelUrl && !isSplat && ( + setViewMode('solid')} + /> + )} + + {/* Bottom-left overlays — mesh stats and the motion bar */} + {(meshStats || clips.length > 0) && ( +
+ {meshStats && ( +

+ {meshStats.triangles.toLocaleString()} tri • {meshStats.vertices.toLocaleString()} verts +

+ )} +
)} @@ -1066,4 +1148,4 @@ export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS, gizmo
) -} \ No newline at end of file +} diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index ffc774c7..8d9bc653 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -13,9 +13,16 @@ import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore' import { useWaitButton } from '@areas/workflows/useWaitButton' import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflows/mockExtensions' import { validateWorkflowPreflight } from '@areas/workflows/preflight' +import { areAllWorkflowNodeParamsBound } from '@areas/workflows/workflowParamBindings' import type { WorkflowExtension } from '@areas/workflows/mockExtensions' import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d' import ChatPanel from './ChatPanel' +import { + THIN_PART_SUGGESTION_MESSAGE, + applyThinPartSuggestion, + getThinPartSuggestion, + measureThinPartConfidence, +} from '../thinPartSuggestion' type PanelMode = 'basic' | 'chat' @@ -59,6 +66,50 @@ function mimeFromPath(p: string): string { return 'image/png' } +// ─── Tag suggestions ────────────────────────────────────────────────────────── + +const TAG_STOPWORDS = new Set([ + 'a', 'an', 'the', 'of', 'for', 'and', 'or', 'to', 'in', 'on', 'my', 'this', 'is', 'with', +]) + +const FIXED_TAG_VOCAB = [ + 'prop', 'character', 'creature', 'environment', 'weapon', + 'furniture', 'hero-asset', 'background', 'low-poly', 'stylized', 'realistic', +] + +const MAX_TAG_SUGGESTIONS = 10 + +/** Slugified words pulled from the typed name/project, plus a small fixed vocabulary — capped and deduped. */ +function suggestedTags(modelName: string, project: string): string[] { + const fromFields = `${modelName} ${project}` + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 1 && !TAG_STOPWORDS.has(word)) + + const seen = new Set() + const suggestions: string[] = [] + for (const tag of [...fromFields, ...FIXED_TAG_VOCAB]) { + if (!tag || seen.has(tag)) continue + seen.add(tag) + suggestions.push(tag) + if (suggestions.length >= MAX_TAG_SUGGESTIONS) break + } + return suggestions +} + +function parseTags(value: string): string[] { + return value.split(',').map((s) => s.trim()).filter(Boolean) +} + +/** Appends a tag to the comma-separated value, or removes it if already present. */ +function toggleTag(value: string, tag: string): string { + const tags = parseTags(value) + const i = tags.indexOf(tag) + if (i >= 0) tags.splice(i, 1) + else tags.push(tag) + return tags.join(', ') +} + // ─── Param field ────────────────────────────────────────────────────────────── const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' @@ -116,6 +167,17 @@ function ParamField({ param, value, onChange }: { value: number | string onChange: (v: number | string) => void }) { + if (param.multiline) { + return ( +