Optimize admin performance and split render surface
This commit is contained in:
parent
4c6982bf68
commit
e9aa82e1e1
@ -14,6 +14,7 @@
|
||||
"@goodgrief/effects": "file:../../packages/effects",
|
||||
"@goodgrief/render-engine": "file:../../packages/render-engine",
|
||||
"@goodgrief/shared-types": "file:../../packages/shared-types",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
|
||||
@ -49,6 +49,8 @@ import {
|
||||
type ProgramOutputState
|
||||
} from "../features/live/output-sync";
|
||||
import { SceneViewport } from "../features/live/SceneViewport";
|
||||
import { VirtualizedGrid } from "../features/live/VirtualizedGrid";
|
||||
import { VirtualizedList } from "../features/live/VirtualizedList";
|
||||
import {
|
||||
adminReducer,
|
||||
createCueDraft,
|
||||
@ -477,6 +479,12 @@ export const App = () => {
|
||||
const deferredMediaSearch = useDeferredValue(mediaSearch);
|
||||
const uploadInputRef = 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 = (
|
||||
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(() => {
|
||||
if (initialize) {
|
||||
const initial = createInitialLiveState(payload);
|
||||
@ -519,23 +540,15 @@ export const App = () => {
|
||||
|
||||
const refreshBootstrap = async (initialize = false) => {
|
||||
const payload = await loadAdminBootstrap();
|
||||
hydrate(payload, initialize);
|
||||
};
|
||||
|
||||
const refreshLiveState = async () => {
|
||||
const payload = await loadAdminLive();
|
||||
startTransition(() => {
|
||||
dispatchAdmin({
|
||||
type: "liveLoaded",
|
||||
cues: payload.cues,
|
||||
pendingCount: payload.pendingCount,
|
||||
approvedCount: payload.approvedCount
|
||||
});
|
||||
hydrate(payload, initialize, {
|
||||
libraryRevision: payload.libraryRevision,
|
||||
programRevision: payload.programRevision
|
||||
});
|
||||
};
|
||||
|
||||
const refreshLibraryState = async () => {
|
||||
const payload = await loadAdminLibrary();
|
||||
liveSnapshotRef.current.loadedLibraryRevision = payload.revision;
|
||||
startTransition(() => {
|
||||
dispatchAdmin({
|
||||
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(() => {
|
||||
void refreshBootstrap(true).catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : "Could not load state.");
|
||||
@ -557,15 +603,11 @@ export const App = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldWatchLibrary = workspaceMode === "build" || showUtilityTab === "media" || showUtilityTab === "moderation";
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshLiveState().catch(() => {
|
||||
void refreshLiveState(shouldWatchLibrary).catch(() => {
|
||||
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);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
@ -861,7 +903,10 @@ export const App = () => {
|
||||
const handleRescanLibrary = async () => {
|
||||
try {
|
||||
const payload = await rescanLibrary();
|
||||
hydrate(payload, false);
|
||||
hydrate(payload, false, {
|
||||
libraryRevision: payload.libraryRevision,
|
||||
programRevision: payload.programRevision
|
||||
});
|
||||
setStatus(
|
||||
`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);
|
||||
dispatchAdmin({ type: "cueUpsertSucceeded", cue: savedCue, scene: selectedScene });
|
||||
syncPreviewFromCue(savedCue);
|
||||
await refreshLiveState();
|
||||
await refreshLiveState(false);
|
||||
setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`);
|
||||
} catch (error) {
|
||||
dispatchAdmin({ type: "cueMutationFinished" });
|
||||
@ -1179,7 +1224,7 @@ export const App = () => {
|
||||
const createdCue = await createCue(payload);
|
||||
dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene });
|
||||
syncPreviewFromCue(createdCue);
|
||||
await refreshLiveState();
|
||||
await refreshLiveState(false);
|
||||
setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`);
|
||||
} catch (error) {
|
||||
dispatchAdmin({ type: "cueMutationFinished" });
|
||||
@ -1208,7 +1253,7 @@ export const App = () => {
|
||||
const duplicatedCue = await createCue(payload);
|
||||
dispatchAdmin({ type: "cueUpsertSucceeded", cue: duplicatedCue, scene: selectedScene });
|
||||
syncPreviewFromCue(duplicatedCue);
|
||||
await refreshLiveState();
|
||||
await refreshLiveState(false);
|
||||
setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`);
|
||||
} catch (error) {
|
||||
dispatchAdmin({ type: "cueMutationFinished" });
|
||||
@ -1232,7 +1277,7 @@ export const App = () => {
|
||||
if (nextCue) {
|
||||
syncPreviewFromCue(nextCue);
|
||||
}
|
||||
await refreshLiveState();
|
||||
await refreshLiveState(false);
|
||||
setStatus("Cue deleted.");
|
||||
} catch (error) {
|
||||
dispatchAdmin({ type: "cueMutationFinished" });
|
||||
@ -1260,7 +1305,7 @@ export const App = () => {
|
||||
setStatus(`Cue moved ${direction}.`);
|
||||
} catch (error) {
|
||||
dispatchAdmin({ type: "cueMoveFailed" });
|
||||
void refreshLiveState();
|
||||
void refreshLiveState(false);
|
||||
setStatus(error instanceof Error ? error.message : `Could not move cue ${direction}.`);
|
||||
}
|
||||
};
|
||||
@ -1474,7 +1519,7 @@ export const App = () => {
|
||||
const createdCue = await createCue(payload);
|
||||
dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene });
|
||||
syncPreviewFromCue(createdCue);
|
||||
await refreshLiveState();
|
||||
await refreshLiveState(false);
|
||||
setStatus(`New cue created: ${createdCue.notes ?? createdCue.id}`);
|
||||
} catch (error) {
|
||||
dispatchAdmin({ type: "cueMutationFinished" });
|
||||
@ -1576,9 +1621,8 @@ export const App = () => {
|
||||
return cue.assetIds?.length && cue.assetIds.length > 0 ? cue.assetIds.length : findCollectionAssets(state, cue.collectionId).length;
|
||||
};
|
||||
|
||||
const renderCueRows = (variant: "show" | "build") => (
|
||||
<div className={`cue-list cue-list--${variant}`}>
|
||||
{cueStack.map((cue) => {
|
||||
const renderCueRows = (variant: "show" | "build") => {
|
||||
const renderCueRow = (cue: Cue) => {
|
||||
const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined;
|
||||
const preset = definition
|
||||
? matchPresetForScene(definition, availablePresets, cue.effectPresetId)
|
||||
@ -1604,10 +1648,30 @@ export const App = () => {
|
||||
</small>
|
||||
</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") => (
|
||||
<div className={`browser-stack browser-stack--${variant}`}>
|
||||
@ -1812,7 +1876,7 @@ export const App = () => {
|
||||
onClick={() => focusMetadataAsset(asset)}
|
||||
>
|
||||
<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 className="selected-asset__body">
|
||||
<div className="asset-meta">
|
||||
@ -1874,7 +1938,7 @@ export const App = () => {
|
||||
<div className="metadata-inspector__preview">
|
||||
<div className="metadata-inspector__thumb">
|
||||
{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" />
|
||||
)}
|
||||
@ -1972,8 +2036,16 @@ export const App = () => {
|
||||
);
|
||||
|
||||
const renderApprovedBank = (variant: "show" | "build") => (
|
||||
<div className={`bank-list bank-list--${variant}`}>
|
||||
{filteredApprovedAssets.map((asset) => {
|
||||
<VirtualizedGrid
|
||||
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 assetLabel = getAssetPrimaryLabel(asset, submission);
|
||||
const assetDetail = getAssetSecondaryLabel(submission);
|
||||
@ -1992,7 +2064,7 @@ export const App = () => {
|
||||
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
|
||||
>
|
||||
<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 className="bank-item__overlay">
|
||||
<div className="bank-item__flags">
|
||||
@ -2020,25 +2092,29 @@ export const App = () => {
|
||||
</button>
|
||||
</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") => (
|
||||
<div className={`asset-list asset-list--${variant}`}>
|
||||
{filteredPendingAssets.length === 0 ? <p className="empty-state">No pending submissions right now.</p> : null}
|
||||
{filteredPendingAssets.map((asset) => {
|
||||
<VirtualizedList
|
||||
items={filteredPendingAssets}
|
||||
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 assetLabel = getAssetPrimaryLabel(asset, submission);
|
||||
return (
|
||||
<article
|
||||
key={asset.id}
|
||||
className={`asset-card ${metadataAssetId === asset.id ? "asset-card--editing" : ""}`}
|
||||
onClick={() => focusMetadataAsset(asset)}
|
||||
>
|
||||
<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 className="asset-card__body">
|
||||
<div className="asset-meta">
|
||||
@ -2065,8 +2141,8 @@ export const App = () => {
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderUploadTools = () => (
|
||||
@ -2322,7 +2398,8 @@ export const App = () => {
|
||||
blackout={cueState.blackout}
|
||||
transition={programOutputState?.transition ?? null}
|
||||
activationKey={programActivationKey}
|
||||
qualityProfile="program"
|
||||
qualityProfile="program-monitor"
|
||||
busy={workspaceMode === "build"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,7 @@ const enterFullscreen = async () => {
|
||||
|
||||
export const ProgramOutputApp = () => {
|
||||
const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState());
|
||||
const [overlayVisible, setOverlayVisible] = useState(true);
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const [overlayDismissed, setOverlayDismissed] = useState(false);
|
||||
const hideTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
@ -74,11 +74,6 @@ export const ProgramOutputApp = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOverlayDismissed(false);
|
||||
showOverlay(2800);
|
||||
}, [outputState?.updatedAt, showOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() === "f") {
|
||||
@ -132,6 +127,7 @@ export const ProgramOutputApp = () => {
|
||||
blackout={outputState?.blackout ?? false}
|
||||
transition={transition}
|
||||
activationKey={createPresentationStructureHash(outputState?.presentation ?? null)}
|
||||
qualityProfile="program-output"
|
||||
/>
|
||||
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
|
||||
<div>
|
||||
|
||||
@ -214,7 +214,6 @@ select:focus-visible {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.show-layout {
|
||||
@ -418,7 +417,6 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.utility-tabpanel,
|
||||
.cue-list,
|
||||
.browser-stack,
|
||||
.build-sidebar-scroll,
|
||||
.build-media-browser,
|
||||
@ -426,11 +424,17 @@ select:focus-visible {
|
||||
.show-media-pane,
|
||||
.show-moderation-pane {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.utility-tabpanel,
|
||||
.build-sidebar-scroll,
|
||||
.build-media-inspector {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.utility-tabpanel,
|
||||
.build-sidebar-scroll,
|
||||
.show-media-pane,
|
||||
@ -439,6 +443,14 @@ select:focus-visible {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
@ -846,6 +858,10 @@ select:focus-visible {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.build-media-stack {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.build-media-workarea {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.92fr);
|
||||
}
|
||||
@ -855,23 +871,45 @@ select:focus-visible {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bank-list {
|
||||
.build-media-browser {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.bank-list--build {
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
.build-media-browser > .bank-list,
|
||||
.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 {
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.bank-item {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 0.86;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -972,8 +1010,7 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
@ -1038,8 +1075,7 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.cue-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cue-row {
|
||||
@ -1047,6 +1083,7 @@ select:focus-visible {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
@ -1102,13 +1139,23 @@ select:focus-visible {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.build-sidebar-panel .cue-list {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.show-cue-panel .cue-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.show-cue-panel .cue-list {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.show-media-pane .bank-list,
|
||||
.show-moderation-pane .asset-list {
|
||||
max-height: 100%;
|
||||
.show-moderation-pane .asset-list,
|
||||
.build-media-browser .bank-list,
|
||||
.build-media-browser .asset-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.danger {
|
||||
|
||||
@ -55,10 +55,9 @@ body.mode-output .output-overlay {
|
||||
align-items: end;
|
||||
padding: 16px 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);
|
||||
color: #f5f2ea;
|
||||
backdrop-filter: blur(18px);
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
pointer-events: none;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type {
|
||||
RenderSurface as RenderSurfaceType,
|
||||
SurfacePresentation,
|
||||
@ -21,12 +21,12 @@ const defaultTransition: CueTransition = {
|
||||
durationMs: 0
|
||||
};
|
||||
|
||||
export const SceneViewport = ({
|
||||
const SceneViewportInner = ({
|
||||
presentation,
|
||||
blackout = false,
|
||||
transition,
|
||||
activationKey,
|
||||
qualityProfile = "program",
|
||||
qualityProfile = "program-monitor",
|
||||
busy = false
|
||||
}: SceneViewportProps) => {
|
||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -55,14 +55,28 @@ export const SceneViewport = ({
|
||||
|
||||
let cancelled = false;
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = new RenderSurface(canvas);
|
||||
surface.registerMany(defaultScenePlugins);
|
||||
surface.setQualityProfile(qualityProfileRef.current);
|
||||
surface.setBusy(busyRef.current);
|
||||
surface.setBlackout(blackoutRef.current, null, true);
|
||||
@ -77,8 +91,22 @@ export const SceneViewport = ({
|
||||
observer = new ResizeObserver(() => resize());
|
||||
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;
|
||||
if (initialPresentation) {
|
||||
surface.preloadPresentation(initialPresentation);
|
||||
void surface.activate(initialPresentation, defaultTransition, activationRef.current);
|
||||
}
|
||||
});
|
||||
@ -86,6 +114,10 @@ export const SceneViewport = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer?.disconnect();
|
||||
intersectionObserver?.disconnect();
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}
|
||||
surfaceRef.current?.dispose();
|
||||
surfaceRef.current = null;
|
||||
};
|
||||
@ -109,10 +141,12 @@ export const SceneViewport = ({
|
||||
return;
|
||||
}
|
||||
|
||||
surface.preloadPresentation(presentationRef.current);
|
||||
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition, activationRef.current);
|
||||
}, [activationKey]);
|
||||
|
||||
useEffect(() => {
|
||||
surfaceRef.current?.preloadPresentation(presentation);
|
||||
surfaceRef.current?.updatePresentation(presentation, activationKey);
|
||||
}, [activationKey, presentation]);
|
||||
|
||||
@ -122,3 +156,5 @@ export const SceneViewport = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SceneViewport = memo(SceneViewportInner);
|
||||
|
||||
104
apps/admin/src/features/live/VirtualizedGrid.tsx
Normal file
104
apps/admin/src/features/live/VirtualizedGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
apps/admin/src/features/live/VirtualizedList.tsx
Normal file
61
apps/admin/src/features/live/VirtualizedList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -11,16 +11,24 @@ import type {
|
||||
SubmissionUpdatePayload
|
||||
} from "@goodgrief/shared-types";
|
||||
|
||||
export interface AdminBootstrapPayload extends RepositoryState {
|
||||
libraryRevision: string;
|
||||
programRevision: string;
|
||||
}
|
||||
|
||||
export interface AdminLivePayload {
|
||||
cues: Cue[];
|
||||
pendingCount: number;
|
||||
approvedCount: number;
|
||||
libraryRevision: string;
|
||||
programRevision: string;
|
||||
}
|
||||
|
||||
export interface AdminLibraryPayload {
|
||||
photoAssets: PhotoAsset[];
|
||||
submissions: Submission[];
|
||||
collections: Collection[];
|
||||
revision: string;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const loadAdminBootstrap = async (): Promise<RepositoryState> =>
|
||||
requestJson<RepositoryState>("/api/admin/bootstrap");
|
||||
export const loadAdminBootstrap = async (): Promise<AdminBootstrapPayload> =>
|
||||
requestJson<AdminBootstrapPayload>("/api/admin/bootstrap");
|
||||
|
||||
export const loadAdminLive = async (): Promise<AdminLivePayload> =>
|
||||
requestJson<AdminLivePayload>("/api/admin/live");
|
||||
@ -65,8 +73,8 @@ export const loadAdminLive = async (): Promise<AdminLivePayload> =>
|
||||
export const loadAdminLibrary = async (): Promise<AdminLibraryPayload> =>
|
||||
requestJson<AdminLibraryPayload>("/api/admin/library");
|
||||
|
||||
export const rescanLibrary = async (): Promise<RepositoryState> =>
|
||||
requestJson<RepositoryState>("/api/library/rescan", {
|
||||
export const rescanLibrary = async (): Promise<AdminBootstrapPayload> =>
|
||||
requestJson<AdminBootstrapPayload>("/api/library/rescan", {
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
@ -11,5 +12,43 @@ export default defineConfig({
|
||||
"/api": 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
28
package-lock.json
generated
@ -26,6 +26,7 @@
|
||||
"@goodgrief/effects": "file:../../packages/effects",
|
||||
"@goodgrief/render-engine": "file:../../packages/render-engine",
|
||||
"@goodgrief/shared-types": "file:../../packages/shared-types",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
@ -1941,6 +1942,33 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
|
||||
@ -20,7 +20,8 @@
|
||||
"dev:api": "npm run dev --workspace @goodgrief/api",
|
||||
"dev:api:watch": "npm run dev:watch --workspace @goodgrief/api",
|
||||
"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": {
|
||||
"@types/node": "^24.0.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
835
packages/render-engine/src/render-surface.ts
Normal file
835
packages/render-engine/src/render-surface.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
911
packages/render-engine/src/scene-helpers.ts
Normal file
911
packages/render-engine/src/scene-helpers.ts
Normal 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?.())
|
||||
};
|
||||
};
|
||||
64
packages/render-engine/src/scene-loader.ts
Normal file
64
packages/render-engine/src/scene-loader.ts
Normal 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);
|
||||
};
|
||||
83
packages/render-engine/src/scenes/arrival-relay.ts
Normal file
83
packages/render-engine/src/scenes/arrival-relay.ts
Normal 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
|
||||
};
|
||||
90
packages/render-engine/src/scenes/chorus-array.ts
Normal file
90
packages/render-engine/src/scenes/chorus-array.ts
Normal 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
|
||||
};
|
||||
85
packages/render-engine/src/scenes/equal-collage.ts
Normal file
85
packages/render-engine/src/scenes/equal-collage.ts
Normal 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
|
||||
};
|
||||
87
packages/render-engine/src/scenes/orbit-gallery.ts
Normal file
87
packages/render-engine/src/scenes/orbit-gallery.ts
Normal 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
|
||||
};
|
||||
99
packages/render-engine/src/scenes/portal-frame.ts
Normal file
99
packages/render-engine/src/scenes/portal-frame.ts
Normal 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
|
||||
};
|
||||
68
packages/render-engine/src/scenes/safe-hold.ts
Normal file
68
packages/render-engine/src/scenes/safe-hold.ts
Normal 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
|
||||
};
|
||||
93
packages/render-engine/src/scenes/suspension-field.ts
Normal file
93
packages/render-engine/src/scenes/suspension-field.ts
Normal 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
|
||||
};
|
||||
87
packages/render-engine/src/scenes/witness-float.ts
Normal file
87
packages/render-engine/src/scenes/witness-float.ts
Normal 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
|
||||
};
|
||||
183
packages/render-engine/src/text-overlay.ts
Normal file
183
packages/render-engine/src/text-overlay.ts
Normal 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())
|
||||
};
|
||||
};
|
||||
59
packages/render-engine/src/types.ts
Normal file
59
packages/render-engine/src/types.ts
Normal 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;
|
||||
81
scripts/report-admin-performance.mjs
Normal file
81
scripts/report-admin-performance.mjs
Normal 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));
|
||||
@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { watch } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@ -62,6 +63,56 @@ const fileExtensionFor = (mimeType: string, filename?: string) => {
|
||||
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 normalized = mimeType?.toLowerCase().trim() ?? "";
|
||||
if (allowedMimeTypes.has(normalized)) {
|
||||
@ -282,7 +333,7 @@ export const buildServer = async () => {
|
||||
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 () => {
|
||||
const state = await store.read();
|
||||
const pendingCount = state.photoAssets.filter((asset) => {
|
||||
@ -296,7 +347,9 @@ export const buildServer = async () => {
|
||||
return {
|
||||
cues: state.cues,
|
||||
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 () => {
|
||||
@ -304,7 +357,8 @@ export const buildServer = async () => {
|
||||
return {
|
||||
photoAssets: state.photoAssets,
|
||||
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/submissions", async () => (await store.read()).submissions);
|
||||
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 }>(
|
||||
"/api/submissions/:submissionId",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user