diff --git a/apps/admin/package.json b/apps/admin/package.json index d6e3db8..67c0646 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -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" }, diff --git a/apps/admin/src/app/App.tsx b/apps/admin/src/app/App.tsx index 3ec19e7..004bbfb 100644 --- a/apps/admin/src/app/App.tsx +++ b/apps/admin/src/app/App.tsx @@ -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(null); const mediaSearchInputRef = useRef(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,38 +1621,57 @@ export const App = () => { return cue.assetIds?.length && cue.assetIds.length > 0 ? cue.assetIds.length : findCollectionAssets(state, cue.collectionId).length; }; - const renderCueRows = (variant: "show" | "build") => ( -
- {cueStack.map((cue) => { - const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined; - const preset = definition - ? matchPresetForScene(definition, availablePresets, cue.effectPresetId) - : undefined; - const cueAssetCount = getCueAssetCount(cue); + 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) + : undefined; + const cueAssetCount = getCueAssetCount(cue); - return ( - - ); - })} - {cueStack.length === 0 ?

No cues have been created yet.

: null} -
- ); + + + {cue.triggerMode} / {cue.transitionIn.style} / {cueAssetCount} + + + ); + }; + + if (variant === "build") { + return ( +
+ {cueStack.map((cue) => renderCueRow(cue))} + {cueStack.length === 0 ?

No cues have been created yet.

: null} +
+ ); + } + + return ( + cue.id} + empty={

No cues have been created yet.

} + renderItem={(cue) => renderCueRow(cue)} + /> + ); + }; const renderSceneModeChooser = (variant: "show" | "build") => (
@@ -1812,7 +1876,7 @@ export const App = () => { onClick={() => focusMetadataAsset(asset)} >
- {asset.thumbKey ? :
} + {asset.thumbKey ? :
}
@@ -1874,7 +1938,7 @@ export const App = () => {
{metadataAsset.previewKey || metadataAsset.thumbKey ? ( - + ) : (
)} @@ -1972,8 +2036,16 @@ export const App = () => { ); const renderApprovedBank = (variant: "show" | "build") => ( -
- {filteredApprovedAssets.map((asset) => { + asset.id} + empty={

Approved images will appear here after import or moderation.

} + 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 ?? ""}`} >
- {asset.thumbKey ? :
} + {asset.thumbKey ? :
}
@@ -2020,25 +2092,29 @@ export const App = () => { ); - })} - {filteredApprovedAssets.length === 0 ?

Approved images will appear here after import or moderation.

: null} -
+ }} + /> ); const renderPendingList = (variant: "show" | "build") => ( -
- {filteredPendingAssets.length === 0 ?

No pending submissions right now.

: null} - {filteredPendingAssets.map((asset) => { + asset.id} + empty={

No pending submissions right now.

} + renderItem={(asset) => { const submission = submissionMap.get(asset.submissionId); const assetLabel = getAssetPrimaryLabel(asset, submission); return (
focusMetadataAsset(asset)} >
- {asset.thumbKey ? :
} + {asset.thumbKey ? :
}
@@ -2065,8 +2141,8 @@ export const App = () => {
); - })} -
+ }} + /> ); 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"} />
diff --git a/apps/admin/src/app/ProgramOutputApp.tsx b/apps/admin/src/app/ProgramOutputApp.tsx index 82a7e29..8dd2ce8 100644 --- a/apps/admin/src/app/ProgramOutputApp.tsx +++ b/apps/admin/src/app/ProgramOutputApp.tsx @@ -20,7 +20,7 @@ const enterFullscreen = async () => { export const ProgramOutputApp = () => { const [outputState, setOutputState] = useState(() => readProgramOutputState()); - const [overlayVisible, setOverlayVisible] = useState(true); + const [overlayVisible, setOverlayVisible] = useState(false); const [overlayDismissed, setOverlayDismissed] = useState(false); const hideTimeoutRef = useRef(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" />
diff --git a/apps/admin/src/app/app.css b/apps/admin/src/app/app.css index 362f805..57b831b 100644 --- a/apps/admin/src/app/app.css +++ b/apps/admin/src/app/app.css @@ -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 { diff --git a/apps/admin/src/app/output.css b/apps/admin/src/app/output.css index 7fa6905..30b9a94 100644 --- a/apps/admin/src/app/output.css +++ b/apps/admin/src/app/output.css @@ -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; diff --git a/apps/admin/src/features/live/SceneViewport.tsx b/apps/admin/src/features/live/SceneViewport.tsx index 364db31..166d5de 100644 --- a/apps/admin/src/features/live/SceneViewport.tsx +++ b/apps/admin/src/features/live/SceneViewport.tsx @@ -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(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 = ({
); }; + +export const SceneViewport = memo(SceneViewportInner); diff --git a/apps/admin/src/features/live/VirtualizedGrid.tsx b/apps/admin/src/features/live/VirtualizedGrid.tsx new file mode 100644 index 0000000..4d3d438 --- /dev/null +++ b/apps/admin/src/features/live/VirtualizedGrid.tsx @@ -0,0 +1,104 @@ +import { useEffect, useMemo, useRef, useState, type Key, type ReactNode } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +interface VirtualizedGridProps { + 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 = ({ + items, + className, + minColumnWidth, + maxColumnWidth, + gap = 8, + overscan = 3, + itemAspectRatio = 0.86, + empty = null, + itemKey, + renderItem +}: VirtualizedGridProps) => { + const scrollRef = useRef(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
{empty}
; + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index] ?? []; + const startIndex = virtualRow.index * columns; + return ( +
+ {row.map((item, offset) => ( +
+ {renderItem(item, startIndex + offset)} +
+ ))} +
+ ); + })} +
+
+ ); +}; diff --git a/apps/admin/src/features/live/VirtualizedList.tsx b/apps/admin/src/features/live/VirtualizedList.tsx new file mode 100644 index 0000000..e2acf0f --- /dev/null +++ b/apps/admin/src/features/live/VirtualizedList.tsx @@ -0,0 +1,61 @@ +import { useRef, type Key, type ReactNode } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +interface VirtualizedListProps { + 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 = ({ + items, + className, + estimateSize, + overscan = 6, + gap = 0, + empty = null, + itemKey, + renderItem +}: VirtualizedListProps) => { + const scrollRef = useRef(null); + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => estimateSize + gap, + overscan + }); + + if (items.length === 0) { + return
{empty}
; + } + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = items[virtualItem.index]; + return ( +
+ {renderItem(item, virtualItem.index)} +
+ ); + })} +
+
+ ); +}; diff --git a/apps/admin/src/features/live/api.ts b/apps/admin/src/features/live/api.ts index 8fd815c..f553da3 100644 --- a/apps/admin/src/features/live/api.ts +++ b/apps/admin/src/features/live/api.ts @@ -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 (url: string, init?: RequestInit) => { return (await response.json()) as T; }; -export const loadAdminBootstrap = async (): Promise => - requestJson("/api/admin/bootstrap"); +export const loadAdminBootstrap = async (): Promise => + requestJson("/api/admin/bootstrap"); export const loadAdminLive = async (): Promise => requestJson("/api/admin/live"); @@ -65,8 +73,8 @@ export const loadAdminLive = async (): Promise => export const loadAdminLibrary = async (): Promise => requestJson("/api/admin/library"); -export const rescanLibrary = async (): Promise => - requestJson("/api/library/rescan", { +export const rescanLibrary = async (): Promise => + requestJson("/api/library/rescan", { method: "POST" }); diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index fc5463b..cb28b43 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -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; + } + } + } } }); diff --git a/package-lock.json b/package-lock.json index 433b2b8..878aaa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9727b93..8d234c2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/render-engine/src/index.ts b/packages/render-engine/src/index.ts index 4babd37..ad8d7c4 100644 --- a/packages/render-engine/src/index.ts +++ b/packages/render-engine/src/index.ts @@ -1,2420 +1,14 @@ -import * as THREE from "three"; -import { - flattenSceneParams, - mergeSceneParams, - type Cue, - type CueTransition, - type PhotoAsset, - type SceneDefinition, - type SceneParamGroups, - type ScenicFieldType -} from "@goodgrief/shared-types"; - -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"; - -export interface LoadedPhotoAsset { - asset: PhotoAsset; - texture: THREE.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: THREE.PerspectiveCamera; - viewport: SceneViewport; -} - -export interface SceneFrameContext { - elapsedMs: number; - deltaMs: number; - viewport: SceneViewport; -} - -export interface SceneInstance { - root: THREE.Object3D; - update?: (context: SceneFrameContext) => void; - dispose?: () => void; -} - -export interface ScenePlugin { - sceneKey: string; - title: string; - build(input: SceneActivationInput): SceneInstance; -} - -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; -}; - -const seededUnit = (seed: string, offset = 0) => { - const hash = stringHash(`${seed}:${offset}`); - return (hash % 10_000) / 10_000; -}; - -const seededSigned = (seed: string, offset = 0) => seededUnit(seed, offset) * 2 - 1; - -const mixColor = (base: string, target: string, amount: number) => - `#${new THREE.Color(base).lerp(new THREE.Color(target), clamp(amount, 0, 1)).getHexString()}`; - -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()}`; -}; - -type ScenicPalette = { - primary: string; - secondary: string; - accent: string; - line: string; - ink: string; -}; - -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 - }; -}; - -type PlaneBundle = { - group: THREE.Group; - image: THREE.Mesh; - width: number; - height: number; -}; - -type LayoutRect = { - x: number; - y: number; - z: number; - width: number; - height: number; - yaw?: number; - pitch?: number; -}; - -const SAFE_FRAME = { - width: 6.9, - height: 4.6 -} as const; - -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); - -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 - })); -}; - -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); -}; - -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); -}; - -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); -}; - -type FieldBundle = { - group: THREE.Group; - uniforms: FieldUniforms[]; -}; - -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 }; -}; - -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 - }) - ); - -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 - }) - ); - -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] - }; -}; - -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); - }); -}; - -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); -}; - -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 }; -}; - -const combineInstances = (...instances: Array): 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?.()) - }; -}; - -type MotionEntry = { - group: THREE.Object3D; - basePosition: THREE.Vector3; - baseRotation: THREE.Euler; - phase: number; - travelX: number; - travelY: number; - orbit: number; - pitch: number; - yaw: number; -}; - -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; -}; - -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); -}; - -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); -}; - -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 seed = `${glyphs[index % glyphs.length]}:${index}`; - addGlyph(glyphs[index % glyphs.length]!, { - 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()) - }; -}; - -type BuildFamilyResult = { - root: THREE.Group; - motionEntries: MotionEntry[]; - backdrop: FieldBundle; - cameraBase: { x: number; y: number; z: number }; - lookAt: THREE.Vector3; - extraUpdate?: (context: SceneFrameContext) => void; -}; - -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); - } - }; -}; - -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); - } - }; -}; - -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); - } - }; -}; - -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); - } - }; -}; - -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); - } - }; -}; - -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); - } - }; -}; - -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); - } - }; -}; - -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: PlaneBundle | null = 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); - } - }; -}; - -const buildSceneByKey = (input: SceneActivationInput): SceneInstance => { - switch (input.definition.sceneKey) { - case "witness-float": - return buildWitnessFloat(input); - case "portal-frame": - return buildPortalFrame(input); - case "orbit-gallery": - return buildOrbitGallery(input); - case "suspension-field": - return buildSuspensionField(input); - case "chorus-array": - return buildChorusArray(input); - case "equal-collage": - return buildEqualCollage(input); - case "arrival-relay": - return buildArrivalRelay(input); - case "safe-hold": - return buildSafeHold(input); - default: - return buildWitnessFloat(input); - } -}; - -export const defaultScenePlugins: ScenePlugin[] = [ - { sceneKey: "witness-float", title: "Witness Float", build: buildWitnessFloat }, - { sceneKey: "portal-frame", title: "Portal Frame", build: buildPortalFrame }, - { sceneKey: "orbit-gallery", title: "Orbit Gallery", build: buildOrbitGallery }, - { sceneKey: "suspension-field", title: "Suspension Field", build: buildSuspensionField }, - { sceneKey: "chorus-array", title: "Chorus Array", build: buildChorusArray }, - { sceneKey: "equal-collage", title: "Equal Collage", build: buildEqualCollage }, - { sceneKey: "arrival-relay", title: "Arrival Relay", build: buildArrivalRelay }, - { sceneKey: "safe-hold", title: "Safe Hold", build: buildSafeHold } -]; - -class TextureCache { - private readonly loader = new THREE.TextureLoader(); - private readonly cache = new Map>(); - - 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 transitionStyleToValue = (style: CueTransition["style"]) => { - switch (style) { - case "dissolve": - return 1; - case "mist_reveal": - return 2; - case "depth_drift": - return 3; - case "shutter_reveal": - return 4; - case "cut": - default: - return 0; - } -}; - -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: SceneFrameContext) => { - 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(); -}; - -export class RenderSurface { - private readonly renderer: THREE.WebGLRenderer; - private readonly registry = new Map(); - 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"; - private busy = false; - - 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.renderer.setAnimationLoop((timestamp) => { - 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; - - this.renderer.setClearColor("#040508", 1); - const context = { - elapsedMs: timestamp, - deltaMs, - viewport: this.viewport - } satisfies SceneFrameContext; - - 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); - }); - } - - register(plugin: ScenePlugin) { - this.registry.set(plugin.sceneKey, plugin); - } - - registerMany(plugins: ScenePlugin[]) { - plugins.forEach((plugin) => this.register(plugin)); - } - - 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(); - } - - 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; - } - - 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 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 scene = new THREE.Scene(); - const camera = createSceneCamera(this.viewport); - const plugin = this.registry.get(presentation.definition.sceneKey) ?? { - sceneKey: presentation.definition.sceneKey, - title: presentation.definition.name, - build: buildSceneByKey - }; - const mergedParams = resolvePresentationParams(presentation); - const baseInstance = plugin.build({ - ...presentation, - loadedAssets, - params: mergedParams, - camera, - viewport: this.viewport - }); - const textOverlay = buildTextOverlay({ - ...presentation, - loadedAssets, - params: mergedParams, - camera, - viewport: this.viewport - }); - 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") { - return 0; - } - - return this.busy ? 1000 / 24 : 1000 / 30; - } - - private applyQualitySettings() { - const devicePixelRatio = typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1); - const scale = this.qualityProfile === "program" ? 1 : this.busy ? 0.72 : 0.85; - const cap = this.qualityProfile === "program" ? 2 : 1.25; - const pixelRatio = Math.min(devicePixelRatio * scale, cap); - this.renderer.setPixelRatio(pixelRatio); - this.lastFrameMs = 0; - this.setSize(this.viewport.width, this.viewport.height); - } -} +export { RenderSurface } from "./render-surface"; +export { defaultScenePluginMetadata, loadScenePlugin, preloadScenePlugin, preloadTextOverlayModule } from "./scene-loader"; +export type { + LoadedPhotoAsset, + SceneActivationInput, + SceneBuilder, + SceneFrameContext, + SceneInstance, + SceneParams, + ScenePlugin, + SceneViewport, + SurfacePresentation, + SurfaceQualityProfile +} from "./types"; diff --git a/packages/render-engine/src/render-surface.ts b/packages/render-engine/src/render-surface.ts new file mode 100644 index 0000000..96eb425 --- /dev/null +++ b/packages/render-engine/src/render-surface.ts @@ -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>(); + + 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 => { + 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(); + 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); + } + } +} diff --git a/packages/render-engine/src/scene-helpers.ts b/packages/render-engine/src/scene-helpers.ts new file mode 100644 index 0000000..07a12f5 --- /dev/null +++ b/packages/render-engine/src/scene-helpers.ts @@ -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 => { + 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?.()) + }; +}; diff --git a/packages/render-engine/src/scene-loader.ts b/packages/render-engine/src/scene-loader.ts new file mode 100644 index 0000000..17708d0 --- /dev/null +++ b/packages/render-engine/src/scene-loader.ts @@ -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>(); +const textOverlayBuilderCache = new Map<"text-overlay", Promise>(); + +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>; + +type SceneLoaderKey = keyof typeof sceneLoaders; + +export const loadScenePlugin = (sceneKey: string): Promise => { + 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); +}; diff --git a/packages/render-engine/src/scenes/arrival-relay.ts b/packages/render-engine/src/scenes/arrival-relay.ts new file mode 100644 index 0000000..213c77c --- /dev/null +++ b/packages/render-engine/src/scenes/arrival-relay.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/chorus-array.ts b/packages/render-engine/src/scenes/chorus-array.ts new file mode 100644 index 0000000..bebfffd --- /dev/null +++ b/packages/render-engine/src/scenes/chorus-array.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/equal-collage.ts b/packages/render-engine/src/scenes/equal-collage.ts new file mode 100644 index 0000000..0438be5 --- /dev/null +++ b/packages/render-engine/src/scenes/equal-collage.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/orbit-gallery.ts b/packages/render-engine/src/scenes/orbit-gallery.ts new file mode 100644 index 0000000..cc7c1e4 --- /dev/null +++ b/packages/render-engine/src/scenes/orbit-gallery.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/portal-frame.ts b/packages/render-engine/src/scenes/portal-frame.ts new file mode 100644 index 0000000..7c7f040 --- /dev/null +++ b/packages/render-engine/src/scenes/portal-frame.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/safe-hold.ts b/packages/render-engine/src/scenes/safe-hold.ts new file mode 100644 index 0000000..a60549b --- /dev/null +++ b/packages/render-engine/src/scenes/safe-hold.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/suspension-field.ts b/packages/render-engine/src/scenes/suspension-field.ts new file mode 100644 index 0000000..dd98397 --- /dev/null +++ b/packages/render-engine/src/scenes/suspension-field.ts @@ -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 +}; diff --git a/packages/render-engine/src/scenes/witness-float.ts b/packages/render-engine/src/scenes/witness-float.ts new file mode 100644 index 0000000..6e56eb5 --- /dev/null +++ b/packages/render-engine/src/scenes/witness-float.ts @@ -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 +}; diff --git a/packages/render-engine/src/text-overlay.ts b/packages/render-engine/src/text-overlay.ts new file mode 100644 index 0000000..4234545 --- /dev/null +++ b/packages/render-engine/src/text-overlay.ts @@ -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()) + }; +}; diff --git a/packages/render-engine/src/types.ts b/packages/render-engine/src/types.ts new file mode 100644 index 0000000..464096c --- /dev/null +++ b/packages/render-engine/src/types.ts @@ -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; diff --git a/scripts/report-admin-performance.mjs b/scripts/report-admin-performance.mjs new file mode 100644 index 0000000..b4b103f --- /dev/null +++ b/scripts/report-admin-performance.mjs @@ -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)); diff --git a/services/api/src/server.ts b/services/api/src/server.ts index 35d3985..bc51426 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -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>) => + 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>) => + 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>) => ({ + ...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",