Optimize admin performance and split render surface

This commit is contained in:
vance 2026-04-11 13:59:58 -07:00
parent 4c6982bf68
commit e9aa82e1e1
28 changed files with 3396 additions and 2526 deletions

View File

@ -14,6 +14,7 @@
"@goodgrief/effects": "file:../../packages/effects", "@goodgrief/effects": "file:../../packages/effects",
"@goodgrief/render-engine": "file:../../packages/render-engine", "@goodgrief/render-engine": "file:../../packages/render-engine",
"@goodgrief/shared-types": "file:../../packages/shared-types", "@goodgrief/shared-types": "file:../../packages/shared-types",
"@tanstack/react-virtual": "^3.13.23",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },

View File

@ -49,6 +49,8 @@ import {
type ProgramOutputState type ProgramOutputState
} from "../features/live/output-sync"; } from "../features/live/output-sync";
import { SceneViewport } from "../features/live/SceneViewport"; import { SceneViewport } from "../features/live/SceneViewport";
import { VirtualizedGrid } from "../features/live/VirtualizedGrid";
import { VirtualizedList } from "../features/live/VirtualizedList";
import { import {
adminReducer, adminReducer,
createCueDraft, createCueDraft,
@ -477,6 +479,12 @@ export const App = () => {
const deferredMediaSearch = useDeferredValue(mediaSearch); const deferredMediaSearch = useDeferredValue(mediaSearch);
const uploadInputRef = useRef<HTMLInputElement | null>(null); const uploadInputRef = useRef<HTMLInputElement | null>(null);
const mediaSearchInputRef = useRef<HTMLInputElement | null>(null); const mediaSearchInputRef = useRef<HTMLInputElement | null>(null);
const liveSnapshotRef = useRef({
programRevision: null as string | null,
loadedLibraryRevision: null as string | null,
pendingCount: -1,
approvedCount: -1
});
const publishProgramOutput = ( const publishProgramOutput = (
presentation: SurfacePresentation | null, presentation: SurfacePresentation | null,
@ -490,7 +498,20 @@ export const App = () => {
} }
}; };
const hydrate = (payload: RepositoryState, initialize: boolean) => { const hydrate = (
payload: RepositoryState,
initialize: boolean,
revisions?: { libraryRevision?: string; programRevision?: string }
) => {
if (revisions?.programRevision) {
liveSnapshotRef.current.programRevision = revisions.programRevision;
}
if (revisions?.libraryRevision) {
liveSnapshotRef.current.loadedLibraryRevision = revisions.libraryRevision;
}
liveSnapshotRef.current.pendingCount = getPendingModerationAssets(payload.photoAssets, payload.submissions).length;
liveSnapshotRef.current.approvedCount = getApprovedAssets(payload).length;
startTransition(() => { startTransition(() => {
if (initialize) { if (initialize) {
const initial = createInitialLiveState(payload); const initial = createInitialLiveState(payload);
@ -519,23 +540,15 @@ export const App = () => {
const refreshBootstrap = async (initialize = false) => { const refreshBootstrap = async (initialize = false) => {
const payload = await loadAdminBootstrap(); const payload = await loadAdminBootstrap();
hydrate(payload, initialize); hydrate(payload, initialize, {
}; libraryRevision: payload.libraryRevision,
programRevision: payload.programRevision
const refreshLiveState = async () => {
const payload = await loadAdminLive();
startTransition(() => {
dispatchAdmin({
type: "liveLoaded",
cues: payload.cues,
pendingCount: payload.pendingCount,
approvedCount: payload.approvedCount
});
}); });
}; };
const refreshLibraryState = async () => { const refreshLibraryState = async () => {
const payload = await loadAdminLibrary(); const payload = await loadAdminLibrary();
liveSnapshotRef.current.loadedLibraryRevision = payload.revision;
startTransition(() => { startTransition(() => {
dispatchAdmin({ dispatchAdmin({
type: "libraryLoaded", type: "libraryLoaded",
@ -546,6 +559,39 @@ export const App = () => {
}); });
}; };
const refreshLiveState = async (allowLibraryRefresh: boolean) => {
const payload = await loadAdminLive();
const currentSnapshot = liveSnapshotRef.current;
const shouldDispatchLive =
payload.programRevision !== currentSnapshot.programRevision ||
payload.pendingCount !== currentSnapshot.pendingCount ||
payload.approvedCount !== currentSnapshot.approvedCount;
const shouldRefreshLibrary =
allowLibraryRefresh && payload.libraryRevision !== currentSnapshot.loadedLibraryRevision;
liveSnapshotRef.current = {
programRevision: payload.programRevision,
loadedLibraryRevision: currentSnapshot.loadedLibraryRevision,
pendingCount: payload.pendingCount,
approvedCount: payload.approvedCount
};
if (shouldDispatchLive) {
startTransition(() => {
dispatchAdmin({
type: "liveLoaded",
cues: payload.cues,
pendingCount: payload.pendingCount,
approvedCount: payload.approvedCount
});
});
}
if (shouldRefreshLibrary) {
await refreshLibraryState();
}
};
useEffect(() => { useEffect(() => {
void refreshBootstrap(true).catch((error) => { void refreshBootstrap(true).catch((error) => {
setStatus(error instanceof Error ? error.message : "Could not load state."); setStatus(error instanceof Error ? error.message : "Could not load state.");
@ -557,15 +603,11 @@ export const App = () => {
return; return;
} }
const shouldWatchLibrary = workspaceMode === "build" || showUtilityTab === "media" || showUtilityTab === "moderation";
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void refreshLiveState().catch(() => { void refreshLiveState(shouldWatchLibrary).catch(() => {
setStatus("Refresh failed. Local state may be stale."); setStatus("Refresh failed. Local state may be stale.");
}); });
if (workspaceMode === "build" || showUtilityTab === "media" || showUtilityTab === "moderation") {
void refreshLibraryState().catch(() => {
setStatus("Library refresh failed. Media state may be stale.");
});
}
}, 4000); }, 4000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
@ -861,7 +903,10 @@ export const App = () => {
const handleRescanLibrary = async () => { const handleRescanLibrary = async () => {
try { try {
const payload = await rescanLibrary(); const payload = await rescanLibrary();
hydrate(payload, false); hydrate(payload, false, {
libraryRevision: payload.libraryRevision,
programRevision: payload.programRevision
});
setStatus( setStatus(
`Library rescanned. ${getApprovedAssets(payload).length} approved assets ready / ${payload.collections.find((collection) => collection.id === "collection-curated-library")?.assetIds.length ?? 0} curated.` `Library rescanned. ${getApprovedAssets(payload).length} approved assets ready / ${payload.collections.find((collection) => collection.id === "collection-curated-library")?.assetIds.length ?? 0} curated.`
); );
@ -1148,7 +1193,7 @@ export const App = () => {
const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload); const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: savedCue, scene: selectedScene }); dispatchAdmin({ type: "cueUpsertSucceeded", cue: savedCue, scene: selectedScene });
syncPreviewFromCue(savedCue); syncPreviewFromCue(savedCue);
await refreshLiveState(); await refreshLiveState(false);
setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`); setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`);
} catch (error) { } catch (error) {
dispatchAdmin({ type: "cueMutationFinished" }); dispatchAdmin({ type: "cueMutationFinished" });
@ -1179,7 +1224,7 @@ export const App = () => {
const createdCue = await createCue(payload); const createdCue = await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene }); dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene });
syncPreviewFromCue(createdCue); syncPreviewFromCue(createdCue);
await refreshLiveState(); await refreshLiveState(false);
setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`); setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`);
} catch (error) { } catch (error) {
dispatchAdmin({ type: "cueMutationFinished" }); dispatchAdmin({ type: "cueMutationFinished" });
@ -1208,7 +1253,7 @@ export const App = () => {
const duplicatedCue = await createCue(payload); const duplicatedCue = await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: duplicatedCue, scene: selectedScene }); dispatchAdmin({ type: "cueUpsertSucceeded", cue: duplicatedCue, scene: selectedScene });
syncPreviewFromCue(duplicatedCue); syncPreviewFromCue(duplicatedCue);
await refreshLiveState(); await refreshLiveState(false);
setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`); setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`);
} catch (error) { } catch (error) {
dispatchAdmin({ type: "cueMutationFinished" }); dispatchAdmin({ type: "cueMutationFinished" });
@ -1232,7 +1277,7 @@ export const App = () => {
if (nextCue) { if (nextCue) {
syncPreviewFromCue(nextCue); syncPreviewFromCue(nextCue);
} }
await refreshLiveState(); await refreshLiveState(false);
setStatus("Cue deleted."); setStatus("Cue deleted.");
} catch (error) { } catch (error) {
dispatchAdmin({ type: "cueMutationFinished" }); dispatchAdmin({ type: "cueMutationFinished" });
@ -1260,7 +1305,7 @@ export const App = () => {
setStatus(`Cue moved ${direction}.`); setStatus(`Cue moved ${direction}.`);
} catch (error) { } catch (error) {
dispatchAdmin({ type: "cueMoveFailed" }); dispatchAdmin({ type: "cueMoveFailed" });
void refreshLiveState(); void refreshLiveState(false);
setStatus(error instanceof Error ? error.message : `Could not move cue ${direction}.`); setStatus(error instanceof Error ? error.message : `Could not move cue ${direction}.`);
} }
}; };
@ -1474,7 +1519,7 @@ export const App = () => {
const createdCue = await createCue(payload); const createdCue = await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene }); dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene });
syncPreviewFromCue(createdCue); syncPreviewFromCue(createdCue);
await refreshLiveState(); await refreshLiveState(false);
setStatus(`New cue created: ${createdCue.notes ?? createdCue.id}`); setStatus(`New cue created: ${createdCue.notes ?? createdCue.id}`);
} catch (error) { } catch (error) {
dispatchAdmin({ type: "cueMutationFinished" }); dispatchAdmin({ type: "cueMutationFinished" });
@ -1576,38 +1621,57 @@ export const App = () => {
return cue.assetIds?.length && cue.assetIds.length > 0 ? cue.assetIds.length : findCollectionAssets(state, cue.collectionId).length; return cue.assetIds?.length && cue.assetIds.length > 0 ? cue.assetIds.length : findCollectionAssets(state, cue.collectionId).length;
}; };
const renderCueRows = (variant: "show" | "build") => ( const renderCueRows = (variant: "show" | "build") => {
<div className={`cue-list cue-list--${variant}`}> const renderCueRow = (cue: Cue) => {
{cueStack.map((cue) => { const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined;
const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined; const preset = definition
const preset = definition ? matchPresetForScene(definition, availablePresets, cue.effectPresetId)
? matchPresetForScene(definition, availablePresets, cue.effectPresetId) : undefined;
: undefined; const cueAssetCount = getCueAssetCount(cue);
const cueAssetCount = getCueAssetCount(cue);
return ( return (
<button <button
key={cue.id} key={cue.id}
className={`cue-row ${cue.id === cueState.previewCueId ? "cue-row--armed" : ""} ${cue.id === cueState.currentCueId ? "cue-row--live" : ""}`} className={`cue-row ${cue.id === cueState.previewCueId ? "cue-row--armed" : ""} ${cue.id === cueState.currentCueId ? "cue-row--live" : ""}`}
onClick={() => syncPreviewFromCue(cue)} onClick={() => syncPreviewFromCue(cue)}
title={`${cue.notes ?? cue.id} · ${definition?.name ?? "Unknown scene"} · ${preset?.name ?? "Default mode"} · ${cue.triggerMode} · ${cue.transitionIn.style} · ${cueAssetCount} assets`} title={`${cue.notes ?? cue.id} · ${definition?.name ?? "Unknown scene"} · ${preset?.name ?? "Default mode"} · ${cue.triggerMode} · ${cue.transitionIn.style} · ${cueAssetCount} assets`}
> >
<span>{cue.orderIndex}</span> <span>{cue.orderIndex}</span>
<div className="cue-row__body"> <div className="cue-row__body">
<strong>{cue.notes ?? cue.id}</strong> <strong>{cue.notes ?? cue.id}</strong>
<small>
{definition?.name ?? "Unknown scene"} / {preset?.name ?? "Default mode"}
</small>
</div>
<small> <small>
{cue.triggerMode} / {cue.transitionIn.style} / {cueAssetCount} {definition?.name ?? "Unknown scene"} / {preset?.name ?? "Default mode"}
</small> </small>
</button> </div>
); <small>
})} {cue.triggerMode} / {cue.transitionIn.style} / {cueAssetCount}
{cueStack.length === 0 ? <p className="empty-state">No cues have been created yet.</p> : null} </small>
</div> </button>
); );
};
if (variant === "build") {
return (
<div className={`cue-list cue-list--${variant}`}>
{cueStack.map((cue) => renderCueRow(cue))}
{cueStack.length === 0 ? <p className="empty-state">No cues have been created yet.</p> : null}
</div>
);
}
return (
<VirtualizedList
items={cueStack}
className={`cue-list cue-list--${variant}`}
estimateSize={58}
overscan={8}
gap={6}
itemKey={(cue) => cue.id}
empty={<p className="empty-state">No cues have been created yet.</p>}
renderItem={(cue) => renderCueRow(cue)}
/>
);
};
const renderSceneModeChooser = (variant: "show" | "build") => ( const renderSceneModeChooser = (variant: "show" | "build") => (
<div className={`browser-stack browser-stack--${variant}`}> <div className={`browser-stack browser-stack--${variant}`}>
@ -1812,7 +1876,7 @@ export const App = () => {
onClick={() => focusMetadataAsset(asset)} onClick={() => focusMetadataAsset(asset)}
> >
<div className="selected-asset__thumb"> <div className="selected-asset__thumb">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />} {asset.thumbKey ? <img src={asset.thumbKey} alt="" loading="lazy" decoding="async" /> : <div className="asset-card__placeholder" />}
</div> </div>
<div className="selected-asset__body"> <div className="selected-asset__body">
<div className="asset-meta"> <div className="asset-meta">
@ -1874,7 +1938,7 @@ export const App = () => {
<div className="metadata-inspector__preview"> <div className="metadata-inspector__preview">
<div className="metadata-inspector__thumb"> <div className="metadata-inspector__thumb">
{metadataAsset.previewKey || metadataAsset.thumbKey ? ( {metadataAsset.previewKey || metadataAsset.thumbKey ? (
<img src={metadataAsset.previewKey ?? metadataAsset.thumbKey} alt="" /> <img src={metadataAsset.previewKey ?? metadataAsset.thumbKey} alt="" loading="lazy" decoding="async" />
) : ( ) : (
<div className="asset-card__placeholder" /> <div className="asset-card__placeholder" />
)} )}
@ -1972,8 +2036,16 @@ export const App = () => {
); );
const renderApprovedBank = (variant: "show" | "build") => ( const renderApprovedBank = (variant: "show" | "build") => (
<div className={`bank-list bank-list--${variant}`}> <VirtualizedGrid
{filteredApprovedAssets.map((asset) => { items={filteredApprovedAssets}
className={`bank-list bank-list--${variant}`}
minColumnWidth={variant === "build" ? 92 : 100}
maxColumnWidth={variant === "build" ? 112 : 118}
gap={8}
overscan={4}
itemKey={(asset) => asset.id}
empty={<p className="empty-state">Approved images will appear here after import or moderation.</p>}
renderItem={(asset) => {
const submission = submissionMap.get(asset.submissionId); const submission = submissionMap.get(asset.submissionId);
const assetLabel = getAssetPrimaryLabel(asset, submission); const assetLabel = getAssetPrimaryLabel(asset, submission);
const assetDetail = getAssetSecondaryLabel(submission); const assetDetail = getAssetSecondaryLabel(submission);
@ -1992,7 +2064,7 @@ export const App = () => {
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`} title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
> >
<div className="bank-item__thumb"> <div className="bank-item__thumb">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />} {asset.thumbKey ? <img src={asset.thumbKey} alt="" loading="lazy" decoding="async" /> : <div className="asset-card__placeholder" />}
</div> </div>
<div className="bank-item__overlay"> <div className="bank-item__overlay">
<div className="bank-item__flags"> <div className="bank-item__flags">
@ -2020,25 +2092,29 @@ export const App = () => {
</button> </button>
</article> </article>
); );
})} }}
{filteredApprovedAssets.length === 0 ? <p className="empty-state">Approved images will appear here after import or moderation.</p> : null} />
</div>
); );
const renderPendingList = (variant: "show" | "build") => ( const renderPendingList = (variant: "show" | "build") => (
<div className={`asset-list asset-list--${variant}`}> <VirtualizedList
{filteredPendingAssets.length === 0 ? <p className="empty-state">No pending submissions right now.</p> : null} items={filteredPendingAssets}
{filteredPendingAssets.map((asset) => { className={`asset-list asset-list--${variant}`}
estimateSize={146}
overscan={6}
gap={8}
itemKey={(asset) => asset.id}
empty={<p className="empty-state">No pending submissions right now.</p>}
renderItem={(asset) => {
const submission = submissionMap.get(asset.submissionId); const submission = submissionMap.get(asset.submissionId);
const assetLabel = getAssetPrimaryLabel(asset, submission); const assetLabel = getAssetPrimaryLabel(asset, submission);
return ( return (
<article <article
key={asset.id}
className={`asset-card ${metadataAssetId === asset.id ? "asset-card--editing" : ""}`} className={`asset-card ${metadataAssetId === asset.id ? "asset-card--editing" : ""}`}
onClick={() => focusMetadataAsset(asset)} onClick={() => focusMetadataAsset(asset)}
> >
<div className="asset-card__media"> <div className="asset-card__media">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />} {asset.thumbKey ? <img src={asset.thumbKey} alt="" loading="lazy" decoding="async" /> : <div className="asset-card__placeholder" />}
</div> </div>
<div className="asset-card__body"> <div className="asset-card__body">
<div className="asset-meta"> <div className="asset-meta">
@ -2065,8 +2141,8 @@ export const App = () => {
</div> </div>
</article> </article>
); );
})} }}
</div> />
); );
const renderUploadTools = () => ( const renderUploadTools = () => (
@ -2322,7 +2398,8 @@ export const App = () => {
blackout={cueState.blackout} blackout={cueState.blackout}
transition={programOutputState?.transition ?? null} transition={programOutputState?.transition ?? null}
activationKey={programActivationKey} activationKey={programActivationKey}
qualityProfile="program" qualityProfile="program-monitor"
busy={workspaceMode === "build"}
/> />
</div> </div>
</div> </div>

View File

@ -20,7 +20,7 @@ const enterFullscreen = async () => {
export const ProgramOutputApp = () => { export const ProgramOutputApp = () => {
const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState()); const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState());
const [overlayVisible, setOverlayVisible] = useState(true); const [overlayVisible, setOverlayVisible] = useState(false);
const [overlayDismissed, setOverlayDismissed] = useState(false); const [overlayDismissed, setOverlayDismissed] = useState(false);
const hideTimeoutRef = useRef<number | null>(null); const hideTimeoutRef = useRef<number | null>(null);
@ -74,11 +74,6 @@ export const ProgramOutputApp = () => {
[] []
); );
useEffect(() => {
setOverlayDismissed(false);
showOverlay(2800);
}, [outputState?.updatedAt, showOverlay]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "f") { if (event.key.toLowerCase() === "f") {
@ -132,6 +127,7 @@ export const ProgramOutputApp = () => {
blackout={outputState?.blackout ?? false} blackout={outputState?.blackout ?? false}
transition={transition} transition={transition}
activationKey={createPresentationStructureHash(outputState?.presentation ?? null)} activationKey={createPresentationStructureHash(outputState?.presentation ?? null)}
qualityProfile="program-output"
/> />
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}> <div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
<div> <div>

View File

@ -214,7 +214,6 @@ select:focus-visible {
background: var(--panel); background: var(--panel);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(18px);
} }
.show-layout { .show-layout {
@ -418,7 +417,6 @@ select:focus-visible {
} }
.utility-tabpanel, .utility-tabpanel,
.cue-list,
.browser-stack, .browser-stack,
.build-sidebar-scroll, .build-sidebar-scroll,
.build-media-browser, .build-media-browser,
@ -426,11 +424,17 @@ select:focus-visible {
.show-media-pane, .show-media-pane,
.show-moderation-pane { .show-moderation-pane {
min-height: 0; min-height: 0;
overflow: auto; overflow: hidden;
scrollbar-gutter: stable; scrollbar-gutter: stable;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.utility-tabpanel,
.build-sidebar-scroll,
.build-media-inspector {
overflow: auto;
}
.utility-tabpanel, .utility-tabpanel,
.build-sidebar-scroll, .build-sidebar-scroll,
.show-media-pane, .show-media-pane,
@ -439,6 +443,14 @@ select:focus-visible {
gap: var(--space-3); gap: var(--space-3);
} }
.show-media-pane {
grid-template-rows: auto auto minmax(0, 1fr);
}
.show-moderation-pane {
grid-template-rows: auto minmax(0, 1fr);
}
.browser-stack { .browser-stack {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
@ -846,6 +858,10 @@ select:focus-visible {
gap: var(--space-2); gap: var(--space-2);
} }
.build-media-stack {
grid-template-rows: auto minmax(0, 1fr);
}
.build-media-workarea { .build-media-workarea {
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.92fr); grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.92fr);
} }
@ -855,23 +871,45 @@ select:focus-visible {
min-height: 0; min-height: 0;
} }
.bank-list { .build-media-browser {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-2); gap: var(--space-2);
} }
.bank-list--build { .build-media-browser > .bank-list,
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); .build-media-browser > .asset-list {
min-height: 0;
} }
.build-media-browser > .bank-list:only-child,
.build-media-browser > .asset-list:only-child {
height: 100%;
}
.bank-list,
.asset-list,
.cue-list {
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
overscroll-behavior: contain;
}
.bank-list {
display: block;
}
.bank-list--build,
.bank-list--show { .bank-list--show {
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); padding-right: 2px;
} }
.bank-item { .bank-item {
display: grid; display: grid;
gap: 0; gap: 0;
padding: 0; padding: 0;
width: 100%;
aspect-ratio: 0.86; aspect-ratio: 0.86;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -972,8 +1010,7 @@ select:focus-visible {
} }
.asset-list { .asset-list {
display: grid; display: block;
gap: var(--space-2);
} }
.asset-card { .asset-card {
@ -1038,8 +1075,7 @@ select:focus-visible {
} }
.cue-list { .cue-list {
display: grid; display: block;
gap: var(--space-2);
} }
.cue-row { .cue-row {
@ -1047,6 +1083,7 @@ select:focus-visible {
grid-template-columns: 24px minmax(0, 1fr) auto; grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: start; align-items: start;
gap: var(--space-2); gap: var(--space-2);
width: 100%;
text-align: left; text-align: left;
padding: 6px 7px; padding: 6px 7px;
} }
@ -1102,13 +1139,23 @@ select:focus-visible {
padding-right: 2px; padding-right: 2px;
} }
.build-sidebar-panel .cue-list {
overflow: visible;
}
.show-cue-panel .cue-list {
height: 100%;
}
.show-cue-panel .cue-list { .show-cue-panel .cue-list {
max-height: 100%; max-height: 100%;
} }
.show-media-pane .bank-list, .show-media-pane .bank-list,
.show-moderation-pane .asset-list { .show-moderation-pane .asset-list,
max-height: 100%; .build-media-browser .bank-list,
.build-media-browser .asset-list {
height: 100%;
} }
.danger { .danger {

View File

@ -55,10 +55,9 @@ body.mode-output .output-overlay {
align-items: end; align-items: end;
padding: 16px 18px; padding: 16px 18px;
border-radius: 18px; border-radius: 18px;
background: rgba(8, 12, 16, 0.78); background: rgba(8, 12, 16, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
color: #f5f2ea; color: #f5f2ea;
backdrop-filter: blur(18px);
opacity: 0; opacity: 0;
transform: translateY(12px); transform: translateY(12px);
pointer-events: none; pointer-events: none;

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { memo, useEffect, useRef } from "react";
import type { import type {
RenderSurface as RenderSurfaceType, RenderSurface as RenderSurfaceType,
SurfacePresentation, SurfacePresentation,
@ -21,12 +21,12 @@ const defaultTransition: CueTransition = {
durationMs: 0 durationMs: 0
}; };
export const SceneViewport = ({ const SceneViewportInner = ({
presentation, presentation,
blackout = false, blackout = false,
transition, transition,
activationKey, activationKey,
qualityProfile = "program", qualityProfile = "program-monitor",
busy = false busy = false
}: SceneViewportProps) => { }: SceneViewportProps) => {
const frameRef = useRef<HTMLDivElement | null>(null); const frameRef = useRef<HTMLDivElement | null>(null);
@ -55,14 +55,28 @@ export const SceneViewport = ({
let cancelled = false; let cancelled = false;
let observer: ResizeObserver | null = null; let observer: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
void import("@goodgrief/render-engine").then(({ RenderSurface, defaultScenePlugins }) => { const syncPausedState = (intersects = true) => {
const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
surfaceRef.current?.setPaused(hidden || !intersects);
};
const handleVisibility = () => {
if (!frame.isConnected) {
return;
}
const rect = frame.getBoundingClientRect();
const visible = rect.width > 0 && rect.height > 0;
syncPausedState(visible);
};
void import("@goodgrief/render-engine").then(({ RenderSurface }) => {
if (cancelled) { if (cancelled) {
return; return;
} }
const surface = new RenderSurface(canvas); const surface = new RenderSurface(canvas);
surface.registerMany(defaultScenePlugins);
surface.setQualityProfile(qualityProfileRef.current); surface.setQualityProfile(qualityProfileRef.current);
surface.setBusy(busyRef.current); surface.setBusy(busyRef.current);
surface.setBlackout(blackoutRef.current, null, true); surface.setBlackout(blackoutRef.current, null, true);
@ -77,8 +91,22 @@ export const SceneViewport = ({
observer = new ResizeObserver(() => resize()); observer = new ResizeObserver(() => resize());
observer.observe(frame); observer.observe(frame);
if (typeof IntersectionObserver !== "undefined") {
intersectionObserver = new IntersectionObserver((entries) => {
const next = entries[0]?.isIntersecting ?? true;
syncPausedState(next);
}, { threshold: 0.05 });
intersectionObserver.observe(frame);
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", handleVisibility);
}
handleVisibility();
const initialPresentation = presentationRef.current; const initialPresentation = presentationRef.current;
if (initialPresentation) { if (initialPresentation) {
surface.preloadPresentation(initialPresentation);
void surface.activate(initialPresentation, defaultTransition, activationRef.current); void surface.activate(initialPresentation, defaultTransition, activationRef.current);
} }
}); });
@ -86,6 +114,10 @@ export const SceneViewport = ({
return () => { return () => {
cancelled = true; cancelled = true;
observer?.disconnect(); observer?.disconnect();
intersectionObserver?.disconnect();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibility);
}
surfaceRef.current?.dispose(); surfaceRef.current?.dispose();
surfaceRef.current = null; surfaceRef.current = null;
}; };
@ -109,10 +141,12 @@ export const SceneViewport = ({
return; return;
} }
surface.preloadPresentation(presentationRef.current);
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition, activationRef.current); void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition, activationRef.current);
}, [activationKey]); }, [activationKey]);
useEffect(() => { useEffect(() => {
surfaceRef.current?.preloadPresentation(presentation);
surfaceRef.current?.updatePresentation(presentation, activationKey); surfaceRef.current?.updatePresentation(presentation, activationKey);
}, [activationKey, presentation]); }, [activationKey, presentation]);
@ -122,3 +156,5 @@ export const SceneViewport = ({
</div> </div>
); );
}; };
export const SceneViewport = memo(SceneViewportInner);

View File

@ -0,0 +1,104 @@
import { useEffect, useMemo, useRef, useState, type Key, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface VirtualizedGridProps<T> {
items: T[];
className?: string;
minColumnWidth: number;
maxColumnWidth?: number;
gap?: number;
overscan?: number;
itemAspectRatio?: number;
empty?: ReactNode;
itemKey?: (item: T, index: number) => Key;
renderItem: (item: T, index: number) => ReactNode;
}
const fallbackWidth = 720;
export const VirtualizedGrid = <T,>({
items,
className,
minColumnWidth,
maxColumnWidth,
gap = 8,
overscan = 3,
itemAspectRatio = 0.86,
empty = null,
itemKey,
renderItem
}: VirtualizedGridProps<T>) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(fallbackWidth);
useEffect(() => {
const element = scrollRef.current;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const measure = () => setWidth(Math.max(element.clientWidth, minColumnWidth));
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(element);
return () => observer.disconnect();
}, [minColumnWidth]);
const columns = Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap)));
const rowCount = Math.ceil(items.length / columns);
const computedCellWidth = Math.max(minColumnWidth, (width - gap * Math.max(columns - 1, 0)) / columns);
const cellWidth = maxColumnWidth ? Math.min(computedCellWidth, maxColumnWidth) : computedCellWidth;
const rowHeight = Math.max(1, cellWidth / itemAspectRatio);
const rows = useMemo(() => {
const grouped: T[][] = [];
for (let index = 0; index < items.length; index += columns) {
grouped.push(items.slice(index, index + columns));
}
return grouped;
}, [columns, items]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight + gap,
overscan
});
if (items.length === 0) {
return <div className={className}>{empty}</div>;
}
return (
<div ref={scrollRef} className={className}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] ?? [];
const startIndex = virtualRow.index * columns;
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
display: "grid",
gap: `${gap}px`,
justifyContent: "start",
gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,
transform: `translateY(${virtualRow.start}px)`
}}
>
{row.map((item, offset) => (
<div key={itemKey ? itemKey(item, startIndex + offset) : `${virtualRow.index}-${offset}`}>
{renderItem(item, startIndex + offset)}
</div>
))}
</div>
);
})}
</div>
</div>
);
};

View File

@ -0,0 +1,61 @@
import { useRef, type Key, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface VirtualizedListProps<T> {
items: T[];
className?: string;
estimateSize: number;
overscan?: number;
gap?: number;
empty?: ReactNode;
itemKey?: (item: T, index: number) => Key;
renderItem: (item: T, index: number) => ReactNode;
}
export const VirtualizedList = <T,>({
items,
className,
estimateSize,
overscan = 6,
gap = 0,
empty = null,
itemKey,
renderItem
}: VirtualizedListProps<T>) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize + gap,
overscan
});
if (items.length === 0) {
return <div className={className}>{empty}</div>;
}
return (
<div ref={scrollRef} className={className}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={itemKey ? itemKey(item, virtualItem.index) : virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
paddingBottom: `${gap}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
{renderItem(item, virtualItem.index)}
</div>
);
})}
</div>
</div>
);
};

View File

@ -11,16 +11,24 @@ import type {
SubmissionUpdatePayload SubmissionUpdatePayload
} from "@goodgrief/shared-types"; } from "@goodgrief/shared-types";
export interface AdminBootstrapPayload extends RepositoryState {
libraryRevision: string;
programRevision: string;
}
export interface AdminLivePayload { export interface AdminLivePayload {
cues: Cue[]; cues: Cue[];
pendingCount: number; pendingCount: number;
approvedCount: number; approvedCount: number;
libraryRevision: string;
programRevision: string;
} }
export interface AdminLibraryPayload { export interface AdminLibraryPayload {
photoAssets: PhotoAsset[]; photoAssets: PhotoAsset[];
submissions: Submission[]; submissions: Submission[];
collections: Collection[]; collections: Collection[];
revision: string;
} }
const postVoid = async (url: string, body?: unknown) => { const postVoid = async (url: string, body?: unknown) => {
@ -56,8 +64,8 @@ const requestJson = async <T>(url: string, init?: RequestInit) => {
return (await response.json()) as T; return (await response.json()) as T;
}; };
export const loadAdminBootstrap = async (): Promise<RepositoryState> => export const loadAdminBootstrap = async (): Promise<AdminBootstrapPayload> =>
requestJson<RepositoryState>("/api/admin/bootstrap"); requestJson<AdminBootstrapPayload>("/api/admin/bootstrap");
export const loadAdminLive = async (): Promise<AdminLivePayload> => export const loadAdminLive = async (): Promise<AdminLivePayload> =>
requestJson<AdminLivePayload>("/api/admin/live"); requestJson<AdminLivePayload>("/api/admin/live");
@ -65,8 +73,8 @@ export const loadAdminLive = async (): Promise<AdminLivePayload> =>
export const loadAdminLibrary = async (): Promise<AdminLibraryPayload> => export const loadAdminLibrary = async (): Promise<AdminLibraryPayload> =>
requestJson<AdminLibraryPayload>("/api/admin/library"); requestJson<AdminLibraryPayload>("/api/admin/library");
export const rescanLibrary = async (): Promise<RepositoryState> => export const rescanLibrary = async (): Promise<AdminBootstrapPayload> =>
requestJson<RepositoryState>("/api/library/rescan", { requestJson<AdminBootstrapPayload>("/api/library/rescan", {
method: "POST" method: "POST"
}); });

View File

@ -1,3 +1,4 @@
import path from "node:path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
@ -11,5 +12,43 @@ export default defineConfig({
"/api": apiProxyTarget, "/api": apiProxyTarget,
"/uploads": apiProxyTarget "/uploads": apiProxyTarget
} }
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes("packages/render-engine/src/render-surface") ||
id.includes("packages/render-engine/src/index") ||
id.includes("packages/render-engine/src/types") ||
id.includes("packages/render-engine/src/scene-loader")
) {
return "render-core";
}
if (id.includes("packages/render-engine/src/scene-helpers")) {
return "render-scene-support";
}
if (id.includes("packages/render-engine/src/text-overlay")) {
return "render-text";
}
if (id.includes("packages/render-engine/src/scenes/")) {
return `scene-${path.basename(id, path.extname(id))}`;
}
if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) {
return "react-vendor";
}
if (id.includes("node_modules/@tanstack/react-virtual")) {
return "admin-virtual";
}
return undefined;
}
}
}
} }
}); });

28
package-lock.json generated
View File

@ -26,6 +26,7 @@
"@goodgrief/effects": "file:../../packages/effects", "@goodgrief/effects": "file:../../packages/effects",
"@goodgrief/render-engine": "file:../../packages/render-engine", "@goodgrief/render-engine": "file:../../packages/render-engine",
"@goodgrief/shared-types": "file:../../packages/shared-types", "@goodgrief/shared-types": "file:../../packages/shared-types",
"@tanstack/react-virtual": "^3.13.23",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@ -1941,6 +1942,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tweenjs/tween.js": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",

View File

@ -20,7 +20,8 @@
"dev:api": "npm run dev --workspace @goodgrief/api", "dev:api": "npm run dev --workspace @goodgrief/api",
"dev:api:watch": "npm run dev:watch --workspace @goodgrief/api", "dev:api:watch": "npm run dev:watch --workspace @goodgrief/api",
"dev:worker": "npm run dev --workspace @goodgrief/worker", "dev:worker": "npm run dev --workspace @goodgrief/worker",
"dev:worker:watch": "npm run dev:watch --workspace @goodgrief/worker" "dev:worker:watch": "npm run dev:watch --workspace @goodgrief/worker",
"perf:admin": "node scripts/report-admin-performance.mjs"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,835 @@
import * as THREE from "three";
import { flattenSceneParams, mergeSceneParams, type CueTransition } from "@goodgrief/shared-types";
import { loadScenePlugin, loadTextOverlayModule, preloadScenePlugin, preloadTextOverlayModule } from "./scene-loader";
import type {
LoadedPhotoAsset,
SceneInstance,
SceneParams,
ScenePlugin,
SceneViewport,
SurfacePresentation,
SurfaceQualityProfile
} from "./types";
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
class TextureCache {
private readonly loader = new THREE.TextureLoader();
private readonly cache = new Map<string, Promise<THREE.Texture | null>>();
async load(url: string | null) {
if (!url) {
return null;
}
const cached = this.cache.get(url);
if (cached) {
return cached;
}
const promise = this.loader
.loadAsync(url)
.then((texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
})
.catch(() => null)
.then((texture) => {
if (!texture) {
this.cache.delete(url);
}
return texture;
});
this.cache.set(url, promise);
return promise;
}
async clear() {
const textures = await Promise.all(this.cache.values());
textures.forEach((texture) => texture?.dispose());
this.cache.clear();
}
}
const disposeObject3D = (root: THREE.Object3D) => {
root.traverse((node) => {
const mesh = node as THREE.Mesh;
if ("geometry" in mesh && mesh.geometry) {
mesh.geometry.dispose();
}
const material = (mesh as { material?: THREE.Material | THREE.Material[] }).material;
if (Array.isArray(material)) {
material.forEach((entry) => entry.dispose());
} else {
material?.dispose();
}
});
};
interface SceneRuntime {
presentation: SurfacePresentation;
params: SceneParams;
targetMotion: number;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
instance: SceneInstance;
}
interface TransitionRuntime {
from: SceneRuntime;
to: SceneRuntime;
style: CueTransition["style"];
durationMs: number;
startedAtMs: number;
}
interface BlackoutRuntime {
fromLevel: number;
toLevel: number;
style: CueTransition["style"];
durationMs: number;
startedAtMs: number;
}
const createSceneCamera = (viewport: SceneViewport) => {
const camera = new THREE.PerspectiveCamera(32, viewport.aspect, 0.1, 100);
camera.position.set(0, 0, 7.2);
camera.lookAt(0, 0, -3.2);
return camera;
};
const updateRuntimeCamera = (runtime: SceneRuntime, viewport: SceneViewport) => {
runtime.camera.aspect = viewport.aspect;
runtime.camera.updateProjectionMatrix();
};
const updateSceneRuntime = (
runtime: SceneRuntime,
context: { elapsedMs: number; deltaMs: number; viewport: SceneViewport }
) => {
const motionLerp = 1 - Math.exp(-context.deltaMs / 140);
runtime.params.composition.motion = THREE.MathUtils.lerp(
runtime.params.composition.motion,
runtime.targetMotion,
clamp(motionLerp, 0, 1)
);
runtime.instance.update?.(context);
};
const resolvePresentationParams = (presentation: SurfacePresentation) =>
mergeSceneParams(presentation.definition.defaultParams, presentation.cue?.parameterOverrides, presentation.params);
const liveMutableParamPaths = new Set([
"composition.motion",
"composition.cameraTravel",
"composition.orbitAmount",
"scenicTreatment.fieldType",
"scenicTreatment.fieldIntensity",
"scenicTreatment.fieldScale",
"scenicTreatment.fieldSpeed",
"scenicTreatment.hue",
"scenicTreatment.saturation",
"scenicTreatment.lightness"
]);
const createPresentationStructureSignature = (presentation: SurfacePresentation) => {
const params = flattenSceneParams(resolvePresentationParams(presentation));
for (const path of liveMutableParamPaths) {
delete params[path];
}
return JSON.stringify({
definitionId: presentation.definition.id,
effectPresetId: presentation.effectPresetId ?? null,
modeKey: presentation.modeKey ?? null,
assetIds: presentation.assets.map((asset) => asset.id),
textFragments: presentation.textFragments ?? [],
anchorCaption: presentation.anchorCaption ?? null,
params
});
};
const canPatchRuntimeInPlace = (runtime: SceneRuntime, presentation: SurfacePresentation) =>
createPresentationStructureSignature(runtime.presentation) === createPresentationStructureSignature(presentation);
const applyPresentationToRuntime = (runtime: SceneRuntime, presentation: SurfacePresentation) => {
const mergedParams = resolvePresentationParams(presentation);
const currentMotion = runtime.params.composition.motion;
Object.assign(runtime.params.photoTreatment, mergedParams.photoTreatment);
Object.assign(runtime.params.scenicTreatment, mergedParams.scenicTreatment);
Object.assign(runtime.params.composition, mergedParams.composition);
Object.assign(runtime.params.textTreatment, mergedParams.textTreatment);
runtime.params.composition.motion = currentMotion;
runtime.presentation = presentation;
runtime.targetMotion = mergedParams.composition.motion;
};
const disposeSceneRuntime = (runtime: SceneRuntime | null) => {
if (!runtime) {
return;
}
runtime.scene.remove(runtime.instance.root);
disposeObject3D(runtime.instance.root);
runtime.instance.dispose?.();
runtime.scene.clear();
};
const combineInstances = (...instances: Array<SceneInstance | null | undefined>): SceneInstance => {
const active = instances.filter((instance): instance is SceneInstance => Boolean(instance));
if (active.length === 1) {
return active[0]!;
}
const root = new THREE.Group();
active.forEach((instance) => root.add(instance.root));
return {
root,
update: (context) => active.forEach((instance) => instance.update?.(context)),
dispose: () => active.forEach((instance) => instance.dispose?.())
};
};
const shouldLoadTextOverlay = (presentation: SurfacePresentation, params: SceneParams) =>
params.textTreatment.mode !== "off" &&
(presentation.textFragments ?? []).some((value) => value.trim().length > 0);
export class RenderSurface {
private readonly renderer: THREE.WebGLRenderer;
private readonly registry = new Map<string, ScenePlugin>();
private readonly textureCache = new TextureCache();
private readonly compositeScene = new THREE.Scene();
private readonly compositeCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
private readonly compositeFromMaterial = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 1,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
toneMapped: false
});
private readonly compositeToMaterial = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
toneMapped: false
});
private readonly compositeFromQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeFromMaterial);
private readonly compositeToQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeToMaterial);
private readonly blackoutQuad = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
new THREE.MeshBasicMaterial({
color: "#000000",
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
toneMapped: false
})
);
private readonly shutterBars = Array.from({ length: 8 }, () =>
new THREE.Mesh(
new THREE.PlaneGeometry(2.4, 0.18),
new THREE.MeshBasicMaterial({
color: "#0a0d14",
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
toneMapped: false
})
)
);
private readonly veilOverlay = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
new THREE.MeshBasicMaterial({
color: "#121a26",
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
blending: THREE.NormalBlending,
toneMapped: false
})
);
private readonly compositeResolution = new THREE.Vector2(1280, 720);
private readonly fromTarget = new THREE.WebGLRenderTarget(1280, 720, {
depthBuffer: true,
stencilBuffer: false
});
private readonly toTarget = new THREE.WebGLRenderTarget(1280, 720, {
depthBuffer: true,
stencilBuffer: false
});
private viewport: SceneViewport = {
width: 1280,
height: 720,
aspect: 16 / 9
};
private currentRuntime: SceneRuntime | null = null;
private transitionRuntime: TransitionRuntime | null = null;
private blackoutRuntime: BlackoutRuntime | null = null;
private lastFrameMs = 0;
private blackoutLevel = 0;
private activationToken = 0;
private activePresentationKey: string | undefined;
private qualityProfile: SurfaceQualityProfile = "program-monitor";
private busy = false;
private paused = false;
private readonly animationLoop = (timestamp: number) => {
const minFrameIntervalMs = this.getMinFrameIntervalMs();
if (minFrameIntervalMs > 0 && this.lastFrameMs !== 0 && timestamp - this.lastFrameMs < minFrameIntervalMs) {
return;
}
const deltaMs = this.lastFrameMs === 0 ? 16.6 : timestamp - this.lastFrameMs;
this.lastFrameMs = timestamp;
const context = {
elapsedMs: timestamp,
deltaMs,
viewport: this.viewport
};
this.renderer.setClearColor("#040508", 1);
if (this.transitionRuntime) {
const progress = clamp((timestamp - this.transitionRuntime.startedAtMs) / this.transitionRuntime.durationMs, 0, 1);
updateSceneRuntime(this.transitionRuntime.from, context);
updateSceneRuntime(this.transitionRuntime.to, context);
this.renderRuntimeToTarget(this.transitionRuntime.from, this.fromTarget);
this.renderRuntimeToTarget(this.transitionRuntime.to, this.toTarget);
this.renderCompositeTransition(progress, this.transitionRuntime.style);
this.renderBlackoutOverlay(timestamp);
if (progress >= 1) {
this.finishTransition();
}
return;
}
if (!this.currentRuntime) {
this.renderer.setRenderTarget(null);
this.renderer.clear();
this.renderBlackoutOverlay(timestamp);
return;
}
updateSceneRuntime(this.currentRuntime, context);
this.renderRuntimeToTarget(this.currentRuntime, this.toTarget);
this.renderTargetToScreen(this.toTarget);
this.renderBlackoutOverlay(timestamp);
};
constructor(canvas: HTMLCanvasElement) {
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true
});
this.renderer.toneMapping = THREE.NoToneMapping;
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.setClearColor("#040508", 1);
this.fromTarget.texture.colorSpace = THREE.NoColorSpace;
this.toTarget.texture.colorSpace = THREE.NoColorSpace;
this.fromTarget.texture.minFilter = THREE.LinearFilter;
this.fromTarget.texture.magFilter = THREE.LinearFilter;
this.toTarget.texture.minFilter = THREE.LinearFilter;
this.toTarget.texture.magFilter = THREE.LinearFilter;
this.compositeCamera.position.set(0, 0, 1);
this.compositeCamera.lookAt(0, 0, 0);
this.compositeFromQuad.position.z = 0;
this.compositeToQuad.position.z = 0.01;
this.blackoutQuad.position.z = 0.019;
this.veilOverlay.position.z = 0.02;
this.compositeScene.add(this.compositeFromQuad, this.compositeToQuad, this.blackoutQuad, this.veilOverlay);
this.shutterBars.forEach((bar, index) => {
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
this.compositeScene.add(bar);
});
this.applyQualitySettings();
this.startAnimationLoop();
preloadScenePlugin("safe-hold");
}
register(plugin: ScenePlugin) {
this.registry.set(plugin.sceneKey, plugin);
}
registerMany(plugins: ScenePlugin[]) {
plugins.forEach((plugin) => this.register(plugin));
}
preloadPresentation(presentation: SurfacePresentation | null) {
if (!presentation) {
return;
}
const params = resolvePresentationParams(presentation);
if (!this.registry.has(presentation.definition.sceneKey)) {
preloadScenePlugin(presentation.definition.sceneKey);
}
if (shouldLoadTextOverlay(presentation, params)) {
preloadTextOverlayModule();
}
}
setQualityProfile(profile: SurfaceQualityProfile) {
if (this.qualityProfile === profile) {
return;
}
this.qualityProfile = profile;
this.applyQualitySettings();
}
setBusy(busy: boolean) {
if (this.busy === busy) {
return;
}
this.busy = busy;
this.applyQualitySettings();
}
setPaused(paused: boolean) {
if (this.paused === paused) {
return;
}
this.paused = paused;
if (paused) {
this.renderer.setAnimationLoop(null);
return;
}
this.lastFrameMs = 0;
this.startAnimationLoop();
}
setSize(width: number, height: number) {
this.viewport = {
width,
height,
aspect: width / Math.max(height, 1)
};
this.renderer.setSize(width, height, false);
this.compositeResolution.set(
Math.max(1, Math.round(width * this.renderer.getPixelRatio())),
Math.max(1, Math.round(height * this.renderer.getPixelRatio()))
);
this.fromTarget.setSize(
Math.max(1, Math.round(width * this.renderer.getPixelRatio())),
Math.max(1, Math.round(height * this.renderer.getPixelRatio()))
);
this.toTarget.setSize(
Math.max(1, Math.round(width * this.renderer.getPixelRatio())),
Math.max(1, Math.round(height * this.renderer.getPixelRatio()))
);
if (this.currentRuntime) {
updateRuntimeCamera(this.currentRuntime, this.viewport);
}
if (this.transitionRuntime) {
updateRuntimeCamera(this.transitionRuntime.from, this.viewport);
updateRuntimeCamera(this.transitionRuntime.to, this.viewport);
}
}
setBlackout(blackout: boolean, transition?: CueTransition | null, immediate = false) {
const nextLevel = blackout ? 1 : 0;
const now = this.lastFrameMs || (typeof performance !== "undefined" ? performance.now() : 0);
if (this.blackoutRuntime) {
const progress = clamp((now - this.blackoutRuntime.startedAtMs) / this.blackoutRuntime.durationMs, 0, 1);
const eased = THREE.MathUtils.smoothstep(progress, 0, 1);
this.blackoutLevel = THREE.MathUtils.lerp(this.blackoutRuntime.fromLevel, this.blackoutRuntime.toLevel, eased);
this.blackoutRuntime = null;
}
if (Math.abs(nextLevel - this.blackoutLevel) < 0.001) {
return;
}
if (immediate || !transition || transition.style === "cut" || transition.durationMs <= 0) {
this.blackoutLevel = nextLevel;
return;
}
this.blackoutRuntime = {
fromLevel: this.blackoutLevel,
toLevel: nextLevel,
style: transition.style,
durationMs: transition.durationMs,
startedAtMs: now
};
}
updatePresentation(presentation: SurfacePresentation | null, activationKey?: string) {
if (!presentation || activationKey !== this.activePresentationKey) {
return;
}
if (this.transitionRuntime?.to && canPatchRuntimeInPlace(this.transitionRuntime.to, presentation)) {
applyPresentationToRuntime(this.transitionRuntime.to, presentation);
return;
}
if (this.currentRuntime && canPatchRuntimeInPlace(this.currentRuntime, presentation)) {
applyPresentationToRuntime(this.currentRuntime, presentation);
}
}
async activate(presentation: SurfacePresentation | null, transition?: CueTransition | null, activationKey?: string) {
const token = this.activationToken + 1;
this.activationToken = token;
this.activePresentationKey = activationKey;
if (!presentation) {
this.clearAll();
return;
}
this.preloadPresentation(presentation);
if (
this.currentRuntime &&
(!transition || transition.style === "cut" || transition.durationMs <= 0) &&
canPatchRuntimeInPlace(this.currentRuntime, presentation)
) {
applyPresentationToRuntime(this.currentRuntime, presentation);
return;
}
const nextRuntime = await this.buildRuntime(presentation);
if (token !== this.activationToken) {
disposeSceneRuntime(nextRuntime);
return;
}
this.collapseTransitionToCurrent();
if (!this.currentRuntime || !transition || transition.style === "cut" || transition.durationMs <= 0) {
disposeSceneRuntime(this.currentRuntime);
this.currentRuntime = nextRuntime;
return;
}
const fromRuntime = this.currentRuntime;
this.currentRuntime = null;
this.transitionRuntime = {
from: fromRuntime,
to: nextRuntime,
style: transition.style,
durationMs: transition.durationMs,
startedAtMs: this.lastFrameMs || (typeof performance !== "undefined" ? performance.now() : 0)
};
}
dispose() {
this.clearAll();
this.renderer.setAnimationLoop(null);
this.fromTarget.dispose();
this.toTarget.dispose();
this.compositeFromMaterial.dispose();
this.compositeToMaterial.dispose();
(this.blackoutQuad.material as THREE.Material).dispose();
(this.veilOverlay.material as THREE.Material).dispose();
this.shutterBars.forEach((bar) => (bar.material as THREE.Material).dispose());
this.renderer.dispose();
void this.textureCache.clear();
}
private async buildRuntime(presentation: SurfacePresentation) {
const mergedParams = resolvePresentationParams(presentation);
const pluginPromise = this.registry.get(presentation.definition.sceneKey)
? Promise.resolve(this.registry.get(presentation.definition.sceneKey)!)
: loadScenePlugin(presentation.definition.sceneKey).catch(() => loadScenePlugin("witness-float"));
const textOverlayPromise = shouldLoadTextOverlay(presentation, mergedParams) ? loadTextOverlayModule() : Promise.resolve(null);
const loadedAssets = await Promise.all(
presentation.assets.map(async (asset) => {
const sourceCandidates = Array.from(
new Set([asset.renderKey, asset.previewKey, asset.thumbKey, asset.originalKey].filter(Boolean))
) as string[];
let texture: THREE.Texture | null = null;
let sourceUrl: string | null = null;
for (const candidate of sourceCandidates) {
texture = await this.textureCache.load(candidate);
if (texture) {
sourceUrl = candidate;
break;
}
}
return {
asset,
texture,
sourceUrl,
aspect: asset.width && asset.height ? asset.width / asset.height : 4 / 3,
dominantColor: asset.dominantColor ?? "#93a6ba"
} satisfies LoadedPhotoAsset;
})
);
const [plugin, textOverlayModule] = await Promise.all([pluginPromise, textOverlayPromise]);
const scene = new THREE.Scene();
const camera = createSceneCamera(this.viewport);
const activationInput = {
...presentation,
loadedAssets,
params: mergedParams,
camera,
viewport: this.viewport
};
const baseInstance = plugin.build(activationInput);
const textOverlay = textOverlayModule?.buildTextOverlay(activationInput) ?? null;
const instance = combineInstances(baseInstance, textOverlay);
scene.add(instance.root);
return {
presentation,
params: mergedParams,
targetMotion: mergedParams.composition.motion,
scene,
camera,
instance
} satisfies SceneRuntime;
}
private renderRuntimeToTarget(runtime: SceneRuntime, target: THREE.WebGLRenderTarget) {
this.renderer.setRenderTarget(target);
this.renderer.clear();
this.renderer.render(runtime.scene, runtime.camera);
}
private renderCompositeTransition(progress: number, style: CueTransition["style"]) {
const eased = THREE.MathUtils.smoothstep(progress, 0, 1);
const reveal = style === "shutter_reveal" ? THREE.MathUtils.smoothstep(progress, 0.08, 1) : eased;
this.compositeFromMaterial.map = this.fromTarget.texture;
this.compositeToMaterial.map = this.toTarget.texture;
this.compositeFromMaterial.needsUpdate = true;
this.compositeToMaterial.needsUpdate = true;
this.compositeFromMaterial.opacity = 1;
this.compositeToMaterial.opacity = reveal;
this.compositeFromQuad.position.set(0, 0, 0);
this.compositeToQuad.position.set(0, 0, 0.01);
this.compositeFromQuad.scale.set(1, 1, 1);
this.compositeToQuad.scale.set(1, 1, 1);
this.compositeFromQuad.rotation.z = 0;
this.compositeToQuad.rotation.z = 0;
(this.blackoutQuad.material as THREE.MeshBasicMaterial).opacity = 0;
const veilMaterial = this.veilOverlay.material as THREE.MeshBasicMaterial;
veilMaterial.opacity = 0;
this.shutterBars.forEach((bar, index) => {
const material = bar.material as THREE.MeshBasicMaterial;
material.opacity = 0;
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
});
if (style === "mist_reveal") {
this.compositeFromQuad.scale.setScalar(1 + (1 - eased) * 0.015);
this.compositeToQuad.scale.setScalar(0.985 + eased * 0.015);
veilMaterial.opacity = Math.sin(eased * Math.PI) * 0.04;
} else if (style === "depth_drift") {
this.compositeFromQuad.position.x = -0.04 * eased;
this.compositeToQuad.position.x = 0.04 * (1 - eased);
this.compositeFromQuad.scale.setScalar(1 + eased * 0.025);
this.compositeToQuad.scale.setScalar(0.975 + eased * 0.025);
} else if (style === "shutter_reveal") {
this.shutterBars.forEach((bar, index) => {
const material = bar.material as THREE.MeshBasicMaterial;
const bandProgress = THREE.MathUtils.clamp((progress - index * 0.05) / 0.45, 0, 1);
material.opacity = (1 - bandProgress) * 0.22;
bar.position.x = -1.2 + bandProgress * 2.4;
});
}
this.renderer.setRenderTarget(null);
this.renderer.clear();
this.renderer.render(this.compositeScene, this.compositeCamera);
}
private renderTargetToScreen(target: THREE.WebGLRenderTarget) {
this.compositeFromMaterial.map = target.texture;
this.compositeToMaterial.map = target.texture;
this.compositeFromMaterial.needsUpdate = true;
this.compositeToMaterial.needsUpdate = true;
this.compositeFromMaterial.opacity = 1;
this.compositeToMaterial.opacity = 0;
this.compositeFromQuad.position.set(0, 0, 0);
this.compositeToQuad.position.set(0, 0, 0.01);
this.compositeFromQuad.scale.set(1, 1, 1);
this.compositeToQuad.scale.set(1, 1, 1);
this.compositeFromQuad.rotation.z = 0;
this.compositeToQuad.rotation.z = 0;
(this.blackoutQuad.material as THREE.MeshBasicMaterial).opacity = 0;
(this.veilOverlay.material as THREE.MeshBasicMaterial).opacity = 0;
this.shutterBars.forEach((bar, index) => {
const material = bar.material as THREE.MeshBasicMaterial;
material.opacity = 0;
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
});
this.renderer.setRenderTarget(null);
this.renderer.clear();
this.renderer.render(this.compositeScene, this.compositeCamera);
}
private renderBlackoutOverlay(timestamp: number) {
let style: CueTransition["style"] = "dissolve";
let progress = this.blackoutLevel > 0 ? 1 : 0;
let toBlack = this.blackoutLevel >= 1;
if (this.blackoutRuntime) {
const rawProgress = clamp((timestamp - this.blackoutRuntime.startedAtMs) / this.blackoutRuntime.durationMs, 0, 1);
const eased = THREE.MathUtils.smoothstep(rawProgress, 0, 1);
this.blackoutLevel = THREE.MathUtils.lerp(this.blackoutRuntime.fromLevel, this.blackoutRuntime.toLevel, eased);
style = this.blackoutRuntime.style;
progress = eased;
toBlack = this.blackoutRuntime.toLevel > this.blackoutRuntime.fromLevel;
if (rawProgress >= 1) {
this.blackoutLevel = this.blackoutRuntime.toLevel;
this.blackoutRuntime = null;
}
}
if (this.blackoutLevel <= 0.001) {
return;
}
this.compositeFromMaterial.opacity = 0;
this.compositeToMaterial.opacity = 0;
this.compositeFromQuad.position.set(0, 0, 0);
this.compositeToQuad.position.set(0, 0, 0.01);
this.compositeFromQuad.scale.set(1, 1, 1);
this.compositeToQuad.scale.set(1, 1, 1);
this.compositeFromQuad.rotation.z = 0;
this.compositeToQuad.rotation.z = 0;
const blackoutMaterial = this.blackoutQuad.material as THREE.MeshBasicMaterial;
blackoutMaterial.opacity = clamp(this.blackoutLevel, 0, 1);
this.blackoutQuad.position.set(0, 0, 0.019);
this.blackoutQuad.scale.set(1, 1, 1);
this.blackoutQuad.rotation.z = 0;
const veilMaterial = this.veilOverlay.material as THREE.MeshBasicMaterial;
veilMaterial.opacity = 0;
this.shutterBars.forEach((bar, index) => {
const material = bar.material as THREE.MeshBasicMaterial;
material.opacity = 0;
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
});
if (style === "mist_reveal" && this.blackoutRuntime) {
veilMaterial.opacity = (0.02 + this.blackoutLevel * 0.08) * Math.sin(progress * Math.PI);
blackoutMaterial.opacity = clamp(this.blackoutLevel * 1.02, 0, 1);
} else if (style === "depth_drift" && this.blackoutRuntime) {
const offset = (toBlack ? 1 - progress : progress - 1) * 0.06;
this.blackoutQuad.position.x = offset;
this.blackoutQuad.scale.set(1.03, 1, 1);
veilMaterial.opacity = Math.sin(progress * Math.PI) * 0.05;
} else if (style === "shutter_reveal" && this.blackoutRuntime) {
blackoutMaterial.opacity = clamp(Math.max(this.blackoutLevel, toBlack ? progress * 0.82 : this.blackoutLevel), 0, 1);
this.shutterBars.forEach((bar, index) => {
const material = bar.material as THREE.MeshBasicMaterial;
const bandProgress = THREE.MathUtils.clamp((progress - index * 0.05) / 0.45, 0, 1);
material.opacity = (1 - bandProgress) * 0.3;
bar.position.x = toBlack ? -1.2 + bandProgress * 2.4 : 1.2 - bandProgress * 2.4;
});
}
const previousAutoClear = this.renderer.autoClear;
this.renderer.autoClear = false;
this.renderer.setRenderTarget(null);
this.renderer.render(this.compositeScene, this.compositeCamera);
this.renderer.autoClear = previousAutoClear;
}
private finishTransition() {
if (!this.transitionRuntime) {
return;
}
disposeSceneRuntime(this.transitionRuntime.from);
this.currentRuntime = this.transitionRuntime.to;
this.transitionRuntime = null;
}
private collapseTransitionToCurrent() {
if (!this.transitionRuntime) {
return;
}
disposeSceneRuntime(this.transitionRuntime.from);
this.currentRuntime = this.transitionRuntime.to;
this.transitionRuntime = null;
}
private clearAll() {
disposeSceneRuntime(this.currentRuntime);
this.currentRuntime = null;
if (this.transitionRuntime) {
disposeSceneRuntime(this.transitionRuntime.from);
disposeSceneRuntime(this.transitionRuntime.to);
this.transitionRuntime = null;
}
this.blackoutRuntime = null;
this.activePresentationKey = undefined;
}
private getMinFrameIntervalMs() {
if (this.qualityProfile === "program-output") {
return this.busy ? 1000 / 30 : 1000 / 45;
}
if (this.qualityProfile === "program-monitor") {
return this.busy ? 1000 / 20 : 1000 / 30;
}
return this.busy ? 1000 / 18 : 1000 / 24;
}
private applyQualitySettings() {
const devicePixelRatio = typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1);
const scale =
this.qualityProfile === "program-output"
? this.busy
? 0.82
: 0.96
: this.qualityProfile === "program-monitor"
? this.busy
? 0.68
: 0.82
: this.busy
? 0.46
: 0.58;
const cap =
this.qualityProfile === "program-output"
? 1.35
: this.qualityProfile === "program-monitor"
? 1
: 0.85;
const pixelRatio = Math.min(devicePixelRatio * scale, cap);
this.renderer.setPixelRatio(pixelRatio);
this.lastFrameMs = 0;
this.setSize(this.viewport.width, this.viewport.height);
}
private startAnimationLoop() {
if (!this.paused) {
this.renderer.setAnimationLoop(this.animationLoop);
}
}
}

