diff --git a/docs/experimental-library-integration-ledger.md b/docs/experimental-library-integration-ledger.md new file mode 100644 index 00000000..fd4fad9a --- /dev/null +++ b/docs/experimental-library-integration-ledger.md @@ -0,0 +1,59 @@ +# Experimental Library & Lineage Integration Ledger — 2026-10-09 + +> **NOT A RELEASE.** Review branch only. Parent: `integration/modly-next-2026-10-09` +> (upstream v0.4.3 + separate candidate fixes). This port has **not** passed +> combined npm/TypeScript/Python tests or a Windows desktop smoke test. + +## Source PRs ported as separate commits + +| Upstream PR | Feature | Port note | +| --- | --- | --- | +| [#242](https://github.com/lightningpixel/modly/pull/242) | Animated GLB clips | Corrected malformed upstream JSX fallback `{clip.name || }` on our branch, then incorporated the MotionBar replacement from #245. | +| [#243](https://github.com/lightningpixel/modly/pull/243) | Library thumbnails and width persistence | Reconciled GeneratePage state with current mesh-export UI. | +| [#244](https://github.com/lightningpixel/modly/pull/244) | Normalize manifest object-shaped input slots | Merged 3 regression cases into the newer existing test file. | +| [#245](https://github.com/lightningpixel/modly/pull/245) | Motion bar, tag UX, export metadata, panel zoom/resize | Reconciled viewer and parameter controls with 0.4.3; intentionally did not overwrite newer `package-lock.json`. Added required free-text param renderer. | +| [#247](https://github.com/lightningpixel/modly/pull/247) | Taller resizable Library | Reconciled stored height and state refs with current viewer. | +| [#248](https://github.com/lightningpixel/modly/pull/248) | Wider Library, direct open, hover-preview | Reconciled preview type imports. | +| [#249](https://github.com/lightningpixel/modly/pull/249) | Gallery grouped by project and lineage | Integrated updated asset-index tests in current suite. | + +**Credits:** The work is based on PRs submitted by OmarB97 to Lightning Pixel's +Modly repository. The adaptations in this fork do not supersede their source PRs. + +## Not ported: requires an independent functional adaptation + +- **#246 (Chat + asset context):** its old Ollama agent backend does not match + current 0.4.3 llama.cpp/chat architecture. The workflow runner input routing + also changed. Do not paste the old agent router or silently drop context. +- **#250 (Optimizer preset semantics):** the source PR targets a legacy + `processor.ts`, whereas 0.4.3 uses a Python workflow adapter feeding a shared + mesh-op registry. A "full detail/no simplification" preset needs a reviewed + end-to-end contract that does not alter unrelated direct mesh API calls. +- **#251 (Workflow parameter binding and library sync):** stack depends on the + #250 behavior. Port only after defining stable override precedence and tests. +- **#252 (Artist intent record and texture hot reload):** much larger divergent + patch (workflow and API changes, new watcher services, extra proof assets). + Requires separate rebase and validation against 0.4.3 and #246/#251. + +## Validation checklist — currently not executed on the combined branch + +- [ ] Install dependencies reproducibly and run `npm run lint`. +- [ ] `npm run test:node` and `npm run test:py`. +- [ ] `npx tsc --noEmit -p tsconfig.node.json`. +- [ ] `npm run build`. +- [ ] Native Windows checks: launch Electron and load GLB with 1 and 3 clips, + switch motion bar clips, unload, and verify mixer teardown. +- [ ] Check Library thumbnails, previews, project/lineage filters and persistent + panel size across restart, with missing/corrupt metadata. +- [ ] Export rigged GLB/OBJ/STL/PLY, verify transforms/tags and project lineage. +- [ ] Retry free-text params and object-shaped manifest inputs. +- [ ] Verify source #370 destructive-storage safeguards and related tests in + the final combined branch. +- [ ] Review source #266 local API security separately (not included). + +## Integration rules + +1. Keep the user's `main` and upstream untouched. +2. Pin reviewed source PR/commit IDs; credit original contributors. +3. A matching patch hunk is **not** a passing test. +4. Do not merge this branch into Modly Next before validating the full + app and resolving the remaining integration ledger. diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts index 276e90a1..9f4a6ec6 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) @@ -76,6 +78,50 @@ test('registers a generated scene directory through its canonical manifest artif assert.equal(scene && scene.state, 'ready') })) +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 }) @@ -210,6 +256,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({ @@ -236,7 +392,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 fb58e6d2..8555ba62 100644 --- a/electron/main/artifact-registry-service.ts +++ b/electron/main/artifact-registry-service.ts @@ -9,18 +9,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']) @@ -52,6 +56,11 @@ export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryR sourceWorkspacePath?: string } +export interface WorkspaceAssetLibraryThumbnailRequest extends WorkspaceAssetLibraryRequest { + workspacePath: string + previewClip?: string +} + interface AssetLibraryMetadata { sourceWorkspacePath?: string manifestWorkspacePath?: string @@ -61,6 +70,11 @@ interface AssetLibraryMetadata { warnings: string[] } +interface AssetLibrarySemanticRead { + semantic?: AssetLibrarySemanticMetadata + warnings: string[] +} + export interface IpcMainLike { handle(channel: string, handler: (event: unknown, payload?: unknown) => Promise): void } @@ -113,7 +127,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 { @@ -151,6 +167,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' @@ -205,12 +226,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 { @@ -227,13 +323,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, @@ -243,11 +344,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, } } @@ -344,12 +446,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, } } @@ -365,4 +586,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 2f3f5b83..5b28bcb3 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, shell, session } from 'electron' import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { setupIpcHandlers } from './ipc-handlers' import { PythonBridge } from './python-bridge' @@ -18,6 +19,37 @@ 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, +// clamped to a sane range and persisted across restarts. This is done at the +// Chromium level (not CSS) so pointer math in the 3D viewport stays correct. +const ZOOM_MIN = -2 +const ZOOM_MAX = 4 + +function zoomFilePath(): string { + return join(app.getPath('userData'), 'ui-zoom.json') +} + +function loadZoomLevel(): number { + try { + const saved = JSON.parse(readFileSync(zoomFilePath(), 'utf-8')) as { level?: number } + return typeof saved.level === 'number' ? saved.level : 0 + } catch { + return 0 + } +} + +function saveZoomLevel(level: number): void { + try { + writeFileSync(zoomFilePath(), JSON.stringify({ level }), 'utf-8') + } catch (err) { + logger.error(`Failed to persist UI zoom level: ${err}`) + } +} + +function clampZoomLevel(level: number): number { + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level)) +} + function createWindow(): void { mainWindow = new BrowserWindow({ width: 1280, @@ -60,6 +92,33 @@ 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() + const level = clampZoomLevel(wc.getZoomLevel() + 0.5) + wc.setZoomLevel(level) + saveZoomLevel(level) + } else if (input.key === '-' || input.key === '_') { + event.preventDefault() + const level = clampZoomLevel(wc.getZoomLevel() - 0.5) + wc.setZoomLevel(level) + saveZoomLevel(level) + } else if (input.key === '0') { + event.preventDefault() + wc.setZoomLevel(0) + saveZoomLevel(0) + } + }) + + mainWindow.webContents.on('did-finish-load', () => { + const level = loadZoomLevel() + if (level) mainWindow?.webContents.setZoomLevel(level) }) mainWindow.webContents.setWindowOpenHandler((details) => { diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 9dec46fd..85661914 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,6 +43,8 @@ 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 f119582e..b6629e36 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -4,6 +4,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' export interface IpcRendererLike { @@ -231,6 +233,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, }, }, diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 073ea487..86636dab 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -10,24 +10,62 @@ import WorkflowPanel from './components/WorkflowPanel' import { getDefaultAssetLibraryService } from './assetLibraryService' import { buildOrcaSlicerDeepLink, canOpenInOrcaSlicer } from './orcaSlicerLink' 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, + formatAssetLibraryClipName, 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)) +} + +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 + } +} + +function storePanelWidth(width: number): void { + try { + localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Resizing still works if persisted settings are unavailable. + } +} + const MAX_POINT_LIGHTS = 6 function createPointLight(): PointLight { @@ -459,6 +497,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, @@ -467,14 +509,25 @@ function AssetLibraryPopover({ error, searchQuery, sortMode, + viewMode, + filters, collapsedSectionKeys, - onSelectEntry, + thumbnails, + previews, + panelWidth, + panelHeight, + onActivateEntry, onSearchQueryChange, onSortModeChange, + onViewModeChange, + onFiltersChange, onToggleSection, onOpenSelected, + onFetchPreviewFrame, onRefresh, onClose, + onResizeMouseDown, + onResizeHeightMouseDown, }: { entries: ProjectedAssetLibraryEntry[] selectedEntryId: string | null @@ -483,25 +536,43 @@ 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 + /** A row click both selects and, when the entry is openable, opens it immediately — no second confirming click. */ + onActivateEntry: (entryId: 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 projectGroups = buildAssetLibraryProjectGroups(entries, searchQuery, sortMode, filters) + const visibleEntryIds = new Set(projectGroups.flatMap((group) => group.families.flatMap((family) => family.entries.map((entry) => entry.id)))) const selectedEntry = selectedEntryId && visibleEntryIds.has(selectedEntryId) ? entries.find((entry) => entry.id === selectedEntryId) ?? null : null const normalizedSearchQuery = searchQuery.trim() + 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 openDisabled = !selectedEntry || !isAssetLibraryEntryOpenable(selectedEntry) || loading || opening const selectedMessage = selectedEntry ? describeAssetLibraryOpenability(selectedEntry) - : scopeGroups.length === 0 && normalizedSearchQuery + : projectGroups.length === 0 && normalizedSearchQuery ? `No workspace assets match “${normalizedSearchQuery}”.` : 'Select an asset to open it in Generate.' @@ -509,44 +580,64 @@ function AssetLibraryPopover({
-
+
-

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 a1edc2d4..0624b939 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -20,6 +20,7 @@ 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 type { LightSettings, PointLight } from '@shared/stores/appStore' import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' @@ -259,6 +260,8 @@ interface MeshModelProps { onStats: (stats: { vertices: number; triangles: number }) => void onSelect: () => void onObject: (obj: THREE.Object3D | null) => void + onClips: (clips: MotionClip[]) => void + clipIndex: number } function centerSceneOnGrid(scene: THREE.Object3D): void { @@ -274,14 +277,63 @@ function centerSceneOnGrid(scene: THREE.Object3D): void { scene.updateMatrixWorld(true) } -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 } @@ -1012,6 +1064,8 @@ export default function Viewer3D({ const [viewMode, setViewMode] = useState('solid') const [autoRotate, setAutoRotate] = useState(false) + const [clips, setClips] = useState([]) + const [activeClipIndex, setActiveClipIndex] = useState(0) const selected = useAppStore((s) => s.meshSelected) const setSelected = useAppStore((s) => s.setMeshSelected) const canvasRef = useRef(null) @@ -1055,6 +1109,8 @@ export default function Viewer3D({ 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]) @@ -1263,6 +1319,8 @@ export default function Viewer3D({ onStats={setStoreMeshStats} onSelect={handleMeshSelect} onObject={setMeshObject} + onClips={setClips} + clipIndex={activeClipIndex} /> @@ -1326,12 +1384,15 @@ export default function Viewer3D({ /> )} - {/* Bottom-left stats overlay */} - {meshStats && ( -
-

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

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

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

+ )} +
)} diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 14b25781..54e40bae 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -58,6 +58,83 @@ function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNo return result } +// ─── 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(', ') +} + + +function TagSuggestionPills({ value, onChange, modelName, project }: { + value: string + onChange: (v: string) => void + modelName: string + project: string +}) { + const suggestions = useMemo(() => suggestedTags(modelName, project), [modelName, project]) + if (suggestions.length === 0) return null + + const active = new Set(parseTags(value)) + + return ( +
+ {suggestions.map((tag) => ( + + ))} +
+ ) +} + + // ─── 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' @@ -92,6 +169,10 @@ function ParamField({ param, value, onChange }: {
) } + if (param.type === 'text') { + return onChange(event.target.value)} className={inputCls} /> + } if (param.type === 'float') { return onChange(v)} className={inputCls} min={param.min} max={param.max} step={param.step} label={param.label} /> @@ -201,7 +282,7 @@ function ImageParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: Flow - Image + Your sketch or photo — required
{preview ? (