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 = (items: T[]) => items[Math.floor(Math.random() * items.length)]!; const shuffle = (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 ) => { 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 = (items: T[]) => Array.from(new Set(items)); const upsertById = (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 { 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 { 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; }); } }