View File

@ -0,0 +1,911 @@
import * as THREE from "three";
import type { ScenicFieldType } from "@goodgrief/shared-types";
import type {
LoadedPhotoAsset,
SceneActivationInput,
SceneFrameContext,
SceneInstance,
SceneParams,
SceneViewport
} from "./types";
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const stringHash = (input: string) => {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
export const seededUnit = (seed: string, offset = 0) => {
const hash = stringHash(`${seed}:${offset}`);
return (hash % 10_000) / 10_000;
};
export const seededSigned = (seed: string, offset = 0) => seededUnit(seed, offset) * 2 - 1;
export const mixColor = (base: string, target: string, amount: number) =>
`#${new THREE.Color(base).lerp(new THREE.Color(target), clamp(amount, 0, 1)).getHexString()}`;
export const shiftColor = (color: string, hueDegrees: number, saturation: number, lightness: number) => {
const source = new THREE.Color(color);
const hsl = { h: 0, s: 0, l: 0 };
source.getHSL(hsl);
return `#${new THREE.Color()
.setHSL(
((hsl.h + hueDegrees / 360) % 1 + 1) % 1,
clamp(hsl.s * saturation, 0, 1),
clamp(hsl.l * lightness, 0, 1)
)
.getHexString()}`;
};
export type ScenicPalette = {
primary: string;
secondary: string;
accent: string;
line: string;
ink: string;
};
export const paletteFromAssets = (
assets: LoadedPhotoAsset[],
scenicTreatment: SceneParams["scenicTreatment"]
): ScenicPalette => {
const base = assets[0]?.dominantColor ?? "#9fb0c4";
const primary = shiftColor(
mixColor(base, "#84ddff", 0.5),
scenicTreatment.hue,
scenicTreatment.saturation * 1.12,
scenicTreatment.lightness * 1.08
);
const secondary = shiftColor(
mixColor(base, "#ff93dc", 0.42),
scenicTreatment.hue + 34,
scenicTreatment.saturation * 1.14,
scenicTreatment.lightness * 1.06
);
const accent = shiftColor(
mixColor(mixColor(primary, secondary, 0.42), "#fff6cf", 0.34),
scenicTreatment.hue * 0.54 + 8,
Math.max(0.88, scenicTreatment.saturation * 1.02),
Math.max(0.88, scenicTreatment.lightness * 1.14)
);
const line = shiftColor("#f7f3ff", scenicTreatment.hue * 0.34, 0.9 + scenicTreatment.saturation * 0.18, 1);
const ink = shiftColor("#05070d", scenicTreatment.hue * 0.2, 0.76, 0.64 + (scenicTreatment.lightness - 1) * 0.18);
return {
primary,
secondary,
accent,
line,
ink
};
};
export type PlaneBundle = {
group: THREE.Group;
image: THREE.Mesh;
width: number;
height: number;
};
export type LayoutRect = {
x: number;
y: number;
z: number;
width: number;
height: number;
yaw?: number;
pitch?: number;
};
export const createPhotoPlane = (
asset: LoadedPhotoAsset,
_params: SceneParams["photoTreatment"],
options: {
height?: number;
opacity?: number;
frameOpacity?: number;
shadowOpacity?: number;
tint?: string;
} = {}
): PlaneBundle => {
const group = new THREE.Group();
const height = options.height ?? 3;
const width = height * clamp(asset.aspect, 0.48, 1.95);
const hasTexture = Boolean(asset.texture);
const fallbackColor = options.tint
? mixColor(asset.dominantColor, options.tint, 0.35)
: asset.dominantColor;
if ((options.shadowOpacity ?? 0.08) > 0) {
const shadow = new THREE.Mesh(
new THREE.PlaneGeometry(width + 0.18, height + 0.18),
new THREE.MeshBasicMaterial({
color: "#020306",
transparent: true,
opacity: options.shadowOpacity ?? 0.08,
depthWrite: false,
depthTest: false
})
);
shadow.renderOrder = 18;
shadow.position.set(0.08, -0.08, -0.08);
group.add(shadow);
}
if ((options.frameOpacity ?? 0.03) > 0) {
const frame = new THREE.Mesh(
new THREE.PlaneGeometry(width + 0.08, height + 0.08),
new THREE.MeshBasicMaterial({
color: "#f5eee5",
transparent: true,
opacity: options.frameOpacity ?? 0.03,
depthWrite: false,
depthTest: false
})
);
frame.renderOrder = 19;
frame.position.z = -0.02;
group.add(frame);
}
const imageMaterial = new THREE.MeshBasicMaterial({
map: asset.texture,
color: hasTexture ? "#ffffff" : fallbackColor,
transparent: true,
opacity: options.opacity ?? 1,
depthWrite: false,
side: THREE.DoubleSide
});
const image = new THREE.Mesh(new THREE.PlaneGeometry(width, height), imageMaterial);
image.renderOrder = 20;
group.add(image);
return {
group,
image,
width,
height
};
};
const fitPlaneHeightToRect = (asset: LoadedPhotoAsset, rect: LayoutRect) =>
clamp(Math.min(rect.height, rect.width / clamp(asset.aspect, 0.48, 1.95)), 0.9, rect.height);
export const createFittedPhotoPlane = (
asset: LoadedPhotoAsset,
params: SceneParams["photoTreatment"],
rect: LayoutRect,
options: {
opacity?: number;
frameOpacity?: number;
shadowOpacity?: number;
tint?: string;
} = {}
) => {
const plane = createPhotoPlane(asset, params, {
...options,
height: fitPlaneHeightToRect(asset, rect)
});
plane.group.position.set(rect.x, rect.y, rect.z);
plane.group.rotation.x = rect.pitch ?? 0;
plane.group.rotation.y = rect.yaw ?? 0;
return plane;
};
const applyLayoutSpread = (rects: LayoutRect[], spread: number, depth: number) => {
const xGain = 0.92 + spread * 0.36;
const yGain = 0.96 + spread * 0.14;
const depthGain = 0.9 + depth * 0.45;
return rects.map((rect, index) => ({
...rect,
x: rect.x * xGain,
y: rect.y * yGain,
z: rect.z * depthGain - index * depth * 0.08
}));
};
export const createHeroLayoutRects = (
count: number,
formation: SceneParams["composition"]["formation"],
composition: SceneParams["composition"]
) => {
let base: LayoutRect[];
if (count <= 1) {
base = [{ x: 0, y: 0.04, z: -0.9, width: 4.9, height: 4.2 }];
} else if (count === 2) {
base =
formation === "arc"
? [
{ x: -1.05, y: 0.02, z: -0.92, width: 4.15, height: 4.12, yaw: 0.04 },
{ x: 2.25, y: 0.7, z: -1.8, width: 2.15, height: 2.18, yaw: -0.08 }
]
: [
{ x: -0.84, y: 0.04, z: -0.92, width: 4.1, height: 4.12, yaw: 0.04 },
{ x: 2.3, y: -0.18, z: -1.72, width: 2.18, height: 2.14, yaw: -0.08 }
];
} else if (count === 3) {
base =
formation === "arc"
? [
{ x: -0.52, y: 0, z: -0.92, width: 3.95, height: 4.04, yaw: 0.02 },
{ x: -2.5, y: 1.24, z: -1.9, width: 1.9, height: 1.96, yaw: 0.1 },
{ x: 2.5, y: 1.02, z: -1.82, width: 1.92, height: 1.98, yaw: -0.1 }
]
: [
{ x: -0.58, y: 0.06, z: -0.92, width: 3.92, height: 4.02, yaw: 0.02 },
{ x: 2.48, y: 1.04, z: -1.84, width: 1.88, height: 1.92, yaw: -0.1 },
{ x: 2.48, y: -1.02, z: -1.98, width: 1.88, height: 1.92, yaw: -0.08 }
];
} else {
base =
formation === "arc"
? [
{ x: -0.42, y: 0, z: -0.92, width: 3.6, height: 3.86, yaw: 0.02 },
{ x: -2.55, y: 1.34, z: -1.86, width: 1.68, height: 1.72, yaw: 0.1 },
{ x: 2.52, y: 1.08, z: -1.94, width: 1.68, height: 1.72, yaw: -0.1 },
{ x: 2.36, y: -1.22, z: -2.02, width: 1.68, height: 1.72, yaw: -0.08 }
]
: [
{ x: -0.74, y: 0.04, z: -0.92, width: 3.66, height: 3.84, yaw: 0.02 },
{ x: 2.44, y: 1.36, z: -1.82, width: 1.66, height: 1.68, yaw: -0.1 },
{ x: 2.44, y: 0, z: -1.9, width: 1.66, height: 1.68, yaw: -0.08 },
{ x: 2.44, y: -1.36, z: -1.98, width: 1.66, height: 1.68, yaw: -0.06 }
];
}
return applyLayoutSpread(base, composition.spread, composition.depth);
};
export const createEqualLayoutRects = (
count: number,
formation: SceneParams["composition"]["formation"],
composition: SceneParams["composition"]
) => {
let base: LayoutRect[];
if (count <= 1) {
base = [{ x: 0, y: 0.04, z: -1, width: 4.6, height: 4 }];
} else if (count === 2) {
if (formation === "arc") {
base = [
{ x: -1.9, y: 0.62, z: -1.08, width: 2.7, height: 3.1, yaw: 0.08 },
{ x: 1.9, y: -0.38, z: -1.18, width: 2.7, height: 3.1, yaw: -0.08 }
];
} else if (formation === "cluster") {
base = [
{ x: -1.6, y: 0.72, z: -1.02, width: 2.75, height: 3.05, yaw: 0.06 },
{ x: 1.42, y: -0.66, z: -1.18, width: 2.75, height: 3.05, yaw: -0.06 }
];
} else {
base = [
{ x: -1.86, y: 0, z: -1.02, width: 2.9, height: 3.3, yaw: 0.04 },
{ x: 1.86, y: 0, z: -1.12, width: 2.9, height: 3.3, yaw: -0.04 }
];
}
} else if (count === 3) {
if (formation === "line") {
base = [
{ x: -2.16, y: 0.16, z: -1.02, width: 2.18, height: 2.72, yaw: 0.04 },
{ x: 0, y: 0, z: -1.1, width: 2.48, height: 3.04 },
{ x: 2.16, y: -0.18, z: -1.18, width: 2.18, height: 2.72, yaw: -0.04 }
];
} else if (formation === "arc" || formation === "cluster") {
base = [
{ x: 0, y: 1.08, z: -1.02, width: 2.46, height: 2.72 },
{ x: -1.96, y: -1.02, z: -1.12, width: 2.18, height: 2.62, yaw: 0.06 },
{ x: 1.96, y: -0.92, z: -1.2, width: 2.18, height: 2.62, yaw: -0.06 }
];
} else {
base = [
{ x: 0, y: 1.02, z: -1.02, width: 2.4, height: 2.68 },
{ x: -1.88, y: -1.04, z: -1.14, width: 2.22, height: 2.62 },
{ x: 1.88, y: -1.04, z: -1.22, width: 2.22, height: 2.62 }
];
}
} else {
if (formation === "cluster") {
base = [
{ x: -1.82, y: 1.06, z: -1.02, width: 2.08, height: 2.28 },
{ x: 1.58, y: 1.22, z: -1.1, width: 2.08, height: 2.28 },
{ x: -1.46, y: -1.14, z: -1.18, width: 2.08, height: 2.28 },
{ x: 1.9, y: -0.98, z: -1.26, width: 2.08, height: 2.28 }
];
} else if (formation === "ribbon") {
base = [
{ x: -2.64, y: 0.96, z: -1.02, width: 1.92, height: 2.24, yaw: 0.05 },
{ x: -0.88, y: -0.16, z: -1.1, width: 1.92, height: 2.24, yaw: 0.02 },
{ x: 0.88, y: 0.26, z: -1.18, width: 1.92, height: 2.24, yaw: -0.02 },
{ x: 2.64, y: -0.88, z: -1.26, width: 1.92, height: 2.24, yaw: -0.05 }
];
} else {
base = [
{ x: -1.84, y: 1.16, z: -1.02, width: 2.08, height: 2.26 },
{ x: 1.84, y: 1.16, z: -1.1, width: 2.08, height: 2.26 },
{ x: -1.84, y: -1.16, z: -1.18, width: 2.08, height: 2.26 },
{ x: 1.84, y: -1.16, z: -1.26, width: 2.08, height: 2.26 }
];
}
}
return applyLayoutSpread(base, composition.spread, composition.depth);
};
export const createArrivalLayoutRects = (
count: number,
mode: string,
composition: SceneParams["composition"]
) => {
const base: LayoutRect[] =
count <= 1
? [{ x: mode === "relay_rail" ? 0.96 : 0.72, y: 0.02, z: -0.92, width: 4.2, height: 4.06, yaw: -0.04 }]
: count === 2
? [
{ x: 1.18, y: 0.02, z: -0.92, width: 3.72, height: 3.84, yaw: -0.04 },
{ x: -2.24, y: 0.74, z: -1.66, width: 1.88, height: 1.98, yaw: 0.08 }
]
: count === 3
? [
{ x: 1.18, y: 0.02, z: -0.92, width: 3.68, height: 3.82, yaw: -0.04 },
{ x: -2.3, y: 1.18, z: -1.66, width: 1.82, height: 1.92, yaw: 0.08 },
{ x: -2.3, y: -1.04, z: -1.76, width: 1.82, height: 1.92, yaw: 0.08 }
]
: [
{ x: 1.22, y: 0.04, z: -0.92, width: 3.54, height: 3.7, yaw: -0.04 },
{ x: -2.42, y: 1.56, z: -1.66, width: 1.68, height: 1.74, yaw: 0.08 },
{ x: -2.42, y: 0, z: -1.78, width: 1.68, height: 1.74, yaw: 0.08 },
{ x: -2.42, y: -1.56, z: -1.9, width: 1.68, height: 1.74, yaw: 0.08 }
];
return applyLayoutSpread(base, composition.spread * 0.8, composition.depth * 0.7);
};
export type FieldBundle = {
group: THREE.Group;
uniforms: FieldUniforms[];
};
export type FieldUniforms = {
uTime: { value: number };
uType: { value: number };
uIntensity: { value: number };
uScale: { value: number };
uSpeed: { value: number };
uAspect: { value: number };
uPrimary: { value: THREE.Color };
uSecondary: { value: THREE.Color };
uAccent: { value: THREE.Color };
uInk: { value: THREE.Color };
};
const FIELD_VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FIELD_FRAGMENT_SHADER = `
varying vec2 vUv;
uniform float uTime;
uniform float uType;
uniform float uIntensity;
uniform float uScale;
uniform float uSpeed;
uniform float uAspect;
uniform vec3 uPrimary;
uniform vec3 uSecondary;
uniform vec3 uAccent;
uniform vec3 uInk;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * noise(p);
p = p * 2.02 + vec2(14.7, 9.2);
amplitude *= 0.52;
}
return value;
}
vec2 rotate2d(vec2 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return mat2(c, -s, s, c) * p;
}
float sparkleField(vec2 p, float t) {
float sparkle = noise(p * 6.5 + vec2(t * 0.16, -t * 0.12));
sparkle *= noise(p * 11.0 - vec2(t * 0.22, t * 0.18));
return smoothstep(0.73, 0.98, sparkle);
}
void main() {
vec2 uv = vUv * 2.0 - 1.0;
uv.x *= uAspect;
float t = uTime * uSpeed;
vec2 p = uv * (0.8 + uScale);
float mask = 0.0;
vec3 field = uInk;
float glow = 0.0;
float sparkle = 0.0;
if (uType < 0.5) {
vec2 q = p + vec2(fbm(p * 0.75 + t * 0.14), fbm(p * 0.92 - t * 0.12));
float n = fbm(q + vec2(t * 0.22, -t * 0.12));
float m = fbm(rotate2d(q * 1.42, 0.34) + vec2(-t * 0.14, t * 0.18));
mask = smoothstep(0.18, 0.98, n * 0.76 + m * 0.56);
glow = smoothstep(0.52, 0.96, m) * 0.42;
sparkle = sparkleField(q, t) * 0.14;
field = mix(uInk, mix(uPrimary, uSecondary, m), mask);
} else if (uType < 1.5) {
vec2 q = rotate2d(p, 0.18);
float waveA = sin((q.x + fbm(q * 0.9)) * 7.2 + t * 1.5) * 0.5 + 0.5;
float waveB = sin((q.y - fbm(q * 1.15)) * 9.1 - t * 1.2) * 0.5 + 0.5;
float caustic = pow(clamp(waveA * waveB + fbm(q * 1.6) * 0.16, 0.0, 1.0), 1.4);
mask = smoothstep(0.16, 0.98, caustic);
glow = smoothstep(0.58, 0.98, caustic) * 0.56;
sparkle = sparkleField(q * 1.2, t) * 0.12;
field = mix(uInk, mix(uPrimary, uAccent, waveA), mask);
} else if (uType < 2.5) {
vec2 q = rotate2d(p, 0.42);
float lattice = abs(sin(q.x * 8.4 + t * 1.1)) + abs(sin(q.y * 9.6 - t * 1.24));
float diagonals = abs(sin((q.x + q.y) * 6.4 + t * 0.7)) + abs(sin((q.x - q.y) * 5.6 - t * 0.64));
float mesh = smoothstep(0.62, 1.72, lattice * 0.76 + diagonals * 0.42 + fbm(q * 2.0) * 0.14);
mask = mesh;
glow = smoothstep(0.54, 1.0, mesh) * 0.44;
sparkle = sparkleField(q * 1.4, t) * 0.18;
field = mix(uInk, mix(uPrimary, uAccent, mesh), mesh);
} else if (uType < 3.5) {
vec2 q = p * 0.88 + vec2(fbm(p * 0.6 + t * 0.05), fbm(p * 0.6 - t * 0.05)) * 0.35;
float n = fbm(q - vec2(t * 0.15, t * 0.12));
float radial = 1.0 - clamp(length(uv) * 0.84, 0.0, 1.0);
float pressure = smoothstep(0.16, 0.96, n * 0.74 + radial * 0.44);
mask = pressure;
glow = radial * 0.34 + smoothstep(0.62, 0.98, n) * 0.22;
sparkle = sparkleField(q * 0.9, t) * radial * 0.1;
field = mix(uInk, mix(uPrimary, uSecondary, radial), mask);
} else if (uType < 4.5) {
float radius = length(uv);
float rings = sin(radius * 20.0 - t * 2.2) * 0.5 + 0.5;
float haze = fbm(p * 1.1 + vec2(t * 0.08, -t * 0.06));
float starburst = pow(abs(cos(atan(uv.y, uv.x) * 6.0)) * 0.5 + 0.5, 7.0) * (1.0 - smoothstep(0.08, 1.24, radius));
mask = smoothstep(0.16, 0.98, (1.0 - radius) * 0.5 + rings * 0.34 + haze * 0.22 + starburst * 0.42);
glow = starburst * 0.62 + smoothstep(0.58, 0.98, rings) * 0.24;
sparkle = sparkleField(p * 1.05, t) * 0.16;
field = mix(uInk, mix(uPrimary, uAccent, rings), mask);
} else if (uType < 5.5) {
float n = fbm(p * 0.65 + vec2(t * 0.05, -t * 0.04));
mask = smoothstep(0.28, 0.86, n);
glow = smoothstep(0.7, 0.96, n) * 0.12;
sparkle = sparkleField(p * 0.8, t) * 0.06;
field = mix(uInk, mix(uPrimary, uSecondary, 0.35), mask * 0.7);
} else if (uType < 6.5) {
vec2 q = rotate2d(p, 0.78);
float crystalA = abs(sin(q.x * 6.8 + fbm(q * 1.2) * 3.2 + t * 0.88));
float crystalB = abs(sin(q.y * 8.6 - fbm(q * 1.4) * 2.6 - t * 1.04));
float bloom = smoothstep(0.62, 1.36, crystalA + crystalB + fbm(q * 2.2) * 0.28);
mask = bloom;
glow = smoothstep(0.54, 0.98, bloom) * 0.48;
sparkle = sparkleField(q * 1.5, t) * 0.22;
field = mix(uInk, mix(uSecondary, uAccent, crystalA), mask);
} else if (uType < 7.5) {
vec2 q = p;
float ribbonA = sin((q.y + fbm(q * 0.8) * 0.8) * 5.2 + t * 1.28);
float ribbonB = sin((q.y * 1.4 - q.x * 0.3) * 7.1 - t * 1.02 + fbm(q * 1.1));
float current = smoothstep(-0.12, 0.86, ribbonA * 0.58 + ribbonB * 0.42 + fbm(q * 1.4) * 0.32);
mask = current;
glow = smoothstep(0.58, 0.96, current) * 0.52;
sparkle = sparkleField(q * 1.1, t) * 0.16;
field = mix(uInk, mix(uPrimary, uSecondary, ribbonB * 0.5 + 0.5), mask);
} else {
vec2 q = rotate2d(p, 0.58);
float cellA = abs(sin(q.x * 9.0 + t * 0.92));
float cellB = abs(sin(q.y * 9.8 - t * 0.84));
float shimmer = smoothstep(0.74, 1.52, cellA * 0.8 + cellB * 0.84 + fbm(q * 2.4) * 0.22);
mask = shimmer;
glow = smoothstep(0.62, 1.0, shimmer) * 0.46;
sparkle = sparkleField(q * 1.8, t) * 0.2;
field = mix(uInk, mix(uPrimary, uAccent, shimmer), shimmer);
}
float vignette = smoothstep(1.48, 0.18, length(uv));
float scenicMix = clamp(uIntensity * (mask * 0.88 + glow * 0.44) * vignette, 0.0, 1.0);
vec3 color = mix(uInk, field, scenicMix);
color += mix(uSecondary, uAccent, 0.65) * glow * uIntensity * 0.26;
color += uAccent * sparkle * uIntensity * 0.42;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
`;
const fieldTypeToValue = (fieldType: ScenicFieldType) => {
switch (fieldType) {
case "stardust_drift":
return 0;
case "nebula_veil":
return 3;
case "crystal_caustic":
return 6;
case "geode_bloom":
return 4;
case "aurora_mesh":
return 7;
case "void_shimmer":
return 8;
case "quiet_ether":
default:
return 5;
}
};
const createFieldPlane = (
palette: ScenicPalette,
scenicTreatment: SceneParams["scenicTreatment"],
viewport: SceneViewport,
options: {
z: number;
scale: number;
intensity: number;
speed: number;
colorMix?: number;
opacity?: number;
renderOrder?: number;
blending?: THREE.Blending;
}
) => {
const uniforms = {
uTime: { value: 0 },
uType: { value: fieldTypeToValue(scenicTreatment.fieldType) },
uIntensity: { value: clamp(options.intensity, 0, 1) },
uScale: { value: scenicTreatment.fieldScale * options.scale },
uSpeed: { value: scenicTreatment.fieldSpeed * options.speed },
uAspect: { value: viewport.aspect },
uPrimary: { value: new THREE.Color(palette.primary) },
uSecondary: { value: new THREE.Color(mixColor(palette.secondary, palette.primary, options.colorMix ?? 0.35)) },
uAccent: { value: new THREE.Color(palette.accent) },
uInk: { value: new THREE.Color(palette.ink) }
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: FIELD_VERTEX_SHADER,
fragmentShader: FIELD_FRAGMENT_SHADER,
transparent: (options.opacity ?? 1) < 1,
opacity: options.opacity ?? 1,
blending: options.blending ?? THREE.NormalBlending,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
});
const plane = new THREE.Mesh(new THREE.PlaneGeometry(24, 14), material);
plane.renderOrder = options.renderOrder ?? -100;
plane.position.z = options.z;
return { plane, uniforms };
};
export const createAccentRail = (width: number, height: number, color: string, opacity: number, z: number) =>
new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide
})
);
export const createAccentRing = (radius: number, thickness: number, color: string, opacity: number) =>
new THREE.Mesh(
new THREE.RingGeometry(Math.max(radius - thickness, 0.05), radius, 96),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide
})
);
export const buildBackdropSystem = (input: SceneActivationInput, palette: ScenicPalette): FieldBundle => {
const group = new THREE.Group();
const far = createFieldPlane(palette, input.params.scenicTreatment, input.viewport, {
z: -13.2,
scale: 0.96,
intensity: input.params.scenicTreatment.fieldIntensity,
speed: 0.34,
colorMix: 0.24
});
const mid = createFieldPlane(palette, input.params.scenicTreatment, input.viewport, {
z: -10.4,
scale: 1.24,
intensity: input.params.scenicTreatment.fieldIntensity * 0.72,
speed: 0.52,
colorMix: 0.48,
opacity: 0.88
});
const shimmer = createFieldPlane(palette, input.params.scenicTreatment, input.viewport, {
z: -8.8,
scale: 1.44,
intensity: input.params.scenicTreatment.fieldIntensity * 0.36,
speed: 0.76,
colorMix: 0.68,
opacity: 0.38,
renderOrder: -96,
blending: THREE.AdditiveBlending
});
group.add(far.plane, mid.plane, shimmer.plane);
const vignette = new THREE.Mesh(
new THREE.PlaneGeometry(24, 14),
new THREE.MeshBasicMaterial({
color: palette.ink,
transparent: true,
opacity: 0.1 + input.params.scenicTreatment.depthFog * 0.14,
depthWrite: false,
depthTest: false
})
);
vignette.renderOrder = -90;
vignette.position.z = -9.8;
group.add(vignette);
return {
group,
uniforms: [far.uniforms, mid.uniforms, shimmer.uniforms]
};
};
export const updateBackdropSystem = (
bundle: FieldBundle,
context: SceneFrameContext,
palette: ScenicPalette,
scenicTreatment: SceneParams["scenicTreatment"]
) => {
const time = context.elapsedMs * 0.001;
bundle.uniforms.forEach((uniforms, index) => {
uniforms.uTime.value = time * (0.8 + index * 0.18);
uniforms.uType.value = fieldTypeToValue(scenicTreatment.fieldType);
uniforms.uIntensity.value = clamp(
scenicTreatment.fieldIntensity * (index === 0 ? 1 : index === 1 ? 0.72 : 0.36),
0,
1
);
uniforms.uScale.value = scenicTreatment.fieldScale * (index === 0 ? 0.96 : index === 1 ? 1.24 : 1.44);
uniforms.uSpeed.value = scenicTreatment.fieldSpeed * (index === 0 ? 0.34 : index === 1 ? 0.52 : 0.76);
uniforms.uPrimary.value.set(palette.primary);
uniforms.uSecondary.value.set(
mixColor(palette.secondary, palette.primary, index === 0 ? 0.24 : index === 1 ? 0.48 : 0.68)
);
uniforms.uAccent.value.set(palette.accent);
uniforms.uInk.value.set(palette.ink);
});
};
export const truncateTextFragment = (value: string, maxLength: number) => {
const trimmed = value.replace(/\s+/g, " ").trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return `${trimmed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}`;
};
const wrapTextLines = (
context: CanvasRenderingContext2D,
text: string,
maxWidth: number,
maxLines: number
) => {
const words = text.split(/\s+/).filter(Boolean);
if (words.length === 0) {
return [text];
}
const lines: string[] = [];
let current = words[0] ?? "";
for (const word of words.slice(1)) {
const candidate = `${current} ${word}`;
if (context.measureText(candidate).width <= maxWidth) {
current = candidate;
continue;
}
lines.push(current);
current = word;
if (lines.length === maxLines - 1) {
break;
}
}
const consumed = lines.join(" ").split(/\s+/).filter(Boolean).length;
const tail = words.slice(consumed).join(" ");
if (tail) {
let trimmed = tail;
while (trimmed.length > 0 && context.measureText(`${trimmed}`).width > maxWidth) {
trimmed = trimmed.slice(0, -1).trimEnd();
}
lines.push(trimmed ? `${trimmed}` : tail);
} else if (current) {
lines.push(current);
}
return lines.slice(0, maxLines);
};
export const createTextStrip = (
text: string,
options: {
color: string;
opacity: number;
fontSize?: number;
maxWidth?: number;
backgroundColor?: string;
backgroundOpacity?: number;
allowWrap?: boolean;
maxLines?: number;
}
) => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
const mesh = createAccentRail(4, 0.5, options.color, options.opacity, 0);
return { mesh, texture: null as THREE.Texture | null };
}
const fontSize = options.fontSize ?? 42;
const maxWidth = options.maxWidth ?? 1500;
const font = `"IBM Plex Sans Condensed", "Aptos Narrow", "Trebuchet MS", "Segoe UI", sans-serif`;
context.font = `600 ${fontSize}px ${font}`;
const lines = options.allowWrap
? wrapTextLines(context, truncateTextFragment(text, 180), maxWidth - 72, options.maxLines ?? 2)
: [truncateTextFragment(text, 100)];
const lineHeight = Math.ceil(fontSize * 1.16);
const textWidth = Math.max(...lines.map((line) => context.measureText(line).width));
const width = Math.max(280, Math.ceil(Math.min(maxWidth, textWidth + 72)));
const height = Math.max(92, Math.ceil(lines.length * lineHeight + 44));
canvas.width = width;
canvas.height = height;
context.clearRect(0, 0, width, height);
context.fillStyle = options.backgroundColor ?? "rgba(4, 6, 8, 0.7)";
context.globalAlpha = options.backgroundOpacity ?? 0.22;
context.beginPath();
if (typeof context.roundRect === "function") {
context.roundRect(0, 0, width, height, 28);
context.fill();
} else {
context.fillRect(0, 0, width, height);
}
context.globalAlpha = 1;
context.font = `600 ${fontSize}px ${font}`;
context.textAlign = "center";
context.textBaseline = "middle";
context.lineJoin = "round";
context.lineWidth = Math.max(4, fontSize * 0.12);
context.strokeStyle = "rgba(3, 5, 7, 0.96)";
context.fillStyle = options.color;
const startY = height / 2 - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
const y = startY + index * lineHeight;
context.strokeText(line, width / 2, y);
context.fillText(line, width / 2, y);
});
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const planeHeight = 0.6 + lines.length * 0.12;
const planeWidth = planeHeight * (width / height);
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(planeWidth, planeHeight),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: options.opacity,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
);
return { mesh, texture };
};
export type MotionEntry = {
group: THREE.Object3D;
basePosition: THREE.Vector3;
baseRotation: THREE.Euler;
phase: number;
travelX: number;
travelY: number;
orbit: number;
pitch: number;
yaw: number;
};
export const applyMotionEntry = (
entry: MotionEntry,
time: number,
motion: number,
orbitAmount: number,
stagger = 0.2
) => {
const gain = 0.55 + motion * 0.7;
entry.group.position.x =
entry.basePosition.x +
Math.sin(time * (0.18 + stagger * 0.08) + entry.phase) * entry.travelX * gain;
entry.group.position.y =
entry.basePosition.y +
Math.cos(time * (0.14 + stagger * 0.06) + entry.phase) * entry.travelY * (0.72 + motion * 0.42);
entry.group.position.z =
entry.basePosition.z +
Math.sin(time * 0.08 + entry.phase) * entry.orbit * orbitAmount * 0.28;
entry.group.rotation.x = entry.baseRotation.x + Math.sin(time * 0.07 + entry.phase) * entry.pitch * 0.82;
entry.group.rotation.y = entry.baseRotation.y + Math.cos(time * 0.09 + entry.phase) * entry.yaw * 0.84;
entry.group.rotation.z = entry.baseRotation.z + Math.sin(time * 0.06 + entry.phase) * 0.003;
};
export const configureCamera = (
camera: THREE.PerspectiveCamera,
base: { x: number; y: number; z: number },
lookAt: THREE.Vector3,
cameraTravel: number,
elapsedMs: number
) => {
const time = elapsedMs * 0.0001;
camera.position.set(
base.x + Math.sin(time * 0.56) * cameraTravel * 0.28,
base.y + Math.cos(time * 0.4) * cameraTravel * 0.1,
base.z + Math.sin(time * 0.32) * cameraTravel * 0.14
);
camera.lookAt(lookAt);
};
export const combineInstances = (...instances: Array<SceneInstance | null | undefined>): SceneInstance => {
const active = instances.filter((instance): instance is SceneInstance => Boolean(instance));
if (active.length === 1) {
return active[0]!;
}
const root = new THREE.Group();
active.forEach((instance) => root.add(instance.root));
return {
root,
update: (context) => active.forEach((instance) => instance.update?.(context)),
dispose: () => active.forEach((instance) => instance.dispose?.())
};
};

