Performance and layout enhancements
This commit is contained in:
parent
d51eef4d42
commit
215ead0768
@ -1,4 +1,4 @@
|
|||||||
import { startTransition, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
import { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||||
import {
|
import {
|
||||||
armCue,
|
armCue,
|
||||||
createCueRuntimeState,
|
createCueRuntimeState,
|
||||||
@ -37,7 +37,9 @@ import {
|
|||||||
deleteCue,
|
deleteCue,
|
||||||
fireCue,
|
fireCue,
|
||||||
generateCue,
|
generateCue,
|
||||||
loadState,
|
loadAdminBootstrap,
|
||||||
|
loadAdminLibrary,
|
||||||
|
loadAdminLive,
|
||||||
moderateAsset,
|
moderateAsset,
|
||||||
moveCue,
|
moveCue,
|
||||||
rescanLibrary,
|
rescanLibrary,
|
||||||
@ -46,6 +48,7 @@ import {
|
|||||||
} from "../features/live/api";
|
} from "../features/live/api";
|
||||||
import {
|
import {
|
||||||
createPresentationHash,
|
createPresentationHash,
|
||||||
|
createPresentationStructureHash,
|
||||||
writeProgramOutputState,
|
writeProgramOutputState,
|
||||||
type ProgramOutputState
|
type ProgramOutputState
|
||||||
} from "../features/live/output-sync";
|
} from "../features/live/output-sync";
|
||||||
@ -199,6 +202,19 @@ const matchPresetForScene = (
|
|||||||
const getApprovedAssets = (payload: RepositoryState) =>
|
const getApprovedAssets = (payload: RepositoryState) =>
|
||||||
payload.photoAssets.filter((asset) => asset.moderationStatus === "approved");
|
payload.photoAssets.filter((asset) => asset.moderationStatus === "approved");
|
||||||
|
|
||||||
|
const getPendingModerationAssets = (photoAssets: PhotoAsset[], submissions: Submission[]) =>
|
||||||
|
photoAssets.filter((asset) => {
|
||||||
|
if (asset.moderationStatus !== "pending") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submission = submissions.find((entry) => entry.id === asset.submissionId);
|
||||||
|
return submission?.source !== "admin_upload";
|
||||||
|
});
|
||||||
|
|
||||||
|
const getPendingModerationCount = (photoAssets: PhotoAsset[], submissions: Submission[]) =>
|
||||||
|
getPendingModerationAssets(photoAssets, submissions).length;
|
||||||
|
|
||||||
const filterAvailableAssetIds = (payload: RepositoryState, assetIds: string[]) => {
|
const filterAvailableAssetIds = (payload: RepositoryState, assetIds: string[]) => {
|
||||||
const available = new Set(getApprovedAssets(payload).map((asset) => asset.id));
|
const available = new Set(getApprovedAssets(payload).map((asset) => asset.id));
|
||||||
return assetIds.filter((assetId) => available.has(assetId));
|
return assetIds.filter((assetId) => available.has(assetId));
|
||||||
@ -491,12 +507,15 @@ export const App = () => {
|
|||||||
const [activePresetId, setActivePresetId] = useState(effectPresetLibrary[0]?.id ?? "");
|
const [activePresetId, setActivePresetId] = useState(effectPresetLibrary[0]?.id ?? "");
|
||||||
const [cueDraft, setCueDraft] = useState<CueDraftState>(createCueDraft());
|
const [cueDraft, setCueDraft] = useState<CueDraftState>(createCueDraft());
|
||||||
const [mediaSearch, setMediaSearch] = useState("");
|
const [mediaSearch, setMediaSearch] = useState("");
|
||||||
|
const deferredMediaSearch = useDeferredValue(mediaSearch);
|
||||||
const [uploadName, setUploadName] = useState("");
|
const [uploadName, setUploadName] = useState("");
|
||||||
const [uploadCaption, setUploadCaption] = useState("");
|
const [uploadCaption, setUploadCaption] = useState("");
|
||||||
const [uploadPromptAnswer, setUploadPromptAnswer] = useState("");
|
const [uploadPromptAnswer, setUploadPromptAnswer] = useState("");
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
const [uploadAddToSelection, setUploadAddToSelection] = useState(true);
|
const [uploadAddToSelection, setUploadAddToSelection] = useState(true);
|
||||||
const [status, setStatus] = useState("Connecting to local show state...");
|
const [status, setStatus] = useState("Connecting to local show state...");
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
const [approvedCount, setApprovedCount] = useState(0);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const mediaSearchInputRef = useRef<HTMLInputElement | null>(null);
|
const mediaSearchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const metadataHydrationKeyRef = useRef<string | null>(null);
|
const metadataHydrationKeyRef = useRef<string | null>(null);
|
||||||
@ -516,11 +535,13 @@ export const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hydrate = (payload: RepositoryState, initialize: boolean) => {
|
const hydrate = (payload: RepositoryState, initialize: boolean) => {
|
||||||
const pendingCount = payload.photoAssets.filter((asset) => asset.moderationStatus === "pending").length;
|
const nextPendingCount = getPendingModerationCount(payload.photoAssets, payload.submissions);
|
||||||
|
const nextApprovedCount = getApprovedAssets(payload).length;
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setState(payload);
|
setState(payload);
|
||||||
setStatus(`Ready. ${pendingCount} pending / ${getApprovedAssets(payload).length} approved.`);
|
setPendingCount(nextPendingCount);
|
||||||
|
setApprovedCount(nextApprovedCount);
|
||||||
|
|
||||||
if (initialize) {
|
if (initialize) {
|
||||||
const initial = createInitialLiveState(payload);
|
const initial = createInitialLiveState(payload);
|
||||||
@ -535,6 +556,7 @@ export const App = () => {
|
|||||||
setActivePresetId(initial.activePresetId);
|
setActivePresetId(initial.activePresetId);
|
||||||
setCueDraft(initial.cueDraft);
|
setCueDraft(initial.cueDraft);
|
||||||
publishProgramOutput(initial.programPresentation, false, initial.programTransition);
|
publishProgramOutput(initial.programPresentation, false, initial.programTransition);
|
||||||
|
setStatus("Ready. Local show state loaded.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -542,20 +564,59 @@ export const App = () => {
|
|||||||
...current,
|
...current,
|
||||||
cueStack: payload.cues
|
cueStack: payload.cues
|
||||||
}));
|
}));
|
||||||
setSelectedAssetIds((current) => {
|
setSelectedAssetIds((current) => filterAvailableAssetIds(payload, current));
|
||||||
const filtered = filterAvailableAssetIds(payload, current);
|
|
||||||
return filtered.length > 0 ? filtered : getDefaultAssetIds(payload);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = async (initialize = false) => {
|
const refreshBootstrap = async (initialize = false) => {
|
||||||
const payload = await loadState();
|
const payload = await loadAdminBootstrap();
|
||||||
hydrate(payload, initialize);
|
hydrate(payload, initialize);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshLiveState = async () => {
|
||||||
|
const payload = await loadAdminLive();
|
||||||
|
startTransition(() => {
|
||||||
|
setPendingCount(payload.pendingCount);
|
||||||
|
setApprovedCount(payload.approvedCount);
|
||||||
|
setState((current) => (current ? { ...current, cues: payload.cues } : current));
|
||||||
|
setCueState((current) => ({
|
||||||
|
...current,
|
||||||
|
cueStack: payload.cues
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshLibraryState = async () => {
|
||||||
|
const payload = await loadAdminLibrary();
|
||||||
|
const nextPendingCount = getPendingModerationCount(payload.photoAssets, payload.submissions);
|
||||||
|
const nextApprovedCount = payload.photoAssets.filter((asset) => asset.moderationStatus === "approved").length;
|
||||||
|
const availableIds = new Set(
|
||||||
|
payload.photoAssets
|
||||||
|
.filter((asset) => asset.moderationStatus === "approved")
|
||||||
|
.map((asset) => asset.id)
|
||||||
|
);
|
||||||
|
const knownIds = new Set(payload.photoAssets.map((asset) => asset.id));
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setPendingCount(nextPendingCount);
|
||||||
|
setApprovedCount(nextApprovedCount);
|
||||||
|
setState((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
photoAssets: payload.photoAssets,
|
||||||
|
submissions: payload.submissions,
|
||||||
|
collections: payload.collections
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
setSelectedAssetIds((current) => current.filter((assetId) => availableIds.has(assetId)));
|
||||||
|
setMetadataAssetId((current) => (current && knownIds.has(current) ? current : null));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh(true).catch((error) => {
|
void refreshBootstrap(true).catch((error) => {
|
||||||
setStatus(error instanceof Error ? error.message : "Could not load state.");
|
setStatus(error instanceof Error ? error.message : "Could not load state.");
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@ -566,34 +627,31 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void refresh(false).catch(() => {
|
void refreshLiveState().catch(() => {
|
||||||
setStatus("Refresh failed. Local state may be stale.");
|
setStatus("Refresh failed. Local state may be stale.");
|
||||||
});
|
});
|
||||||
|
if (workspaceMode === "build" || showUtilityTab === "media" || showUtilityTab === "moderation") {
|
||||||
|
void refreshLibraryState().catch(() => {
|
||||||
|
setStatus("Library refresh failed. Media state may be stale.");
|
||||||
|
});
|
||||||
|
}
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [state]);
|
}, [showUtilityTab, state, workspaceMode]);
|
||||||
|
|
||||||
const pendingAssets = useMemo(
|
const pendingAssets = useMemo(
|
||||||
() =>
|
() => (state ? getPendingModerationAssets(state.photoAssets, state.submissions) : []),
|
||||||
state?.photoAssets.filter((asset) => {
|
[state?.photoAssets, state?.submissions]
|
||||||
if (asset.moderationStatus !== "pending") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
|
|
||||||
return submission?.source !== "admin_upload";
|
|
||||||
}) ?? [],
|
|
||||||
[state]
|
|
||||||
);
|
);
|
||||||
const approvedAssets = useMemo(() => (state ? getApprovedAssets(state) : []), [state]);
|
const approvedAssets = useMemo(() => (state ? getApprovedAssets(state) : []), [state?.photoAssets]);
|
||||||
const availablePresets = useMemo(
|
const availablePresets = useMemo(
|
||||||
() => (state && state.effectPresets.length > 0 ? state.effectPresets : effectPresetLibrary),
|
() => (state && state.effectPresets.length > 0 ? state.effectPresets : effectPresetLibrary),
|
||||||
[state]
|
[state?.effectPresets]
|
||||||
);
|
);
|
||||||
const selectedScene = useMemo(
|
const selectedScene = useMemo(
|
||||||
() => (state ? findSceneById(state, selectedSceneId) : undefined),
|
() => (state ? findSceneById(state, selectedSceneId) : undefined),
|
||||||
[selectedSceneId, state]
|
[selectedSceneId, state?.scenes]
|
||||||
);
|
);
|
||||||
const selectedScenePresets = useMemo(
|
const selectedScenePresets = useMemo(
|
||||||
() => getScenePresets(selectedScene, availablePresets),
|
() => getScenePresets(selectedScene, availablePresets),
|
||||||
@ -604,7 +662,7 @@ export const App = () => {
|
|||||||
(state?.scenes ?? []).filter(
|
(state?.scenes ?? []).filter(
|
||||||
(scene) => sceneBrowserFilter === "all" || scene.sceneFamily === sceneBrowserFilter
|
(scene) => sceneBrowserFilter === "all" || scene.sceneFamily === sceneBrowserFilter
|
||||||
),
|
),
|
||||||
[sceneBrowserFilter, state]
|
[sceneBrowserFilter, state?.scenes]
|
||||||
);
|
);
|
||||||
const sceneFamilyCounts = useMemo(() => {
|
const sceneFamilyCounts = useMemo(() => {
|
||||||
const counts: Record<SceneBrowserFilter, number> = {
|
const counts: Record<SceneBrowserFilter, number> = {
|
||||||
@ -620,31 +678,31 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return counts;
|
return counts;
|
||||||
}, [state]);
|
}, [state?.scenes]);
|
||||||
const previewCue = useMemo(
|
const previewCue = useMemo(
|
||||||
() => (state ? findCueById(state, cueState.previewCueId) : undefined),
|
() => (state ? findCueById(state, cueState.previewCueId) : undefined),
|
||||||
[cueState.previewCueId, state]
|
[cueState.previewCueId, state?.cues]
|
||||||
);
|
);
|
||||||
const currentCue = useMemo(
|
const currentCue = useMemo(
|
||||||
() => (state ? findCueById(state, cueState.currentCueId) : undefined),
|
() => (state ? findCueById(state, cueState.currentCueId) : undefined),
|
||||||
[cueState.currentCueId, state]
|
[cueState.currentCueId, state?.cues]
|
||||||
);
|
);
|
||||||
const safeCue = useMemo(
|
const safeCue = useMemo(
|
||||||
() => state?.cues.find((cue) => cue.id === state.showConfig.safeSceneCueId),
|
() => state?.cues.find((cue) => cue.id === state.showConfig.safeSceneCueId),
|
||||||
[state]
|
[state?.cues, state?.showConfig.safeSceneCueId]
|
||||||
);
|
);
|
||||||
const cueStack = state?.cues ?? [];
|
const cueStack = state?.cues ?? [];
|
||||||
const favoriteCollection: Collection | undefined = useMemo(
|
const favoriteCollection: Collection | undefined = useMemo(
|
||||||
() => state?.collections.find((collection) => collection.kind === "favorites"),
|
() => state?.collections.find((collection) => collection.kind === "favorites"),
|
||||||
[state]
|
[state?.collections]
|
||||||
);
|
);
|
||||||
const curatedCollection: Collection | undefined = useMemo(
|
const curatedCollection: Collection | undefined = useMemo(
|
||||||
() => state?.collections.find((collection) => collection.id === "collection-curated-library"),
|
() => state?.collections.find((collection) => collection.id === "collection-curated-library"),
|
||||||
[state]
|
[state?.collections]
|
||||||
);
|
);
|
||||||
const submissionMap = useMemo(
|
const submissionMap = useMemo(
|
||||||
() => new Map((state?.submissions ?? []).map((submission) => [submission.id, submission] as const)),
|
() => new Map((state?.submissions ?? []).map((submission) => [submission.id, submission] as const)),
|
||||||
[state]
|
[state?.submissions]
|
||||||
);
|
);
|
||||||
const selectedAssets = useMemo(() => {
|
const selectedAssets = useMemo(() => {
|
||||||
const assetMap = new Map(approvedAssets.map((asset) => [asset.id, asset] as const));
|
const assetMap = new Map(approvedAssets.map((asset) => [asset.id, asset] as const));
|
||||||
@ -670,21 +728,21 @@ export const App = () => {
|
|||||||
[metadataAsset, submissionMap]
|
[metadataAsset, submissionMap]
|
||||||
);
|
);
|
||||||
const filteredPendingAssets = useMemo(() => {
|
const filteredPendingAssets = useMemo(() => {
|
||||||
const query = mediaSearch.trim().toLowerCase();
|
const query = deferredMediaSearch.trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return pendingAssets;
|
return pendingAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pendingAssets.filter((asset) => getAssetSearchText(asset, submissionMap.get(asset.submissionId)).includes(query));
|
return pendingAssets.filter((asset) => getAssetSearchText(asset, submissionMap.get(asset.submissionId)).includes(query));
|
||||||
}, [mediaSearch, pendingAssets, submissionMap]);
|
}, [deferredMediaSearch, pendingAssets, submissionMap]);
|
||||||
const filteredApprovedAssets = useMemo(() => {
|
const filteredApprovedAssets = useMemo(() => {
|
||||||
const query = mediaSearch.trim().toLowerCase();
|
const query = deferredMediaSearch.trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return approvedAssets;
|
return approvedAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
return approvedAssets.filter((asset) => getAssetSearchText(asset, submissionMap.get(asset.submissionId)).includes(query));
|
return approvedAssets.filter((asset) => getAssetSearchText(asset, submissionMap.get(asset.submissionId)).includes(query));
|
||||||
}, [approvedAssets, mediaSearch, submissionMap]);
|
}, [approvedAssets, deferredMediaSearch, submissionMap]);
|
||||||
const activePreset: EffectPreset | undefined =
|
const activePreset: EffectPreset | undefined =
|
||||||
selectedScenePresets.find((preset) => preset.id === activePresetId) ??
|
selectedScenePresets.find((preset) => preset.id === activePresetId) ??
|
||||||
availablePresets.find((preset) => preset.id === activePresetId);
|
availablePresets.find((preset) => preset.id === activePresetId);
|
||||||
@ -774,11 +832,11 @@ export const App = () => {
|
|||||||
}, [activePreset?.id, activePreset?.modeKey, previewCue, previewLabel, previewParams, previewUsesArmedCue, selectedAssets, selectedScene, state]);
|
}, [activePreset?.id, activePreset?.modeKey, previewCue, previewLabel, previewParams, previewUsesArmedCue, selectedAssets, selectedScene, state]);
|
||||||
|
|
||||||
const previewActivationKey = useMemo(
|
const previewActivationKey = useMemo(
|
||||||
() => createPresentationHash(previewPresentation, false, null),
|
() => createPresentationStructureHash(previewPresentation),
|
||||||
[previewPresentation]
|
[previewPresentation]
|
||||||
);
|
);
|
||||||
const programPresentation = programOutputState?.presentation ?? null;
|
const programPresentation = programOutputState?.presentation ?? null;
|
||||||
const programActivationKey = programOutputState?.presentationHash ?? "program-empty";
|
const programActivationKey = `${programOutputState?.outputRevision ?? 0}:${createPresentationStructureHash(programPresentation)}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (metadataAsset?.id && metadataAssetId !== metadataAsset.id) {
|
if (metadataAsset?.id && metadataAssetId !== metadataAsset.id) {
|
||||||
@ -902,7 +960,7 @@ export const App = () => {
|
|||||||
const handleRescanLibrary = async () => {
|
const handleRescanLibrary = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await rescanLibrary();
|
const payload = await rescanLibrary();
|
||||||
hydrate(payload, true);
|
hydrate(payload, false);
|
||||||
setStatus(
|
setStatus(
|
||||||
`Library rescanned. ${getApprovedAssets(payload).length} approved assets ready / ${payload.collections.find((collection) => collection.id === "collection-curated-library")?.assetIds.length ?? 0} curated.`
|
`Library rescanned. ${getApprovedAssets(payload).length} approved assets ready / ${payload.collections.find((collection) => collection.id === "collection-curated-library")?.assetIds.length ?? 0} curated.`
|
||||||
);
|
);
|
||||||
@ -945,7 +1003,7 @@ export const App = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createAdminUpload(form);
|
const result = await createAdminUpload(form);
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setMetadataAssetId(result.assetId);
|
setMetadataAssetId(result.assetId);
|
||||||
setMetadataDirty(false);
|
setMetadataDirty(false);
|
||||||
|
|
||||||
@ -1001,7 +1059,7 @@ export const App = () => {
|
|||||||
setMetadataSaving(true);
|
setMetadataSaving(true);
|
||||||
await updateSubmissionMetadata(metadataSubmission.id, payload);
|
await updateSubmissionMetadata(metadataSubmission.id, payload);
|
||||||
setMetadataDirty(false);
|
setMetadataDirty(false);
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setStatus(`Metadata saved for ${metadataTitle}.`);
|
setStatus(`Metadata saved for ${metadataTitle}.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message : "Could not save image metadata.");
|
setStatus(error instanceof Error ? error.message : "Could not save image metadata.");
|
||||||
@ -1033,7 +1091,7 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
setSelectedAssetIds((current) => current.filter((assetId) => assetId !== asset.id));
|
setSelectedAssetIds((current) => current.filter((assetId) => assetId !== asset.id));
|
||||||
setMetadataAssetId((current) => (current === asset.id ? null : current));
|
setMetadataAssetId((current) => (current === asset.id ? null : current));
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setStatus(
|
setStatus(
|
||||||
submission?.source === "library_import"
|
submission?.source === "library_import"
|
||||||
? `Removed ${assetLabel} from the approved bank.`
|
? `Removed ${assetLabel} from the approved bank.`
|
||||||
@ -1184,7 +1242,7 @@ export const App = () => {
|
|||||||
|
|
||||||
const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload);
|
const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload);
|
||||||
syncPreviewFromCue(savedCue);
|
syncPreviewFromCue(savedCue);
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setCueDraft(createCueDraft(savedCue, selectedScene));
|
setCueDraft(createCueDraft(savedCue, selectedScene));
|
||||||
setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`);
|
setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`);
|
||||||
};
|
};
|
||||||
@ -1205,7 +1263,7 @@ export const App = () => {
|
|||||||
|
|
||||||
const createdCue = await createCue(payload);
|
const createdCue = await createCue(payload);
|
||||||
syncPreviewFromCue(createdCue);
|
syncPreviewFromCue(createdCue);
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setCueDraft(createCueDraft(createdCue, selectedScene));
|
setCueDraft(createCueDraft(createdCue, selectedScene));
|
||||||
setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`);
|
setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`);
|
||||||
};
|
};
|
||||||
@ -1224,7 +1282,7 @@ export const App = () => {
|
|||||||
payload.notes = `${cueDraft.notes || selectedScene?.name || "Cue"} copy`;
|
payload.notes = `${cueDraft.notes || selectedScene?.name || "Cue"} copy`;
|
||||||
const duplicatedCue = await createCue(payload);
|
const duplicatedCue = await createCue(payload);
|
||||||
syncPreviewFromCue(duplicatedCue);
|
syncPreviewFromCue(duplicatedCue);
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setCueDraft(createCueDraft(duplicatedCue, selectedScene));
|
setCueDraft(createCueDraft(duplicatedCue, selectedScene));
|
||||||
setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`);
|
setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`);
|
||||||
};
|
};
|
||||||
@ -1235,7 +1293,7 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await deleteCue(cueDraft.id);
|
await deleteCue(cueDraft.id);
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setCueDraft(createCueDraft(undefined, selectedScene));
|
setCueDraft(createCueDraft(undefined, selectedScene));
|
||||||
setStatus("Cue deleted.");
|
setStatus("Cue deleted.");
|
||||||
};
|
};
|
||||||
@ -1246,7 +1304,7 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await moveCue(cueDraft.id, { direction });
|
await moveCue(cueDraft.id, { direction });
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
setStatus(`Cue moved ${direction}.`);
|
setStatus(`Cue moved ${direction}.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1255,7 +1313,7 @@ export const App = () => {
|
|||||||
decision,
|
decision,
|
||||||
reasonCode: decision === "rejected" ? "operator_review" : undefined
|
reasonCode: decision === "rejected" ? "operator_review" : undefined
|
||||||
});
|
});
|
||||||
await refresh(false);
|
await refreshBootstrap(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTakeCue = async () => {
|
const handleTakeCue = async () => {
|
||||||
@ -2251,7 +2309,12 @@ export const App = () => {
|
|||||||
{selectedAssetCountLabel} / {selectedScene?.renderMode ?? "none"}
|
{selectedAssetCountLabel} / {selectedScene?.renderMode ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<SceneViewport presentation={previewPresentation} activationKey={previewActivationKey} />
|
<SceneViewport
|
||||||
|
presentation={previewPresentation}
|
||||||
|
activationKey={previewActivationKey}
|
||||||
|
qualityProfile="preview"
|
||||||
|
busy={workspaceMode === "build"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="surface-card surface-card--program">
|
<div className="surface-card surface-card--program">
|
||||||
@ -2269,6 +2332,7 @@ export const App = () => {
|
|||||||
blackout={cueState.blackout}
|
blackout={cueState.blackout}
|
||||||
transition={programOutputState?.transition ?? null}
|
transition={programOutputState?.transition ?? null}
|
||||||
activationKey={`${programActivationKey}:${cueState.blackout ? "blackout" : "live"}`}
|
activationKey={`${programActivationKey}:${cueState.blackout ? "blackout" : "live"}`}
|
||||||
|
qualityProfile="program"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2290,6 +2354,7 @@ export const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-status">
|
<div className="admin-status">
|
||||||
<span className="admin-topbar__status-text">{status}</span>
|
<span className="admin-topbar__status-text">{status}</span>
|
||||||
|
<span className="source-badge">{pendingCount} pending / {approvedCount} approved</span>
|
||||||
<div className="workspace-toggle" role="tablist" aria-label="Workspace mode">
|
<div className="workspace-toggle" role="tablist" aria-label="Workspace mode">
|
||||||
<button
|
<button
|
||||||
className={workspaceMode === "show" ? "workspace-toggle__button workspace-toggle__button--active" : "workspace-toggle__button"}
|
className={workspaceMode === "show" ? "workspace-toggle__button workspace-toggle__button--active" : "workspace-toggle__button"}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RenderSurface as RenderSurfaceType, SurfacePresentation } from "@goodgrief/render-engine";
|
import type {
|
||||||
|
RenderSurface as RenderSurfaceType,
|
||||||
|
SurfacePresentation,
|
||||||
|
SurfaceQualityProfile
|
||||||
|
} from "@goodgrief/render-engine";
|
||||||
import type { CueTransition } from "@goodgrief/shared-types";
|
import type { CueTransition } from "@goodgrief/shared-types";
|
||||||
import "./viewport.css";
|
import "./viewport.css";
|
||||||
|
|
||||||
@ -8,6 +12,8 @@ interface SceneViewportProps {
|
|||||||
blackout?: boolean;
|
blackout?: boolean;
|
||||||
transition?: CueTransition | null;
|
transition?: CueTransition | null;
|
||||||
activationKey?: string;
|
activationKey?: string;
|
||||||
|
qualityProfile?: SurfaceQualityProfile;
|
||||||
|
busy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTransition: CueTransition = {
|
const defaultTransition: CueTransition = {
|
||||||
@ -15,7 +21,14 @@ const defaultTransition: CueTransition = {
|
|||||||
durationMs: 0
|
durationMs: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SceneViewport = ({ presentation, blackout = false, transition, activationKey }: SceneViewportProps) => {
|
export const SceneViewport = ({
|
||||||
|
presentation,
|
||||||
|
blackout = false,
|
||||||
|
transition,
|
||||||
|
activationKey,
|
||||||
|
qualityProfile = "program",
|
||||||
|
busy = false
|
||||||
|
}: SceneViewportProps) => {
|
||||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const surfaceRef = useRef<RenderSurfaceType | null>(null);
|
const surfaceRef = useRef<RenderSurfaceType | null>(null);
|
||||||
@ -23,11 +36,15 @@ export const SceneViewport = ({ presentation, blackout = false, transition, acti
|
|||||||
const activationRef = useRef<string | undefined>(activationKey);
|
const activationRef = useRef<string | undefined>(activationKey);
|
||||||
const transitionRef = useRef<CueTransition | null | undefined>(transition);
|
const transitionRef = useRef<CueTransition | null | undefined>(transition);
|
||||||
const blackoutRef = useRef(blackout);
|
const blackoutRef = useRef(blackout);
|
||||||
|
const qualityProfileRef = useRef<SurfaceQualityProfile>(qualityProfile);
|
||||||
|
const busyRef = useRef(busy);
|
||||||
|
|
||||||
presentationRef.current = presentation;
|
presentationRef.current = presentation;
|
||||||
activationRef.current = activationKey;
|
activationRef.current = activationKey;
|
||||||
transitionRef.current = transition;
|
transitionRef.current = transition;
|
||||||
blackoutRef.current = blackout;
|
blackoutRef.current = blackout;
|
||||||
|
qualityProfileRef.current = qualityProfile;
|
||||||
|
busyRef.current = busy;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = frameRef.current;
|
const frame = frameRef.current;
|
||||||
@ -46,6 +63,8 @@ export const SceneViewport = ({ presentation, blackout = false, transition, acti
|
|||||||
|
|
||||||
const surface = new RenderSurface(canvas);
|
const surface = new RenderSurface(canvas);
|
||||||
surface.registerMany(defaultScenePlugins);
|
surface.registerMany(defaultScenePlugins);
|
||||||
|
surface.setQualityProfile(qualityProfileRef.current);
|
||||||
|
surface.setBusy(busyRef.current);
|
||||||
surface.setBlackout(blackoutRef.current);
|
surface.setBlackout(blackoutRef.current);
|
||||||
surfaceRef.current = surface;
|
surfaceRef.current = surface;
|
||||||
|
|
||||||
@ -60,7 +79,7 @@ export const SceneViewport = ({ presentation, blackout = false, transition, acti
|
|||||||
|
|
||||||
const initialPresentation = presentationRef.current;
|
const initialPresentation = presentationRef.current;
|
||||||
if (initialPresentation) {
|
if (initialPresentation) {
|
||||||
void surface.activate(initialPresentation, defaultTransition);
|
void surface.activate(initialPresentation, defaultTransition, activationRef.current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -76,15 +95,27 @@ export const SceneViewport = ({ presentation, blackout = false, transition, acti
|
|||||||
surfaceRef.current?.setBlackout(blackout);
|
surfaceRef.current?.setBlackout(blackout);
|
||||||
}, [blackout]);
|
}, [blackout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
surfaceRef.current?.setQualityProfile(qualityProfile);
|
||||||
|
}, [qualityProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
surfaceRef.current?.setBusy(busy);
|
||||||
|
}, [busy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const surface = surfaceRef.current;
|
const surface = surfaceRef.current;
|
||||||
if (!surface) {
|
if (!surface) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition);
|
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition, activationRef.current);
|
||||||
}, [activationKey]);
|
}, [activationKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
surfaceRef.current?.updatePresentation(presentation, activationKey);
|
||||||
|
}, [activationKey, presentation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={frameRef} className="surface-viewport">
|
<div ref={frameRef} className="surface-viewport">
|
||||||
<canvas ref={canvasRef} className="surface-viewport__canvas" />
|
<canvas ref={canvasRef} className="surface-viewport__canvas" />
|
||||||
|
|||||||
@ -1,14 +1,28 @@
|
|||||||
import type {
|
import type {
|
||||||
|
Collection,
|
||||||
Cue,
|
Cue,
|
||||||
CueGeneratePayload,
|
CueGeneratePayload,
|
||||||
CueMovePayload,
|
CueMovePayload,
|
||||||
CueUpsertPayload,
|
CueUpsertPayload,
|
||||||
ModerationActionPayload,
|
ModerationActionPayload,
|
||||||
|
PhotoAsset,
|
||||||
RepositoryState,
|
RepositoryState,
|
||||||
Submission,
|
Submission,
|
||||||
SubmissionUpdatePayload
|
SubmissionUpdatePayload
|
||||||
} from "@goodgrief/shared-types";
|
} from "@goodgrief/shared-types";
|
||||||
|
|
||||||
|
export interface AdminLivePayload {
|
||||||
|
cues: Cue[];
|
||||||
|
pendingCount: number;
|
||||||
|
approvedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLibraryPayload {
|
||||||
|
photoAssets: PhotoAsset[];
|
||||||
|
submissions: Submission[];
|
||||||
|
collections: Collection[];
|
||||||
|
}
|
||||||
|
|
||||||
const postVoid = async (url: string, body?: unknown) => {
|
const postVoid = async (url: string, body?: unknown) => {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -42,13 +56,14 @@ const requestJson = async <T>(url: string, init?: RequestInit) => {
|
|||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadState = async (): Promise<RepositoryState> => {
|
export const loadAdminBootstrap = async (): Promise<RepositoryState> =>
|
||||||
const response = await fetch("/api/state");
|
requestJson<RepositoryState>("/api/admin/bootstrap");
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Could not load admin state.");
|
export const loadAdminLive = async (): Promise<AdminLivePayload> =>
|
||||||
}
|
requestJson<AdminLivePayload>("/api/admin/live");
|
||||||
return (await response.json()) as RepositoryState;
|
|
||||||
};
|
export const loadAdminLibrary = async (): Promise<AdminLibraryPayload> =>
|
||||||
|
requestJson<AdminLibraryPayload>("/api/admin/library");
|
||||||
|
|
||||||
export const rescanLibrary = async (): Promise<RepositoryState> =>
|
export const rescanLibrary = async (): Promise<RepositoryState> =>
|
||||||
requestJson<RepositoryState>("/api/library/rescan", {
|
requestJson<RepositoryState>("/api/library/rescan", {
|
||||||
|
|||||||
@ -13,6 +13,18 @@ export interface ProgramOutputState {
|
|||||||
|
|
||||||
const storageKey = "goodgrief:program-output";
|
const storageKey = "goodgrief:program-output";
|
||||||
const channelName = "goodgrief-program-output";
|
const channelName = "goodgrief-program-output";
|
||||||
|
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 canUseWindow = () => typeof window !== "undefined";
|
const canUseWindow = () => typeof window !== "undefined";
|
||||||
|
|
||||||
@ -28,6 +40,36 @@ const hashString = (input: string) => {
|
|||||||
return (hash >>> 0).toString(16);
|
return (hash >>> 0).toString(16);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStructuralParams = (presentation: SurfacePresentation | null) => {
|
||||||
|
if (!presentation?.params) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattened = flattenSceneParams(presentation.params);
|
||||||
|
for (const path of liveMutableParamPaths) {
|
||||||
|
delete flattened[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattened;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPresentationStructureHash = (presentation: SurfacePresentation | null) =>
|
||||||
|
hashString(
|
||||||
|
JSON.stringify({
|
||||||
|
presentation: presentation
|
||||||
|
? {
|
||||||
|
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: getStructuralParams(presentation)
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const createPresentationHash = (
|
export const createPresentationHash = (
|
||||||
presentation: SurfacePresentation | null,
|
presentation: SurfacePresentation | null,
|
||||||
blackout: boolean,
|
blackout: boolean,
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export interface SurfacePresentation {
|
|||||||
anchorCaption?: string | null;
|
anchorCaption?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SurfaceQualityProfile = "preview" | "program";
|
||||||
|
|
||||||
export interface LoadedPhotoAsset {
|
export interface LoadedPhotoAsset {
|
||||||
asset: PhotoAsset;
|
asset: PhotoAsset;
|
||||||
texture: THREE.Texture | null;
|
texture: THREE.Texture | null;
|
||||||
@ -144,6 +146,21 @@ type PlaneBundle = {
|
|||||||
height: 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 = (
|
const createPhotoPlane = (
|
||||||
asset: LoadedPhotoAsset,
|
asset: LoadedPhotoAsset,
|
||||||
_params: SceneParams["photoTreatment"],
|
_params: SceneParams["photoTreatment"],
|
||||||
@ -216,13 +233,214 @@ const createPhotoPlane = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
type FieldBundle = {
|
||||||
group: THREE.Group;
|
group: THREE.Group;
|
||||||
uniforms: Array<{
|
uniforms: FieldUniforms[];
|
||||||
uTime: { value: number };
|
};
|
||||||
uIntensity: { value: number };
|
|
||||||
uSpeed: { value: number };
|
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 = `
|
const FIELD_VERTEX_SHADER = `
|
||||||
@ -535,17 +753,26 @@ const buildBackdropSystem = (input: SceneActivationInput, palette: ScenicPalette
|
|||||||
const updateBackdropSystem = (
|
const updateBackdropSystem = (
|
||||||
bundle: FieldBundle,
|
bundle: FieldBundle,
|
||||||
context: SceneFrameContext,
|
context: SceneFrameContext,
|
||||||
|
palette: ScenicPalette,
|
||||||
scenicTreatment: SceneParams["scenicTreatment"]
|
scenicTreatment: SceneParams["scenicTreatment"]
|
||||||
) => {
|
) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
bundle.uniforms.forEach((uniforms, index) => {
|
bundle.uniforms.forEach((uniforms, index) => {
|
||||||
uniforms.uTime.value = time * (0.8 + index * 0.18);
|
uniforms.uTime.value = time * (0.8 + index * 0.18);
|
||||||
|
uniforms.uType.value = fieldTypeToValue(scenicTreatment.fieldType);
|
||||||
uniforms.uIntensity.value = clamp(
|
uniforms.uIntensity.value = clamp(
|
||||||
scenicTreatment.fieldIntensity * (index === 0 ? 1 : index === 1 ? 0.72 : 0.36),
|
scenicTreatment.fieldIntensity * (index === 0 ? 1 : index === 1 ? 0.72 : 0.36),
|
||||||
0,
|
0,
|
||||||
1
|
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.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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -948,31 +1175,6 @@ type BuildFamilyResult = {
|
|||||||
extraUpdate?: (context: SceneFrameContext) => void;
|
extraUpdate?: (context: SceneFrameContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildHeroPlanes = (
|
|
||||||
assets: LoadedPhotoAsset[],
|
|
||||||
photoTreatment: SceneParams["photoTreatment"],
|
|
||||||
config: {
|
|
||||||
heroHeight: number;
|
|
||||||
supportHeight: number;
|
|
||||||
}
|
|
||||||
) => {
|
|
||||||
const hero = createPhotoPlane(assets[0]!, photoTreatment, {
|
|
||||||
height: config.heroHeight,
|
|
||||||
opacity: 1,
|
|
||||||
frameOpacity: 0.04,
|
|
||||||
shadowOpacity: 0.12
|
|
||||||
});
|
|
||||||
const supports = assets.slice(1).map((asset) =>
|
|
||||||
createPhotoPlane(asset, photoTreatment, {
|
|
||||||
height: config.supportHeight,
|
|
||||||
opacity: 0.94,
|
|
||||||
frameOpacity: 0.025,
|
|
||||||
shadowOpacity: 0.08
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return { hero, supports };
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
|
const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
|
||||||
const { composition, photoTreatment } = input.params;
|
const { composition, photoTreatment } = input.params;
|
||||||
const mode = input.modeKey ?? "near_witness";
|
const mode = input.modeKey ?? "near_witness";
|
||||||
@ -983,49 +1185,33 @@ const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const motionEntries: MotionEntry[] = [];
|
const motionEntries: MotionEntry[] = [];
|
||||||
const { hero, supports } = buildHeroPlanes(assets, photoTreatment, {
|
const layout = createHeroLayoutRects(
|
||||||
heroHeight: mode === "twin_witness" ? 3.7 : 4.6,
|
assets.length,
|
||||||
supportHeight: 2
|
mode === "twin_witness" ? "arc" : mode === "sidecar_drift" ? "line" : "stack",
|
||||||
});
|
composition
|
||||||
|
);
|
||||||
|
|
||||||
hero.group.position.set(mode === "sidecar_drift" ? -1.1 : 0, 0.08, -0.7);
|
assets.forEach((asset, index) => {
|
||||||
hero.group.rotation.y = mode === "sidecar_drift" ? 0.08 : 0;
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
root.add(hero.group);
|
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||||
motionEntries.push({
|
opacity: index === 0 ? 1 : 0.94,
|
||||||
group: hero.group,
|
frameOpacity: index === 0 ? 0.04 : 0.025,
|
||||||
basePosition: hero.group.position.clone(),
|
shadowOpacity: index === 0 ? 0.12 : 0.08
|
||||||
baseRotation: hero.group.rotation.clone(),
|
});
|
||||||
phase: seededUnit(assets[0]!.asset.id, 1) * Math.PI * 2,
|
if (mode === "sidecar_drift" && index === 0) {
|
||||||
travelX: mode === "sidecar_drift" ? 0.22 : 0.16,
|
plane.group.rotation.y += 0.05;
|
||||||
travelY: 0.1,
|
|
||||||
orbit: 0.12,
|
|
||||||
pitch: 0.02,
|
|
||||||
yaw: 0.04
|
|
||||||
});
|
|
||||||
|
|
||||||
supports.forEach((plane, index) => {
|
|
||||||
if (mode === "twin_witness" && index === 0) {
|
|
||||||
plane.group.position.set(2.45, -0.12, -1.3);
|
|
||||||
plane.group.scale.setScalar(1.55);
|
|
||||||
plane.group.rotation.y = -0.08;
|
|
||||||
} else if (mode === "sidecar_drift") {
|
|
||||||
plane.group.position.set(3.15 - index * 0.6, 0.95 - index * 1.1, -2.4 - index * 0.5);
|
|
||||||
plane.group.rotation.y = -0.14;
|
|
||||||
} else {
|
|
||||||
plane.group.position.set(-2.5 + index * 4.9, 1.1 - index * 1.6, -2.3 - index * 0.42);
|
|
||||||
plane.group.rotation.y = seededSigned(plane.group.uuid, 3) * 0.12;
|
|
||||||
}
|
}
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
motionEntries.push({
|
motionEntries.push({
|
||||||
group: plane.group,
|
group: plane.group,
|
||||||
basePosition: plane.group.position.clone(),
|
basePosition: plane.group.position.clone(),
|
||||||
baseRotation: plane.group.rotation.clone(),
|
baseRotation: plane.group.rotation.clone(),
|
||||||
phase: seededUnit(assets[index + 1]?.asset.id ?? plane.group.uuid, 2) * Math.PI * 2,
|
phase: seededUnit(asset.asset.id, 1 + index) * Math.PI * 2,
|
||||||
travelX: 0.28,
|
travelX: index === 0 ? 0.14 : 0.18,
|
||||||
travelY: 0.16,
|
travelY: index === 0 ? 0.08 : 0.12,
|
||||||
orbit: 0.16,
|
orbit: index === 0 ? 0.12 : 0.16,
|
||||||
pitch: 0.02,
|
pitch: 0.02,
|
||||||
yaw: 0.06
|
yaw: index === 0 ? 0.04 : 0.06
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1040,7 +1226,7 @@ const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) =>
|
motionEntries.forEach((entry, index) =>
|
||||||
applyMotionEntry(entry, time + index * 0.18, composition.motion, composition.orbitAmount * 0.35, composition.stagger)
|
applyMotionEntry(entry, time + index * 0.18, composition.motion, composition.orbitAmount * 0.35, composition.stagger)
|
||||||
);
|
);
|
||||||
@ -1060,22 +1246,30 @@ const buildPortalFrame = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const motionEntries: MotionEntry[] = [];
|
const motionEntries: MotionEntry[] = [];
|
||||||
const { hero, supports } = buildHeroPlanes(assets, photoTreatment, {
|
const layout = createHeroLayoutRects(assets.length, mode === "fold_gate" ? "arc" : "stack", composition);
|
||||||
heroHeight: mode === "monolith_aperture" ? 5.8 : 5,
|
assets.forEach((asset, index) => {
|
||||||
supportHeight: 1.95
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
});
|
const adjustedRect =
|
||||||
hero.group.position.set(0, 0, -0.95);
|
mode === "monolith_aperture" && index === 0
|
||||||
root.add(hero.group);
|
? { ...rect, width: rect.width * 0.92, height: rect.height * 1.14 }
|
||||||
motionEntries.push({
|
: rect;
|
||||||
group: hero.group,
|
const plane = createFittedPhotoPlane(asset, photoTreatment, adjustedRect, {
|
||||||
basePosition: hero.group.position.clone(),
|
opacity: index === 0 ? 1 : 0.94,
|
||||||
baseRotation: hero.group.rotation.clone(),
|
frameOpacity: index === 0 ? 0.04 : 0.025,
|
||||||
phase: seededUnit(assets[0]!.asset.id, 3) * Math.PI * 2,
|
shadowOpacity: index === 0 ? 0.12 : 0.08
|
||||||
travelX: 0.14,
|
});
|
||||||
travelY: 0.08,
|
root.add(plane.group);
|
||||||
orbit: 0.08,
|
motionEntries.push({
|
||||||
pitch: 0.015,
|
group: plane.group,
|
||||||
yaw: 0.03
|
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 leftFrame = createAccentRail(0.18, 8.6, palette.line, 0.1 + input.params.scenicTreatment.accentIntensity * 0.08, -4.5);
|
||||||
@ -1097,23 +1291,6 @@ const buildPortalFrame = (input: SceneActivationInput): SceneInstance => {
|
|||||||
topFrame.position.y = 3.8;
|
topFrame.position.y = 3.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
supports.forEach((plane, index) => {
|
|
||||||
plane.group.position.set(index === 0 ? -3.35 : 3.35, -1.45 + index * 0.6, -2.7 - index * 0.3);
|
|
||||||
plane.group.rotation.y = index === 0 ? 0.18 : -0.18;
|
|
||||||
root.add(plane.group);
|
|
||||||
motionEntries.push({
|
|
||||||
group: plane.group,
|
|
||||||
basePosition: plane.group.position.clone(),
|
|
||||||
baseRotation: plane.group.rotation.clone(),
|
|
||||||
phase: seededUnit(plane.group.uuid, 1) * Math.PI * 2,
|
|
||||||
travelX: 0.18,
|
|
||||||
travelY: 0.1,
|
|
||||||
orbit: 0.08,
|
|
||||||
pitch: 0.015,
|
|
||||||
yaw: 0.04
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
input.camera.position.set(0, 0.08, 6.85);
|
input.camera.position.set(0, 0.08, 6.85);
|
||||||
input.camera.lookAt(0, 0, -2.8);
|
input.camera.lookAt(0, 0, -2.8);
|
||||||
|
|
||||||
@ -1121,7 +1298,7 @@ const buildPortalFrame = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) =>
|
motionEntries.forEach((entry, index) =>
|
||||||
applyMotionEntry(entry, time + index * 0.12, composition.motion, composition.orbitAmount * 0.22, composition.stagger)
|
applyMotionEntry(entry, time + index * 0.12, composition.motion, composition.orbitAmount * 0.22, composition.stagger)
|
||||||
);
|
);
|
||||||
@ -1141,37 +1318,26 @@ const buildOrbitGallery = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const backdrop = buildBackdropSystem(input, palette);
|
const backdrop = buildBackdropSystem(input, palette);
|
||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const planes = assets.map((asset, index) =>
|
const motionEntries: MotionEntry[] = [];
|
||||||
createPhotoPlane(asset, photoTreatment, {
|
const layout = createHeroLayoutRects(assets.length, "arc", composition);
|
||||||
height: index === 0 ? 4.2 : 2.35,
|
assets.forEach((asset, index) => {
|
||||||
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
|
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||||
frameOpacity: 0.03,
|
frameOpacity: 0.03,
|
||||||
shadowOpacity: 0.09
|
shadowOpacity: 0.09
|
||||||
})
|
});
|
||||||
);
|
|
||||||
const motionEntries: MotionEntry[] = [];
|
|
||||||
|
|
||||||
planes.forEach((plane, index) => {
|
|
||||||
const assetId = assets[index]?.asset.id ?? plane.group.uuid;
|
|
||||||
const angle = THREE.MathUtils.lerp(-0.85, 0.85, planes.length === 1 ? 0.5 : index / Math.max(1, planes.length - 1));
|
|
||||||
const radius = index === 0 ? 0.6 : 2.9 + index * 0.6;
|
|
||||||
plane.group.position.set(
|
|
||||||
index === 0 ? 0 : Math.cos(angle) * radius,
|
|
||||||
index === 0 ? 0.08 : Math.sin(angle) * 1.4,
|
|
||||||
-1.0 - index * 0.8
|
|
||||||
);
|
|
||||||
if (mode === "mirror_sweep" && index > 0) {
|
if (mode === "mirror_sweep" && index > 0) {
|
||||||
plane.group.position.x *= index % 2 === 0 ? 1 : -1;
|
plane.group.position.x *= index % 2 === 0 ? 1 : -1;
|
||||||
}
|
}
|
||||||
plane.group.rotation.y = index === 0 ? 0 : seededSigned(assetId, 7) * 0.18;
|
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
motionEntries.push({
|
motionEntries.push({
|
||||||
group: plane.group,
|
group: plane.group,
|
||||||
basePosition: plane.group.position.clone(),
|
basePosition: plane.group.position.clone(),
|
||||||
baseRotation: plane.group.rotation.clone(),
|
baseRotation: plane.group.rotation.clone(),
|
||||||
phase: seededUnit(assetId, 6) * Math.PI * 2,
|
phase: seededUnit(asset.asset.id, 6 + index) * Math.PI * 2,
|
||||||
travelX: 0.16 + index * 0.08,
|
travelX: 0.14 + index * 0.06,
|
||||||
travelY: 0.1 + index * 0.05,
|
travelY: 0.08 + index * 0.04,
|
||||||
orbit: 0.28 + index * 0.12,
|
orbit: 0.24 + index * 0.1,
|
||||||
pitch: 0.02,
|
pitch: 0.02,
|
||||||
yaw: 0.06 + index * 0.02
|
yaw: 0.06 + index * 0.02
|
||||||
});
|
});
|
||||||
@ -1193,7 +1359,7 @@ const buildOrbitGallery = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) => {
|
motionEntries.forEach((entry, index) => {
|
||||||
const orbitGain = composition.orbitAmount * (mode === "lantern_orbit" ? 0.75 : 0.52);
|
const orbitGain = composition.orbitAmount * (mode === "lantern_orbit" ? 0.75 : 0.52);
|
||||||
applyMotionEntry(entry, time + index * 0.24, composition.motion, orbitGain, composition.stagger);
|
applyMotionEntry(entry, time + index * 0.24, composition.motion, orbitGain, composition.stagger);
|
||||||
@ -1214,29 +1380,34 @@ const buildSuspensionField = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const motionEntries: MotionEntry[] = [];
|
const motionEntries: MotionEntry[] = [];
|
||||||
|
const layout = createHeroLayoutRects(
|
||||||
|
assets.length,
|
||||||
|
mode === "diagonal_relay" ? "line" : mode === "depth_table" ? "cluster" : "stack",
|
||||||
|
composition
|
||||||
|
);
|
||||||
assets.forEach((asset, index) => {
|
assets.forEach((asset, index) => {
|
||||||
const plane = createPhotoPlane(asset, photoTreatment, {
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
height: index === 0 ? 3.25 : 2.45,
|
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,
|
frameOpacity: 0.025,
|
||||||
shadowOpacity: 0.07
|
shadowOpacity: 0.07
|
||||||
});
|
});
|
||||||
if (mode === "diagonal_relay") {
|
|
||||||
plane.group.position.set(-3.4 + index * 2.2, 1.8 - index * 1.12, -1.1 - index * 0.7);
|
|
||||||
} else if (mode === "depth_table") {
|
|
||||||
plane.group.position.set(-2.8 + index * 1.8, seededSigned(asset.asset.id, 10) * 0.9, -1.2 - index * 1.0);
|
|
||||||
} else {
|
|
||||||
plane.group.position.set(-3.2 + index * 2.1, 0.7 - index * 0.2, -1.0 - index * 0.55);
|
|
||||||
}
|
|
||||||
plane.group.rotation.y = seededSigned(asset.asset.id, 4) * 0.18;
|
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
motionEntries.push({
|
motionEntries.push({
|
||||||
group: plane.group,
|
group: plane.group,
|
||||||
basePosition: plane.group.position.clone(),
|
basePosition: plane.group.position.clone(),
|
||||||
baseRotation: plane.group.rotation.clone(),
|
baseRotation: plane.group.rotation.clone(),
|
||||||
phase: seededUnit(asset.asset.id, 9) * Math.PI * 2,
|
phase: seededUnit(asset.asset.id, 9) * Math.PI * 2,
|
||||||
travelX: 0.22 + composition.spread * 0.18,
|
travelX: 0.18 + composition.spread * 0.14,
|
||||||
travelY: 0.12 + composition.stagger * 0.12,
|
travelY: 0.1 + composition.stagger * 0.1,
|
||||||
orbit: 0.18,
|
orbit: 0.18,
|
||||||
pitch: 0.016,
|
pitch: 0.016,
|
||||||
yaw: 0.05
|
yaw: 0.05
|
||||||
@ -1255,7 +1426,7 @@ const buildSuspensionField = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) =>
|
motionEntries.forEach((entry, index) =>
|
||||||
applyMotionEntry(entry, time + index * 0.16, composition.motion, composition.orbitAmount * 0.2, composition.stagger)
|
applyMotionEntry(entry, time + index * 0.16, composition.motion, composition.orbitAmount * 0.2, composition.stagger)
|
||||||
);
|
);
|
||||||
@ -1275,28 +1446,25 @@ const buildChorusArray = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const motionEntries: MotionEntry[] = [];
|
const motionEntries: MotionEntry[] = [];
|
||||||
|
const layout = createEqualLayoutRects(
|
||||||
|
assets.length,
|
||||||
|
mode === "ribbon_quartet" ? "ribbon" : mode === "offset_choir" ? "cluster" : "grid",
|
||||||
|
composition
|
||||||
|
);
|
||||||
assets.forEach((asset, index) => {
|
assets.forEach((asset, index) => {
|
||||||
const plane = createPhotoPlane(asset, photoTreatment, {
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
height: 2.6,
|
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||||
frameOpacity: 0.02,
|
frameOpacity: 0.02,
|
||||||
shadowOpacity: 0.06
|
shadowOpacity: 0.06
|
||||||
});
|
});
|
||||||
if (mode === "ribbon_quartet") {
|
|
||||||
plane.group.position.set(-3.8 + index * 2.55, Math.sin(index * 0.9) * 1.0, -1.1 - index * 0.45);
|
|
||||||
} else if (mode === "offset_choir") {
|
|
||||||
plane.group.position.set(index % 2 === 0 ? -2.5 : 2.5, 1.55 - Math.floor(index / 2) * 2.7, -1.1 - index * 0.35);
|
|
||||||
} else {
|
|
||||||
plane.group.position.set(index % 2 === 0 ? -2.2 : 2.2, 1.45 - Math.floor(index / 2) * 2.9, -1.1 - index * 0.3);
|
|
||||||
}
|
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
motionEntries.push({
|
motionEntries.push({
|
||||||
group: plane.group,
|
group: plane.group,
|
||||||
basePosition: plane.group.position.clone(),
|
basePosition: plane.group.position.clone(),
|
||||||
baseRotation: plane.group.rotation.clone(),
|
baseRotation: plane.group.rotation.clone(),
|
||||||
phase: seededUnit(asset.asset.id, 13) * Math.PI * 2,
|
phase: seededUnit(asset.asset.id, 13) * Math.PI * 2,
|
||||||
travelX: 0.16 + composition.spread * 0.16,
|
travelX: 0.14 + composition.spread * 0.14,
|
||||||
travelY: 0.12 + composition.stagger * 0.1,
|
travelY: 0.1 + composition.stagger * 0.08,
|
||||||
orbit: 0.14,
|
orbit: 0.14,
|
||||||
pitch: 0.01,
|
pitch: 0.01,
|
||||||
yaw: 0.04
|
yaw: 0.04
|
||||||
@ -1318,7 +1486,7 @@ const buildChorusArray = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) =>
|
motionEntries.forEach((entry, index) =>
|
||||||
applyMotionEntry(entry, time + index * 0.2, composition.motion, composition.orbitAmount * 0.16, composition.stagger)
|
applyMotionEntry(entry, time + index * 0.2, composition.motion, composition.orbitAmount * 0.16, composition.stagger)
|
||||||
);
|
);
|
||||||
@ -1339,30 +1507,25 @@ const buildEqualCollage = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const motionEntries: MotionEntry[] = [];
|
const motionEntries: MotionEntry[] = [];
|
||||||
|
const layout = createEqualLayoutRects(
|
||||||
|
assets.length,
|
||||||
|
mode === "floating_blocks" ? "cluster" : mode === "arc_cluster" ? "arc" : "grid",
|
||||||
|
composition
|
||||||
|
);
|
||||||
assets.forEach((asset, index) => {
|
assets.forEach((asset, index) => {
|
||||||
const plane = createPhotoPlane(asset, photoTreatment, {
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
height: 2.85,
|
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||||
frameOpacity: 0.022,
|
frameOpacity: 0.022,
|
||||||
shadowOpacity: 0.06
|
shadowOpacity: 0.06
|
||||||
});
|
});
|
||||||
if (mode === "floating_blocks") {
|
|
||||||
plane.group.position.set(-3.4 + index * 2.1, seededSigned(asset.asset.id, 2) * 1.4, -1.1 - index * 0.5);
|
|
||||||
} else if (mode === "arc_cluster") {
|
|
||||||
const angle = THREE.MathUtils.lerp(-0.8, 0.8, assets.length === 1 ? 0.5 : index / Math.max(1, assets.length - 1));
|
|
||||||
plane.group.position.set(Math.cos(angle) * 3.0, Math.sin(angle) * 1.5, -1.1 - index * 0.4);
|
|
||||||
} else {
|
|
||||||
plane.group.position.set(index % 2 === 0 ? -2.7 : 2.7, 1.75 - Math.floor(index / 2) * 3.1, -1.1 - index * 0.25);
|
|
||||||
}
|
|
||||||
plane.group.rotation.y = seededSigned(asset.asset.id, 5) * 0.12;
|
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
motionEntries.push({
|
motionEntries.push({
|
||||||
group: plane.group,
|
group: plane.group,
|
||||||
basePosition: plane.group.position.clone(),
|
basePosition: plane.group.position.clone(),
|
||||||
baseRotation: plane.group.rotation.clone(),
|
baseRotation: plane.group.rotation.clone(),
|
||||||
phase: seededUnit(asset.asset.id, 15) * Math.PI * 2,
|
phase: seededUnit(asset.asset.id, 15) * Math.PI * 2,
|
||||||
travelX: 0.18 + composition.spread * 0.18,
|
travelX: 0.16 + composition.spread * 0.16,
|
||||||
travelY: 0.14,
|
travelY: 0.12,
|
||||||
orbit: 0.16 + composition.orbitAmount * 0.14,
|
orbit: 0.16 + composition.orbitAmount * 0.14,
|
||||||
pitch: 0.01,
|
pitch: 0.01,
|
||||||
yaw: 0.04
|
yaw: 0.04
|
||||||
@ -1383,7 +1546,7 @@ const buildEqualCollage = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) =>
|
motionEntries.forEach((entry, index) =>
|
||||||
applyMotionEntry(entry, time + index * 0.22, composition.motion, composition.orbitAmount * 0.24, composition.stagger)
|
applyMotionEntry(entry, time + index * 0.22, composition.motion, composition.orbitAmount * 0.24, composition.stagger)
|
||||||
);
|
);
|
||||||
@ -1403,28 +1566,23 @@ const buildArrivalRelay = (input: SceneActivationInput): SceneInstance => {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
const motionEntries: MotionEntry[] = [];
|
const motionEntries: MotionEntry[] = [];
|
||||||
|
const layout = createArrivalLayoutRects(assets.length, mode, composition);
|
||||||
assets.forEach((asset, index) => {
|
assets.forEach((asset, index) => {
|
||||||
const plane = createPhotoPlane(asset, photoTreatment, {
|
const rect = layout[index] ?? layout.at(-1)!;
|
||||||
height: index === 0 ? 3.65 : 1.9,
|
const plane = createFittedPhotoPlane(asset, photoTreatment, rect, {
|
||||||
|
opacity: index === 0 ? 1 : 0.92,
|
||||||
frameOpacity: 0.02,
|
frameOpacity: 0.02,
|
||||||
shadowOpacity: 0.06
|
shadowOpacity: 0.06
|
||||||
});
|
});
|
||||||
if (index === 0) {
|
|
||||||
plane.group.position.set(mode === "relay_rail" ? 0.95 : 1.2, 0, -0.9);
|
|
||||||
} else {
|
|
||||||
plane.group.position.set(-4.4, 1.8 - index * 1.18, -1.6 - index * 0.34);
|
|
||||||
}
|
|
||||||
plane.group.rotation.y = index === 0 ? -0.08 : 0.16;
|
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
motionEntries.push({
|
motionEntries.push({
|
||||||
group: plane.group,
|
group: plane.group,
|
||||||
basePosition: plane.group.position.clone(),
|
basePosition: plane.group.position.clone(),
|
||||||
baseRotation: plane.group.rotation.clone(),
|
baseRotation: plane.group.rotation.clone(),
|
||||||
phase: seededUnit(asset.asset.id, 18) * Math.PI * 2,
|
phase: seededUnit(asset.asset.id, 18 + index) * Math.PI * 2,
|
||||||
travelX: index === 0 ? 0.08 : 0.18 + index * 0.06,
|
travelX: index === 0 ? 0.06 : 0.12 + index * 0.04,
|
||||||
travelY: 0.05,
|
travelY: 0.04,
|
||||||
orbit: 0.08,
|
orbit: 0.06,
|
||||||
pitch: 0.01,
|
pitch: 0.01,
|
||||||
yaw: 0.03
|
yaw: 0.03
|
||||||
});
|
});
|
||||||
@ -1444,18 +1602,10 @@ const buildArrivalRelay = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
motionEntries.forEach((entry, index) => {
|
motionEntries.forEach((entry, index) =>
|
||||||
if (index > 0) {
|
applyMotionEntry(entry, time + index * 0.12, composition.motion * 0.82, composition.orbitAmount * 0.08, composition.stagger)
|
||||||
entry.group.position.x =
|
);
|
||||||
entry.basePosition.x + Math.sin(time * (0.12 + composition.motion * 0.08) + index * 0.8) * entry.travelX;
|
|
||||||
entry.group.position.y =
|
|
||||||
entry.basePosition.y + Math.cos(time * 0.14 + index * 0.6) * Math.max(0.03, entry.travelY);
|
|
||||||
entry.group.position.z = entry.basePosition.z + Math.sin(time * 0.08 + index * 0.4) * 0.06;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyMotionEntry(entry, time, composition.motion, composition.orbitAmount * 0.1, composition.stagger);
|
|
||||||
});
|
|
||||||
rail.position.y = Math.sin(time * 0.12) * 0.08;
|
rail.position.y = Math.sin(time * 0.12) * 0.08;
|
||||||
lower.position.x = Math.sin(time * 0.08) * 0.12;
|
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);
|
configureCamera(input.camera, { x: 0.18, y: 0, z: 7.35 }, new THREE.Vector3(0.2, 0, -3.1), composition.cameraTravel, context.elapsedMs);
|
||||||
@ -1470,13 +1620,17 @@ const buildSafeHold = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root.add(backdrop.group);
|
root.add(backdrop.group);
|
||||||
let plane: PlaneBundle | null = null;
|
let plane: PlaneBundle | null = null;
|
||||||
if (assets[0]) {
|
if (assets[0]) {
|
||||||
plane = createPhotoPlane(assets[0], input.params.photoTreatment, {
|
plane = createFittedPhotoPlane(assets[0], input.params.photoTreatment, {
|
||||||
height: 4.1,
|
x: 0,
|
||||||
|
y: -0.06,
|
||||||
|
z: -1.6,
|
||||||
|
width: 4.4,
|
||||||
|
height: 3.9
|
||||||
|
}, {
|
||||||
opacity: 0.58,
|
opacity: 0.58,
|
||||||
frameOpacity: 0.015,
|
frameOpacity: 0.015,
|
||||||
shadowOpacity: 0.04
|
shadowOpacity: 0.04
|
||||||
});
|
});
|
||||||
plane.group.position.set(0, -0.06, -1.6);
|
|
||||||
root.add(plane.group);
|
root.add(plane.group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1487,7 +1641,7 @@ const buildSafeHold = (input: SceneActivationInput): SceneInstance => {
|
|||||||
root,
|
root,
|
||||||
update: (context) => {
|
update: (context) => {
|
||||||
const time = context.elapsedMs * 0.001;
|
const time = context.elapsedMs * 0.001;
|
||||||
updateBackdropSystem(backdrop, context, input.params.scenicTreatment);
|
updateBackdropSystem(backdrop, context, paletteFromAssets(assets, input.params.scenicTreatment), input.params.scenicTreatment);
|
||||||
if (plane) {
|
if (plane) {
|
||||||
plane.group.position.y = -0.06 + Math.cos(time * 0.2) * 0.05;
|
plane.group.position.y = -0.06 + Math.cos(time * 0.2) * 0.05;
|
||||||
}
|
}
|
||||||
@ -1646,29 +1800,49 @@ const updateSceneRuntime = (runtime: SceneRuntime, context: SceneFrameContext) =
|
|||||||
const resolvePresentationParams = (presentation: SurfacePresentation) =>
|
const resolvePresentationParams = (presentation: SurfacePresentation) =>
|
||||||
mergeSceneParams(presentation.definition.defaultParams, presentation.cue?.parameterOverrides, presentation.params);
|
mergeSceneParams(presentation.definition.defaultParams, presentation.cue?.parameterOverrides, presentation.params);
|
||||||
|
|
||||||
const canSmoothMotionUpdate = (runtime: SceneRuntime, presentation: SurfacePresentation) => {
|
const liveMutableParamPaths = new Set([
|
||||||
if (runtime.presentation.definition.id !== presentation.definition.id) {
|
"composition.motion",
|
||||||
return false;
|
"composition.cameraTravel",
|
||||||
}
|
"composition.orbitAmount",
|
||||||
if ((runtime.presentation.modeKey ?? "") !== (presentation.modeKey ?? "")) {
|
"scenicTreatment.fieldType",
|
||||||
return false;
|
"scenicTreatment.fieldIntensity",
|
||||||
}
|
"scenicTreatment.fieldScale",
|
||||||
if ((runtime.presentation.effectPresetId ?? "") !== (presentation.effectPresetId ?? "")) {
|
"scenicTreatment.fieldSpeed",
|
||||||
return false;
|
"scenicTreatment.hue",
|
||||||
}
|
"scenicTreatment.saturation",
|
||||||
if (runtime.presentation.assets.length !== presentation.assets.length) {
|
"scenicTreatment.lightness"
|
||||||
return false;
|
]);
|
||||||
}
|
|
||||||
if (runtime.presentation.assets.some((asset, index) => asset.id !== presentation.assets[index]?.id)) {
|
const createPresentationStructureSignature = (presentation: SurfacePresentation) => {
|
||||||
return false;
|
const params = flattenSceneParams(resolvePresentationParams(presentation));
|
||||||
|
for (const path of liveMutableParamPaths) {
|
||||||
|
delete params[path];
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentParams = flattenSceneParams(resolvePresentationParams(runtime.presentation));
|
return JSON.stringify({
|
||||||
const nextParams = flattenSceneParams(resolvePresentationParams(presentation));
|
definitionId: presentation.definition.id,
|
||||||
delete currentParams["composition.motion"];
|
effectPresetId: presentation.effectPresetId ?? null,
|
||||||
delete nextParams["composition.motion"];
|
modeKey: presentation.modeKey ?? null,
|
||||||
|
assetIds: presentation.assets.map((asset) => asset.id),
|
||||||
|
textFragments: presentation.textFragments ?? [],
|
||||||
|
anchorCaption: presentation.anchorCaption ?? null,
|
||||||
|
params
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return JSON.stringify(currentParams) === JSON.stringify(nextParams);
|
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) => {
|
const disposeSceneRuntime = (runtime: SceneRuntime | null) => {
|
||||||
@ -1751,6 +1925,9 @@ export class RenderSurface {
|
|||||||
private lastFrameMs = 0;
|
private lastFrameMs = 0;
|
||||||
private blackout = false;
|
private blackout = false;
|
||||||
private activationToken = 0;
|
private activationToken = 0;
|
||||||
|
private activePresentationKey: string | undefined;
|
||||||
|
private qualityProfile: SurfaceQualityProfile = "program";
|
||||||
|
private busy = false;
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement) {
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
this.renderer = new THREE.WebGLRenderer({
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
@ -1760,7 +1937,6 @@ export class RenderSurface {
|
|||||||
});
|
});
|
||||||
this.renderer.toneMapping = THREE.NoToneMapping;
|
this.renderer.toneMapping = THREE.NoToneMapping;
|
||||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
this.renderer.setPixelRatio(typeof window === "undefined" ? 1 : Math.min(window.devicePixelRatio, 2));
|
|
||||||
this.renderer.setClearColor("#040508", 1);
|
this.renderer.setClearColor("#040508", 1);
|
||||||
this.fromTarget.texture.colorSpace = THREE.NoColorSpace;
|
this.fromTarget.texture.colorSpace = THREE.NoColorSpace;
|
||||||
this.toTarget.texture.colorSpace = THREE.NoColorSpace;
|
this.toTarget.texture.colorSpace = THREE.NoColorSpace;
|
||||||
@ -1781,7 +1957,14 @@ export class RenderSurface {
|
|||||||
this.compositeScene.add(bar);
|
this.compositeScene.add(bar);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.applyQualitySettings();
|
||||||
|
|
||||||
this.renderer.setAnimationLoop((timestamp) => {
|
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;
|
const deltaMs = this.lastFrameMs === 0 ? 16.6 : timestamp - this.lastFrameMs;
|
||||||
this.lastFrameMs = timestamp;
|
this.lastFrameMs = timestamp;
|
||||||
|
|
||||||
@ -1832,6 +2015,24 @@ export class RenderSurface {
|
|||||||
plugins.forEach((plugin) => this.register(plugin));
|
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) {
|
setSize(width: number, height: number) {
|
||||||
this.viewport = {
|
this.viewport = {
|
||||||
width,
|
width,
|
||||||
@ -1864,9 +2065,25 @@ export class RenderSurface {
|
|||||||
this.blackout = blackout;
|
this.blackout = blackout;
|
||||||
}
|
}
|
||||||
|
|
||||||
async activate(presentation: SurfacePresentation | null, transition?: CueTransition | null) {
|
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;
|
const token = this.activationToken + 1;
|
||||||
this.activationToken = token;
|
this.activationToken = token;
|
||||||
|
this.activePresentationKey = activationKey;
|
||||||
|
|
||||||
if (!presentation) {
|
if (!presentation) {
|
||||||
this.clearAll();
|
this.clearAll();
|
||||||
@ -1876,10 +2093,9 @@ export class RenderSurface {
|
|||||||
if (
|
if (
|
||||||
this.currentRuntime &&
|
this.currentRuntime &&
|
||||||
(!transition || transition.style === "cut" || transition.durationMs <= 0) &&
|
(!transition || transition.style === "cut" || transition.durationMs <= 0) &&
|
||||||
canSmoothMotionUpdate(this.currentRuntime, presentation)
|
canPatchRuntimeInPlace(this.currentRuntime, presentation)
|
||||||
) {
|
) {
|
||||||
this.currentRuntime.presentation = presentation;
|
applyPresentationToRuntime(this.currentRuntime, presentation);
|
||||||
this.currentRuntime.targetMotion = resolvePresentationParams(presentation).composition.motion;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2087,5 +2303,24 @@ export class RenderSurface {
|
|||||||
disposeSceneRuntime(this.transitionRuntime.to);
|
disposeSceneRuntime(this.transitionRuntime.to);
|
||||||
this.transitionRuntime = null;
|
this.transitionRuntime = 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -278,6 +278,32 @@ export const buildServer = async () => {
|
|||||||
service: "api"
|
service: "api"
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get("/api/admin/bootstrap", async () => store.read());
|
||||||
|
app.get("/api/admin/live", async () => {
|
||||||
|
const state = await store.read();
|
||||||
|
const pendingCount = state.photoAssets.filter((asset) => {
|
||||||
|
if (asset.moderationStatus !== "pending") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
|
||||||
|
return submission?.source !== "admin_upload";
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cues: state.cues,
|
||||||
|
pendingCount,
|
||||||
|
approvedCount: state.photoAssets.filter((asset) => asset.moderationStatus === "approved").length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.get("/api/admin/library", async () => {
|
||||||
|
const state = await store.read();
|
||||||
|
return {
|
||||||
|
photoAssets: state.photoAssets,
|
||||||
|
submissions: state.submissions,
|
||||||
|
collections: state.collections
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/state", async () => store.read());
|
app.get("/api/state", async () => store.read());
|
||||||
app.get("/api/scenes", async () => (await store.read()).scenes);
|
app.get("/api/scenes", async () => (await store.read()).scenes);
|
||||||
app.get("/api/cues", async () => (await store.read()).cues);
|
app.get("/api/cues", async () => (await store.read()).cues);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user