| 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 |