View File

@ -0,0 +1,64 @@
import type { ScenePlugin } from "./types";
const sceneLoaders = {
"witness-float": () => import("./scenes/witness-float"),
"portal-frame": () => import("./scenes/portal-frame"),
"orbit-gallery": () => import("./scenes/orbit-gallery"),
"suspension-field": () => import("./scenes/suspension-field"),
"chorus-array": () => import("./scenes/chorus-array"),
"equal-collage": () => import("./scenes/equal-collage"),
"arrival-relay": () => import("./scenes/arrival-relay"),
"safe-hold": () => import("./scenes/safe-hold")
} as const;
const scenePluginCache = new Map<string, Promise<ScenePlugin>>();
const textOverlayBuilderCache = new Map<"text-overlay", Promise<typeof import("./text-overlay")>>();
export const defaultScenePluginMetadata = [
{ sceneKey: "witness-float", title: "Witness Float" },
{ sceneKey: "portal-frame", title: "Portal Frame" },
{ sceneKey: "orbit-gallery", title: "Orbit Gallery" },
{ sceneKey: "suspension-field", title: "Suspension Field" },
{ sceneKey: "chorus-array", title: "Chorus Array" },
{ sceneKey: "equal-collage", title: "Equal Collage" },
{ sceneKey: "arrival-relay", title: "Arrival Relay" },
{ sceneKey: "safe-hold", title: "Safe Hold" }
] as const satisfies ReadonlyArray<Pick<ScenePlugin, "sceneKey" | "title">>;
type SceneLoaderKey = keyof typeof sceneLoaders;
export const loadScenePlugin = (sceneKey: string): Promise<ScenePlugin> => {
const cached = scenePluginCache.get(sceneKey);
if (cached) {
return cached;
}
const loader = sceneLoaders[sceneKey as SceneLoaderKey];
if (!loader) {
return Promise.reject(new Error(`Unknown render scene: ${sceneKey}`));
}
const promise = loader().then((module) => module.plugin);
scenePluginCache.set(sceneKey, promise);
return promise;
};
export const preloadScenePlugin = (sceneKey: string) => {
void loadScenePlugin(sceneKey).catch(() => undefined);
};
export const loadTextOverlayModule = () => {
const cacheKey = "text-overlay" as const;
const cached = textOverlayBuilderCache.get(cacheKey);
if (cached) {
return cached;
}
const promise = import("./text-overlay");
textOverlayBuilderCache.set(cacheKey, promise);
return promise;
};
export const preloadTextOverlayModule = () => {
void loadTextOverlayModule().catch(() => undefined);
};

