/** * The derived-artifact builders a saved review decision fans out into: * reference board, director Seedance plan, storyboard-stills plan, scene * selection, Go Bananas character brief, post plan and the review report with * its publish-readiness verdict. Extracted verbatim from `review-ui.ts` * (roadmap Phase 3e), which re-exports the public names. */ import { join } from 'node:path'; import { readSceneCandidatesArtifact } from './scene-candidate-store.js'; import { buildStoryboardScenesFromTemplate, readStoryboardTemplate } from './storyboard-templates.js'; import { writeTextFileAtomic } from './atomic-write.js'; import type { SceneCandidatesArtifact, SceneSelectionArtifact } from './types.js'; import type { ReviewDecisionSaveResult } from './review-ui.js'; import { reviewVerdict, storyboardScenesForReview, existingStoryboardScenes, type ReviewDecisionRecord, isRecord, findSceneCandidate, stringValue, readJsonArtifact } from './review-ui-shared.js'; export async function writeDerivedReviewArtifacts(input: { root: string; artifactsDir: string; projectSlug: string; savedAt: string; decision: ReviewDecisionRecord; }): Promise { const sceneCandidates = await readSceneCandidatesArtifact(input.root, input.projectSlug); const existingStoryboard = await readJsonArtifact(input.artifactsDir, 'storyboard.json'); const reviewReport = buildReviewReportArtifact( input.projectSlug, input.savedAt, input.decision, sceneCandidates, existingStoryboard, ); const reviewMetrics = isRecord(reviewReport.metrics) ? reviewReport.metrics : {}; const publishReady = reviewMetrics.publishReady === true; const reviewNextAction = nextActionFromReviewReport(reviewReport, input.decision.recommendedNextAction); const artifacts = [ { name: 'reference-board.json', purpose: 'Role-tagged reference intent for image prompting and Seedance source images.', body: buildReferenceBoardArtifact(input.projectSlug, input.savedAt, input.decision), }, { name: 'director-seedance-plan.json', purpose: 'Director-grade Seedance still, motion, continuity, variant, and bridge-pose plan.', body: buildDirectorSeedancePlanArtifact(input.projectSlug, input.savedAt, input.decision, reviewNextAction), }, { name: 'storyboard-stills-plan.json', purpose: 'Scene-by-scene still image prompts, cleanup prompts, and upscale locks before video.', body: buildStoryboardStillsPlanArtifact( input.projectSlug, input.savedAt, input.decision, sceneCandidates, existingStoryboard, ), }, { name: 'scene-selection.json', purpose: 'Mutable scene candidate selection ledger derived from locked storyboard stills.', body: buildSceneSelectionArtifact(input.decision, sceneCandidates), }, { name: 'gobananas-character-brief.json', purpose: 'Reusable character-iteration brief for Go Bananas character generation.', body: buildGoBananasCharacterBriefArtifact(input.projectSlug, input.savedAt, input.decision), }, { name: 'post-plan.json', purpose: 'Post-production timing, voiceover, retiming, and final assembly plan.', body: buildPostPlanArtifact(input.projectSlug, input.savedAt, input.decision, publishReady), }, { name: 'review-report.json', purpose: 'Compact final human review verdict and handoff evidence from the review UI.', body: reviewReport, }, ]; const written: ReviewDecisionSaveResult['derivedArtifacts'] = []; for (const artifact of artifacts) { const path = join(input.artifactsDir, artifact.name); await writeTextFileAtomic(path, `${JSON.stringify(artifact.body, null, 2)}\n`); written.push({ name: artifact.name, path, purpose: artifact.purpose, }); } return written; } function buildReferenceBoardArtifact( projectSlug: string, generatedAt: string, decision: ReviewDecisionRecord, ): Record { const selections = decision.selections; const notes = decision.notes; const references = [ selectedReference('workflow-guide', selections.reference, { role: 'workflow-guide', allowedUse: 'Prompting and quality guidance only.', notes: stringValue(notes.reference), }), selectedReference('character', selections.character ?? selections.characterSource ?? selections.characterPlan, { role: 'identity', allowedUse: 'Character identity, proportions, texture, and repeatability.', }), selectedReference('operator-asset', selections.asset, { role: 'operator-selected-reference', allowedUse: 'Use only after assigning a precise role before generation.', }), selectedReference('storyboard-template', selections.template, { role: 'story-structure', allowedUse: 'Scene count and beat structure.', }), ].filter((entry): entry is Record => entry !== null); return { schemaVersion: 1, projectSlug, generatedAt, source: 'review-ui', workflowSource: workflowSource(decision), references, roleAssignments: referenceRoleAssignments(selections), requiredReferenceRoles: [ 'identity', 'pose', 'lookdev', 'background', 'ui-structure', 'prop', 'start-frame', 'end-frame', 'texture', ], rule: 'Every image reference must have exactly one job before it influences a prompt.', }; } function selectedReference( id: string, value: unknown, metadata: Record, ): Record | null { const selected = stringValue(value); if (!selected) return null; return { id, selected, ...metadata, }; } function buildDirectorSeedancePlanArtifact( projectSlug: string, generatedAt: string, decision: ReviewDecisionRecord, nextActionOverride?: string, ): Record { const selections = decision.selections; const workflow = isRecord(decision.seedanceWorkflow) ? decision.seedanceWorkflow : {}; const promptPatterns = isRecord(workflow.promptPatterns) ? workflow.promptPatterns : {}; return { schemaVersion: 1, projectSlug, generatedAt, source: 'review-ui', workflowSource: workflowSource(decision), qualityBar: stringValue(workflow.qualityBar) || 'award-winning director cinematic video with minimal operator work', qualityScore: decision.qualityScore ?? null, qualityChecks: decision.qualityChecks ?? [], stillStoryboard: { template: stringValue(selections.template) || null, lockedStills: Object.entries(selections) .filter(([key]) => key.startsWith('lockedStill-')) .filter(([, value]) => { const candidateId = stringValue(value); return Boolean(candidateId && !rejectedCandidateIds(selections).includes(candidateId)); }) .map(([key, value]) => ({ key, value })), editInstruction: stringValue(decision.notes.storyboard) || null, mustLockBeforeMotion: true, upscaleBeforeSeedance: true, }, motion: { candidateStrategy: stringValue(selections.motionCandidate) || 'control-pass', variantStrategy: stringValue(selections.variantStrategy) || 'control-plus-short-variant', continuityPlan: stringValue(selections.continuityPlan) || 'start-end-frame-chain', continuityFrames: continuityFramePlan(selections), bridgePosePlan: stringValue(selections.bridgePosePlan), bridgePoseRequired: Boolean(stringValue(selections.bridgePosePlan)), cameraRule: 'Static camera unless a deliberate cinematic move is selected.', promptPatterns, bridgeTriggers: Array.isArray(workflow.bridgeTriggers) ? workflow.bridgeTriggers : [], negativeGuidance: Array.isArray(workflow.negativeGuidance) ? workflow.negativeGuidance : [], }, nextAction: nextActionOverride ?? decision.recommendedNextAction ?? null, }; } function buildGoBananasCharacterBriefArtifact( projectSlug: string, generatedAt: string, decision: ReviewDecisionRecord, ): Record { const workflow = isRecord(decision.seedanceWorkflow) ? decision.seedanceWorkflow : {}; const negativeGuidance = Array.isArray(workflow.negativeGuidance) ? workflow.negativeGuidance : []; return { schemaVersion: 1, projectSlug, generatedAt, source: 'review-ui', workflowSource: workflowSource(decision), status: stringValue(decision.selections.characterPlan) === 'generate-gobananas-iterations' ? 'ready-for-character-iterations' : 'operator-selected-character-source', selectedCharacter: stringValue(decision.selections.character) || null, selectedCharacterSource: stringValue(decision.selections.characterSource) || null, characterPlan: stringValue(decision.selections.characterPlan) || null, iterationCount: 4, preferredAspectRatio: 'portrait', basePromptFormula: [ 'Create a reusable cinematic character design suitable for multi-scene Seedance storyboard-to-motion production.', 'Prioritize a clear silhouette, expressive face, stable wardrobe, readable materials, and repeatable proportions.', 'Keep the design premium, director-grade, and compatible with role-tagged references.', 'Provide full-body, close-up texture, expression, and action-bridge variants.', ], negativePrompt: [ ...negativeGuidance, 'inconsistent face', 'wardrobe drift', 'extra fingers', 'unreadable silhouette', 'overly busy accessories', ].join(', '), requiredOutputs: [ 'full-body canonical identity reference', 'close-up face and material texture reference', 'expression variants', 'action bridge pose reference', ], saveBackToVideoClaw: { command: `vclaw video character-add --project ${projectSlug} --name --gb-id --ref gobananas://character/`, artifact: `projects/${projectSlug}/characters/characters.json`, }, }; } function buildStoryboardStillsPlanArtifact( projectSlug: string, generatedAt: string, decision: ReviewDecisionRecord, sceneCandidates: SceneCandidatesArtifact, existingStoryboard: Record | null = null, ): Record { const selections = decision.selections; const notes = decision.notes; const templateId = stringValue(selections.template) || 'product-commercial-4'; const template = readStoryboardTemplate(templateId); const characterName = stringValue(selections.character) || stringValue(selections.characterSource) || 'the selected hero character'; const scenes = storyboardScenesForReview({ existingStoryboard, templateId: template?.id ?? 'product-commercial-4', characterName, selectedCharacterName: characterName, }); const environment = 'a premium cinematic product world with clean composition'; const workflow = isRecord(decision.seedanceWorkflow) ? decision.seedanceWorkflow : {}; const promptPatterns = isRecord(workflow.promptPatterns) ? workflow.promptPatterns : {}; const rejected = handoffRejectedCandidateIds(selections, sceneCandidates); const stillCreatePattern = stringValue(promptPatterns.stillCreate) || 'Use reference roles as source truth. Create one visual beat. Preserve identity, materials, colors, lighting, and camera.'; const stillEditPattern = stringValue(promptPatterns.stillEdit) || 'Edit only the named defect or layout issue. Keep identity, pose, camera, lighting, composition, and background unchanged.'; const negativeGuidance = Array.isArray(workflow.negativeGuidance) ? workflow.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', ]; return { schemaVersion: 1, projectSlug, generatedAt, source: 'review-ui', workflowSource: workflowSource(decision), template: template ? { id: template.id, name: template.name, emotionalArc: template.emotionalArc, bestFor: template.bestFor, } : { id: templateId }, qualityBar: 'Storyboard stills must be strong enough to animate before any video generation starts.', globalRules: [ 'Generate still frames before motion.', 'Iterate with narrow edit prompts until the still is clean.', 'Lock and upscale each approved still before Seedance.', 'Use each locked still as start/end frame material for later video continuity.', 'Keep one readable story beat per frame.', ], promptPatterns: { stillCreate: stillCreatePattern, stillEdit: stillEditPattern, }, negativeGuidance, referenceRolesRequired: [ 'identity', 'pose', 'lookdev', 'background', 'prop', 'start-frame', 'end-frame', ], scenes: scenes.map((scene) => { const sceneIndex = scene.sceneIndex; const lockedKey = `lockedStill-${sceneIndex}`; const selectedLockedStill = stringValue(selections[lockedKey]); const lockedStill = selectedLockedStill && !rejected.includes(selectedLockedStill) ? selectedLockedStill : null; const draftStill = selections[`draftStill-${sceneIndex}`]; const editStill = selections[`editStill-${sceneIndex}`]; const upscaledStill = stringValue(selections[`upscaledStill-${sceneIndex}`]); const upscaledStillMatchesLock = Boolean( lockedStill && upscaledStill && upscaledStill.startsWith(`${lockedStill}-`), ); const upscaledStillHasAsset = Boolean( upscaledStillMatchesLock && upscaledStill && sceneHasCandidate(sceneCandidates, sceneIndex, upscaledStill), ); const panel = template?.panels.find((candidate) => candidate.sceneIndex === scene.sceneIndex); const createPrompt = [ stillCreatePattern, `Scene ${scene.sceneIndex}: ${scene.scenePrompt?.imagePrompt ?? scene.description}`, `Character source: ${characterName}.`, `Environment: ${environment}.`, 'Composition: cinematic, premium, clear focal hierarchy, no clutter.', `Negative guidance: ${negativeGuidance.join(', ')}.`, ].join('\n'); return { sceneIndex: scene.sceneIndex, shotType: panel?.shotType ?? 'cinematic still', purpose: panel?.purpose ?? 'story beat', beat: scene.description, createPrompt, editPrompt: [ stillEditPattern, 'Only fix defects that reduce clarity, continuity, or cinematic quality.', stringValue(notes.storyboard) || 'Preserve identity, camera, lighting, composition, and background during edits.', ].join('\n'), goBananas: { tool: 'generate_with_character', characterName, aspectRatio: '16:9', scenePrompt: createPrompt, additionalDetails: 'Generate storyboard still only. Do not animate. Keep the image clean enough to upscale and use as a Seedance start/end frame.', }, lockState: lockedStill ? 'operator-marked-locked' : 'needs-generated-still', draftStillReference: stringValue(draftStill), editStillReference: stringValue(editStill), lockedStillReference: lockedStill, upscaledStillReference: upscaledStillMatchesLock ? upscaledStill : null, submittedUpscaledStillReference: upscaledStill, upscaleState: upscaledStillMatchesLock ? 'operator-marked-upscaled' : 'needs-upscale-confirmation', upscaleEvidenceState: upscaledStillHasAsset ? 'artifact-backed-upscale' : upscaledStillMatchesLock ? 'operator-marker-only' : 'missing-upscale', upscaleTarget: '4k-before-seedance', continuityRole: sceneIndex === 0 ? 'opening-start-frame' : 'target-end-frame-for-previous-scene', rejectedStillReferences: rejected.filter((candidateId) => candidateId.startsWith(`scene-${sceneIndex}-take-`)), nextAction: lockedStill ? 'Generate or verify upscaled still asset.' : 'Generate first still candidate, review, edit, then lock.', }; }), }; } function referenceRoleAssignments(selections: Record): Array<{ role: string; selected: string; }> { return Object.entries(selections) .filter(([key, value]) => key.startsWith('referenceRole-') && stringValue(value)) .map(([key, value]) => ({ role: key.replace(/^referenceRole-/, ''), selected: stringValue(value) as string, })) .sort((left, right) => left.role.localeCompare(right.role)); } function continuityFramePlan(selections: Record): Array<{ sceneIndex: number; startFrame: string | null; endFrame: string | null; continuityDecision: string | null; }> { const sceneIndexes = new Set(); for (const key of Object.keys(selections)) { const match = /^(?:lockedStill|continuity)-(\d+)$/.exec(key); if (match) sceneIndexes.add(Number(match[1])); } return [...sceneIndexes] .sort((left, right) => left - right) .map((sceneIndex) => ({ sceneIndex, startFrame: sceneIndex === 0 ? stringValue(selections[`lockedStill-${sceneIndex}`]) : stringValue(selections[`lockedStill-${sceneIndex - 1}`]), endFrame: stringValue(selections[`lockedStill-${sceneIndex}`]), continuityDecision: stringValue(selections[`continuity-${sceneIndex}`]), })); } export function buildSceneSelectionArtifact( decision: ReviewDecisionRecord, sceneCandidates: SceneCandidatesArtifact, ): SceneSelectionArtifact { const selectionEntries: SceneSelectionArtifact['scenes'] = []; const rejected = handoffRejectedCandidateIds(decision.selections, sceneCandidates); const expectedCharacter = expectedStoryboardCharacter(decision.selections); const sceneIndexes = new Set(); for (const [key, value] of Object.entries(decision.selections)) { const match = /^lockedStill-(\d+)$/.exec(key); const selectedCandidateId = stringValue(value); if (match && selectedCandidateId && isSceneCandidateId(selectedCandidateId)) { sceneIndexes.add(Number(match[1])); } } for (const scene of sceneCandidates.scenes) { if (scene.candidates.some((candidate) => rejected.includes(candidate.id))) { sceneIndexes.add(scene.sceneIndex); } } for (const sceneIndex of [...sceneIndexes].sort((left, right) => left - right)) { const selectedCandidateId = stringValue(decision.selections[`lockedStill-${sceneIndex}`]); const previousSelectedCandidateId = sceneIndex > 0 ? stringValue(decision.selections[`lockedStill-${sceneIndex - 1}`]) : null; const previousSelectedCandidate = previousSelectedCandidateId ? findSceneCandidate(sceneCandidates, sceneIndex - 1, previousSelectedCandidateId) : null; const canChainFromPrev = Boolean( sceneIndex > 0 && previousSelectedCandidate?.outputs.some((output) => output.kind === 'video'), ); const selectedExists = Boolean( selectedCandidateId && isSceneCandidateId(selectedCandidateId) && !rejected.includes(selectedCandidateId) && sceneHasCandidate(sceneCandidates, sceneIndex, selectedCandidateId) && sceneCandidateMatchesExpectedCharacter(sceneCandidates, sceneIndex, selectedCandidateId, expectedCharacter), ); const rejectedForScene = rejected.filter((candidateId) => sceneHasCandidate(sceneCandidates, sceneIndex, candidateId)); const pendingForScene = sceneCandidates.scenes .find((scene) => scene.sceneIndex === sceneIndex) ?.candidates .map((candidate) => candidate.id) .filter((candidateId) => ( candidateId !== selectedCandidateId && !isUpscaledStillCandidateId(candidateId) && !rejectedForScene.includes(candidateId) && sceneCandidateMatchesExpectedCharacter(sceneCandidates, sceneIndex, candidateId, expectedCharacter) )) ?? []; if (!selectedExists && rejectedForScene.length === 0 && pendingForScene.length === 0) continue; selectionEntries.push({ sceneIndex, selectedCandidateId: selectedExists ? selectedCandidateId : null, rejectedCandidateIds: rejectedForScene, pendingCandidateIds: pendingForScene, rerollRequested: false, chainFromPrev: canChainFromPrev, notes: [ selectedExists ? 'Selected from review-ui locked storyboard still.' : 'No locked storyboard still selected yet.', expectedCharacter && selectedCandidateId && !selectedExists ? `Selected still must match character "${expectedCharacter}".` : null, rejectedForScene.length ? `Rejected in review UI: ${rejectedForScene.join(', ')}.` : null, stringValue(decision.notes.storyboard), ].filter(Boolean).join(' '), }); } selectionEntries.sort((left, right) => left.sceneIndex - right.sceneIndex); return { schemaVersion: 1, scenes: selectionEntries, }; } export function isSceneCandidateId(value: string): boolean { return /^scene-\d+-take-\d+$/.test(value); } function isUpscaledStillCandidateId(value: string): boolean { return /^scene-\d+-take-\d+-4k$/.test(value); } function expectedStoryboardCharacter(selections: Record): string | null { const selected = stringValue(selections.character); if (!selected) return null; const normalized = selected.toLowerCase(); if (normalized.startsWith('gobananas://character/')) return null; if (normalized === 'generate-gobananas-iterations') return null; if (normalized === 'selected character') return null; return normalized; } function sceneCandidateMatchesExpectedCharacter( artifact: SceneCandidatesArtifact, sceneIndex: number, candidateId: string, expectedCharacter: string | null, ): boolean { if (!expectedCharacter) return true; const candidate = artifact.scenes .find((scene) => scene.sceneIndex === sceneIndex) ?.candidates .find((entry) => entry.id === candidateId); if (!candidate) return false; const haystack = [ candidate.prompt, candidate.route, candidate.source.externalJobId, ...candidate.outputs.map((output) => output.path), ].join('\n').toLowerCase(); return haystack.includes(expectedCharacter); } export function sceneHasCandidate( artifact: SceneCandidatesArtifact, sceneIndex: number, candidateId: string, ): boolean { return artifact.scenes.some((scene) => ( scene.sceneIndex === sceneIndex && scene.candidates.some((candidate) => candidate.id === candidateId) )); } function rejectedCandidateIds(selections: Record): string[] { const value = selections.rejectedStillCandidates; return Array.isArray(value) ? value.map(stringValue).filter((candidateId): candidateId is string => Boolean(candidateId)) : []; } function handoffRejectedCandidateIds( selections: Record, sceneCandidates: SceneCandidatesArtifact, ): string[] { const rejected = rejectedCandidateIds(selections); const expectedCharacter = expectedStoryboardCharacter(selections); if (!expectedCharacter) return rejected; return rejected.filter((candidateId) => { const scene = sceneCandidates.scenes.find((entry) => ( entry.candidates.some((candidate) => candidate.id === candidateId) )); return Boolean( scene && sceneCandidateMatchesExpectedCharacter(sceneCandidates, scene.sceneIndex, candidateId, expectedCharacter), ); }); } function buildPostPlanArtifact( projectSlug: string, generatedAt: string, decision: ReviewDecisionRecord, publishReady: boolean, ): Record { const assemblyApprovals = assemblyApprovalEntries(decision.selections); return { schemaVersion: 1, projectSlug, generatedAt, source: 'review-ui', workflowSource: workflowSource(decision), assemblyPlan: stringValue(decision.selections.assemblyPlan) || 'balanced', voiceoverTiming: stringValue(decision.notes.voiceover) || 'Map every scene to voiceover timing before final assembly.', retiming: 'Use simple time remapping to tighten slow generated clips while preserving readable motion beats.', assemblyApprovals, publishReady, polishChecklist: [ 'Scene pacing matches voiceover.', 'Continuity frames cut cleanly between shots.', 'Opacity and transitions are intentional.', 'Logo reveal is readable and timed as the final payoff.', 'Final render is reviewed before publish.', ], }; } function buildReviewReportArtifact( projectSlug: string, generatedAt: string, decision: ReviewDecisionRecord, sceneCandidates: SceneCandidatesArtifact, existingStoryboard: Record | null = null, ): Record { const selections = decision.selections; const qualityChecks = decision.qualityChecks ?? []; const failedChecks = qualityChecks .filter((check) => check.done === false) .map((check) => stringValue(check.label)) .filter((label): label is string => Boolean(label)); const lockedSceneSelections = buildSceneSelectionArtifact(decision, sceneCandidates).scenes; const publishApprovals = assemblyApprovalEntries(selections); const reviewCompleteAt = stringValue(selections.reviewCompleteAt); const templateId = stringValue(selections.template) || 'product-commercial-4'; const customSceneCount = existingStoryboardScenes(existingStoryboard).length; const expectedSceneCount = customSceneCount || buildStoryboardScenesFromTemplate({ templateId, environment: 'a premium cinematic product world with clean composition', characterA: stringValue(selections.character) || stringValue(selections.characterSource) || 'the selected hero character', }).length; const mismatchedLocks = lockedStoryboardMismatchIds(decision, sceneCandidates); const lockedSceneCount = lockedSceneSelections.filter((scene) => scene.selectedCandidateId).length; const upscaleEvidence = storyboardUpscaleEvidence(decision, sceneCandidates); const missingLocks = Math.max(0, expectedSceneCount - lockedSceneCount); const missingApprovals = Math.max(0, 5 - publishApprovals.length); const missingUpscaleAssets = Math.max(0, expectedSceneCount - upscaleEvidence.artifactBackedCount); const publishReady = Boolean( publishApprovals.length >= 5 && !missingLocks && !missingApprovals && !mismatchedLocks.length && !missingUpscaleAssets, ); const verdict = failedChecks.length || !reviewCompleteAt || missingLocks || missingApprovals || mismatchedLocks.length || missingUpscaleAssets ? 'retry' : 'pass'; const nextAction = buildReviewNextAction({ verdict, failedChecks, reviewCompleteAt, missingLocks, missingUpscaleAssets, missingApprovals, mismatchedLocks, }); return { projectSlug, verdict, generatedAt, findings: failedChecks.length ? failedChecks.map((label) => `Incomplete quality check: ${label}`) : [ ...(!reviewCompleteAt ? ['Review UI has not been finished by the operator.'] : []), ...(mismatchedLocks.length ? [`Storyboard locks do not match selected character: ${mismatchedLocks.join(', ')}.`] : []), ...(missingLocks ? [`Missing locked storyboard stills: ${missingLocks}.`] : []), ...(missingUpscaleAssets ? [`Missing artifact-backed 4k/upscaled still assets: ${missingUpscaleAssets}.`] : []), ...(missingApprovals ? [`Missing publish approvals: ${missingApprovals}.`] : []), ...(reviewCompleteAt && !mismatchedLocks.length && !missingLocks && !missingUpscaleAssets && !missingApprovals ? ['Human review UI gates completed and saved.'] : []), ], metrics: { qualityScore: decision.qualityScore ?? null, activeGate: decision.activeGate ?? null, reviewCompleteAt, expectedSceneCount, lockedSceneCount, characterMismatchCount: mismatchedLocks.length, upscaleMarkerCount: upscaleEvidence.markerCount, artifactBackedUpscaleCount: upscaleEvidence.artifactBackedCount, operatorOnlyUpscaleCount: upscaleEvidence.operatorOnlyCount, missingUpscaleAssetCount: missingUpscaleAssets, rejectedCandidateCount: lockedSceneSelections.reduce((count, scene) => count + scene.rejectedCandidateIds.length, 0), publishApprovalCount: publishApprovals.length, publishReady, motionCandidate: stringValue(selections.motionCandidate), bridgePosePlan: stringValue(selections.bridgePosePlan), nextAction, }, }; } function buildReviewNextAction(input: { verdict: 'pass' | 'retry' | 'fail'; failedChecks: string[]; reviewCompleteAt: string | null; missingLocks: number; missingUpscaleAssets: number; missingApprovals: number; mismatchedLocks: string[]; }): string { if (input.verdict === 'pass') return 'Ready for publish handoff.'; if (input.failedChecks.length) { return `Resolve review quality checks: ${input.failedChecks.join(', ')}.`; } if (!input.reviewCompleteAt) return 'Finish the review gate and save the review artifacts.'; if (input.mismatchedLocks.length) { return `Replace mismatched storyboard locks before publishing: ${input.mismatchedLocks.join(', ')}.`; } if (input.missingLocks) return `Lock generated storyboard stills for ${input.missingLocks} scene(s).`; if (input.missingUpscaleAssets) return `Attach artifact-backed 4k/upscaled stills for ${input.missingUpscaleAssets} scene(s).`; if (input.missingApprovals) return `Approve ${input.missingApprovals} remaining final assembly check(s).`; return 'Resolve review findings before publishing.'; } export function isReviewReportPublishReady(reviewReport: Record | undefined | null): boolean { if (!reviewReport) return false; const metrics = isRecord(reviewReport.metrics) ? reviewReport.metrics : {}; return reviewVerdict(reviewReport.verdict) === 'pass' && metrics.publishReady === true; } export function nextActionFromReviewReport(reviewReport: Record | undefined | null, fallback?: string): string | undefined { if (!reviewReport) return fallback; const metrics = isRecord(reviewReport.metrics) ? reviewReport.metrics : {}; const nextAction = stringValue(metrics.nextAction); const verdict = reviewVerdict(reviewReport.verdict); if (isReviewReportPublishReady(reviewReport)) return 'Ready for publish handoff.'; if (nextAction && nextAction !== 'Ready for publish handoff.') return nextAction; const findings = Array.isArray(reviewReport.findings) ? reviewReport.findings.map(stringValue).filter((finding): finding is string => Boolean(finding)) : []; if (findings.length) return `Resolve review findings: ${findings.join('; ')}.`; if (verdict === 'pass') return 'Complete publish readiness evidence before publishing.'; return fallback; } function storyboardUpscaleEvidence( decision: ReviewDecisionRecord, sceneCandidates: SceneCandidatesArtifact, ): { markerCount: number; artifactBackedCount: number; operatorOnlyCount: number } { let markerCount = 0; let artifactBackedCount = 0; for (const [key, value] of Object.entries(decision.selections)) { const match = /^upscaledStill-(\d+)$/.exec(key); const upscaledStill = stringValue(value); if (!match || !upscaledStill) continue; markerCount += 1; if (sceneHasCandidate(sceneCandidates, Number(match[1]), upscaledStill)) { artifactBackedCount += 1; } } return { markerCount, artifactBackedCount, operatorOnlyCount: Math.max(0, markerCount - artifactBackedCount), }; } function lockedStoryboardMismatchIds( decision: ReviewDecisionRecord, sceneCandidates: SceneCandidatesArtifact, ): string[] { const expectedCharacter = expectedStoryboardCharacter(decision.selections); if (!expectedCharacter) return []; return Object.entries(decision.selections) .map(([key, value]) => { const match = /^lockedStill-(\d+)$/.exec(key); const candidateId = stringValue(value); if (!match || !candidateId || !isSceneCandidateId(candidateId)) return null; const sceneIndex = Number(match[1]); return sceneCandidateMatchesExpectedCharacter(sceneCandidates, sceneIndex, candidateId, expectedCharacter) ? null : candidateId; }) .filter((candidateId): candidateId is string => Boolean(candidateId)); } function assemblyApprovalEntries(selections: Record): Array<{ id: string; approved: boolean; value: string; }> { return Object.entries(selections) .filter(([key, value]) => key.startsWith('assemblyCheck-') && stringValue(value)) .map(([key, value]) => ({ id: key.replace(/^assemblyCheck-/, ''), approved: true, value: stringValue(value) as string, })) .sort((left, right) => left.id.localeCompare(right.id)); } export function workflowSource(decision: ReviewDecisionRecord): string { const workflow = isRecord(decision.seedanceWorkflow) ? decision.seedanceWorkflow : {}; return stringValue(workflow.source) || 'docs/REFERENCE_VIDEO_SEEDANCE_MOTION_DESIGN_WORKFLOW.md'; }