返回 ViMax
ArtifactViews.tsx
根目录 / web / src / ArtifactViews.tsx
1 import {useEffect, useMemo, useState} from 'react';
2 import {ChevronDown, ChevronUp, FileJson, Film, Files, Image as ImageIcon, Maximize2, Video, X} from 'lucide-react';
3 import {getJsonArtifact} from './api';
4 import {
5 activeRenderCheckpoint,
6 deriveStoryboardReadiness,
7 extractStoryboardPreviews,
8 formatStructuredValue,
9 friendlyArtifactTitle,
10 friendlyFieldLabel,
11 isJsonArtifact,
12 isJsonObject,
13 isJsonPrimitive,
14 isArtifactPathField,
15 isStoryboardArtifact,
16 relatedVisualArtifacts,
17 structuredRecordTitle,
18 type ReadinessStatus,
19 type StoryboardPreview,
20 } from './artifactPresentation';
21 import type {Artifact, JsonValue, SessionSummary} from './types';
22
23 export function ArtifactsView({session, artifacts}: {session?: SessionSummary; artifacts: Artifact[]}) {
24 const jsonArtifacts = useMemo(
25 () => artifacts.filter(isJsonArtifact).sort((left, right) => left.path.localeCompare(right.path, undefined, {numeric: true})),
26 [artifacts],
27 );
28 const [selectedPath, setSelectedPath] = useState('');
29 const selected = jsonArtifacts.find((artifact) => artifact.path === selectedPath) || jsonArtifacts[0];
30 const [document, setDocument] = useState<JsonValue>();
31 const [loading, setLoading] = useState(false);
32 const [error, setError] = useState('');
33 const [view, setView] = useState<'all' | 'documents' | 'visuals'>('all');
34 const [previewArtifact, setPreviewArtifact] = useState<Artifact>();
35 const mediaArtifacts = useMemo(
36 () => artifacts
37 .filter((artifact) => artifact.kind === 'image' || artifact.kind === 'video')
38 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
39 [artifacts],
40 );
41 const relatedMedia = useMemo(
42 () => selected ? relatedVisualArtifacts(selected, mediaArtifacts) : [],
43 [selected, mediaArtifacts],
44 );
45
46 useEffect(() => {
47 setSelectedPath((current) => jsonArtifacts.some((artifact) => artifact.path === current) ? current : jsonArtifacts[0]?.path || '');
48 }, [jsonArtifacts]);
49
50 useEffect(() => {
51 let cancelled = false;
52 setDocument(undefined);
53 setError('');
54 if (!selected) return () => { cancelled = true; };
55 setLoading(true);
56 void getJsonArtifact(selected)
57 .then((payload) => !cancelled && setDocument(payload))
58 .catch((reason) => !cancelled && setError(reason instanceof Error ? reason.message : String(reason)))
59 .finally(() => !cancelled && setLoading(false));
60 return () => { cancelled = true; };
61 }, [selected?.path, selected?.updatedAt]);
62
63 return (
64 <section className="artifacts-view">
65 <header>
66 <div><span>Generated files</span><h1>{session ? projectTitle(session) : 'Artifacts'}</h1></div>
67 <span className="artifact-file-count">{jsonArtifacts.length} documents · {mediaArtifacts.length} visuals</span>
68 </header>
69 <div className="artifact-view-switcher" role="tablist" aria-label="Artifact type">
70 {(['all', 'documents', 'visuals'] as const).map((option) => (
71 <button
72 key={option}
73 role="tab"
74 aria-selected={view === option}
75 className={view === option ? 'is-selected' : ''}
76 onClick={() => setView(option)}
77 >
78 {option === 'all' ? 'All' : option === 'documents' ? 'Documents' : 'Visuals'}
79 </button>
80 ))}
81 </div>
82 {!session ? (
83 <ArtifactsEmpty title="Select a project" detail="Project files appear here after planning" />
84 ) : (
85 <>
86 {(view === 'all' || view === 'documents') && (
87 <section className="artifact-content-section">
88 {view === 'all' && <SectionHeading title="Project artifacts" detail="Documents with related visuals" />}
89 {jsonArtifacts.length === 0 ? (
90 <ArtifactsEmpty title="No structured files yet" detail="JSON artifacts appear here after planning" />
91 ) : (
92 <div className="artifact-browser">
93 <nav className="artifact-document-list" aria-label="Generated JSON files">
94 <div className="artifact-document-list-title"><span>Documents</span><span>{jsonArtifacts.length}</span></div>
95 {jsonArtifacts.map((artifact) => (
96 <button
97 key={artifact.path}
98 className={artifact.path === selected?.path ? 'is-selected' : ''}
99 onClick={() => setSelectedPath(artifact.path)}
100 >
101 <FileJson size={16} />
102 <span><strong>{friendlyArtifactTitle(artifact)}</strong><small>{formatBytes(artifact.size)}</small></span>
103 </button>
104 ))}
105 </nav>
106 <article className={`artifact-document ${view === 'all' ? 'has-related-visuals' : ''}`}>
107 {selected && (
108 <header>
109 <div><span>Structured view</span><h2>{friendlyArtifactTitle(selected)}</h2></div>
110 <small>{formatBytes(selected.size)}</small>
111 </header>
112 )}
113 <div className="artifact-document-layout">
114 <div className="artifact-structured-content">
115 {loading ? (
116 <div className="artifact-document-state">Loading document…</div>
117 ) : error ? (
118 <div className="artifact-document-state is-error">{error}</div>
119 ) : document !== undefined && selected ? (
120 <StructuredDocument value={document} artifact={selected} />
121 ) : null}
122 </div>
123 {view === 'all' && (
124 <RelatedVisuals artifacts={relatedMedia} onPreview={setPreviewArtifact} />
125 )}
126 </div>
127 </article>
128 </div>
129 )}
130 </section>
131 )}
132 {view === 'visuals' && (
133 <VisualArtifacts artifacts={mediaArtifacts} onPreview={setPreviewArtifact} />
134 )}
135 </>
136 )}
137 {previewArtifact && <MediaPreviewDialog artifact={previewArtifact} onClose={() => setPreviewArtifact(undefined)} />}
138 </section>
139 );
140 }
141
142 function VisualArtifacts({artifacts, onPreview}: {artifacts: Artifact[]; onPreview: (artifact: Artifact) => void}) {
143 return (
144 <section className="artifact-visuals">
145 {artifacts.length > 0 ? (
146 <div className="render-grid">
147 {artifacts.map((artifact) => (
148 <article key={artifact.path} className="render-item">
149 <button className="render-media" onClick={() => onPreview(artifact)} aria-label={`Preview ${artifact.name}`}>
150 {artifact.kind === 'image'
151 ? <img src={mediaUrl(artifact)} alt={artifact.name} />
152 : <video src={mediaUrl(artifact)} muted playsInline preload="metadata" />}
153 <i>{artifact.kind === 'video' ? <Video size={15} /> : <ImageIcon size={15} />}</i>
154 </button>
155 <span className="render-copy">
156 <strong>{visualArtifactLabel(artifact)}</strong>
157 <small>{formatBytes(artifact.size)}</small>
158 </span>
159 </article>
160 ))}
161 </div>
162 ) : (
163 <div className="renders-empty">
164 <Film size={24} />
165 <strong>No visual artifacts yet</strong>
166 <span>Images and videos appear here during rendering</span>
167 </div>
168 )}
169 </section>
170 );
171 }
172
173 function RelatedVisuals({artifacts, onPreview}: {artifacts: Artifact[]; onPreview: (artifact: Artifact) => void}) {
174 return (
175 <aside className="artifact-related-visuals" aria-label="Related visuals">
176 <header>
177 <div><span>Related visuals</span><strong>{artifacts.length}</strong></div>
178 <small>{artifacts.length > 0 ? 'Select to preview' : 'Awaiting render'}</small>
179 </header>
180 {artifacts.length > 0 ? (
181 <div className="related-visual-grid">
182 {artifacts.map((artifact) => (
183 <button key={artifact.path} onClick={() => onPreview(artifact)} aria-label={`Preview ${visualArtifactLabel(artifact)}`}>
184 <span>
185 {artifact.kind === 'image'
186 ? <img src={mediaUrl(artifact)} alt={visualArtifactLabel(artifact)} />
187 : <video src={mediaUrl(artifact)} muted playsInline preload="metadata" />}
188 <i><Maximize2 size={14} /></i>
189 </span>
190 <strong>{visualArtifactLabel(artifact)}</strong>
191 <small>{formatBytes(artifact.size)}</small>
192 </button>
193 ))}
194 </div>
195 ) : (
196 <div className="related-visual-empty">
197 <ImageIcon size={19} />
198 <span>Matching images and clips will appear alongside this document.</span>
199 </div>
200 )}
201 </aside>
202 );
203 }
204
205 function MediaPreviewDialog({artifact, onClose}: {artifact: Artifact; onClose: () => void}) {
206 useEffect(() => {
207 const onKeyDown = (event: KeyboardEvent) => {
208 if (event.key === 'Escape') onClose();
209 };
210 window.addEventListener('keydown', onKeyDown);
211 return () => window.removeEventListener('keydown', onKeyDown);
212 }, [onClose]);
213
214 return (
215 <div className="media-preview-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
216 <section className="media-preview-dialog" role="dialog" aria-modal="true" aria-label={`Preview ${visualArtifactLabel(artifact)}`}>
217 <header>
218 <div><strong>{visualArtifactLabel(artifact)}</strong><span>{formatBytes(artifact.size)}</span></div>
219 <button className="icon-button" onClick={onClose} aria-label="Close preview"><X size={18} /></button>
220 </header>
221 <div className="media-preview-stage">
222 {artifact.kind === 'image'
223 ? <img src={mediaUrl(artifact)} alt={visualArtifactLabel(artifact)} />
224 : <video src={mediaUrl(artifact)} controls autoPlay playsInline preload="metadata" />}
225 </div>
226 </section>
227 </div>
228 );
229 }
230
231 function SectionHeading({title, detail}: {title: string; detail: string}) {
232 return <header className="artifact-section-heading"><h2>{title}</h2><span>{detail}</span></header>;
233 }
234
235 export function StoryboardPanel({open, artifacts, activeRenderStage, onClose, onCountChange}: {
236 open: boolean;
237 artifacts: Artifact[];
238 activeRenderStage?: string;
239 onClose: () => void;
240 onCountChange: (count: number) => void;
241 }) {
242 const storyboardArtifacts = useMemo(
243 () => artifacts.filter(isStoryboardArtifact).sort((left, right) => left.path.localeCompare(right.path, undefined, {numeric: true})),
244 [artifacts],
245 );
246 const [previews, setPreviews] = useState<StoryboardPreview[]>([]);
247 const [index, setIndex] = useState(0);
248 const [loading, setLoading] = useState(false);
249 const [error, setError] = useState('');
250
251 useEffect(() => {
252 let cancelled = false;
253 setError('');
254 setIndex(0);
255 if (storyboardArtifacts.length === 0) {
256 setLoading(false);
257 setPreviews([]);
258 onCountChange(0);
259 return () => { cancelled = true; };
260 }
261 setLoading(true);
262 void Promise.allSettled(storyboardArtifacts.map(async (artifact) => ({
263 artifact,
264 document: await getJsonArtifact(artifact),
265 }))).then((results) => {
266 if (cancelled) return;
267 const loaded = results.flatMap((result) => result.status === 'fulfilled'
268 ? extractStoryboardPreviews(result.value.document, result.value.artifact.path)
269 : []);
270 setPreviews(loaded);
271 onCountChange(loaded.length);
272 if (loaded.length === 0 && results.some((result) => result.status === 'rejected')) {
273 setError('Storyboard descriptions could not be loaded');
274 }
275 }).finally(() => !cancelled && setLoading(false));
276 return () => { cancelled = true; };
277 }, [storyboardArtifacts, onCountChange]);
278
279 useEffect(() => {
280 if (!open) return;
281 const onKeyDown = (event: KeyboardEvent) => {
282 if (event.key === 'ArrowUp') setIndex((current) => Math.max(0, current - 1));
283 if (event.key === 'ArrowDown') setIndex((current) => Math.min(previews.length - 1, current + 1));
284 };
285 window.addEventListener('keydown', onKeyDown);
286 return () => window.removeEventListener('keydown', onKeyDown);
287 }, [open, previews.length]);
288
289 const current = previews[index];
290 const readiness = useMemo(
291 () => deriveStoryboardReadiness(artifacts, previews.length),
292 [artifacts, previews.length],
293 );
294 const activeCheckpoint = activeRenderStage ? activeRenderCheckpoint(activeRenderStage) : undefined;
295 return (
296 <aside className={`storyboard-panel ${open ? 'is-open' : ''}`}>
297 <header>
298 <div><strong>Storyboard</strong><span>{previews.length > 0 ? `${index + 1} of ${previews.length}` : 'Preview'}</span></div>
299 <button className="icon-button" onClick={onClose} aria-label="Close storyboard preview"><X size={18} /></button>
300 </header>
301 <section className="render-readiness" aria-label="Render readiness">
302 <div className="render-readiness-summary">
303 <strong>{readiness.readyToRender ? 'Ready to render' : 'Planning incomplete'}</strong>
304 </div>
305 <div className="render-checkpoints">
306 <ReadinessCheckpoint
307 label="Storyboards"
308 detail={readiness.storyboards.count === 1 ? '1 shot' : `${readiness.storyboards.count} shots`}
309 status={readiness.storyboards.status}
310 />
311 <ReadinessCheckpoint
312 label="Shot descriptions"
313 detail={`${readiness.shotDescriptions.count}/${readiness.shotDescriptions.expected || 0}`}
314 status={readiness.shotDescriptions.status}
315 />
316 <ReadinessCheckpoint
317 label="Camera plans"
318 detail={`${readiness.cameraPlans.count}/${readiness.cameraPlans.expected || 0}`}
319 status={readiness.cameraPlans.status}
320 />
321 </div>
322 <div className="render-stage-heading">
323 <span>Render</span>
324 <small>{activeRenderStage ? 'Generating' : readiness.render.finalVideo.status === 'ready' ? 'Complete' : readiness.render.started ? 'Partial' : 'Not started'}</small>
325 </div>
326 <div className="render-checkpoints">
327 <ReadinessCheckpoint
328 label="Keyframes"
329 detail={`${readiness.render.frames.count}/${readiness.render.frames.expected || 0}`}
330 status={readiness.render.frames.status}
331 generating={activeCheckpoint === 'frames'}
332 />
333 <ReadinessCheckpoint
334 label="Video clips"
335 detail={`${readiness.render.clips.count}/${readiness.render.clips.expected || 0}`}
336 status={readiness.render.clips.status}
337 generating={activeCheckpoint === 'clips'}
338 />
339 <ReadinessCheckpoint
340 label="Final video"
341 detail={readiness.render.finalVideo.status === 'ready' ? 'Ready' : 'Pending'}
342 status={readiness.render.finalVideo.status}
343 generating={activeCheckpoint === 'finalVideo'}
344 />
345 </div>
346 </section>
347 <div className="storyboard-description" aria-live="polite">
348 {loading ? <span>Loading storyboard…</span> : error ? <span className="is-error">{error}</span> : current ? <p>{current.description}</p> : <span>No storyboard descriptions yet</span>}
349 </div>
350 <footer>
351 <button onClick={() => setIndex((currentIndex) => Math.max(0, currentIndex - 1))} disabled={index === 0 || previews.length === 0} aria-label="Previous storyboard">
352 <ChevronUp size={17} /><span>Previous</span>
353 </button>
354 <button onClick={() => setIndex((currentIndex) => Math.min(previews.length - 1, currentIndex + 1))} disabled={index >= previews.length - 1 || previews.length === 0} aria-label="Next storyboard">
355 <ChevronDown size={17} /><span>Next</span>
356 </button>
357 </footer>
358 </aside>
359 );
360 }
361
362 function ReadinessCheckpoint({label, detail, status, generating = false}: {label: string; detail: string; status: ReadinessStatus; generating?: boolean}) {
363 return (
364 <div className="render-checkpoint">
365 <StatusLight status={status} generating={generating} />
366 <span>{label}</span>
367 <small>{detail}</small>
368 </div>
369 );
370 }
371
372 function StatusLight({status, generating = false}: {status: ReadinessStatus; generating?: boolean}) {
373 return <i className={`status-light is-${status} ${generating ? 'is-generating' : ''}`} aria-label={generating ? `${status}, generating` : status} />;
374 }
375
376 function StructuredDocument({value, artifact}: {value: JsonValue; artifact: Artifact}) {
377 if (Array.isArray(value)) {
378 if (value.length === 0) return <div className="structured-empty">This document is empty</div>;
379 if (value.every(isJsonPrimitive)) return <StructuredField label="Contents" value={formatStructuredValue(value)} />;
380 return (
381 <div className="structured-records">
382 {value.map((record, index) => (
383 <StructuredRecord key={index} title={structuredRecordTitle(record, index, artifact)} value={record} depth={0} />
384 ))}
385 </div>
386 );
387 }
388 if (isJsonObject(value)) return <StructuredRecord title="Overview" value={value} depth={0} />;
389 return <StructuredField label="Contents" value={formatStructuredValue(value)} />;
390 }
391
392 function StructuredRecord({title, value, depth}: {title: string; value: JsonValue; depth: number}) {
393 if (!isJsonObject(value)) return <StructuredField label={title} value={formatStructuredValue(value)} />;
394 const entries = Object.entries(value).filter(([key]) => !isArtifactPathField(key));
395 const fields = entries.filter(([, entryValue]) => isJsonPrimitive(entryValue) || isPrimitiveArray(entryValue));
396 const nested = entries.filter(([, entryValue]) => !isJsonPrimitive(entryValue) && !isPrimitiveArray(entryValue));
397 return (
398 <section className={`structured-record depth-${Math.min(depth, 3)}`}>
399 <header><h3>{title}</h3></header>
400 {fields.length > 0 && (
401 <div className="structured-fields">
402 {fields.map(([key, entryValue]) => (
403 <StructuredField key={key} label={friendlyFieldLabel(key)} value={formatStructuredValue(entryValue, key)} />
404 ))}
405 </div>
406 )}
407 {nested.map(([key, entryValue]) => (
408 <StructuredNested key={key} label={friendlyFieldLabel(key)} value={entryValue} depth={depth + 1} />
409 ))}
410 </section>
411 );
412 }
413
414 function StructuredNested({label, value, depth}: {label: string; value: JsonValue; depth: number}) {
415 if (depth > 5) return <StructuredField label={label} value="Additional structured details" />;
416 if (Array.isArray(value)) {
417 if (value.length === 0) return <StructuredField label={label} value="None" />;
418 return (
419 <section className="structured-nested">
420 <h4>{label}</h4>
421 {value.map((item, index) => <StructuredRecord key={index} title={`${label} ${index + 1}`} value={item} depth={depth} />)}
422 </section>
423 );
424 }
425 return <StructuredRecord title={label} value={value} depth={depth} />;
426 }
427
428 function StructuredField({label, value}: {label: string; value: string}) {
429 return (
430 <div className={`structured-field ${value.length > 100 ? 'is-long' : ''}`}>
431 <span>{label}</span>
432 <p>{value}</p>
433 </div>
434 );
435 }
436
437 function ArtifactsEmpty({title, detail}: {title: string; detail: string}) {
438 return (
439 <div className="artifacts-empty">
440 <Files size={24} />
441 <strong>{title}</strong>
442 <span>{detail}</span>
443 </div>
444 );
445 }
446
447 function isPrimitiveArray(value: JsonValue): value is Array<string | number | boolean | null> {
448 return Array.isArray(value) && value.every(isJsonPrimitive);
449 }
450
451 function projectTitle(session: SessionSummary): string {
452 return session.projectName || session.idea || session.summary || 'Untitled video';
453 }
454
455 function formatBytes(bytes: number) {
456 if (bytes < 1024) return `${bytes} B`;
457 if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
458 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
459 }
460
461 function mediaUrl(artifact: Artifact): string {
462 const separator = artifact.url.includes('?') ? '&' : '?';
463 return `${artifact.url}${separator}updated=${encodeURIComponent(artifact.updatedAt)}`;
464 }
465
466 function visualArtifactLabel(artifact: Artifact): string {
467 const scene = artifact.path.match(/(?:^|\/)scene_(\d+)(?:\/|$)/i)?.[1];
468 const shot = artifact.path.match(/(?:^|\/)shots\/(\d+)(?:\/|$)/i)?.[1];
469 const portrait = artifact.path.match(/(?:^|\/)character_portraits\/([^/]+)\/([^/]+)$/i);
470 const base = artifact.name
471 .replace(/\.[^.]+$/, '')
472 .replace(/[_-]+/g, ' ')
473 .replace(/\b\w/g, (character) => character.toUpperCase());
474 if (portrait) return `${portrait[1]} · ${base}`;
475 const context = [];
476 if (scene !== undefined) context.push(`Scene ${Number(scene) + 1}`);
477 if (shot !== undefined) context.push(`Shot ${Number(shot) + 1}`);
478 return context.length > 0 ? `${context.join(' · ')} · ${base}` : base;
479 }
480
480 lines Plain Text