View File

@ -0,0 +1,83 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRail,
createArrivalLayoutRects,
createFittedPhotoPlane,
type MotionEntry,
paletteFromAssets,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildArrivalRelay = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "edge_queue";
const count = clamp(1 + Math.round(composition.supportCount), 1, 4);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createArrivalLayoutRects(assets.length, mode, composition);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
opacity: index === 0 ? 1 : 0.92,
frameOpacity: 0.02,
shadowOpacity: 0.06
});
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 18 + index) * Math.PI * 2,
travelX: index === 0 ? 0.06 : 0.12 + index * 0.04,
travelY: 0.04,
orbit: 0.06,
pitch: 0.01,
yaw: 0.03
});
});
const rail = createAccentRail(0.18, 9.2, palette.accent, input.params.scenicTreatment.accentIntensity * 0.16, -4.8);
rail.position.set(-5.5, 0.1, -4.8);
root.add(rail);
const lower = createAccentRail(15, 0.12, palette.line, input.params.scenicTreatment.accentIntensity * 0.14, -4.9);
lower.position.set(0, -2.8, -4.9);
root.add(lower);
input.camera.position.set(0.18, 0, 7.35);
input.camera.lookAt(0.2, 0, -3.1);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) =>
applyMotionEntry(entry, time + index * 0.12, composition.motion * 0.82, composition.orbitAmount * 0.08, composition.stagger)
);
rail.position.y = Math.sin(time * 0.12) * 0.08;
lower.position.x = Math.sin(time * 0.08) * 0.12;
configureCamera(input.camera, { x: 0.18, y: 0, z: 7.35 }, new THREE.Vector3(0.2, 0, -3.1), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "arrival-relay",
title: "Arrival Relay",
build: buildArrivalRelay
};

