import { existsSync } from 'node:fs'; import { readdir, readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { listProjects } from '../projects.js'; import { CANONICAL_WORKSPACE_ROOT } from '../workspace-root.js'; import { looseThumbPath } from './thumbnails.js'; import type { ProjectRef, ProjectSurface } from './types.js'; const MEANINGFUL_ARTIFACT_THRESHOLD = 3; const RECENT_MS = 14 * 24 * 60 * 60 * 1000; const IMAGE_EXTS = ['.jpg', '.jpeg', '.png']; /** Sub-paths (relative to projects/) searched in priority order for a representative image. */ const THUMB_DIRS = ['final/images', 'keyframes', 'images', 'reference-sheets', 'characters', 'cast']; /** * Find a representative image for a project, returned RELATIVE to workspaceRoot. * Searches THUMB_DIRS in priority order; within a dir picks the first match * after a deterministic sort. Returns null when no image is found. */ async function findThumbRelPath(root: string, slug: string): Promise { for (const sub of THUMB_DIRS) { const dir = join(root, 'projects', slug, sub); const entries = await readdir(dir).catch(() => [] as string[]); const matches = entries .filter((f) => IMAGE_EXTS.includes(f.slice(f.lastIndexOf('.')).toLowerCase())) .sort(); if (matches.length) return `projects/${slug}/${sub}/${matches[0]}`; } return null; } /** Max directory depth (below a base dir) scanned for a `projects/` subdir. */ const MAX_SCAN_DEPTH = 4; /** Directory names never descended into during the recursive workspace scan. */ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.Trash', 'projects']); /** * Read the watch-config file (`~/.videoclaw-monitor.json`, or an injected path). * Returns extra BASE dirs from its `roots: string[]` (a leading `~/` expands to * the home dir). Missing/malformed files are ignored gracefully (returns []). * Exported so workspace.ts / serve.ts can reuse the same config path. */ export async function readWatchConfigRoots( configPath: string, key: 'roots' | 'looseRoots' = 'roots', ): Promise { const home = homedir(); const raw = await readFile(configPath, 'utf-8').catch(() => null); if (!raw) return []; try { const parsed = JSON.parse(raw) as Record; const value = parsed[key]; if (!Array.isArray(value)) return []; return value .filter((r): r is string => typeof r === 'string') .map((r) => (r.startsWith('~/') ? join(home, r.slice(2)) : r)); } catch { return []; } } /** A workspace root directly contains `projects/`; a SOURCE checkout also has package.json + src/. */ function isWorkspaceRoot(dir: string): boolean { if (!existsSync(join(dir, 'projects'))) return false; // Exclude videoclaw source checkouts — their `projects/` is a test fixture, not a user workspace. if (existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'src'))) return false; return true; } /** * Recursively collect workspace roots under `base` (inclusive), capped at * MAX_SCAN_DEPTH levels deep. A "workspace root" is any dir containing a * `projects/` subdir (and not a videoclaw source checkout). Skips SKIP_DIRS and * dotfiles. Unreadable dirs are ignored. */ async function scanForWorkspaceRoots(base: string, depth: number, out: Set): Promise { if (isWorkspaceRoot(base)) out.add(base); if (depth >= MAX_SCAN_DEPTH) return; const entries = await readdir(base, { withFileTypes: true }).catch(() => []); for (const e of entries) { if (!e.isDirectory()) continue; if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue; await scanForWorkspaceRoots(join(base, e.name), depth + 1, out); } } /** * Gather the candidate BASE dirs scanned for workspaces and loose deliverables: * `~/.videoclaw-*` dirs + cwd + `extraRoots` + watch-config roots, deduped. * `configPath` is injectable for hermetic tests; it defaults to * `~/.videoclaw-monitor.json`. */ export async function gatherBaseDirs( extraRoots: string[] = [], configPath: string = join(homedir(), '.videoclaw-monitor.json'), ): Promise { const home = homedir(); const homeEntries = await readdir(home, { withFileTypes: true }).catch(() => []); const videoclawBases = homeEntries .filter((e) => e.isDirectory() && e.name.startsWith('.videoclaw-')) .map((e) => join(home, e.name)); const configBases = await readWatchConfigRoots(configPath); return [...new Set([...videoclawBases, CANONICAL_WORKSPACE_ROOT, process.cwd(), ...extraRoots, ...configBases])]; } /** * Discover workspace roots — dirs that directly contain `projects/`. * * Gathers candidate BASE dirs (~/.videoclaw-* + cwd + extraRoots + watch-config * roots), then recursively scans each (up to MAX_SCAN_DEPTH levels deep) for * dirs containing a `projects/` subdir, excluding videoclaw source checkouts * (whose `projects/` is a test fixture). `configPath` is injectable for hermetic * tests; it defaults to `~/.videoclaw-monitor.json`. */ export async function discoverWorkspaceRoots( extraRoots: string[] = [], configPath: string = join(homedir(), '.videoclaw-monitor.json'), ): Promise { const bases = await gatherBaseDirs(extraRoots, configPath); const roots = new Set(); for (const base of bases) await scanForWorkspaceRoots(base, 0, roots); return [...roots]; } /** A rendered video file living OUTSIDE any `projects//` workspace. */ export interface LooseDeliverable { path: string; name: string; dir: string; sizeBytes: number; mtimeMs: number; /** Absolute path to a cached poster frame, set ONLY when it already exists on disk (cache hit). */ thumbPath?: string; } /** Video extensions surfaced as loose deliverables. */ const LOOSE_VIDEO_EXTS = ['.mp4', '.mov']; /** Files at or below this size are diagnostic/scratch clips, not deliverables. */ const LOOSE_MIN_SIZE_BYTES = 500 * 1024; /** Cap on the loose-deliverable list (the overview notes the cap). */ const LOOSE_MAX = 60; /** * Directory names skipped when scanning for loose deliverables. Extends the * base SKIP_DIRS with common intermediate/working dir names that are managed * workspace internals, not user-curated deliverable folders. */ const LOOSE_SKIP_DIRS = new Set([ ...SKIP_DIRS, 'build', 'outputs', 'output', 'work', 'cache', 'tmp', 'samples', 'sample', 'tests', 'test', 'test-output', '__tests__', 'coverage', '.vclaw-jobs', ]); /** * Recursively collect loose video files under `base` (depth ≤ MAX_SCAN_DEPTH). * Skips LOOSE_SKIP_DIRS (incl. ANY dir named `projects` and all intermediate * working dirs) and dotdirs. Only files ending `.mp4`/`.mov` larger than * LOOSE_MIN_SIZE_BYTES are collected. Unreadable dirs/files are ignored. */ async function scanForLooseVideos(base: string, depth: number, out: Map): Promise { const entries = await readdir(base, { withFileTypes: true }).catch(() => []); for (const e of entries) { const full = join(base, e.name); if (e.isDirectory()) { if (depth >= MAX_SCAN_DEPTH) continue; if (LOOSE_SKIP_DIRS.has(e.name) || e.name.startsWith('.')) continue; await scanForLooseVideos(full, depth + 1, out); continue; } if (!e.isFile()) continue; const ext = e.name.slice(e.name.lastIndexOf('.')).toLowerCase(); if (!LOOSE_VIDEO_EXTS.includes(ext)) continue; if (out.has(full)) continue; const st = await stat(full).catch(() => null); if (!st || !st.isFile() || st.size <= LOOSE_MIN_SIZE_BYTES) continue; out.set(full, { path: full, name: e.name, dir: base, sizeBytes: st.size, mtimeMs: st.mtimeMs }); } } /** * Discover loose deliverables — rendered `.mp4`/`.mov` files that live under the * user-curated watch-config roots (from `~/.videoclaw-monitor.json`) but NOT * inside any `projects//` workspace or intermediate working dir. * * Scans ONLY the watch-config roots — NOT ~/.videoclaw-* dirs or cwd, which * are managed workspaces whose loose mp4s are intermediates already surfaced as * project cards. If the watch-config is missing/empty, returns []. * * `configPath` is injectable for hermetic tests; defaults to * `~/.videoclaw-monitor.json`. */ export async function discoverLooseDeliverables( configPath: string = join(homedir(), '.videoclaw-monitor.json'), ): Promise { // Loose deliverables come from a SEPARATE `looseRoots` config list (curated // folders that hold finished VideoClaw videos), NOT the project-discovery // `roots`. Empty when `looseRoots` is absent — never falls back to `roots`, // so a broad project-scan root never floods this with unrelated client files. const bases = await readWatchConfigRoots(configPath, 'looseRoots'); const found = new Map(); for (const base of bases) await scanForLooseVideos(base, 0, found); const list = [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, LOOSE_MAX); // Attach a cached poster frame ONLY when one already exists on disk (cache hit). // Discovery never shells out to ffmpeg — extraction happens in serve.ts at startup. for (const d of list) { const thumb = looseThumbPath(d.path); if (existsSync(thumb)) d.thumbPath = thumb; } return list; } async function classify(root: string, slug: string): Promise { const projectDir = join(root, 'projects', slug); const artifactsDir = join(projectDir, 'artifacts'); const artifacts = await readdir(artifactsDir).catch(() => []); const artifactCount = artifacts.filter((f) => f.endsWith('.json')).length; const hasFinal = existsSync(join(projectDir, 'final')); let updatedAt: string | null = null; const manifest = await readFile(join(projectDir, 'project.json'), 'utf-8').catch(() => null); if (manifest) { try { updatedAt = JSON.parse(manifest).updatedAt ?? null; } catch { /* ignore */ } } if (!updatedAt) { const st = await stat(projectDir).catch(() => null); updatedAt = st ? st.mtime.toISOString() : null; } const recent = updatedAt ? Date.now() - new Date(updatedAt).getTime() < RECENT_MS : false; const isMeaningful = hasFinal || artifactCount >= MEANINGFUL_ARTIFACT_THRESHOLD || recent; // Drill-in surfaces — HTML the server can serve back (dashboard first, then preview/review/run). const surfaces: ProjectSurface[] = []; // Any project that has its own dashboard/run-status HTML gets it as the primary // drill-in (prefer dashboard.html; fall back to run-status.html). One per project. for (const dash of ['dashboard', 'run-status'] as const) { if (existsSync(join(projectDir, `${dash}.html`))) { surfaces.push({ kind: 'dashboard', relPath: `projects/${slug}/${dash}.html` }); break; } } for (const name of ['preview', 'review', 'run'] as const) { if (existsSync(join(projectDir, `${name}.html`))) { surfaces.push({ kind: name, relPath: `projects/${slug}/${name}.html` }); } } const thumbRelPath = await findThumbRelPath(root, slug); return { slug, workspaceRoot: root, displayName: slug, isMeaningful, lastActivity: updatedAt, surfaces, thumbRelPath }; } export async function discoverProjects(roots: string[]): Promise { const refs: ProjectRef[] = []; for (const root of roots) { const slugs = await listProjects(root).catch(() => []); for (const slug of slugs) refs.push(await classify(root, slug)); } return refs; }