goodgrief/services/api/src/state-store.ts

649 lines
22 KiB
TypeScript
Raw Normal View History

2026-04-08 10:01:19 -07:00
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
createEmptyRepositoryState,
defaultCollections,
defaultCueStack,
defaultEffectPresets,
defaultOutputSurfaces,
defaultSceneDefinitions,
defaultShowConfig,
defaultTags,
flattenSceneParams,
mergeSceneParams,
setSceneParamValue,
type ContributorConsent,
type Cue,
type CueGeneratePayload,
type CueMovePayload,
type CueUpsertPayload,
type ModerationActionPayload,
type ModerationDecision,
type PhotoAsset,
type RepositoryState,
type SessionEvent,
type Submission,
type SubmissionPayload
} from "@goodgrief/shared-types";
interface SeedAssetInput {
asset: PhotoAsset;
submission: Submission;
consent: ContributorConsent;
}
const curatedLibraryCollectionId = "collection-curated-library";
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const random = (min = 0, max = 1) => min + Math.random() * (max - min);
const sample = <T>(items: T[]) => items[Math.floor(Math.random() * items.length)]!;
const shuffle = <T>(items: T[]) => {
const next = [...items];
for (let index = next.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[next[index], next[swapIndex]] = [next[swapIndex]!, next[index]!];
}
return next;
};
const randomizeParameterValue = (
path: string,
value: string | number | boolean,
safeRanges: Record<string, { min: number; max: number }>
) => {
if (typeof value === "number") {
const range = safeRanges[path];
if (range) {
const spread = range.max - range.min;
const centered = clamp(value + random(-0.5, 0.5) * spread * 0.95, range.min, range.max);
const isIntegerRange =
Number.isInteger(range.min) && Number.isInteger(range.max) && Number.isInteger(Math.round(value));
return isIntegerRange ? Math.round(centered) : Number(centered.toFixed(2));
}
return Number((value + random(-0.25, 0.25)).toFixed(2));
}
if (typeof value === "boolean") {
return Math.random() > 0.5;
}
if (path === "composition.edge") {
return Math.random() > 0.5 ? "left" : "right";
}
if (path === "textTreatment.mode") {
const modes = ["off", "edge_whispers", "relay_ticker", "anchor_caption"] as const;
return sample([...modes]);
}
return value;
};
const pruneLegacyLibraryVariants = (state: RepositoryState) => {
const librarySubmissionIds = new Set(
state.submissions.filter((submission) => submission.source === "library_import").map((submission) => submission.id)
);
const removedAssetIds = new Set(
state.photoAssets
.filter((asset) => librarySubmissionIds.has(asset.submissionId) && asset.id.endsWith("-detail"))
.map((asset) => asset.id)
);
if (removedAssetIds.size === 0) {
return state;
}
const removedSubmissionIds = new Set(
state.photoAssets
.filter((asset) => removedAssetIds.has(asset.id))
.map((asset) => asset.submissionId)
);
const removedConsentIds = new Set(
state.submissions
.filter((submission) => removedSubmissionIds.has(submission.id))
.map((submission) => submission.consentId)
);
state.photoAssets = state.photoAssets.filter((asset) => !removedAssetIds.has(asset.id));
state.submissions = state.submissions.filter((submission) => !removedSubmissionIds.has(submission.id));
state.consents = state.consents.filter((consent) => !removedConsentIds.has(consent.id));
state.collections = state.collections.map((collection) => ({
...collection,
assetIds: collection.assetIds.filter((assetId) => !removedAssetIds.has(assetId))
}));
return state;
};
export interface CreateSubmissionInput extends SubmissionPayload {
originalKey: string;
mimeType: string;
}
export interface ProcessedAssetPayload {
thumbKey: string;
previewKey: string;
renderKey: string;
width: number;
height: number;
orientation: "portrait" | "landscape" | "square";
sha256: string;
dominantColor: string;
qualityFlags?: PhotoAsset["qualityFlags"];
}
const ensureDirectory = async (dirPath: string) => {
await mkdir(dirPath, { recursive: true });
};
const writeJsonAtomic = async (filePath: string, data: RepositoryState) => {
const tempPath = `${filePath}.tmp`;
await writeFile(tempPath, JSON.stringify(data, null, 2), "utf8");
await rename(tempPath, filePath);
};
const createSessionEvent = (
sessionId: string,
type: SessionEvent["type"],
payload: SessionEvent["payload"]
): SessionEvent => ({
id: crypto.randomUUID(),
sessionId,
timestamp: new Date().toISOString(),
type,
payload
});
const dedupe = <T>(items: T[]) => Array.from(new Set(items));
const upsertById = <T extends { id: string }>(items: T[], nextItem: T) => {
const index = items.findIndex((item) => item.id === nextItem.id);
if (index >= 0) {
items[index] = nextItem;
} else {
items.unshift(nextItem);
}
};
const normalizeCueOrder = (cues: Cue[]) =>
[...cues]
.sort((left, right) => left.orderIndex - right.orderIndex)
.map((cue, index) => ({
...cue,
orderIndex: index
}));
const ensureSafeCue = (cues: Cue[]) => {
const safeCue = defaultCueStack.find((cue) => cue.id === defaultShowConfig.safeSceneCueId);
const next = normalizeCueOrder(cues);
if (safeCue && !next.some((cue) => cue.id === safeCue.id)) {
return normalizeCueOrder([safeCue, ...next]);
}
return next.length > 0 ? next : defaultCueStack;
};
const mergeCollections = (state: RepositoryState, importedAssetIds: string[]) => {
const defaultCollectionIds = new Set(defaultCollections.map((collection) => collection.id));
const existingCollectionMap = new Map(state.collections.map((collection) => [collection.id, collection] as const));
const mergedDefaults = defaultCollections.map((collection) => {
const existing = existingCollectionMap.get(collection.id);
return existing
? {
...collection,
createdAt: existing.createdAt ?? collection.createdAt,
description: existing.description ?? collection.description,
locked: existing.locked ?? collection.locked,
assetIds: [...existing.assetIds],
tagIds: existing.tagIds.length > 0 ? [...existing.tagIds] : [...collection.tagIds]
}
: {
...collection,
assetIds: [...collection.assetIds],
tagIds: [...collection.tagIds]
};
});
const customCollections = state.collections
.filter((collection) => !defaultCollectionIds.has(collection.id))
.map((collection) => ({
...collection,
assetIds: [...collection.assetIds],
tagIds: [...collection.tagIds]
}));
const collections = [...mergedDefaults, ...customCollections];
const curatedLibrary = collections.find((collection) => collection.id === curatedLibraryCollectionId);
if (curatedLibrary) {
curatedLibrary.assetIds = dedupe([...importedAssetIds, ...curatedLibrary.assetIds]);
}
return collections;
};
const reconcileState = (state: RepositoryState) => {
const base = createEmptyRepositoryState();
base.submissions = [...state.submissions];
base.consents = [...state.consents];
base.photoAssets = [...state.photoAssets];
const defaultTagIds = new Set(defaultTags.map((tag) => tag.id));
base.tags = [
...defaultTags,
...state.tags.filter((tag) => !defaultTagIds.has(tag.id))
];
base.collections = mergeCollections(state, []);
base.scenes = defaultSceneDefinitions;
base.cues = ensureSafeCue(state.cues);
base.effectPresets = defaultEffectPresets;
base.outputSurfaces = defaultOutputSurfaces;
base.showConfig = {
...defaultShowConfig,
venueName: state.showConfig?.venueName ?? defaultShowConfig.venueName,
retentionDays: state.showConfig?.retentionDays ?? defaultShowConfig.retentionDays,
ingestPolicy: state.showConfig?.ingestPolicy ?? defaultShowConfig.ingestPolicy,
theme: state.showConfig?.theme ?? defaultShowConfig.theme,
projectionNotes: state.showConfig?.projectionNotes ?? defaultShowConfig.projectionNotes
};
base.operatorSessions = state.operatorSessions.length > 0 ? state.operatorSessions : base.operatorSessions;
base.moderationDecisions = state.moderationDecisions;
base.sessionEvents = state.sessionEvents;
return base;
};
const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayload = {}): CueUpsertPayload => {
const approvedAssets = state.photoAssets.filter(
(asset) => asset.moderationStatus === "approved" && asset.processingStatus === "ready"
);
if (approvedAssets.length === 0) {
throw new Error("No approved assets are available for cue generation.");
}
const requestedScene = payload.sceneDefinitionId
? state.scenes.find((scene) => scene.id === payload.sceneDefinitionId)
: undefined;
const scenePool = requestedScene
? [requestedScene]
: state.scenes.filter((scene) => {
if (scene.sceneFamily === "safe") {
return false;
}
if (scene.sceneFamily === "rupture" && !payload.includeRupture) {
return false;
}
return scene.inputRules.minAssets <= approvedAssets.length;
});
const scene = sample(scenePool.length > 0 ? scenePool : state.scenes.filter((candidate) => candidate.sceneFamily !== "safe"));
const assetPool = payload.preferredAssetIds?.length
? approvedAssets.filter((asset) => payload.preferredAssetIds?.includes(asset.id))
: approvedAssets;
const usablePool =
assetPool.length >= scene.inputRules.minAssets ? assetPool : approvedAssets;
const maxAssets = Math.min(scene.inputRules.maxAssets ?? usablePool.length, usablePool.length);
const minAssets = Math.min(scene.inputRules.minAssets, maxAssets);
const assetCount = Math.max(minAssets, Math.round(random(minAssets, maxAssets + 0.49)));
const selectedAssets = shuffle(usablePool).slice(0, assetCount);
const presetPool = state.effectPresets.filter((preset) => scene.supportedPresetIds.includes(preset.id));
const effectPreset = sample(presetPool.length > 0 ? presetPool : state.effectPresets);
let randomizedParams = mergeSceneParams(scene.defaultParams, effectPreset.paramDefaults);
for (const [path, currentValue] of Object.entries(flattenSceneParams(randomizedParams))) {
randomizedParams = setSceneParamValue(
randomizedParams,
path,
randomizeParameterValue(path, currentValue, effectPreset.safeRanges)
);
}
if (randomizedParams.textTreatment.mode !== "off") {
const opacityFloor =
randomizedParams.textTreatment.mode === "anchor_caption"
? 0.64 + random(0.08, 0.18)
: 0.5 + random(0.08, 0.2);
randomizedParams = setSceneParamValue(
randomizedParams,
"textTreatment.opacity",
Number(clamp(Math.max(randomizedParams.textTreatment.opacity, opacityFloor), 0.4, 0.96).toFixed(2))
);
randomizedParams = setSceneParamValue(
randomizedParams,
"textTreatment.scale",
Number(clamp(Math.max(randomizedParams.textTreatment.scale, 0.82), 0.55, 1.2).toFixed(2))
);
}
const anchorSubmission = state.submissions.find((submission) => submission.id === selectedAssets[0]?.submissionId);
const anchorLabel =
anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || anchorSubmission?.displayName?.trim();
const transitionOptions =
scene.sceneFamily === "rupture"
? (["rupture_offset", "dissolve"] as const)
: (["dissolve", "veil_wipe", "luma_hold"] as const);
return {
sceneDefinitionId: scene.id,
triggerMode: "manual",
transitionIn: {
style: sample([...transitionOptions]),
durationMs: Math.round(random(750, 1200) / 50) * 50
},
transitionOut: {
style: scene.sceneFamily === "arrival" ? "veil_wipe" : "dissolve",
durationMs: Math.round(random(700, 1000) / 50) * 50
},
assetIds: selectedAssets.map((asset) => asset.id),
effectPresetId: effectPreset.id,
parameterOverrides: randomizedParams,
notes: anchorLabel ? `${scene.name} / ${effectPreset.name} / ${anchorLabel}` : `${scene.name} / ${effectPreset.name}`
};
};
export class StateStore {
constructor(private readonly stateFile: string) {}
async ensure() {
await ensureDirectory(path.dirname(this.stateFile));
let state: RepositoryState;
try {
await stat(this.stateFile);
state = await this.read();
} catch {
state = createEmptyRepositoryState();
}
const reconciled = reconcileState(state);
await writeJsonAtomic(this.stateFile, reconciled);
}
async read(): Promise<RepositoryState> {
const raw = await readFile(this.stateFile, "utf8");
return JSON.parse(raw) as RepositoryState;
}
async write(state: RepositoryState) {
await writeJsonAtomic(this.stateFile, state);
}
async update(mutator: (state: RepositoryState) => RepositoryState | void): Promise<RepositoryState> {
const state = await this.read();
const updated = reconcileState(mutator(state) ?? state);
await this.write(updated);
return updated;
}
async syncImportedAssets(importedAssets: SeedAssetInput[]) {
return this.update((state) => {
const next = pruneLegacyLibraryVariants(reconcileState(state));
for (const imported of importedAssets) {
upsertById(next.submissions, imported.submission);
upsertById(next.consents, imported.consent);
upsertById(next.photoAssets, imported.asset);
}
next.collections = mergeCollections(next, importedAssets.map((entry) => entry.asset.id));
return next;
});
}
async createSubmission(input: CreateSubmissionInput) {
return this.update((state) => {
const submissionId = crypto.randomUUID();
const assetId = crypto.randomUUID();
const consentId = crypto.randomUUID();
const now = new Date().toISOString();
const submission: Submission = {
id: submissionId,
source: input.source ?? "live",
submittedAt: now,
status: "processing",
consentId,
displayName: input.displayName,
caption: input.caption,
promptAnswer: input.promptAnswer
};
const consent: ContributorConsent = {
id: consentId,
submissionId,
hasRights: input.hasRights,
allowProjection: input.allowProjection,
acknowledgePublicPerformance: input.acknowledgePublicPerformance,
allowArchive: input.allowArchive,
agreedAt: now
};
const asset: PhotoAsset = {
id: assetId,
submissionId,
originalKey: input.originalKey,
mimeType: input.mimeType,
processingStatus: "queued",
moderationStatus: "pending",
createdAt: now
};
state.submissions.unshift(submission);
state.consents.unshift(consent);
state.photoAssets.unshift(asset);
state.sessionEvents.unshift(
createSessionEvent(state.operatorSessions[0]?.id ?? "session-default", "submission_received", {
submissionId,
assetId
})
);
return state;
});
}
async markProcessed(assetId: string, payload: ProcessedAssetPayload) {
return this.update((state) => {
const asset = state.photoAssets.find((entry) => entry.id === assetId);
if (!asset) {
throw new Error("Asset not found.");
}
asset.thumbKey = payload.thumbKey;
asset.previewKey = payload.previewKey;
asset.renderKey = payload.renderKey;
asset.width = payload.width;
asset.height = payload.height;
asset.orientation = payload.orientation;
asset.sha256 = payload.sha256;
asset.dominantColor = payload.dominantColor;
asset.qualityFlags = payload.qualityFlags;
asset.processingStatus = "ready";
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
if (submission) {
if (submission.source === "admin_upload") {
submission.status = "approved_all";
asset.moderationStatus = "approved";
asset.approvedAt = new Date().toISOString();
const favorites = state.collections.find((collection) => collection.kind === "favorites");
if (favorites && !favorites.assetIds.includes(assetId)) {
favorites.assetIds.unshift(assetId);
}
} else {
submission.status = "pending_moderation";
}
}
return state;
});
}
async markFailed(assetId: string, message: string) {
return this.update((state) => {
const asset = state.photoAssets.find((entry) => entry.id === assetId);
if (!asset) {
throw new Error("Asset not found.");
}
asset.processingStatus = "failed";
asset.rejectionReason = message;
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
if (submission) {
submission.status = "pending_moderation";
submission.notes = message;
}
return state;
});
}
async moderateAsset(assetId: string, payload: ModerationActionPayload) {
return this.update((state) => {
const asset = state.photoAssets.find((entry) => entry.id === assetId);
if (!asset) {
throw new Error("Asset not found.");
}
asset.moderationStatus =
payload.decision === "archive_only" ? "archived" : payload.decision === "approved" ? "approved" : payload.decision;
asset.approvedAt = payload.decision === "approved" ? new Date().toISOString() : asset.approvedAt;
asset.rejectionReason = payload.reasonCode;
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
if (submission) {
submission.status =
payload.decision === "approved"
? "approved_all"
: payload.decision === "rejected"
? "rejected"
: "pending_moderation";
}
const sessionId = state.operatorSessions[0]?.id ?? "session-default";
const decision: ModerationDecision = {
id: crypto.randomUUID(),
assetId,
operatorSessionId: sessionId,
decision: payload.decision,
decidedAt: new Date().toISOString(),
reasonCode: payload.reasonCode,
note: payload.note
};
state.moderationDecisions.unshift(decision);
state.sessionEvents.unshift(
createSessionEvent(sessionId, payload.decision === "approved" ? "asset_approved" : "asset_rejected", {
assetId,
decision: payload.decision
})
);
if (payload.decision === "approved") {
const favorites = state.collections.find((collection) => collection.kind === "favorites");
if (favorites && !favorites.assetIds.includes(assetId)) {
favorites.assetIds.unshift(assetId);
}
}
if (payload.collectionIds?.length) {
const collections = state.collections.filter((collection) => payload.collectionIds?.includes(collection.id));
for (const collection of collections) {
if (!collection.assetIds.includes(assetId)) {
collection.assetIds.unshift(assetId);
}
}
}
return state;
});
}
async logCueEvent(type: SessionEvent["type"], payload: SessionEvent["payload"]) {
return this.update((state) => {
state.sessionEvents.unshift(
createSessionEvent(state.operatorSessions[0]?.id ?? "session-default", type, payload)
);
return state;
});
}
async upsertCue(payload: CueUpsertPayload) {
return this.update((state) => {
const cueId = payload.id ?? `cue-${crypto.randomUUID()}`;
const baseCue: Cue = {
id: cueId,
showConfigId: payload.showConfigId ?? state.showConfig.id,
orderIndex: payload.orderIndex ?? state.cues.length,
sceneDefinitionId: payload.sceneDefinitionId,
triggerMode: payload.triggerMode,
transitionIn: payload.transitionIn,
transitionOut: payload.transitionOut,
collectionId: payload.collectionId,
assetIds: payload.assetIds ?? [],
durationMs: payload.durationMs,
effectPresetId: payload.effectPresetId,
parameterOverrides: payload.parameterOverrides,
notes: payload.notes,
nextCueId: payload.nextCueId
};
const sorted = normalizeCueOrder(state.cues);
const existingIndex = sorted.findIndex((cue) => cue.id === cueId);
const targetIndex = Math.max(0, Math.min(payload.orderIndex ?? sorted.length, sorted.length));
if (existingIndex >= 0) {
const existing = sorted[existingIndex]!;
sorted.splice(existingIndex, 1);
sorted.splice(Math.min(targetIndex, sorted.length), 0, {
...existing,
...baseCue,
id: existing.id
});
} else {
sorted.splice(targetIndex, 0, baseCue);
}
state.cues = normalizeCueOrder(sorted);
return state;
});
}
async generateCueDraft(payload: CueGeneratePayload = {}) {
const state = await this.read();
return buildGeneratedCueDraft(reconcileState(state), payload);
}
async moveCue(cueId: string, payload: CueMovePayload) {
return this.update((state) => {
const sorted = normalizeCueOrder(state.cues);
const currentIndex = sorted.findIndex((cue) => cue.id === cueId);
if (currentIndex < 0) {
throw new Error("Cue not found.");
}
const swapIndex = payload.direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= sorted.length) {
state.cues = sorted;
return state;
}
const current = sorted[currentIndex]!;
sorted[currentIndex] = sorted[swapIndex]!;
sorted[swapIndex] = current;
state.cues = normalizeCueOrder(sorted);
return state;
});
}
async deleteCue(cueId: string) {
return this.update((state) => {
if (cueId === state.showConfig.safeSceneCueId) {
throw new Error("Cannot delete the configured safe cue.");
}
state.cues = normalizeCueOrder(state.cues.filter((cue) => cue.id !== cueId));
return state;
});
}
}