View File

@ -0,0 +1,90 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRail,
createEqualLayoutRects,
createFittedPhotoPlane,
type MotionEntry,
paletteFromAssets,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildChorusArray = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "grid_choir";
const count = clamp(1 + Math.round(composition.supportCount), 3, 4);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createEqualLayoutRects(
assets.length,
mode === "ribbon_quartet" ? "ribbon" : mode === "offset_choir" ? "cluster" : "grid",
composition
);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
frameOpacity: 0.02,
shadowOpacity: 0.06
});
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 13) * Math.PI * 2,
travelX: 0.14 + composition.spread * 0.14,
travelY: 0.1 + composition.stagger * 0.08,
orbit: 0.14,
pitch: 0.01,
yaw: 0.04
});
});
const gridLines = [
createAccentRail(0.12, 8.5, palette.line, 0.08, -5.2),
createAccentRail(11.5, 0.12, palette.line, 0.08, -5.2)
];
gridLines[0]!.position.set(0, 0, -5.2);
gridLines[1]!.position.set(0, 0, -5.2);
if (mode === "ribbon_quartet") {
gridLines[1]!.rotation.z = 0.14;
}
root.add(...gridLines);
input.camera.position.set(0, 0, 7.6);
input.camera.lookAt(0, 0, -3.6);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) =>
applyMotionEntry(entry, time + index * 0.2, composition.motion, composition.orbitAmount * 0.16, composition.stagger)
);
gridLines[0]!.position.x = Math.sin(time * 0.12) * 0.16;
gridLines[1]!.position.y = Math.cos(time * 0.14) * 0.12;
configureCamera(input.camera, { x: 0, y: 0, z: 7.6 }, new THREE.Vector3(0, 0, -3.6), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "chorus-array",
title: "Chorus Array",
build: buildChorusArray
};

