import { assertFilmReviewGate, inspectFilmReviewGate } from './film-review-gate.js'; import { readFile } from 'node:fs/promises'; import { join, normalize, sep } 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 { listPlaybooks } from './playbooks.js'; import { listPromptReferences } from './prompt-library.js'; import { listStoryboardTemplates } from './storyboard-templates.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { writeArtifact } from './artifact-store.js'; import { writeStageCheckpoint } from './checkpoints.js'; import { ensureProjectWorkspace, readProjectManifest, resolveProjectWorkspace, updateProjectManifestState } from './workspace.js'; import { recordStoryboardStillCandidate } from './storyboard-still-candidates.js'; import { appendProjectEvent } from './events.js'; import type { SceneCandidate, SceneCandidatesArtifact, SceneSelectionArtifact } from './types.js'; import type { ReviewUiMediaProxyDependencies } from './review-ui-media-proxy.js'; import { launchReviewUiServer, timingSafeStringEqual } from './review-ui-server.js'; export { isBlockedReviewMediaAddress } from './review-ui-media-proxy.js'; export type { ReviewUiMediaProxyDependencies } from './review-ui-media-proxy.js'; // Phase 3e: the shared readers/validators, the derived-artifact builders and the inventory live in their own modules; the autopilot sits ABOVE this file (it drives saveReviewDecision) and is imported directly by its two callers. import { reviewVerdict, projectRelativeAssetPath, storyboardScenesForReview, type ReviewDecisionRecord, asReviewDecision, isRecord, findSceneCandidate, stringValue, numberValue, readJsonArtifact } from './review-ui-shared.js'; import { buildReviewInventory, readStoryboardStillGenerationQueue, readCharacterIterationQueue, normalizeCharacterAspectRatio } from './review-ui-inventory.js'; import { writeDerivedReviewArtifacts, buildSceneSelectionArtifact, isSceneCandidateId, sceneHasCandidate, isReviewReportPublishReady, nextActionFromReviewReport, workflowSource } from './review-ui-artifacts.js'; export { buildReviewInventory } from './review-ui-inventory.js'; // The autopilot's option/result TYPES stay importable from this path (a type-only // re-export is erased at emit, so the runtime graph still has no edge to the autopilot). export type { ReviewAutopilotOptions, ReviewAutopilotResult } from './review-ui-autopilot.js'; export { isReviewReportPublishReady, nextActionFromReviewReport } from './review-ui-artifacts.js'; export interface ReviewUiOptions { root: string; projectSlug: string; host?: string; port?: number; uiPath?: string; dryRun?: boolean; allowRemote?: boolean; mediaProxyDependencies?: ReviewUiMediaProxyDependencies; } export interface ReviewUiLaunch { url: string; host: string; port: number; root: string; projectSlug: string; uiPath: string; dryRun: boolean; authRequired: true; remoteAccess: boolean; close?: () => Promise; } export interface ReviewInventory { filmReview?: Awaited>; root: string; projectSlug: string; projectDir: string; projectExists: boolean; projects: string[]; characters: Awaited>; referenceSheets: Awaited>; sceneCandidates: Awaited>; sceneSelection: Awaited>; reviewLedger: Record | null; reviewReport: Record | null; brief: Record | null; storyboard: Record | null; assetManifest: Record | null; executionReport: Record | null; publishReport: Record | null; generationQueue: ReviewStoryboardStillGenerationQueue | null; characterQueue: ReviewCharacterIterationQueue | null; playbooks: Awaited>; promptReferences: ReturnType; storyboardTemplates: ReturnType; mediaAssets: Array<{ path: string; kind: string }>; remoteMediaIds: Record; schemas: string[]; } export interface ReviewDecisionSaveResult { projectSlug: string; path: string; savedAt: string; derivedArtifacts: Array<{ name: string; path: string; purpose: string; }>; lifecycle?: { checkpointPath: string; status: 'completed' | 'retry-required' | 'failed'; currentStage: 'publish' | 'review'; lastCompletedStage: 'review' | 'assets'; manifestUpdated: boolean; }; reviewReport?: Record; } export interface ReviewStoryboardStillCandidateResult { sceneIndex: number; candidate: SceneCandidate; reused: boolean; } export interface ReviewUpscaledStillCandidateResult { sceneIndex: number; sourceCandidateId: string; candidate: SceneCandidate; reused: boolean; } export interface ReviewStoryboardStillGenerationRequest { id: string; sceneIndex: number; provider: 'gobananas'; route: 'gobananas-storyboard-still'; status: 'queued' | 'fulfilled'; prompt: string; negativePrompt: string; aspectRatio: string; requestedAt: string; source: 'review-ui'; notes?: string; fulfilledAt?: string; candidateId?: string; imageUrl?: string; imageId?: string; } export interface ReviewStoryboardStillGenerationQueue { schemaVersion: 1; projectSlug: string; updatedAt: string; requests: ReviewStoryboardStillGenerationRequest[]; } export interface ReviewStoryboardStillGenerationRequestResult { path: string; request: ReviewStoryboardStillGenerationRequest; queue: ReviewStoryboardStillGenerationQueue; } export interface ReviewCharacterIterationRequest { id: string; provider: 'gobananas'; route: 'gobananas-character-iteration'; status: 'queued' | 'fulfilled' | 'failed'; characterName: string; prompt: string; negativePrompt: string; aspectRatio: string; count: number; requestedAt: string; source: 'review-ui'; notes?: string; fulfilledAt?: string; characterProfileId?: string; goBananasId?: number; referenceImageUrl?: string; imageId?: string; failedAt?: string; error?: string; } export interface ReviewCharacterIterationQueue { schemaVersion: 1; projectSlug: string; updatedAt: string; requests: ReviewCharacterIterationRequest[]; } export interface ReviewCharacterIterationRequestResult { path: string; request: ReviewCharacterIterationRequest; queue: ReviewCharacterIterationQueue; } export async function launchReviewUi(options: ReviewUiOptions): Promise { return launchReviewUiServer(options, { buildInventory: buildReviewInventory, saveDecision: saveReviewDecision, recordStillCandidate: recordReviewStoryboardStillCandidate, recordUpscaledCandidate: recordReviewUpscaledStillCandidate, recordStillRequest: recordReviewStoryboardStillGenerationRequest, recordCharacterRequest: recordReviewCharacterIterationRequest, resolveRemoteMedia: resolveAuthorizedRemoteMedia, }); } async function resolveAuthorizedRemoteMedia( root: string, projectSlug: string, rawId: string | null, ): Promise { const id = stringValue(rawId); if (!id || !/^[A-Za-z0-9_-]{43}$/.test(id)) return null; const inventory = await buildReviewInventory(root, projectSlug); return Object.entries(inventory.remoteMediaIds).find(([, candidateId]) => timingSafeStringEqual(candidateId, id))?.[0] ?? null; } export async function saveReviewDecision( root: string, projectSlug: string, decision: unknown, ): Promise { const workspace = await ensureProjectWorkspace(projectSlug, root); const savedAt = new Date().toISOString(); const normalizedDecision = asReviewDecision(decision); if (stringValue(normalizedDecision.selections.reviewCompleteAt)) await assertFilmReviewGate(workspace.projectDir, workspace.artifactsDir); const path = join(workspace.artifactsDir, 'review-ui-ledger.json'); const derivedArtifacts = await writeDerivedReviewArtifacts({ root, artifactsDir: workspace.artifactsDir, projectSlug, savedAt, decision: normalizedDecision, }); const lifecycle = stringValue(normalizedDecision.selections.reviewCompleteAt) ? await writeReviewLifecycleState(workspace, savedAt, normalizedDecision, derivedArtifacts) : undefined; if (lifecycle?.status === 'completed') { await updateCompletedLifecycleDerivedArtifacts(derivedArtifacts, 'Ready for publish handoff.'); } const reviewReport = await readReviewReportFromDerivedArtifacts(derivedArtifacts); const savedNextAction = lifecycle?.status === 'completed' ? 'Ready for publish handoff.' : nextActionFromReviewReport(reviewReport, normalizedDecision.recommendedNextAction); const savedDecision: ReviewDecisionRecord = { ...normalizedDecision, recommendedNextAction: savedNextAction, savedArtifact: { path, savedAt, derivedArtifacts, ...(lifecycle ? { lifecycle } : {}), }, }; await writeTextFileAtomic(path, `${JSON.stringify({ schemaVersion: 1, projectSlug, savedAt, decision: savedDecision, }, null, 2)}\n`); return { projectSlug, path, savedAt, derivedArtifacts, ...(reviewReport ? { reviewReport } : {}), ...(lifecycle ? { lifecycle } : {}), }; } async function readReviewReportFromDerivedArtifacts( derivedArtifacts: ReviewDecisionSaveResult['derivedArtifacts'], ): Promise | undefined> { const reviewReportPath = derivedArtifacts.find((artifact) => artifact.name === 'review-report.json')?.path; if (!reviewReportPath) return undefined; const raw = JSON.parse(await readFile(reviewReportPath, 'utf-8')) as unknown; return isRecord(raw) ? raw : {}; } async function updateCompletedLifecycleDerivedArtifacts( derivedArtifacts: ReviewDecisionSaveResult['derivedArtifacts'], nextAction: string, ): Promise { for (const artifact of derivedArtifacts) { if (artifact.name !== 'director-seedance-plan.json' && artifact.name !== 'review-report.json') continue; const raw = JSON.parse(await readFile(artifact.path, 'utf-8')) as unknown; const body = isRecord(raw) ? raw : {}; if (artifact.name === 'director-seedance-plan.json') { body.nextAction = nextAction; } if (artifact.name === 'review-report.json') { const metrics = isRecord(body.metrics) ? body.metrics : {}; metrics.nextAction = nextAction; body.metrics = metrics; } await writeTextFileAtomic(artifact.path, `${JSON.stringify(body, null, 2)}\n`); } } async function writeReviewLifecycleState( workspace: Awaited>, savedAt: string, decision: ReviewDecisionRecord, derivedArtifacts: ReviewDecisionSaveResult['derivedArtifacts'], ): Promise> { const reviewReportPath = derivedArtifacts.find((artifact) => artifact.name === 'review-report.json')?.path; if (!reviewReportPath) { throw new Error('review lifecycle update requires review-report.json'); } const rawReviewReport = JSON.parse(await readFile(reviewReportPath, 'utf-8')) as unknown; const reviewReport = isRecord(rawReviewReport) ? rawReviewReport : {}; const verdict = reviewVerdict(reviewReport.verdict); const publishReady = isReviewReportPublishReady(reviewReport); const status = publishReady ? 'completed' : verdict === 'fail' ? 'failed' : 'retry-required'; const currentStage = publishReady ? 'publish' : 'review'; const lastCompletedStage = publishReady ? 'review' : 'assets'; const issues = Array.isArray(reviewReport.findings) ? reviewReport.findings.map(stringValue).filter((finding): finding is string => Boolean(finding)) : []; const reviewNextAction = nextActionFromReviewReport( reviewReport, decision.recommendedNextAction ?? 'Resolve review findings before publishing.', ); const manifest = await readProjectManifest(workspace); if (publishReady) { await writeCanonicalReviewUiPipelineState( workspace, manifest?.productionMode ?? 'director', savedAt, decision, ); } await writeStageCheckpoint(workspace, { stage: 'review', status, generatedAt: savedAt, artifacts: { 'review-report': reviewReportPath, }, summary: `Review UI recorded with verdict: ${verdict}.`, issues, nextAction: publishReady ? 'Publish the project.' : reviewNextAction, }); await appendProjectEvent(workspace, { type: 'artifact.review-report.written', recordedAt: savedAt, payload: { source: 'review-ui', artifactPath: reviewReportPath, verdict, }, }); let manifestUpdated = false; if (manifest) { await updateProjectManifestState(workspace, { updatedAt: savedAt, currentStage, lastCompletedStage, lastCheckpointStatus: status, }); manifestUpdated = true; } return { checkpointPath: join(workspace.checkpointsDir, 'review.json'), status, currentStage, lastCompletedStage, manifestUpdated, }; } async function writeCanonicalReviewUiPipelineState( workspace: Awaited>, productionMode: 'storyboard' | 'director', savedAt: string, decision: ReviewDecisionRecord, ): Promise { const sceneCandidates = await readSceneCandidatesArtifact(workspace.root, workspace.slug); const templateId = stringValue(decision.selections.template) || 'product-commercial-4'; const selectedCharacterName = stringValue(decision.selections.character) || stringValue(decision.selections.characterSource); const characterName = selectedCharacterName || 'the selected hero character'; const existingStoryboard = await readJsonArtifact(workspace.artifactsDir, 'storyboard.json'); const storyboardScenes = storyboardScenesForReview({ existingStoryboard, templateId, characterName, ...(selectedCharacterName ? { selectedCharacterName } : {}), }); const sceneSelection = buildSceneSelectionArtifact(decision, sceneCandidates); const selectedAssets = sceneSelection.scenes.flatMap((scene) => { const candidate = selectPublishStillCandidate(sceneCandidates, scene, decision); const output = candidate?.outputs.find((entry) => entry.kind === 'image') ?? candidate?.outputs[0]; if (!candidate || !output?.path) return []; return [{ id: `${candidate.id}-${candidate.route === 'upscaled-storyboard-still' ? 'selected-upscaled-still' : 'selected-still'}`, kind: 'image' as const, path: projectRelativeAssetPath(workspace.slug, output.path), sceneIndex: scene.sceneIndex, backend: candidate.route, }]; }); const briefPath = await writeArtifact(workspace, 'brief', { title: workspace.slug, intent: 'Review UI storyboard-to-motion production handoff.', productionMode, createdAt: savedAt, metadata: { source: 'review-ui', workflowSource: workflowSource(decision), qualityScore: decision.qualityScore ?? null, }, }); await writeStageCheckpoint(workspace, { stage: 'brief', status: 'completed', generatedAt: savedAt, artifacts: { brief: briefPath }, summary: 'Brief hydrated from the completed Review UI handoff.', issues: [], nextAction: 'Use the storyboard still plan to generate or refine scene images.', }); const storyboardPath = await writeArtifact(workspace, 'storyboard', { ...(storyboardScenes.length && existingStoryboard ? existingStoryboard : { scenes: storyboardScenes.map((scene) => ({ sceneIndex: scene.sceneIndex, description: scene.description, ...(scene.characters?.length ? { characters: scene.characters } : {}), scenePrompt: scene.scenePrompt ?? { imagePrompt: scene.description, animationPrompt: 'Animate only after the storyboard still is locked and upscaled.', cameraMove: 'static', styleFooter: 'Seedance reference workflow: still first, edit narrowly, lock, upscale, then motion.', }, })), }), projectSlug: workspace.slug, productionMode, }); await writeStageCheckpoint(workspace, { stage: 'storyboard', status: 'completed', generatedAt: savedAt, artifacts: { storyboard: storyboardPath }, summary: 'Storyboard hydrated from the completed Review UI scene plan.', issues: [], nextAction: 'Use selected still assets as the source frames for motion planning.', }); const assetManifestPath = await writeArtifact(workspace, 'asset-manifest', { projectSlug: workspace.slug, assets: selectedAssets, }); await writeStageCheckpoint(workspace, { stage: 'assets', status: 'completed', generatedAt: savedAt, artifacts: { 'asset-manifest': assetManifestPath }, summary: 'Selected storyboard stills were promoted into the canonical asset manifest.', issues: selectedAssets.length ? [] : ['No selected storyboard still assets were available.'], nextAction: 'Run final review or publish handoff.', }); } function selectPublishStillCandidate( sceneCandidates: SceneCandidatesArtifact, scene: SceneSelectionArtifact['scenes'][number], decision: ReviewDecisionRecord, ): SceneCandidate | null { if (!scene.selectedCandidateId) return null; const lockedCandidate = findSceneCandidate(sceneCandidates, scene.sceneIndex, scene.selectedCandidateId); if (!lockedCandidate) return null; const upscaledCandidateId = stringValue(decision.selections[`upscaledStill-${scene.sceneIndex}`]); if (!upscaledCandidateId) return lockedCandidate; const upscaledCandidate = findSceneCandidate(sceneCandidates, scene.sceneIndex, upscaledCandidateId); const isUpscaledFromLock = Boolean( upscaledCandidate && ( upscaledCandidate.source.chainedFromCandidateId === lockedCandidate.id || upscaledCandidate.id.startsWith(`${lockedCandidate.id}-`) ), ); const hasImageOutput = Boolean( upscaledCandidate?.outputs.some((output) => output.kind === 'image' && output.path), ); return isUpscaledFromLock && hasImageOutput ? upscaledCandidate : lockedCandidate; } export async function recordReviewStoryboardStillCandidate( root: string, projectSlug: string, body: unknown, ): Promise { const record = isRecord(body) ? body : {}; const sceneIndex = numberValue(record.sceneIndex); const imageUrl = stringValue(record.imageUrl); if (sceneIndex === null || !imageUrl) { throw new Error('storyboard still candidate requires sceneIndex and imageUrl'); } validateReviewImageUrl(imageUrl); const imageId = stringValue(record.imageId); const prompt = stringValue(record.prompt); const notes = stringValue(record.notes); const existing = findExistingStoryboardStillCandidate( await readSceneCandidatesArtifact(root, projectSlug), sceneIndex, imageUrl, ); if (existing) { const workspace = await ensureProjectWorkspace(projectSlug, root); await appendProjectEvent(workspace, { type: 'storyboard-still.candidate.reused', payload: { source: 'review-ui', sceneIndex, candidateId: existing.id, imageUrl, ...(imageId ? { imageId } : {}), ...(notes ? { notes } : {}), }, }); return { sceneIndex, candidate: existing, reused: true, }; } const result = await recordStoryboardStillCandidate({ root, projectSlug, sceneIndex, imageUrl, ...(imageId ? { imageId } : {}), ...(prompt ? { prompt } : {}), ...(notes ? { notes } : {}), }); const workspace = await ensureProjectWorkspace(projectSlug, root); await fulfillLatestStoryboardStillGenerationRequest({ root, projectSlug, sceneIndex, candidateId: result.candidate.id, imageUrl, ...(imageId ? { imageId } : {}), }); await appendProjectEvent(workspace, { type: 'storyboard-still.candidate.added', payload: { source: 'review-ui', sceneIndex, candidateId: result.candidate.id, imageUrl, ...(imageId ? { imageId } : {}), ...(notes ? { notes } : {}), }, }); return { ...result, reused: false, }; } export async function recordReviewUpscaledStillCandidate( root: string, projectSlug: string, body: unknown, ): Promise { const record = isRecord(body) ? body : {}; const sceneIndex = numberValue(record.sceneIndex); const sourceCandidateId = stringValue(record.sourceCandidateId); const imageUrl = stringValue(record.imageUrl); if (sceneIndex === null || !sourceCandidateId || !imageUrl) { throw new Error('upscaled still candidate requires sceneIndex, sourceCandidateId, and imageUrl'); } if (!isSceneCandidateId(sourceCandidateId)) { throw new Error('upscaled still sourceCandidateId must be a storyboard candidate id'); } validateReviewImageReference(imageUrl, { allowLocalPath: true }); const artifact = await readSceneCandidatesArtifact(root, projectSlug); if (!sceneHasCandidate(artifact, sceneIndex, sourceCandidateId)) { throw new Error(`upscaled still source candidate not found: ${sourceCandidateId}`); } const candidateId = `${sourceCandidateId}-4k`; const existing = artifact.scenes .find((scene) => scene.sceneIndex === sceneIndex) ?.candidates .find((candidate) => candidate.id === candidateId || candidate.outputs.some((output) => output.kind === 'image' && output.path === imageUrl)); if (existing) { return { sceneIndex, sourceCandidateId, candidate: existing, reused: true, }; } const imageId = stringValue(record.imageId); const prompt = stringValue(record.prompt) || `Upscaled 4k still asset for ${sourceCandidateId}.`; const result = await recordStoryboardStillCandidate({ root, projectSlug, sceneIndex, candidateId, imageUrl, ...(imageId ? { imageId } : {}), prompt, route: 'upscaled-storyboard-still', chainedFromCandidateId: sourceCandidateId, }); const workspace = await ensureProjectWorkspace(projectSlug, root); await appendProjectEvent(workspace, { type: 'storyboard-still.upscale.added', payload: { source: 'review-ui', sceneIndex, sourceCandidateId, candidateId: result.candidate.id, imageUrl, ...(imageId ? { imageId } : {}), }, }); return { sceneIndex, sourceCandidateId, candidate: result.candidate, reused: false, }; } async function fulfillLatestStoryboardStillGenerationRequest(input: { root: string; projectSlug: string; sceneIndex: number; candidateId: string; imageUrl: string; imageId?: string; }): Promise { const workspace = resolveProjectWorkspace(input.projectSlug, input.root); const path = join(workspace.artifactsDir, 'storyboard-still-generation-requests.json'); const queue = await readStoryboardStillGenerationQueue(path, input.projectSlug); if (!queue) return; let requestIndex = -1; for (let index = queue.requests.length - 1; index >= 0; index -= 1) { const request = queue.requests[index]; if (request?.sceneIndex === input.sceneIndex && request.status === 'queued') { requestIndex = index; break; } } if (requestIndex < 0) return; const fulfilledAt = new Date().toISOString(); const nextRequests = queue.requests.map((request, index) => ( index === requestIndex ? { ...request, status: 'fulfilled' as const, fulfilledAt, candidateId: input.candidateId, imageUrl: input.imageUrl, ...(input.imageId ? { imageId: input.imageId } : {}), } : request )); const nextQueue: ReviewStoryboardStillGenerationQueue = { ...queue, updatedAt: fulfilledAt, requests: nextRequests, }; await writeTextFileAtomic(path, `${JSON.stringify(nextQueue, null, 2)}\n`); } export async function recordReviewStoryboardStillGenerationRequest( root: string, projectSlug: string, body: unknown, ): Promise { const record = isRecord(body) ? body : {}; const sceneIndex = numberValue(record.sceneIndex); const prompt = stringValue(record.prompt); if (sceneIndex === null || !prompt) { throw new Error('storyboard still generation request requires sceneIndex and prompt'); } const requestedAt = new Date().toISOString(); const workspace = await ensureProjectWorkspace(projectSlug, root); const path = join(workspace.artifactsDir, 'storyboard-still-generation-requests.json'); const queue = await readStoryboardStillGenerationQueue(path, projectSlug) ?? { schemaVersion: 1, projectSlug, updatedAt: '', requests: [], }; const request: ReviewStoryboardStillGenerationRequest = { id: `gobananas-still-${String(sceneIndex).padStart(2, '0')}-${String(queue.requests.length + 1).padStart(3, '0')}`, sceneIndex, provider: 'gobananas', route: 'gobananas-storyboard-still', status: 'queued', prompt, negativePrompt: stringValue(record.negativePrompt) ?? 'readable text, random logos, clutter, extra objects, real humans, distorted anatomy, inconsistent character identity, unwanted camera change', aspectRatio: stringValue(record.aspectRatio) ?? '16:9', requestedAt, source: 'review-ui', ...(stringValue(record.notes) ? { notes: stringValue(record.notes)! } : {}), }; const nextQueue: ReviewStoryboardStillGenerationQueue = { schemaVersion: 1, projectSlug, updatedAt: requestedAt, requests: [...queue.requests, request], }; await writeTextFileAtomic(path, `${JSON.stringify(nextQueue, null, 2)}\n`); await appendProjectEvent(workspace, { type: 'storyboard-still.generation-request.queued', payload: { source: 'review-ui', sceneIndex, requestId: request.id, provider: request.provider, route: request.route, }, }); return { path, request, queue: nextQueue, }; } export async function recordReviewCharacterIterationRequest( root: string, projectSlug: string, body: unknown, ): Promise { const record = isRecord(body) ? body : {}; const characterName = stringValue(record.characterName) ?? 'Komo'; const prompt = stringValue(record.prompt); if (!prompt) { throw new Error('character iteration request requires prompt'); } const requestedAt = new Date().toISOString(); const workspace = await ensureProjectWorkspace(projectSlug, root); const path = join(workspace.artifactsDir, 'gobananas-character-iteration-requests.json'); const queue = await readCharacterIterationQueue(path, projectSlug) ?? { schemaVersion: 1, projectSlug, updatedAt: '', requests: [], }; const request: ReviewCharacterIterationRequest = { id: `gobananas-character-${String(queue.requests.length + 1).padStart(3, '0')}`, provider: 'gobananas', route: 'gobananas-character-iteration', status: 'queued', characterName, prompt, negativePrompt: stringValue(record.negativePrompt) ?? 'inconsistent identity, realistic human photo, readable text, logos, clutter, distorted anatomy, extra limbs', aspectRatio: normalizeCharacterAspectRatio(stringValue(record.aspectRatio)), count: numberValue(record.count) ?? 4, requestedAt, source: 'review-ui', ...(stringValue(record.notes) ? { notes: stringValue(record.notes)! } : {}), }; const nextQueue: ReviewCharacterIterationQueue = { schemaVersion: 1, projectSlug, updatedAt: requestedAt, requests: [...queue.requests, request], }; await writeTextFileAtomic(path, `${JSON.stringify(nextQueue, null, 2)}\n`); await appendProjectEvent(workspace, { type: 'character-iteration.request.queued', payload: { source: 'review-ui', requestId: request.id, provider: request.provider, route: request.route, characterName: request.characterName, count: request.count, }, }); return { path, request, queue: nextQueue, }; } function findExistingStoryboardStillCandidate( artifact: SceneCandidatesArtifact, sceneIndex: number, imageUrl: string, ): SceneCandidate | null { const scene = artifact.scenes.find((entry) => entry.sceneIndex === sceneIndex); if (!scene) return null; return [...scene.candidates].reverse().find((candidate) => ( candidate.outputs.some((output) => output.kind === 'image' && output.path === imageUrl) )) ?? null; } function validateReviewImageUrl(imageUrl: string): void { validateReviewImageReference(imageUrl, { allowLocalPath: false }); } function validateReviewImageReference(imageUrl: string, options: { allowLocalPath: boolean }): void { if (options.allowLocalPath && !/^[a-z][a-z0-9+.-]*:/i.test(imageUrl)) { validateReviewImagePath(imageUrl); return; } let parsed: URL; try { parsed = new URL(imageUrl); } catch { throw new Error('storyboard still image URL must be a valid URL'); } if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { throw new Error('storyboard still image URL must use http or https'); } const host = parsed.hostname.toLowerCase(); if (['example.com', 'example.net', 'example.org'].includes(host)) { throw new Error('storyboard still image URL cannot use a placeholder domain'); } const path = parsed.pathname.toLowerCase(); if (!/\.(jpg|jpeg|png|webp|gif)(?:$|\?)/.test(`${path}${parsed.search}`)) { throw new Error('storyboard still image URL must point to a jpg, png, webp, or gif image'); } } function validateReviewImagePath(path: string): void { const normalizedPath = normalize(path); if (normalizedPath.startsWith('..') || normalizedPath.startsWith(sep) || normalizedPath.includes(`${sep}..${sep}`)) { throw new Error('storyboard still image path must stay inside the project workspace'); } if (!/\.(jpg|jpeg|png|webp|gif)$/i.test(normalizedPath)) { throw new Error('storyboard still image path must point to a jpg, png, webp, or gif image'); } }