/** * The read-only review inventory: everything the review station lists — * projects, playbooks, prompt references, characters, reference sheets, scene * candidates, the two generation queues and the media assets. Extracted * verbatim from `review-ui.ts` (roadmap Phase 3e), which re-exports * `buildReviewInventory`. */ import { existsSync } from 'node:fs'; import { inspectFilmReviewGate } from './film-review-gate.js'; import { readFile, readdir } from 'node:fs/promises'; import { extname, join, resolve } from 'node:path'; import { listCharacterProfiles } from './characters.js'; import { readReferenceSheetsArtifact } from './reference-sheet-store.js'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { readSceneSelectionArtifact } from './scene-selection-store.js'; import { listProjects } from './projects.js'; import { listPlaybooks } from './playbooks.js'; import { listPromptReferences } from './prompt-library.js'; import { listStoryboardTemplates } from './storyboard-templates.js'; import { resolveProjectWorkspace } from './workspace.js'; import { buildRemoteMediaIds } from './review-ui-media-proxy.js'; import type { ReviewInventory, ReviewStoryboardStillGenerationRequest, ReviewStoryboardStillGenerationQueue, ReviewCharacterIterationRequest, ReviewCharacterIterationQueue } from './review-ui.js'; import { isRecord, stringValue, numberValue, readReviewLedger, readJsonArtifact } from './review-ui-shared.js'; export async function buildReviewInventory(root: string, projectSlug: string): Promise { const resolvedRoot = resolve(root); const workspace = resolveProjectWorkspace(projectSlug, resolvedRoot); const projects = await listProjects(resolvedRoot); const characters = existsSync(workspace.projectDir) ? await listCharacterProfiles(workspace) : []; const referenceSheets = await readReferenceSheetsArtifact(resolvedRoot, projectSlug); const sceneCandidates = await readSceneCandidatesArtifact(resolvedRoot, projectSlug); const sceneSelection = await readSceneSelectionArtifact(resolvedRoot, projectSlug); const reviewLedger = await readReviewLedger(workspace.artifactsDir); const reviewReport = await readJsonArtifact(workspace.artifactsDir, 'review-report.json'); const brief = await readJsonArtifact(workspace.artifactsDir, 'brief.json'); const storyboard = await readJsonArtifact(workspace.artifactsDir, 'storyboard.json'); const assetManifest = await readJsonArtifact(workspace.artifactsDir, 'asset-manifest.json'); const executionReport = await readJsonArtifact(workspace.artifactsDir, 'execution-report.json'); const publishReport = await readJsonArtifact(workspace.artifactsDir, 'publish-report.json'); const filmReview = existsSync(workspace.projectDir) ? await inspectFilmReviewGate(workspace.projectDir, workspace.artifactsDir) : undefined; const generationQueue = await readStoryboardStillGenerationQueue( join(workspace.artifactsDir, 'storyboard-still-generation-requests.json'), projectSlug, ); const characterQueue = await readCharacterIterationQueue( join(workspace.artifactsDir, 'gobananas-character-iteration-requests.json'), projectSlug, ); // Playbooks + presenter media are repo-bundled (playbooks/, docs/assets/, skills/*/assets/), // NOT per-project — resolve them from the repo (cwd), never the project workspace home. const playbooks = await listPlaybooks(); const promptReferences = listPromptReferences(); const storyboardTemplates = listStoryboardTemplates(); const mediaAssets = await findReviewMediaAssets(process.cwd()); const remoteMediaIds = buildRemoteMediaIds( characters, referenceSheets, sceneCandidates, sceneSelection, reviewLedger, reviewReport, brief, storyboard, assetManifest, executionReport, publishReport, generationQueue, characterQueue, mediaAssets, ); return { root: resolvedRoot, projectSlug, projectDir: workspace.projectDir, filmReview, projectExists: existsSync(workspace.projectDir), projects, characters, referenceSheets, sceneCandidates, sceneSelection, reviewLedger, reviewReport, brief, storyboard, assetManifest, executionReport, publishReport, generationQueue, characterQueue, playbooks, promptReferences, storyboardTemplates, mediaAssets, remoteMediaIds, schemas: [ 'analyze-output', 'asset-manifest', 'brief', 'clone-plan', 'execution-plan', 'execution-report', 'publish-report', 'reference-sheets', 'review-report', 'scene-candidates', 'scene-selection', 'storyboard', ], }; } export async function readStoryboardStillGenerationQueue( path: string, projectSlug: string, ): Promise { if (!existsSync(path)) return null; const raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw) as unknown; if (!isRecord(parsed) || !Array.isArray(parsed.requests)) return null; return { schemaVersion: 1, projectSlug: stringValue(parsed.projectSlug) ?? projectSlug, updatedAt: stringValue(parsed.updatedAt) ?? '', requests: parsed.requests .filter(isRecord) .map((request): ReviewStoryboardStillGenerationRequest | null => { const sceneIndex = numberValue(request.sceneIndex); const prompt = stringValue(request.prompt); const id = stringValue(request.id); if (sceneIndex === null || !prompt || !id) return null; return { id, sceneIndex, provider: 'gobananas', route: 'gobananas-storyboard-still', status: request.status === 'fulfilled' ? 'fulfilled' : 'queued', prompt, negativePrompt: stringValue(request.negativePrompt) ?? '', aspectRatio: stringValue(request.aspectRatio) ?? '16:9', requestedAt: stringValue(request.requestedAt) ?? '', source: 'review-ui', ...(stringValue(request.notes) ? { notes: stringValue(request.notes)! } : {}), ...(stringValue(request.fulfilledAt) ? { fulfilledAt: stringValue(request.fulfilledAt)! } : {}), ...(stringValue(request.candidateId) ? { candidateId: stringValue(request.candidateId)! } : {}), ...(stringValue(request.imageUrl) ? { imageUrl: stringValue(request.imageUrl)! } : {}), ...(stringValue(request.imageId) ? { imageId: stringValue(request.imageId)! } : {}), }; }) .filter((request): request is ReviewStoryboardStillGenerationRequest => request !== null), }; } export async function readCharacterIterationQueue( path: string, projectSlug: string, ): Promise { if (!existsSync(path)) return null; const raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw) as unknown; if (!isRecord(parsed) || !Array.isArray(parsed.requests)) return null; return { schemaVersion: 1, projectSlug: stringValue(parsed.projectSlug) ?? projectSlug, updatedAt: stringValue(parsed.updatedAt) ?? '', requests: parsed.requests .filter(isRecord) .map((request): ReviewCharacterIterationRequest | null => { const id = stringValue(request.id); const prompt = stringValue(request.prompt); if (!id || !prompt) return null; return { id, provider: 'gobananas', route: 'gobananas-character-iteration', status: request.status === 'failed' ? 'failed' : request.status === 'fulfilled' ? 'fulfilled' : 'queued', characterName: stringValue(request.characterName) ?? 'Komo', prompt, negativePrompt: stringValue(request.negativePrompt) ?? '', aspectRatio: normalizeCharacterAspectRatio(stringValue(request.aspectRatio)), count: numberValue(request.count) ?? 4, requestedAt: stringValue(request.requestedAt) ?? '', source: 'review-ui', ...(stringValue(request.notes) ? { notes: stringValue(request.notes)! } : {}), ...(stringValue(request.fulfilledAt) ? { fulfilledAt: stringValue(request.fulfilledAt)! } : {}), ...(stringValue(request.characterProfileId) ? { characterProfileId: stringValue(request.characterProfileId)! } : {}), ...(numberValue(request.goBananasId) ? { goBananasId: numberValue(request.goBananasId)! } : {}), ...(stringValue(request.referenceImageUrl) ? { referenceImageUrl: stringValue(request.referenceImageUrl)! } : {}), ...(stringValue(request.imageId) ? { imageId: stringValue(request.imageId)! } : {}), ...(stringValue(request.failedAt) ? { failedAt: stringValue(request.failedAt)! } : {}), ...(stringValue(request.error) ? { error: stringValue(request.error)! } : {}), }; }) .filter((request): request is ReviewCharacterIterationRequest => request !== null), }; } export function normalizeCharacterAspectRatio(value: string | null): string { if (!value || value === '1:1') return 'square'; return value; } async function findReviewMediaAssets(root: string): Promise> { const roots = [join(root, 'docs', 'assets')]; // Any shipped skill can contribute review media from its assets/ subdir (e.g. // presenter skills). Enumerate skills instead of hardcoding specific names. const skillsDir = join(root, 'skills'); if (existsSync(skillsDir)) { const skillEntries = await readdir(skillsDir, { withFileTypes: true }).catch(() => []); for (const skill of skillEntries) { if (skill.isDirectory()) roots.push(join(skillsDir, skill.name, 'assets')); } } const assets: Array<{ path: string; kind: string }> = []; for (const dir of roots) { if (!existsSync(dir)) continue; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; const filePath = join(dir, entry.name); const kind = extname(entry.name).replace('.', '').toLowerCase(); if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'webm', 'mov'].includes(kind)) continue; assets.push({ path: filePath.slice(root.length + 1), kind, }); } } assets.sort((left, right) => left.path.localeCompare(right.path)); return assets; }