View File

@ -0,0 +1,85 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRing,
createEqualLayoutRects,
createFittedPhotoPlane,
type MotionEntry,
paletteFromAssets,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildEqualCollage = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "quadrant";
const count = clamp(1 + Math.round(composition.supportCount), 2, 4);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createEqualLayoutRects(
assets.length,
mode === "floating_blocks" ? "cluster" : mode === "arc_cluster" ? "arc" : "grid",
composition
);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
frameOpacity: 0.022,
shadowOpacity: 0.06
});
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 15) * Math.PI * 2,
travelX: 0.16 + composition.spread * 0.16,
travelY: 0.12,
orbit: 0.16 + composition.orbitAmount * 0.14,
pitch: 0.01,
yaw: 0.04
});
});
const accent = createAccentRing(4.45, 0.05, palette.accent, input.params.scenicTreatment.accentIntensity * 0.18);
accent.position.set(0, 0.1, -4.8);
if (mode === "quadrant") {
accent.scale.set(1.15, 0.78, 1);
}
root.add(accent);
input.camera.position.set(0, 0.04, 7.75);
input.camera.lookAt(0, 0, -3.8);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) =>
applyMotionEntry(entry, time + index * 0.22, composition.motion, composition.orbitAmount * 0.24, composition.stagger)
);
accent.rotation.z = Math.sin(time * 0.16) * 0.12;
configureCamera(input.camera, { x: 0, y: 0.04, z: 7.75 }, new THREE.Vector3(0, 0, -3.8), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "equal-collage",
title: "Equal Collage",
build: buildEqualCollage
};

