diff --git a/README.md b/README.md index 9ceeba9..6f53b78 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,8 @@ To reset runtime state inside the container stack: - Podman: `podman compose -f docker-compose.yml run --rm api npm run reset:runtime` - Docker: `docker compose -f docker-compose.yml run --rm api npm run reset:runtime` + +Or use the bundled production-reset script, which detects Docker or Podman Compose, stops the prod stack if it is running, clears runtime data, and starts it again: + +- `npm run reset:prod` +- `npm run reset:prod -- --keep-down` diff --git a/apps/admin/src/app/App.tsx b/apps/admin/src/app/App.tsx index 302eac5..5d7b960 100644 --- a/apps/admin/src/app/App.tsx +++ b/apps/admin/src/app/App.tsx @@ -20,6 +20,7 @@ import type { SceneParamGroups, SceneParamScalar, Submission, + SubmissionUpdatePayload, TextTreatmentMode } from "@goodgrief/shared-types"; import { @@ -39,6 +40,7 @@ import { moderateAsset, moveCue, rescanLibrary, + updateSubmissionMetadata, updateCue } from "../features/live/api"; import { @@ -59,12 +61,21 @@ interface CueDraftState { transitionOutDurationMs: number; } +interface MetadataDraftState { + displayName: string; + caption: string; + promptAnswer: string; + notes: string; +} + type SceneBrowserFilter = "all" | SceneDefinition["sceneFamily"]; type WorkspaceMode = "show" | "build"; +type ShowUtilityTab = "controls" | "notes" | "media" | "moderation"; +type BuildLibraryTab = "approved" | "pending" | "upload"; const defaultCueTransition: CueTransition = { style: "dissolve", - durationMs: 900 + durationMs: 4000 }; const createCueDraft = (cue?: Cue | null, scene?: SceneDefinition): CueDraftState => ({ @@ -73,18 +84,25 @@ const createCueDraft = (cue?: Cue | null, scene?: SceneDefinition): CueDraftStat triggerMode: cue?.triggerMode ?? "manual", transitionInStyle: cue?.transitionIn.style ?? defaultCueTransition.style, transitionInDurationMs: cue?.transitionIn.durationMs ?? defaultCueTransition.durationMs, - transitionOutStyle: cue?.transitionOut.style ?? "veil_wipe", - transitionOutDurationMs: cue?.transitionOut.durationMs ?? 1100 + transitionOutStyle: cue?.transitionOut.style ?? "mist_reveal", + transitionOutDurationMs: cue?.transitionOut.durationMs ?? 4000 +}); + +const createMetadataDraft = (submission?: Submission | null): MetadataDraftState => ({ + displayName: submission?.displayName ?? "", + caption: submission?.caption ?? "", + promptAnswer: submission?.promptAnswer ?? "", + notes: submission?.notes ?? "" }); const scenePaletteMap: Record = { - "scene-signal-shutters": { accent: "#ff9f6c", accentSoft: "#9bd9ff", ink: "#17110f" }, - "scene-window-grid": { accent: "#f4cb93", accentSoft: "#97bcff", ink: "#11161b" }, - "scene-monolith-sweep": { accent: "#ffe1a8", accentSoft: "#88c1ff", ink: "#121520" }, - "scene-aperture-spill": { accent: "#f1bf82", accentSoft: "#b4d7ff", ink: "#151622" }, - "scene-floor-ribbons": { accent: "#ffbc6d", accentSoft: "#8cd5ff", ink: "#101417" }, - "scene-edge-relay": { accent: "#84e6ff", accentSoft: "#ffb584", ink: "#09131a" }, - "scene-rupture-xerox": { accent: "#ff5aac", accentSoft: "#2fd9ff", ink: "#171219" }, + "scene-witness-float": { accent: "#ffcb8a", accentSoft: "#8cc8ff", ink: "#151218" }, + "scene-portal-frame": { accent: "#f2c2a2", accentSoft: "#9db8ff", ink: "#141821" }, + "scene-orbit-gallery": { accent: "#f6dfa0", accentSoft: "#8fdcff", ink: "#101822" }, + "scene-suspension-field": { accent: "#d7b2ff", accentSoft: "#7ed5ff", ink: "#12171f" }, + "scene-chorus-array": { accent: "#ffd08e", accentSoft: "#8be7d2", ink: "#11161a" }, + "scene-equal-collage": { accent: "#ffba9a", accentSoft: "#8bc7ff", ink: "#15151d" }, + "scene-arrival-relay": { accent: "#8fe6ff", accentSoft: "#ffc08b", ink: "#09121a" }, "scene-safe-hold": { accent: "#8fc8ff", accentSoft: "#e7c08c", ink: "#081016" } }; @@ -92,18 +110,14 @@ const sceneBrowserFilters: Array<{ id: SceneBrowserFilter; label: string; summar { id: "all", label: "All", summary: "Entire show library" }, { id: "hero", label: "Hero", summary: "Readable anchors and witness images" }, { id: "chorus", label: "Chorus", summary: "Multi-image fields and collective bodies" }, - { id: "floor_paint", label: "Floor paint", summary: "Wall and performer-zone spill looks" }, { id: "arrival", label: "Arrival", summary: "Live intake and edge relay states" }, - { id: "rupture", label: "Rupture", summary: "Deliberate disruption accents" }, { id: "safe", label: "Safe", summary: "Neutral holds and recovery looks" } ]; const sceneFamilyLabelMap: Record = { hero: "Hero", chorus: "Chorus", - floor_paint: "Floor paint", arrival: "Arrival", - rupture: "Rupture", safe: "Safe" }; @@ -130,9 +144,9 @@ const moveItem = (items: T[], fromIndex: number, toIndex: number) => { }; const sharedLookControlPaths = [ - "scenicTreatment.fillHue", - "scenicTreatment.fillSaturation", - "scenicTreatment.fillLightness" + "scenicTreatment.hue", + "scenicTreatment.saturation", + "scenicTreatment.lightness" ] as const; const textControlPaths = [ @@ -217,7 +231,6 @@ const buildPresentationTextPayload = (payload: RepositoryState, assets: PhotoAss const anchorCaption = anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || - anchorSubmission?.displayName?.trim() || null; return { @@ -249,12 +262,19 @@ const getAssetSearchText = (asset: PhotoAsset, submission: Submission | undefine submission?.displayName, submission?.caption, submission?.promptAnswer, + submission?.notes, submission?.source ] .filter(Boolean) .join(" ") .toLowerCase(); +const getAssetPrimaryLabel = (asset: PhotoAsset, submission: Submission | undefined) => + submission?.caption?.trim() || submission?.promptAnswer?.trim() || submission?.displayName?.trim() || asset.id; + +const getAssetSecondaryLabel = (submission: Submission | undefined) => + submission?.caption?.trim() || submission?.promptAnswer?.trim() || submission?.notes?.trim() || ""; + const getDefaultAssetIds = (payload: RepositoryState) => { const approvedIds = new Set(getApprovedAssets(payload).map((asset) => asset.id)); const favorites = payload.collections.find((collection) => collection.kind === "favorites"); @@ -405,24 +425,8 @@ const getNumberControlMeta = (path: string, value: number, preset?: EffectPreset }; } - if (path.includes("columns")) { - return { min: 2, max: 6, step: 1 }; - } - - if (path.includes("bands")) { - return { min: 2, max: 8, step: 1 }; - } - - if (path.includes("shutters")) { - return { min: 2, max: 9, step: 1 }; - } - - if (path.includes("tiles")) { - return { min: 3, max: 12, step: 1 }; - } - - if (path.includes("lanes")) { - return { min: 2, max: 6, step: 1 }; + if (path.includes("supportCount")) { + return { min: 0, max: 3, step: 1 }; } if (value > 1.5) { @@ -471,11 +475,17 @@ const createInitialLiveState = (payload: RepositoryState) => { export const App = () => { const [state, setState] = useState(null); const [workspaceMode, setWorkspaceMode] = useState("show"); + const [showUtilityTab, setShowUtilityTab] = useState("controls"); + const [buildLibraryTab, setBuildLibraryTab] = useState("approved"); const [cueState, setCueState] = useState(createCueRuntimeState([])); const [programOutputState, setProgramOutputState] = useState(null); const [selectedSceneId, setSelectedSceneId] = useState(""); const [sceneBrowserFilter, setSceneBrowserFilter] = useState("all"); const [selectedAssetIds, setSelectedAssetIds] = useState([]); + const [metadataAssetId, setMetadataAssetId] = useState(null); + const [metadataDraft, setMetadataDraft] = useState(createMetadataDraft()); + const [metadataDirty, setMetadataDirty] = useState(false); + const [metadataSaving, setMetadataSaving] = useState(false); const [previewParams, setPreviewParams] = useState(null); const [activePresetId, setActivePresetId] = useState(effectPresetLibrary[0]?.id ?? ""); const [cueDraft, setCueDraft] = useState(createCueDraft()); @@ -487,6 +497,8 @@ export const App = () => { const [uploadAddToSelection, setUploadAddToSelection] = useState(true); const [status, setStatus] = useState("Connecting to local show state..."); const uploadInputRef = useRef(null); + const mediaSearchInputRef = useRef(null); + const metadataHydrationKeyRef = useRef(null); const publishProgramOutput = ( presentation: SurfacePresentation | null, @@ -515,6 +527,9 @@ export const App = () => { setSelectedSceneId(initial.selectedSceneId); setSceneBrowserFilter("all"); setSelectedAssetIds(initial.selectedAssetIds); + setMetadataAssetId(initial.selectedAssetIds[0] ?? null); + setMetadataDraft(createMetadataDraft()); + setMetadataDirty(false); setPreviewParams(initial.previewParams ?? null); setActivePresetId(initial.activePresetId); setCueDraft(initial.cueDraft); @@ -595,9 +610,7 @@ export const App = () => { all: state?.scenes.length ?? 0, hero: 0, chorus: 0, - floor_paint: 0, arrival: 0, - rupture: 0, safe: 0 }; @@ -611,6 +624,10 @@ export const App = () => { () => (state ? findCueById(state, cueState.previewCueId) : undefined), [cueState.previewCueId, state] ); + const currentCue = useMemo( + () => (state ? findCueById(state, cueState.currentCueId) : undefined), + [cueState.currentCueId, state] + ); const safeCue = useMemo( () => state?.cues.find((cue) => cue.id === state.showConfig.safeSceneCueId), [state] @@ -634,6 +651,23 @@ export const App = () => { .map((assetId) => assetMap.get(assetId)) .filter((asset): asset is PhotoAsset => Boolean(asset)); }, [approvedAssets, selectedAssetIds]); + const metadataAsset = useMemo(() => { + if (!state) { + return undefined; + } + + const allAssetMap = new Map(state.photoAssets.map((asset) => [asset.id, asset] as const)); + return ( + (metadataAssetId ? allAssetMap.get(metadataAssetId) : undefined) ?? + selectedAssets[0] ?? + pendingAssets[0] ?? + approvedAssets[0] + ); + }, [approvedAssets, metadataAssetId, pendingAssets, selectedAssets, state]); + const metadataSubmission = useMemo( + () => (metadataAsset ? submissionMap.get(metadataAsset.submissionId) : undefined), + [metadataAsset, submissionMap] + ); const filteredPendingAssets = useMemo(() => { const query = mediaSearch.trim().toLowerCase(); if (!query) { @@ -653,6 +687,46 @@ export const App = () => { const activePreset: EffectPreset | undefined = selectedScenePresets.find((preset) => preset.id === activePresetId) ?? availablePresets.find((preset) => preset.id === activePresetId); + const currentCueScene = useMemo( + () => (currentCue && state ? findSceneById(state, currentCue.sceneDefinitionId) : undefined), + [currentCue, state] + ); + const currentCuePreset = useMemo( + () => + currentCueScene + ? matchPresetForScene(currentCueScene, availablePresets, currentCue?.effectPresetId) + : undefined, + [availablePresets, currentCue?.effectPresetId, currentCueScene] + ); + const previewCueScene = useMemo( + () => (previewCue && state ? findSceneById(state, previewCue.sceneDefinitionId) : undefined), + [previewCue, state] + ); + const previewCuePreset = useMemo( + () => + previewCueScene + ? matchPresetForScene(previewCueScene, availablePresets, previewCue?.effectPresetId) + : undefined, + [availablePresets, previewCue?.effectPresetId, previewCueScene] + ); + const selectedAssetCountLabel = `${selectedAssets.length} ${selectedAssets.length === 1 ? "image" : "images"}`; + const buildLibraryTabs: Array<{ id: BuildLibraryTab; label: string; count?: number }> = useMemo( + () => [ + { id: "approved", label: "Approved", count: filteredApprovedAssets.length }, + { id: "pending", label: "Pending", count: filteredPendingAssets.length }, + { id: "upload", label: "Upload" } + ], + [filteredApprovedAssets.length, filteredPendingAssets.length] + ); + const showUtilityTabs: Array<{ id: ShowUtilityTab; label: string; count?: number }> = useMemo( + () => [ + { id: "controls", label: "Controls" }, + { id: "notes", label: "Notes" }, + { id: "media", label: "Media", count: selectedAssets.length }, + { id: "moderation", label: "Moderation", count: filteredPendingAssets.length } + ], + [filteredPendingAssets.length, selectedAssets.length] + ); const sceneColorControls = useMemo( () => selectedScene @@ -672,6 +746,8 @@ export const App = () => { () => (state ? buildPresentationTextPayload(state, selectedAssets) : { textFragments: [], anchorCaption: null }), [selectedAssets, state] ); + const metadataTitle = metadataAsset ? getAssetPrimaryLabel(metadataAsset, metadataSubmission) : "Select an image"; + const metadataSourceLabel = formatSubmissionSource(metadataSubmission?.source); const previewUsesArmedCue = Boolean(previewCue && cueDraft.id === previewCue.id && previewCue.sceneDefinitionId === selectedSceneId); const previewLabel = selectedScene ? previewUsesArmedCue @@ -703,6 +779,35 @@ export const App = () => { const programPresentation = programOutputState?.presentation ?? null; const programActivationKey = programOutputState?.presentationHash ?? "program-empty"; + useEffect(() => { + if (metadataAsset?.id && metadataAssetId !== metadataAsset.id) { + setMetadataAssetId(metadataAsset.id); + return; + } + + if (!metadataAsset && metadataAssetId !== null) { + setMetadataAssetId(null); + } + }, [metadataAsset, metadataAssetId]); + + useEffect(() => { + const nextKey = metadataAsset && metadataSubmission ? `${metadataAsset.id}:${metadataSubmission.id}` : null; + if (!nextKey) { + if (!metadataDirty) { + setMetadataDraft(createMetadataDraft()); + } + metadataHydrationKeyRef.current = null; + return; + } + + const targetChanged = metadataHydrationKeyRef.current !== nextKey; + if (targetChanged || !metadataDirty) { + setMetadataDraft(createMetadataDraft(metadataSubmission)); + setMetadataDirty(false); + metadataHydrationKeyRef.current = nextKey; + } + }, [metadataAsset, metadataDirty, metadataSubmission]); + const selectScene = (scene: SceneDefinition) => { if (!state) { return; @@ -746,6 +851,7 @@ export const App = () => { setActivePresetId(matchedPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); setPreviewParams(buildParamsForScene(scene, cue.parameterOverrides, matchedPreset)); setCueDraft(createCueDraft(cue, scene)); + setMetadataAssetId(cueAssetIds[0] ?? null); setSelectedAssetIds( cueAssetIds.length > 0 ? cueAssetIds : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)) ); @@ -779,6 +885,7 @@ export const App = () => { setSceneBrowserFilter(scene.sceneFamily); setActivePresetId(preset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); setPreviewParams(buildParamsForScene(scene, draft.parameterOverrides, preset)); + setMetadataAssetId(nextAssetIds[0] ?? null); setSelectedAssetIds(nextAssetIds); setCueDraft({ id: null, @@ -838,6 +945,8 @@ export const App = () => { try { const result = await createAdminUpload(form); await refresh(false); + setMetadataAssetId(result.assetId); + setMetadataDirty(false); if (uploadAddToSelection && result.assetId) { setSelectedAssetIds((current) => { @@ -860,6 +969,46 @@ export const App = () => { } }; + const handleMetadataDraftChange = (field: keyof MetadataDraftState, value: string) => { + setMetadataDraft((current) => ({ + ...current, + [field]: value + })); + setMetadataDirty(true); + }; + + const handleResetMetadataDraft = () => { + setMetadataDraft(createMetadataDraft(metadataSubmission)); + setMetadataDirty(false); + setStatus("Metadata draft reset to the saved values."); + }; + + const handleSaveMetadata = async () => { + if (!metadataSubmission) { + setStatus("Select an image to edit its caption and metadata."); + return; + } + + const payload: SubmissionUpdatePayload = { + displayName: metadataDraft.displayName, + caption: metadataDraft.caption, + promptAnswer: metadataDraft.promptAnswer, + notes: metadataDraft.notes + }; + + try { + setMetadataSaving(true); + await updateSubmissionMetadata(metadataSubmission.id, payload); + setMetadataDirty(false); + await refresh(false); + setStatus(`Metadata saved for ${metadataTitle}.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Could not save image metadata."); + } finally { + setMetadataSaving(false); + } + }; + const setBlackout = (blackout: boolean) => { setCueState((current) => ({ ...current, @@ -875,6 +1024,12 @@ export const App = () => { return; } + if (event.key === "/") { + event.preventDefault(); + mediaSearchInputRef.current?.focus(); + return; + } + if (event.code === "Space") { event.preventDefault(); void handleTakeCue(); @@ -897,6 +1052,50 @@ export const App = () => { return; } + if (event.key === "ArrowUp" && state) { + event.preventDefault(); + const currentIndex = cueStack.findIndex((cue) => cue.id === cueState.previewCueId); + const previousCue = cueStack[Math.max(0, currentIndex - 1)]; + if (previousCue) { + setCueState((current) => skipToCue(current, previousCue.id)); + syncPreviewFromCue(previousCue); + } + return; + } + + if (event.key === "[") { + event.preventDefault(); + if (metadataAssetId && selectedAssetIds.includes(metadataAssetId)) { + handleReorderAsset(metadataAssetId, "earlier"); + } + return; + } + + if (event.key === "]") { + event.preventDefault(); + if (metadataAssetId && selectedAssetIds.includes(metadataAssetId)) { + handleReorderAsset(metadataAssetId, "later"); + } + return; + } + + if (event.key.toLowerCase() === "a") { + if (metadataAssetId && selectedAssetIds.includes(metadataAssetId)) { + event.preventDefault(); + handlePromoteAsset(metadataAssetId); + return; + } + } + + if (workspaceMode === "show") { + const showTabs: ShowUtilityTab[] = ["controls", "notes", "media", "moderation"]; + const numeric = Number(event.key); + if (numeric >= 1 && numeric <= showTabs.length) { + setShowUtilityTab(showTabs[numeric - 1]!); + return; + } + } + if (event.key.toLowerCase() === "o") { openOutputWindow(); } @@ -1126,6 +1325,7 @@ export const App = () => { }; const toggleAssetSelection = (assetId: string) => { + setMetadataAssetId(assetId); setSelectedAssetIds((current) => current.includes(assetId) ? current.filter((candidate) => candidate !== assetId) : [...current, assetId].slice(-12) ); @@ -1136,7 +1336,9 @@ export const App = () => { return; } - setSelectedAssetIds(getSuggestedAssetsForScene(state, selectedScene.id, getDefaultAssetIds(state))); + const nextAssetIds = getSuggestedAssetsForScene(state, selectedScene.id, getDefaultAssetIds(state)); + setSelectedAssetIds(nextAssetIds); + setMetadataAssetId(nextAssetIds[0] ?? null); setStatus(`Suggested media loaded for ${selectedScene.name}.`); }; @@ -1145,7 +1347,9 @@ export const App = () => { return; } - setSelectedAssetIds(getDefaultAssetIds(state)); + const nextAssetIds = getDefaultAssetIds(state); + setSelectedAssetIds(nextAssetIds); + setMetadataAssetId(nextAssetIds[0] ?? null); setStatus("Favorites bank loaded into preview."); }; @@ -1198,12 +1402,12 @@ export const App = () => { ...previewParams, scenicTreatment: { ...previewParams.scenicTreatment, - fillHue: base.scenicTreatment.fillHue, - fillSaturation: base.scenicTreatment.fillSaturation, - fillLightness: base.scenicTreatment.fillLightness + hue: base.scenicTreatment.hue, + saturation: base.scenicTreatment.saturation, + lightness: base.scenicTreatment.lightness } }); - setStatus("Fill color reset to the current mode defaults."); + setStatus("Backdrop color reset to the current mode defaults."); }; const handleNewCueFromPreview = () => { @@ -1268,27 +1472,6 @@ export const App = () => { ); } - if (path === "composition.edge") { - return ( - - ); - } - if (path === "textTreatment.mode") { return ( ); @@ -1330,6 +1513,713 @@ export const App = () => { ); }; + const getCueAssetCount = (cue: Cue | undefined) => { + if (!cue || !state) { + return 0; + } + + return cue.assetIds?.length && cue.assetIds.length > 0 ? cue.assetIds.length : findCollectionAssets(state, cue.collectionId).length; + }; + + const renderCueRows = (variant: "show" | "build") => ( +
+ {cueStack.map((cue) => { + const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined; + const preset = definition + ? matchPresetForScene(definition, availablePresets, cue.effectPresetId) + : undefined; + const cueAssetCount = getCueAssetCount(cue); + + return ( + + ); + })} + {cueStack.length === 0 ?

No cues have been created yet.

: null} +
+ ); + + const renderSceneModeChooser = (variant: "show" | "build") => ( +
+
+
+
+

Scenes

+

Family and scene

+
+
+ {selectedScene ? {sceneFamilyLabelMap[selectedScene.sceneFamily]} : null} + {selectedScene ? {selectedScene.renderMode} : null} +
+
+
+ {sceneBrowserFilters.map((filter) => ( + + ))} +
+
+ {visibleScenes.map((scene) => ( + + ))} +
+
+ +
+
+
+

Modes

+

Look behavior

+
+
+
+ {selectedScenePresets.map((preset) => ( + + ))} +
+ {activePreset ?

{activePreset.performanceNotes}

: null} +
+
+ ); + + const renderLookControls = (variant: "show" | "build") => { + if (!selectedScene || !previewParams) { + return

Select a scene or cue to shape the preview.

; + } + + return ( +
+ {variant === "show" ? ( + + ) : null} + +
+
+
+

Current look

+

{selectedScene.name}

+
+
+ {activePreset ? {activePreset.name} : null} + {selectedAssetCountLabel} +
+
+
+ {selectedScenePresets.map((preset) => ( + + ))} +
+
+ + + + +
+
+ +
+

Motion and structure

+
{primarySceneControls.map((path) => renderControl(path))}
+
+ +
+

Backdrop tone

+
{sceneColorControls.map((path) => renderControl(path))}
+
+ + {selectedScene.sceneFamily === "arrival" ? ( +
+

Abstract text

+
{textControls.map((path) => renderControl(path))}
+
+ ) : null} +
+ ); + }; + + const renderSelectedTray = (variant: "show" | "build") => ( +
+
+
+

Selected for preview

+

{selectedAssetCountLabel}

+
+
+ + + +
+
+
+ {selectedAssets.map((asset, index) => { + const submission = submissionMap.get(asset.submissionId); + const assetLabel = getAssetPrimaryLabel(asset, submission); + const assetDetail = getAssetSecondaryLabel(submission); + return ( +
setMetadataAssetId(asset.id)} + > +
+ {asset.thumbKey ? :
} +
+
+
+ {index === 0 ? "Anchor" : `Slot ${index + 1}`} + {formatSubmissionSource(submission?.source)} +
+

{assetLabel}

+ {assetDetail ? {assetDetail} : null} +
+
+ + + + +
+
+ ); + })} + {selectedAssets.length === 0 ?

Select images from the approved bank to build the current look.

: null} +
+
+ ); + + const renderMetadataInspector = () => ( +
+
+
+

Inspector

+

{metadataTitle}

+
+ {metadataAsset ? {metadataSourceLabel} : null} +
+ {metadataSubmission && metadataAsset ? ( + <> +
+
+ {metadataAsset.previewKey || metadataAsset.thumbKey ? ( + + ) : ( +
+ )} +
+

+ {metadataAsset.orientation ?? "orientation pending"} / {metadataAsset.processingStatus} + {metadataSubmission.source === "library_import" ? " / Curated library" : ""} +

+
+
+ + + +