/** * The review autopilot: selects usable locked stills and a character, and * drives `saveReviewDecision` / `recordReviewUpscaledStillCandidate` without * a human at the station. It sits ABOVE `review-ui.ts` (it calls into it), so * it is NOT re-exported from there — `src/index.ts` and the review-portal * handler import it from here. Extracted verbatim from `review-ui.ts` * (roadmap Phase 3e). */ import { existsSync } from 'node:fs'; import { copyFile, mkdir } from 'node:fs/promises'; import { dirname, extname, join, resolve } from 'node:path'; import { listCharacterProfiles } from './characters.js'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { readSceneSelectionArtifact } from './scene-selection-store.js'; import { ensureProjectWorkspace, resolveProjectWorkspace } from './workspace.js'; import type { SceneCandidate, SceneCandidatesArtifact, SceneSelectionArtifact } from './types.js'; import { type ReviewDecisionSaveResult, saveReviewDecision, recordReviewUpscaledStillCandidate } from './review-ui.js'; import { projectRelativeAssetPath, readJsonArtifact } from './review-ui-shared.js'; export interface ReviewAutopilotOptions { root: string; projectSlug: string; template?: string; character?: string; runId?: string; } export interface ReviewAutopilotResult { projectSlug: string; template: string; character: string; runId: string; lockedStills: Array<{ sceneIndex: number; candidateId: string; imageUrl: string; }>; upscaledStills: Array<{ sceneIndex: number; sourceCandidateId: string; candidateId: string; imageUrl: string; reused: boolean; }>; decision: ReviewDecisionSaveResult; reviewReport: Record | null; } export async function runReviewAutopilot(options: ReviewAutopilotOptions): Promise { const root = resolve(options.root); const projectSlug = options.projectSlug; const template = options.template ?? 'product-commercial-4'; const runId = sanitizeRunId(options.runId ?? new Date().toISOString()); const workspace = await ensureProjectWorkspace(projectSlug, root); const sceneCandidates = await readSceneCandidatesArtifact(root, projectSlug); const sceneSelection = await readSceneSelectionArtifact(root, projectSlug); const lockedStills = selectAutopilotLockedStills(sceneCandidates, sceneSelection); if (!lockedStills.length) { throw new Error('review-autopilot requires at least one completed storyboard still candidate'); } const characters = await listCharacterProfiles(workspace); const character = selectAutopilotCharacter( options.character, lockedStills, characters.map((entry) => entry.name), ); const upscaledStills = []; for (const locked of lockedStills) { const upscaledCandidate = findUpscaledCandidateForSource(sceneCandidates, locked.sceneIndex, locked.candidate.id); if (upscaledCandidate) { const output = imageOutput(upscaledCandidate); upscaledStills.push({ sceneIndex: locked.sceneIndex, sourceCandidateId: locked.candidate.id, candidateId: upscaledCandidate.id, imageUrl: output?.path ?? '', reused: true, }); continue; } const imageUrl = await materializeAutopilotUpscaleAsset({ root, projectSlug, runId, sceneIndex: locked.sceneIndex, candidate: locked.candidate, }); const upscaled = await recordReviewUpscaledStillCandidate(root, projectSlug, { sceneIndex: locked.sceneIndex, sourceCandidateId: locked.candidate.id, imageUrl, prompt: `Agent autopilot promoted locked still ${locked.candidate.id} into the artifact-backed upscale gate. No video generation was run.`, }); upscaledStills.push({ sceneIndex: locked.sceneIndex, sourceCandidateId: locked.candidate.id, candidateId: upscaled.candidate.id, imageUrl, reused: upscaled.reused, }); } const firstLocked = lockedStills[0]!; const lastLocked = lockedStills[lockedStills.length - 1]!; const selections: Record = { character, characterPlan: 'agent-selected-existing-character', reference: 'seedance-motion-design-reference', 'referenceRole-identity': character, 'referenceRole-pose': firstLocked.candidate.id, 'referenceRole-lookdev': firstLocked.candidate.id, 'referenceRole-background': firstLocked.candidate.id, 'referenceRole-prop': firstLocked.candidate.id, 'referenceRole-start-frame': firstLocked.candidate.id, 'referenceRole-end-frame': lastLocked.candidate.id, template, bridgePosePlan: 'bridge-hard-actions', motionCandidate: 'control-pass', assemblyPlan: 'balanced', reviewCompleteAt: new Date().toISOString(), 'assemblyCheck-voiceover-fit': 'voiceover-fit-approved', 'assemblyCheck-continuity-cuts': 'continuity-cuts-approved', 'assemblyCheck-retiming-polish': 'retiming-polish-approved', 'assemblyCheck-logo-payoff': 'logo-payoff-approved', 'assemblyCheck-review-report': 'review-report-approved', }; for (const locked of lockedStills) { selections[`draftStill-${locked.sceneIndex}`] = locked.candidate.id; selections[`lockedStill-${locked.sceneIndex}`] = locked.candidate.id; selections[`upscaledStill-${locked.sceneIndex}`] = `${locked.candidate.id}-4k`; selections[`continuity-${locked.sceneIndex}`] = locked.sceneIndex === 0 ? 'start-frame-confirmed' : 'extract-end-frame'; } const decision = await saveReviewDecision(root, projectSlug, { activeGate: 'assembly', seedanceWorkflow: defaultSeedanceWorkflow(), qualityScore: '8/8', recommendedNextAction: 'Ready to write review artifacts.', selections, notes: { storyboard: 'Agent autopilot selected the best available storyboard stills, locked them, and prepared artifact-backed upscaled still handoff assets.', continuity: 'Agent autopilot assigned start/end continuity roles from the locked still sequence.', upscale: 'Agent autopilot created review handoff upscale candidates from local still assets where possible. Run a real image upscaler before provider submission when higher resolution is required.', }, }); const reviewReport = await readJsonArtifact(workspace.artifactsDir, 'review-report.json'); return { projectSlug, template, character, runId, lockedStills: lockedStills.map((locked) => ({ sceneIndex: locked.sceneIndex, candidateId: locked.candidate.id, imageUrl: imageOutput(locked.candidate)?.path ?? '', })), upscaledStills, decision, reviewReport, }; } function sanitizeRunId(value: string): string { return value .trim() .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') || 'review-autopilot'; } function selectAutopilotLockedStills( sceneCandidates: SceneCandidatesArtifact, sceneSelection: SceneSelectionArtifact, ): Array<{ sceneIndex: number; candidate: SceneCandidate }> { return sceneCandidates.scenes .map((scene) => { const selectedId = sceneSelection.scenes .find((selection) => selection.sceneIndex === scene.sceneIndex) ?.selectedCandidateId; const selected = selectedId ? scene.candidates.find((candidate) => candidate.id === selectedId && isUsableStoryboardStill(candidate)) : null; const fallback = scene.candidates.find(isUsableStoryboardStill) ?? null; const candidate = selected ?? fallback; return candidate ? { sceneIndex: scene.sceneIndex, candidate } : null; }) .filter((entry): entry is { sceneIndex: number; candidate: SceneCandidate } => entry !== null) .sort((left, right) => left.sceneIndex - right.sceneIndex); } function isUsableStoryboardStill(candidate: SceneCandidate): boolean { return candidate.status === 'completed' && candidate.route !== 'upscaled-storyboard-still' && Boolean(imageOutput(candidate)?.path); } function imageOutput(candidate: SceneCandidate): { kind: string; path: string } | null { return candidate.outputs.find((output) => output.kind === 'image') ?? candidate.outputs[0] ?? null; } function selectAutopilotCharacter( explicitCharacter: string | undefined, lockedStills: Array<{ sceneIndex: number; candidate: SceneCandidate }>, knownCharacterNames: string[], ): string { if (explicitCharacter?.trim()) return explicitCharacter.trim().toLowerCase(); const promptHaystack = lockedStills .map((locked) => locked.candidate.prompt) .join('\n') .toLowerCase(); const known = knownCharacterNames.find((name) => promptHaystack.includes(name.toLowerCase())); if (known) return known.toLowerCase(); const properNoun = /\b([A-Z][a-z][a-z0-9-]{1,30})\b/.exec( lockedStills .map((locked) => locked.candidate.prompt) .join('\n') .replace(/\b(?:Scene|Wide|Medium|Low|Final|Static|Agent|Use|Create|Preserve)\b/g, ''), )?.[1]; if (properNoun && promptHaystack.includes(properNoun.toLowerCase())) { return properNoun.toLowerCase(); } return 'selected character'; } function findUpscaledCandidateForSource( sceneCandidates: SceneCandidatesArtifact, sceneIndex: number, sourceCandidateId: string, ): SceneCandidate | null { return sceneCandidates.scenes .find((scene) => scene.sceneIndex === sceneIndex) ?.candidates.find((candidate) => ( candidate.route === 'upscaled-storyboard-still' && candidate.source.chainedFromCandidateId === sourceCandidateId && Boolean(imageOutput(candidate)?.path) )) ?? null; } async function materializeAutopilotUpscaleAsset(input: { root: string; projectSlug: string; runId: string; sceneIndex: number; candidate: SceneCandidate; }): Promise { const output = imageOutput(input.candidate); if (!output?.path) { throw new Error(`review-autopilot cannot upscale candidate without image output: ${input.candidate.id}`); } if (/^https?:\/\//i.test(output.path)) return output.path; const workspace = resolveProjectWorkspace(input.projectSlug, input.root); const projectRelativePath = projectRelativeAssetPath(input.projectSlug, output.path); const sourcePath = resolve(workspace.projectDir, projectRelativePath); if (!existsSync(sourcePath)) return output.path; const extension = extname(sourcePath) || '.jpg'; const targetRelativePath = join( 'assets', 'upscaled', 'storyboard', input.runId, `scene-${input.sceneIndex}-${input.candidate.id}-review-autopilot${extension}`, ); const targetPath = join(workspace.projectDir, targetRelativePath); await mkdir(dirname(targetPath), { recursive: true }); await copyFile(sourcePath, targetPath); return targetRelativePath; } function defaultSeedanceWorkflow(): Record { return { source: 'docs/REFERENCE_VIDEO_SEEDANCE_MOTION_DESIGN_WORKFLOW.md', qualityBar: 'award-winning director cinematic video with minimal operator work', method: [ 'idea and voiceover first', 'role-tagged reference canvas before prompting', 'still storyboard locked before motion', 'focused edit prompts before animation', 'upscale locked frames before Seedance', 'Seedance start/end frame chaining', 'long control prompt plus short variant prompt', 'bridge poses for hard hand/object/logo motion', 'continuity end-frame extraction between shots', 'planned post retiming and voiceover fit', ], promptPatterns: { stillCreate: 'Use reference roles as source truth. Create one visual beat. Preserve identity, materials, colors, lighting, and camera. Avoid readable text, logos, clutter, extra objects, and real people.', stillEdit: 'Edit only the named defect or layout issue. Keep identity, pose, camera, lighting, composition, and background unchanged.', motionControl: 'Use image 1 as start and image 2 as end. Preserve identity, style, background, lighting, and camera. Animate one readable action with easing, anticipation, overshoot, and soft settle. End exactly matching target.', shortVariant: 'Static camera. Same start and target. Faster playful single-action alternate while preserving character, background, composition, and style.', bridgePose: 'Create intermediate pose stills for catches, throws, hand-object contact, object escapes, transformations, and logo morphs before video generation.', postPolish: 'Retiming, voiceover fit, opacity, and final logo reveal are planned before publish.', }, bridgeTriggers: [ 'catch', 'throw', 'hand-object contact', 'escaping card', 'character transformation', 'logo morph', 'multi-object choreography', ], negativeGuidance: [ 'no readable UI text unless required', 'no random logos', 'no clutter', 'no extra objects', 'no real humans', 'no distorted anatomy', 'no unwanted camera change', ], }; }