View File

@ -0,0 +1,87 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRing,
createFittedPhotoPlane,
createHeroLayoutRects,
type MotionEntry,
paletteFromAssets,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildOrbitGallery = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "halo_arc";
const count = clamp(1 + Math.round(composition.supportCount), 1, 3);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createHeroLayoutRects(assets.length, "arc", composition);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
frameOpacity: 0.03,
shadowOpacity: 0.09
});
if (mode === "mirror_sweep" && index > 0) {
plane.group.position.x *= index % 2 === 0 ? 1 : -1;
}
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 6 + index) * Math.PI * 2,
travelX: 0.14 + index * 0.06,
travelY: 0.08 + index * 0.04,
orbit: 0.24 + index * 0.1,
pitch: 0.02,
yaw: 0.06 + index * 0.02
});
});
const ring = createAccentRing(4.9, 0.06, palette.accent, input.params.scenicTreatment.accentIntensity * 0.2);
ring.position.set(0, 0.12, -5.1);
root.add(ring);
if (mode === "lantern_orbit") {
const inner = createAccentRing(2.6, 0.04, palette.line, input.params.scenicTreatment.accentIntensity * 0.12);
inner.position.set(0, -0.18, -4.6);
root.add(inner);
}
input.camera.position.set(0, 0, 7.45);
input.camera.lookAt(0, 0, -3.2);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) => {
const orbitGain = composition.orbitAmount * (mode === "lantern_orbit" ? 0.75 : 0.52);
applyMotionEntry(entry, time + index * 0.24, composition.motion, orbitGain, composition.stagger);
});
ring.rotation.z = Math.sin(time * 0.18) * 0.22;
configureCamera(input.camera, { x: 0, y: 0, z: 7.45 }, new THREE.Vector3(0, 0, -3.2), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "orbit-gallery",
title: "Orbit Gallery",
build: buildOrbitGallery
};

View File

@ -0,0 +1,99 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRail,
createFittedPhotoPlane,
createHeroLayoutRects,
type MotionEntry,
paletteFromAssets,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildPortalFrame = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "soft_gate";
const count = clamp(1 + Math.round(composition.supportCount), 1, 2);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createHeroLayoutRects(assets.length, mode === "fold_gate" ? "arc" : "stack", composition);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const adjustedRect =
mode === "monolith_aperture" && index === 0
? { ...rect, width: rect.width * 0.92, height: rect.height * 1.14 }
: rect;
const plane = createFittedPhotoPlane(asset, photoTreatment, adjustedRect, {
opacity: index === 0 ? 1 : 0.94,
frameOpacity: index === 0 ? 0.04 : 0.025,
shadowOpacity: index === 0 ? 0.12 : 0.08
});
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 3 + index) * Math.PI * 2,
travelX: index === 0 ? 0.1 : 0.14,
travelY: index === 0 ? 0.06 : 0.08,
orbit: 0.08,
pitch: 0.015,
yaw: index === 0 ? 0.03 : 0.04
});
});
const leftFrame = createAccentRail(0.18, 8.6, palette.line, 0.1 + input.params.scenicTreatment.accentIntensity * 0.08, -4.5);
const rightFrame = createAccentRail(0.18, 8.6, palette.line, 0.1 + input.params.scenicTreatment.accentIntensity * 0.08, -4.5);
leftFrame.position.set(-3.45, 0, -4.5);
rightFrame.position.set(3.45, 0, -4.5);
root.add(leftFrame, rightFrame);
const topFrame = createAccentRail(7.3, 0.18, palette.accent, 0.12 + input.params.scenicTreatment.accentIntensity * 0.1, -4.4);
topFrame.position.set(0, 3.2, -4.4);
root.add(topFrame);
if (mode === "fold_gate") {
leftFrame.rotation.y = 0.38;
rightFrame.rotation.y = -0.38;
} else if (mode === "monolith_aperture") {
leftFrame.scale.y = 1.22;
rightFrame.scale.y = 1.22;
topFrame.position.y = 3.8;
}
input.camera.position.set(0, 0.08, 6.85);
input.camera.lookAt(0, 0, -2.8);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) =>
applyMotionEntry(entry, time + index * 0.12, composition.motion, composition.orbitAmount * 0.22, composition.stagger)
);
leftFrame.position.x = -3.45 + Math.sin(time * 0.16) * 0.08;
rightFrame.position.x = 3.45 - Math.sin(time * 0.16) * 0.08;
configureCamera(input.camera, { x: 0, y: 0.08, z: 6.85 }, new THREE.Vector3(0, 0, -2.8), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "portal-frame",
title: "Portal Frame",
build: buildPortalFrame
};

View File

@ -0,0 +1,68 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
buildBackdropSystem,
configureCamera,
createFittedPhotoPlane,
paletteFromAssets,
updateBackdropSystem
} from "../scene-helpers";
const buildSafeHold = (input: SceneActivationInput): SceneInstance => {
const assets = input.loadedAssets.slice(0, 1);
const backdrop = buildBackdropSystem(input, paletteFromAssets(assets, input.params.scenicTreatment));
const root = new THREE.Group();
root.add(backdrop.group);
let plane = null;
if (assets[0]) {
plane = createFittedPhotoPlane(
assets[0],
input.params.photoTreatment,
{
x: 0,
y: -0.06,
z: -1.6,
width: 4.4,
height: 3.9
},
{
opacity: 0.58,
frameOpacity: 0.015,
shadowOpacity: 0.04
}
);
root.add(plane.group);
}
input.camera.position.set(0, 0, 7.2);
input.camera.lookAt(0, 0, -3.2);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
if (plane) {
plane.group.position.y = -0.06 + Math.cos(time * 0.2) * 0.05;
}
configureCamera(
input.camera,
{ x: 0, y: 0, z: 7.2 },
new THREE.Vector3(0, 0, -3.2),
input.params.composition.cameraTravel * 0.4,
context.elapsedMs
);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "safe-hold",
title: "Safe Hold",
build: buildSafeHold
};

View File

@ -0,0 +1,93 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRail,
createFittedPhotoPlane,
createHeroLayoutRects,
type MotionEntry,
paletteFromAssets,
seededSigned,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildSuspensionField = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "hover_shelf";
const count = clamp(1 + Math.round(composition.supportCount), 2, 4);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createHeroLayoutRects(
assets.length,
mode === "diagonal_relay" ? "line" : mode === "depth_table" ? "cluster" : "stack",
composition
);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const adjustedRect =
mode === "depth_table"
? {
...rect,
y: rect.y + seededSigned(asset.asset.id, 10) * 0.28,
z: rect.z - index * 0.18
}
: rect;
const plane = createFittedPhotoPlane(asset, photoTreatment, adjustedRect, {
opacity: index === 0 ? 1 : 0.94,
frameOpacity: 0.025,
shadowOpacity: 0.07
});
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 9) * Math.PI * 2,
travelX: 0.18 + composition.spread * 0.14,
travelY: 0.1 + composition.stagger * 0.1,
orbit: 0.18,
pitch: 0.016,
yaw: 0.05
});
});
const rail = createAccentRail(13.6, 0.12, palette.line, input.params.scenicTreatment.accentIntensity * 0.16, -4.7);
rail.position.set(0, mode === "diagonal_relay" ? 0.2 : -0.35, -4.7);
rail.rotation.z = mode === "diagonal_relay" ? -0.2 : 0;
root.add(rail);
input.camera.position.set(0, 0, 7.25);
input.camera.lookAt(0, 0, -3.2);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) =>
applyMotionEntry(entry, time + index * 0.16, composition.motion, composition.orbitAmount * 0.2, composition.stagger)
);
rail.position.x = Math.sin(time * 0.18) * 0.24;
configureCamera(input.camera, { x: 0, y: 0, z: 7.25 }, new THREE.Vector3(0, 0, -3.2), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "suspension-field",
title: "Suspension Field",
build: buildSuspensionField
};

View File

@ -0,0 +1,87 @@
import * as THREE from "three";
import type { SceneActivationInput, SceneInstance, ScenePlugin } from "../types";
import {
applyMotionEntry,
buildBackdropSystem,
clamp,
configureCamera,
createAccentRing,
createFittedPhotoPlane,
createHeroLayoutRects,
type MotionEntry,
paletteFromAssets,
seededUnit,
updateBackdropSystem
} from "../scene-helpers";
const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "near_witness";
const count = clamp(1 + Math.round(composition.supportCount), 1, 3);
const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
root.add(backdrop.group);
const motionEntries: MotionEntry[] = [];
const layout = createHeroLayoutRects(
assets.length,
mode === "twin_witness" ? "arc" : mode === "sidecar_drift" ? "line" : "stack",
composition
);
assets.forEach((asset, index) => {
const rect = layout[index] ?? layout.at(-1)!;
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
opacity: index === 0 ? 1 : 0.94,
frameOpacity: index === 0 ? 0.04 : 0.025,
shadowOpacity: index === 0 ? 0.12 : 0.08
});
if (mode === "sidecar_drift" && index === 0) {
plane.group.rotation.y += 0.05;
}
root.add(plane.group);
motionEntries.push({
group: plane.group,
basePosition: plane.group.position.clone(),
baseRotation: plane.group.rotation.clone(),
phase: seededUnit(asset.asset.id, 1 + index) * Math.PI * 2,
travelX: index === 0 ? 0.14 : 0.18,
travelY: index === 0 ? 0.08 : 0.12,
orbit: index === 0 ? 0.12 : 0.16,
pitch: 0.02,
yaw: index === 0 ? 0.04 : 0.06
});
});
const halo = createAccentRing(3.25, 0.05, palette.accent, input.params.scenicTreatment.accentIntensity * 0.18);
halo.position.set(0, 0.12, -4.6);
root.add(halo);
input.camera.position.set(0, 0, 7.1);
input.camera.lookAt(0, 0.04, -2.6);
return {
root,
update: (context) => {
const time = context.elapsedMs * 0.001;
updateBackdropSystem(
backdrop,
context,
paletteFromAssets(assets, input.params.scenicTreatment),
input.params.scenicTreatment
);
motionEntries.forEach((entry, index) =>
applyMotionEntry(entry, time + index * 0.18, composition.motion, composition.orbitAmount * 0.35, composition.stagger)
);
halo.rotation.z = Math.sin(time * 0.22) * 0.16;
configureCamera(input.camera, { x: 0, y: 0, z: 7.1 }, new THREE.Vector3(0, 0.04, -2.6), composition.cameraTravel, context.elapsedMs);
}
};
};
export const plugin: ScenePlugin = {
sceneKey: "witness-float",
title: "Witness Float",
build: buildWitnessFloat
};

