| 1 | import type {Artifact, JsonValue} from './types'; |
| 2 | |
| 3 | export type StoryboardPreview = { |
| 4 | id: string; |
| 5 | description: string; |
| 6 | }; |
| 7 | |
| 8 | export type ReadinessStatus = 'ready' | 'partial' | 'missing' | 'inactive'; |
| 9 | |
| 10 | export type StoryboardReadiness = { |
| 11 | overall: ReadinessStatus; |
| 12 | readyToRender: boolean; |
| 13 | storyboards: {status: ReadinessStatus; count: number}; |
| 14 | shotDescriptions: {status: ReadinessStatus; count: number; expected: number}; |
| 15 | cameraPlans: {status: ReadinessStatus; count: number; expected: number}; |
| 16 | render: { |
| 17 | started: boolean; |
| 18 | frames: {status: ReadinessStatus; count: number; expected: number}; |
| 19 | clips: {status: ReadinessStatus; count: number; expected: number}; |
| 20 | finalVideo: {status: ReadinessStatus; count: number; expected: number}; |
| 21 | }; |
| 22 | }; |
| 23 | |
| 24 | export type RenderCheckpoint = 'frames' | 'clips' | 'finalVideo'; |
| 25 | |
| 26 | const FIELD_LABELS: Record<string, string> = { |
| 27 | idx: 'Number', |
| 28 | is_last: 'Final shot', |
| 29 | cam_idx: 'Camera', |
| 30 | visual_desc: 'Visual description', |
| 31 | visual_description: 'Visual description', |
| 32 | audio_desc: 'Audio', |
| 33 | description: 'Description', |
| 34 | ff_desc: 'First frame', |
| 35 | lf_desc: 'Last frame', |
| 36 | motion_desc: 'Motion', |
| 37 | variation_type: 'Transition type', |
| 38 | variation_reason: 'Transition notes', |
| 39 | ff_vis_char_idxs: 'Characters in first frame', |
| 40 | lf_vis_char_idxs: 'Characters in last frame', |
| 41 | character_idx: 'Character', |
| 42 | character_id: 'Character', |
| 43 | shot_idx: 'Shot', |
| 44 | scene_idx: 'Scene', |
| 45 | camera_idx: 'Camera', |
| 46 | }; |
| 47 | |
| 48 | const FILE_TITLES: Record<string, string> = { |
| 49 | 'camera_tree.json': 'Camera plan', |
| 50 | 'characters.json': 'Characters', |
| 51 | 'script.json': 'Script', |
| 52 | 'shot_description.json': 'Shot description', |
| 53 | 'storyboard.json': 'Storyboard', |
| 54 | }; |
| 55 | |
| 56 | export function isJsonArtifact(artifact: Artifact): boolean { |
| 57 | const name = artifact.name.toLowerCase(); |
| 58 | return name.endsWith('.json') && name !== 'render_status.json'; |
| 59 | } |
| 60 | |
| 61 | export function isStoryboardArtifact(artifact: Artifact): boolean { |
| 62 | return artifact.name.toLowerCase() === 'storyboard.json'; |
| 63 | } |
| 64 | |
| 65 | export function relatedVisualArtifacts(documentArtifact: Artifact, artifacts: Artifact[]): Artifact[] { |
| 66 | const media = artifacts.filter((artifact) => artifact.kind === 'image' || artifact.kind === 'video'); |
| 67 | const documentPath = normalizeArtifactPath(documentArtifact.path); |
| 68 | const documentDirectory = parentArtifactPath(documentPath); |
| 69 | const shotDirectory = documentPath.match(/^(.*\/shots\/\d+)(?:\/|$)/i)?.[1]; |
| 70 | const sceneDirectory = documentPath.match(/^(.*\/scene_\d+)(?:\/|$)/i)?.[1]; |
| 71 | const workflowRoot = documentPath.split('/')[0] || ''; |
| 72 | const name = documentArtifact.name.toLowerCase(); |
| 73 | |
| 74 | const related = media.filter((artifact) => { |
| 75 | const mediaPath = normalizeArtifactPath(artifact.path); |
| 76 | if (shotDirectory) return parentArtifactPath(mediaPath) === shotDirectory; |
| 77 | if (sceneDirectory) return mediaPath.startsWith(`${sceneDirectory}/`); |
| 78 | if (name === 'characters.json' || name === 'character_portraits_registry.json') { |
| 79 | return mediaPath.startsWith(`${workflowRoot}/character_portraits/`); |
| 80 | } |
| 81 | if (name === 'script.json') { |
| 82 | return mediaPath.startsWith(`${workflowRoot}/scene_`) || mediaPath === `${workflowRoot}/final_video.mp4`; |
| 83 | } |
| 84 | return parentArtifactPath(mediaPath) === documentDirectory; |
| 85 | }); |
| 86 | |
| 87 | return related.sort((left, right) => left.path.localeCompare(right.path, undefined, {numeric: true})); |
| 88 | } |
| 89 | |
| 90 | export function friendlyFieldLabel(key: string): string { |
| 91 | if (FIELD_LABELS[key]) return FIELD_LABELS[key]; |
| 92 | return key |
| 93 | .replace(/_/g, ' ') |
| 94 | .replace(/\b\w/g, (character) => character.toUpperCase()); |
| 95 | } |
| 96 | |
| 97 | export function isArtifactPathField(key: string): boolean { |
| 98 | const normalized = key.trim().toLowerCase(); |
| 99 | return normalized.split('_').some((part) => part === 'path' || part === 'dir' || part === 'directory'); |
| 100 | } |
| 101 | |
| 102 | export function friendlyArtifactTitle(artifact: Artifact): string { |
| 103 | const baseTitle = FILE_TITLES[artifact.name.toLowerCase()] |
| 104 | || artifact.name.replace(/\.json$/i, '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase()); |
| 105 | const sceneMatch = artifact.path.match(/(?:^|\/)scene_(\d+)(?:\/|$)/i); |
| 106 | const shotMatch = artifact.path.match(/(?:^|\/)shots\/(\d+)(?:\/|$)/i); |
| 107 | const context = []; |
| 108 | if (sceneMatch) context.push(`Scene ${Number(sceneMatch[1]) + 1}`); |
| 109 | if (shotMatch) context.push(`Shot ${Number(shotMatch[1]) + 1}`); |
| 110 | return context.length ? `${context.join(' · ')} · ${baseTitle}` : baseTitle; |
| 111 | } |
| 112 | |
| 113 | export function structuredRecordTitle(value: JsonValue, index: number, artifact: Artifact): string { |
| 114 | if (isJsonObject(value)) { |
| 115 | for (const key of ['name', 'title', 'character_name', 'scene_title']) { |
| 116 | const candidate = value[key]; |
| 117 | if (typeof candidate === 'string' && candidate.trim()) return candidate.trim(); |
| 118 | } |
| 119 | const explicitIndex = value.idx; |
| 120 | if (typeof explicitIndex === 'number') return `${recordNoun(artifact)} ${explicitIndex + 1}`; |
| 121 | } |
| 122 | return `${recordNoun(artifact)} ${index + 1}`; |
| 123 | } |
| 124 | |
| 125 | export function formatStructuredValue(value: JsonValue, key = ''): string { |
| 126 | if (value === null) return 'Not specified'; |
| 127 | if (typeof value === 'boolean') return value ? 'Yes' : 'No'; |
| 128 | if (typeof value === 'number') return isIndexKey(key) ? String(value + 1) : String(value); |
| 129 | if (typeof value === 'string') return value.trim() || 'Not specified'; |
| 130 | if (Array.isArray(value) && value.every(isJsonPrimitive)) { |
| 131 | if (value.length === 0) return 'None'; |
| 132 | return value.map((item) => typeof item === 'number' && isIndexListKey(key) ? item + 1 : formatStructuredValue(item)).join(', '); |
| 133 | } |
| 134 | return ''; |
| 135 | } |
| 136 | |
| 137 | export function extractStoryboardPreviews(document: JsonValue, sourcePath: string): StoryboardPreview[] { |
| 138 | const previews: StoryboardPreview[] = []; |
| 139 | |
| 140 | function visit(value: JsonValue) { |
| 141 | if (Array.isArray(value)) { |
| 142 | value.forEach(visit); |
| 143 | return; |
| 144 | } |
| 145 | if (!isJsonObject(value)) return; |
| 146 | const description = ['visual_desc', 'visual_description', 'description'] |
| 147 | .map((key) => value[key]) |
| 148 | .find((candidate) => typeof candidate === 'string' && candidate.trim()); |
| 149 | if (typeof description === 'string') { |
| 150 | previews.push({id: `${sourcePath}:${previews.length}`, description: description.trim()}); |
| 151 | return; |
| 152 | } |
| 153 | for (const key of ['storyboards', 'storyboard', 'shots', 'scenes', 'items']) { |
| 154 | const nested = value[key]; |
| 155 | if (nested !== undefined) visit(nested); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | visit(document); |
| 160 | return previews; |
| 161 | } |
| 162 | |
| 163 | export function deriveStoryboardReadiness(artifacts: Artifact[], storyboardCount: number): StoryboardReadiness { |
| 164 | const storyboardFiles = artifacts.filter(isStoryboardArtifact).length; |
| 165 | const shotDescriptions = artifacts.filter((artifact) => artifact.name.toLowerCase() === 'shot_description.json').length; |
| 166 | const cameraPlans = artifacts.filter((artifact) => artifact.name.toLowerCase() === 'camera_tree.json').length; |
| 167 | const frames = artifacts.filter((artifact) => artifact.name.toLowerCase() === 'first_frame.png').length; |
| 168 | const clips = artifacts.filter((artifact) => artifact.name.toLowerCase() === 'video.mp4').length; |
| 169 | const finalVideos = artifacts.filter((artifact) => artifact.name.toLowerCase() === 'final_video.mp4').length; |
| 170 | const storyboards = checkpointStatus(storyboardCount, Math.max(storyboardFiles, 1)); |
| 171 | const shots = checkpointStatus(shotDescriptions, storyboardCount); |
| 172 | const cameras = checkpointStatus(cameraPlans, storyboardFiles); |
| 173 | const readyToRender = storyboardCount > 0 |
| 174 | && storyboards === 'ready' |
| 175 | && shots === 'ready' |
| 176 | && cameras === 'ready'; |
| 177 | const hasPlanningOutput = storyboardCount > 0 || shotDescriptions > 0 || cameraPlans > 0; |
| 178 | |
| 179 | return { |
| 180 | overall: readyToRender ? 'ready' : hasPlanningOutput ? 'partial' : 'missing', |
| 181 | readyToRender, |
| 182 | storyboards: {status: storyboards, count: storyboardCount}, |
| 183 | shotDescriptions: {status: shots, count: shotDescriptions, expected: storyboardCount}, |
| 184 | cameraPlans: {status: cameras, count: cameraPlans, expected: storyboardFiles}, |
| 185 | render: { |
| 186 | started: frames > 0 || clips > 0 || finalVideos > 0, |
| 187 | frames: {status: renderCheckpointStatus(frames, storyboardCount), count: frames, expected: storyboardCount}, |
| 188 | clips: {status: renderCheckpointStatus(clips, storyboardCount), count: clips, expected: storyboardCount}, |
| 189 | finalVideo: {status: renderCheckpointStatus(finalVideos, 1), count: finalVideos, expected: 1}, |
| 190 | }, |
| 191 | }; |
| 192 | } |
| 193 | |
| 194 | export function activeRenderCheckpoint(stage: string): RenderCheckpoint { |
| 195 | const normalized = stage.toLowerCase(); |
| 196 | if (normalized.includes('video_clip')) return 'clips'; |
| 197 | if (normalized.includes('concat') || normalized.includes('final_video') || normalized === 'render_done') return 'finalVideo'; |
| 198 | return 'frames'; |
| 199 | } |
| 200 | |
| 201 | export function isJsonObject(value: JsonValue): value is {[key: string]: JsonValue} { |
| 202 | return Boolean(value) && typeof value === 'object' && !Array.isArray(value); |
| 203 | } |
| 204 | |
| 205 | export function isJsonPrimitive(value: JsonValue): value is string | number | boolean | null { |
| 206 | return value === null || ['string', 'number', 'boolean'].includes(typeof value); |
| 207 | } |
| 208 | |
| 209 | function recordNoun(artifact: Artifact): string { |
| 210 | const name = artifact.name.toLowerCase(); |
| 211 | if (name === 'storyboard.json' || name === 'shot_description.json') return 'Shot'; |
| 212 | if (name === 'characters.json') return 'Character'; |
| 213 | if (name === 'script.json') return 'Scene'; |
| 214 | return 'Item'; |
| 215 | } |
| 216 | |
| 217 | function isIndexKey(key: string): boolean { |
| 218 | return key === 'idx' || key.endsWith('_idx') || key.endsWith('_index'); |
| 219 | } |
| 220 | |
| 221 | function isIndexListKey(key: string): boolean { |
| 222 | return key.endsWith('_idxs') || key.endsWith('_indices'); |
| 223 | } |
| 224 | |
| 225 | function checkpointStatus(count: number, expected: number): ReadinessStatus { |
| 226 | if (expected <= 0 || count <= 0) return 'missing'; |
| 227 | return count >= expected ? 'ready' : 'partial'; |
| 228 | } |
| 229 | |
| 230 | function renderCheckpointStatus(count: number, expected: number): ReadinessStatus { |
| 231 | if (count <= 0) return 'inactive'; |
| 232 | return expected > 0 && count >= expected ? 'ready' : 'partial'; |
| 233 | } |
| 234 | |
| 235 | function normalizeArtifactPath(path: string): string { |
| 236 | return path.replace(/\\/g, '/').replace(/^\.\//, ''); |
| 237 | } |
| 238 | |
| 239 | function parentArtifactPath(path: string): string { |
| 240 | const separator = path.lastIndexOf('/'); |
| 241 | return separator >= 0 ? path.slice(0, separator) : ''; |
| 242 | } |
| 243 |