返回 ViMax
artifactPresentation.ts
根目录 / web / src / artifactPresentation.ts
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
243 lines TYPESCRIPT