View File

@ -0,0 +1,183 @@
import type { SceneActivationInput, SceneInstance } from "./types";
import {
clamp,
createTextStrip,
paletteFromAssets,
seededSigned,
seededUnit,
truncateTextFragment
} from "./scene-helpers";
import * as THREE from "three";
const buildAbstractTextTokens = (fragments: string[]) => {
const source = fragments.join(" ").replace(/\s+/g, " ").trim().toUpperCase();
const glyphSource = source.replace(/[^A-Z0-9]/g, "");
const glyphs: string[] = [];
for (let index = 0; index < glyphSource.length && glyphs.length < 22; index += 2) {
const size = glyphs.length % 4 === 0 ? 3 : 2;
const token = glyphSource.slice(index, index + size).trim();
if (token.length > 0) {
glyphs.push(token);
}
}
if (glyphs.length > 0) {
return glyphs;
}
return fragments
.flatMap((fragment) => fragment.split(/\s+/))
.map((fragment) => truncateTextFragment(fragment, 6).toUpperCase())
.filter(Boolean)
.slice(0, 16);
};
export const shouldRenderTextOverlay = (input: SceneActivationInput) =>
input.params.textTreatment.mode !== "off" &&
(input.textFragments ?? []).some((value) => value.trim().length > 0);
export const buildTextOverlay = (input: SceneActivationInput): SceneInstance | null => {
const mode = input.params.textTreatment.mode;
if (mode === "off") {
return null;
}
const fragments = (input.textFragments ?? []).map((value) => truncateTextFragment(value, 72)).filter(Boolean);
const glyphs = buildAbstractTextTokens(fragments);
if (glyphs.length === 0) {
return null;
}
const palette = paletteFromAssets(input.loadedAssets, input.params.scenicTreatment);
const root = new THREE.Group();
const textures: THREE.Texture[] = [];
const animated: Array<{
mesh: THREE.Mesh;
x: number;
y: number;
z: number;
rot: number;
swayX: number;
swayY: number;
speed: number;
}> = [];
const opacity = clamp(input.params.textTreatment.opacity, 0, 0.56);
const density = clamp(input.params.textTreatment.density, 0.08, 0.56);
const scale = clamp(input.params.textTreatment.scale, 0.56, 0.96);
const addGlyph = (
value: string,
options: {
x: number;
y: number;
z: number;
rot?: number;
speed?: number;
swayX?: number;
swayY?: number;
fontSize?: number;
color?: string;
backgroundOpacity?: number;
opacityScale?: number;
}
) => {
const strip = createTextStrip(value, {
color: options.color ?? palette.line,
opacity: opacity * (options.opacityScale ?? 1),
fontSize: options.fontSize,
backgroundOpacity: options.backgroundOpacity ?? 0.02
});
strip.mesh.position.set(options.x, options.y, options.z);
strip.mesh.rotation.z = options.rot ?? 0;
strip.mesh.scale.setScalar(scale);
strip.mesh.renderOrder = 12;
root.add(strip.mesh);
if (strip.texture) {
textures.push(strip.texture);
}
animated.push({
mesh: strip.mesh,
x: options.x,
y: options.y,
z: options.z,
rot: options.rot ?? 0,
swayX: options.swayX ?? 0.06,
swayY: options.swayY ?? 0.04,
speed: options.speed ?? 0.00004
});
};
if (mode === "glyph_dust") {
const count = Math.min(glyphs.length, 8 + Math.round(density * 10));
for (let index = 0; index < count; index += 1) {
const glyph = glyphs[index % glyphs.length]!;
const seed = `${glyph}:${index}`;
addGlyph(glyph, {
x: seededSigned(seed, 1) * 5.4,
y: seededSigned(seed, 2) * 2.8,
z: -5.8 + seededUnit(seed, 3) * 1.4,
rot: seededSigned(seed, 4) * 0.3,
speed: 0.00002 + seededUnit(seed, 5) * 0.00003,
swayX: 0.08 + seededUnit(seed, 6) * 0.12,
swayY: 0.04 + seededUnit(seed, 7) * 0.08,
fontSize: 16 + Math.round(seededUnit(seed, 8) * 8),
color: index % 3 === 0 ? palette.accent : index % 2 === 0 ? palette.secondary : palette.line,
backgroundOpacity: 0.012,
opacityScale: 0.7
});
}
} else if (mode === "constellation_trace") {
const count = Math.min(glyphs.length, 10 + Math.round(density * 8));
for (let index = 0; index < count; index += 1) {
const progress = count === 1 ? 0.5 : index / Math.max(1, count - 1);
const angle = THREE.MathUtils.lerp(-1.05, 1.05, progress);
const radius = 4.9 + Math.sin(progress * Math.PI) * 0.9;
addGlyph(glyphs[index % glyphs.length]!, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * 1.7 + Math.cos(progress * Math.PI * 2) * 0.22,
z: -5.4 - progress * 0.9,
rot: angle * 0.22,
speed: 0.000018 + progress * 0.000018,
swayX: 0.06,
swayY: 0.05,
fontSize: 17 + (index % 3) * 2,
color: index % 2 === 0 ? palette.line : palette.accent,
backgroundOpacity: 0.01,
opacityScale: 0.74
});
}
} else if (mode === "crystal_runes") {
const columns = Math.min(6, Math.max(4, 3 + Math.round(density * 6)));
for (let index = 0; index < columns; index += 1) {
const token = Array.from({ length: 3 }, (_, part) => glyphs[(index * 2 + part) % glyphs.length]!).join(" ");
const left = index % 2 === 0;
addGlyph(token, {
x: left ? -5.6 + index * 0.2 : 5.6 - index * 0.2,
y: 2.4 - index * 0.9,
z: -5.6 + index * 0.18,
rot: left ? -Math.PI / 2 : Math.PI / 2,
speed: 0.000014 + index * 0.000006,
swayX: 0.03,
swayY: 0.08,
fontSize: 15 + (index % 2),
color: left ? palette.secondary : palette.accent,
backgroundOpacity: 0.008,
opacityScale: 0.76
});
}
}
return {
root,
update: ({ elapsedMs }) => {
animated.forEach((entry, index) => {
entry.mesh.position.x = entry.x + Math.sin(elapsedMs * entry.speed + index * 0.7) * entry.swayX;
entry.mesh.position.y = entry.y + Math.cos(elapsedMs * entry.speed * 0.84 + index * 0.42) * entry.swayY;
entry.mesh.position.z = entry.z + Math.sin(elapsedMs * entry.speed * 0.45 + index * 0.3) * 0.08;
entry.mesh.rotation.z = entry.rot + Math.sin(elapsedMs * entry.speed * 1.8 + index) * 0.02;
});
},
dispose: () => textures.forEach((texture) => texture.dispose())
};
};

View File

@ -0,0 +1,59 @@
import type { Cue, PhotoAsset, SceneDefinition, SceneParamGroups } from "@goodgrief/shared-types";
import type { Object3D, PerspectiveCamera, Texture } from "three";
export type SceneParams = SceneParamGroups;
export interface SurfacePresentation {
cue?: Cue | null;
definition: SceneDefinition;
assets: PhotoAsset[];
params?: SceneParams;
effectPresetId?: string;
modeKey?: string;
label?: string;
textFragments?: string[];
anchorCaption?: string | null;
}
export type SurfaceQualityProfile = "preview" | "program-monitor" | "program-output";
export interface LoadedPhotoAsset {
asset: PhotoAsset;
texture: Texture | null;
aspect: number;
dominantColor: string;
sourceUrl: string | null;
}
export interface SceneViewport {
width: number;
height: number;
aspect: number;
}
export interface SceneActivationInput extends SurfacePresentation {
loadedAssets: LoadedPhotoAsset[];
params: SceneParams;
camera: PerspectiveCamera;
viewport: SceneViewport;
}
export interface SceneFrameContext {
elapsedMs: number;
deltaMs: number;
viewport: SceneViewport;
}
export interface SceneInstance {
root: Object3D;
update?: (context: SceneFrameContext) => void;
dispose?: () => void;
}
export interface ScenePlugin {
sceneKey: string;
title: string;
build(input: SceneActivationInput): SceneInstance;
}
export type SceneBuilder = (input: SceneActivationInput) => SceneInstance;

View File

@ -0,0 +1,81 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const statePath = path.join(rootDir, "data", "runtime", "state.json");
const distDir = path.join(rootDir, "apps", "admin", "dist", "assets");
const state = JSON.parse(readFileSync(statePath, "utf8"));
const revision = (value) => createHash("sha1").update(JSON.stringify(value)).digest("hex");
const pendingCount = state.photoAssets.filter((asset) => asset.moderationStatus === "pending" && state.submissions.find((submission) => submission.id === asset.submissionId)?.source !== "admin_upload").length;
const approvedCount = state.photoAssets.filter((asset) => asset.moderationStatus === "approved").length;
const libraryRevision = revision({
photoAssets: state.photoAssets.map((asset) => ({
id: asset.id,
submissionId: asset.submissionId,
moderationStatus: asset.moderationStatus,
processingStatus: asset.processingStatus,
thumbKey: asset.thumbKey,
previewKey: asset.previewKey,
renderKey: asset.renderKey,
approvedAt: asset.approvedAt
})),
submissions: state.submissions.map((submission) => ({
id: submission.id,
status: submission.status,
contributorName: submission.contributorName,
lovedOneName: submission.lovedOneName,
displayName: submission.displayName,
caption: submission.caption,
promptAnswer: submission.promptAnswer,
notes: submission.notes,
source: submission.source
})),
collections: state.collections.map((collection) => ({
id: collection.id,
assetIds: collection.assetIds,
coverAssetId: collection.coverAssetId
}))
});
const programRevision = revision({
cues: state.cues.map((cue) => ({
id: cue.id,
orderIndex: cue.orderIndex,
sceneDefinitionId: cue.sceneDefinitionId,
effectPresetId: cue.effectPresetId,
updated: [cue.transitionIn, cue.transitionOut, cue.assetIds, cue.notes, cue.triggerMode, cue.durationMs, cue.nextCueId, cue.collectionId]
})),
safeSceneCueId: state.showConfig.safeSceneCueId
});
const payloads = {
bootstrap: JSON.stringify({ ...state, libraryRevision, programRevision }).length,
library: JSON.stringify({ photoAssets: state.photoAssets, submissions: state.submissions, collections: state.collections, revision: libraryRevision }).length,
live: JSON.stringify({ cues: state.cues, pendingCount, approvedCount, libraryRevision, programRevision }).length
};
const chunks = readdirSync(distDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.css'))
.map((file) => ({ file, size: statSync(path.join(distDir, file)).size }))
.sort((left, right) => right.size - left.size);
const totalJs = chunks.filter((chunk) => chunk.file.endsWith('.js')).reduce((sum, chunk) => sum + chunk.size, 0);
console.log(JSON.stringify({
assets: chunks,
totalJsBytes: totalJs,
payloadBytes: payloads,
stablePollingBytesPerMinute: payloads.live * 15,
legacyPollingBytesPerMinute: (payloads.live + payloads.library) * 15,
stateCounts: {
photoAssets: state.photoAssets.length,
submissions: state.submissions.length,
cues: state.cues.length
},
viewportProfiles: {
preview: { targetFps: '18-24', dprCap: 0.85 },
programMonitor: { targetFps: '20-30', dprCap: 1 },
programOutput: { targetFps: '30-45', dprCap: 1.35 }
}
}, null, 2));

View File

@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { watch } from "node:fs"; import { watch } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises"; import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@ -62,6 +63,56 @@ const fileExtensionFor = (mimeType: string, filename?: string) => {
return fallback || ".bin"; return fallback || ".bin";
}; };
const createRevision = (value: unknown) => createHash("sha1").update(JSON.stringify(value)).digest("hex");
const getProgramRevision = (state: Awaited<ReturnType<StateStore["read"]>>) =>
createRevision({
cues: state.cues.map((cue) => ({
id: cue.id,
orderIndex: cue.orderIndex,
sceneDefinitionId: cue.sceneDefinitionId,
effectPresetId: cue.effectPresetId,
updated: [cue.transitionIn, cue.transitionOut, cue.assetIds, cue.notes, cue.triggerMode, cue.durationMs, cue.nextCueId, cue.collectionId]
})),
safeSceneCueId: state.showConfig.safeSceneCueId
});
const getLibraryRevision = (state: Awaited<ReturnType<StateStore["read"]>>) =>
createRevision({
photoAssets: state.photoAssets.map((asset) => ({
id: asset.id,
submissionId: asset.submissionId,
moderationStatus: asset.moderationStatus,
processingStatus: asset.processingStatus,
thumbKey: asset.thumbKey,
previewKey: asset.previewKey,
renderKey: asset.renderKey,
approvedAt: asset.approvedAt
})),
submissions: state.submissions.map((submission) => ({
id: submission.id,
status: submission.status,
contributorName: submission.contributorName,
lovedOneName: submission.lovedOneName,
displayName: submission.displayName,
caption: submission.caption,
promptAnswer: submission.promptAnswer,
notes: submission.notes,
source: submission.source
})),
collections: state.collections.map((collection) => ({
id: collection.id,
assetIds: collection.assetIds,
coverAssetId: collection.coverAssetId
}))
});
const createAdminBootstrapPayload = (state: Awaited<ReturnType<StateStore["read"]>>) => ({
...state,
libraryRevision: getLibraryRevision(state),
programRevision: getProgramRevision(state)
});
const normalizeMimeType = (mimeType: string | undefined, filename?: string) => { const normalizeMimeType = (mimeType: string | undefined, filename?: string) => {
const normalized = mimeType?.toLowerCase().trim() ?? ""; const normalized = mimeType?.toLowerCase().trim() ?? "";
if (allowedMimeTypes.has(normalized)) { if (allowedMimeTypes.has(normalized)) {
@ -282,7 +333,7 @@ export const buildServer = async () => {
service: "api" service: "api"
})); }));
app.get("/api/admin/bootstrap", async () => store.read()); app.get("/api/admin/bootstrap", async () => createAdminBootstrapPayload(await store.read()));
app.get("/api/admin/live", async () => { app.get("/api/admin/live", async () => {
const state = await store.read(); const state = await store.read();
const pendingCount = state.photoAssets.filter((asset) => { const pendingCount = state.photoAssets.filter((asset) => {
@ -296,7 +347,9 @@ export const buildServer = async () => {
return { return {
cues: state.cues, cues: state.cues,
pendingCount, pendingCount,
approvedCount: state.photoAssets.filter((asset) => asset.moderationStatus === "approved").length approvedCount: state.photoAssets.filter((asset) => asset.moderationStatus === "approved").length,
libraryRevision: getLibraryRevision(state),
programRevision: getProgramRevision(state)
}; };
}); });
app.get("/api/admin/library", async () => { app.get("/api/admin/library", async () => {
@ -304,7 +357,8 @@ export const buildServer = async () => {
return { return {
photoAssets: state.photoAssets, photoAssets: state.photoAssets,
submissions: state.submissions, submissions: state.submissions,
collections: state.collections collections: state.collections,
revision: getLibraryRevision(state)
}; };
}); });
@ -315,7 +369,7 @@ export const buildServer = async () => {
app.get("/api/assets", async () => (await store.read()).photoAssets); app.get("/api/assets", async () => (await store.read()).photoAssets);
app.get("/api/submissions", async () => (await store.read()).submissions); app.get("/api/submissions", async () => (await store.read()).submissions);
app.get("/api/show-config", async () => (await store.read()).showConfig); app.get("/api/show-config", async () => (await store.read()).showConfig);
app.post("/api/library/rescan", async () => syncLibrary()); app.post("/api/library/rescan", async () => createAdminBootstrapPayload(await syncLibrary()));
app.put<{ Params: { submissionId: string }; Body: SubmissionUpdatePayload }>( app.put<{ Params: { submissionId: string }; Body: SubmissionUpdatePayload }>(
"/api/submissions/:submissionId", "/api/submissions/:submissionId",