Initial commit

This commit is contained in:
2026-04-08 10:01:19 -07:00
commit 6657125a1e
68 changed files with 15886 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@goodgrief/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/index.ts",
"dev:watch": "tsx watch src/index.ts",
"build": "tsc --noEmit",
"check": "tsc --noEmit"
},
"dependencies": {
"@fastify/cors": "^11.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.0",
"@goodgrief/shared-types": "file:../../packages/shared-types",
"fastify": "^5.2.1",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
const isRepoRoot = (dirPath: string) =>
existsSync(path.join(dirPath, "package.json")) &&
existsSync(path.join(dirPath, "apps")) &&
existsSync(path.join(dirPath, "packages")) &&
existsSync(path.join(dirPath, "services"));
const findRepoRoot = (...startDirs: string[]) => {
for (const startDir of startDirs) {
let current = path.resolve(startDir);
while (true) {
if (isRepoRoot(current)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
}
return process.cwd();
};
const rootDir = findRepoRoot(process.cwd(), sourceDir);
export const config = {
port: Number(process.env.PORT ?? 4300),
host: process.env.HOST ?? "0.0.0.0",
dataDir: path.join(rootDir, "data", "runtime"),
storageDir: path.join(rootDir, "storage"),
stateFile: path.join(rootDir, "data", "runtime", "state.json")
};
+14
View File
@@ -0,0 +1,14 @@
import { buildServer } from "./server.ts";
import { config } from "./config.ts";
const app = await buildServer();
try {
await app.listen({
port: config.port,
host: config.host
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
+198
View File
@@ -0,0 +1,198 @@
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import type { ContributorConsent, PhotoAsset, Submission } from "@goodgrief/shared-types";
import { config } from "./config.ts";
interface ImportedAssetRecord {
asset: PhotoAsset;
submission: Submission;
consent: ContributorConsent;
}
const supportedExtensions = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const repoRoot = path.dirname(config.storageDir);
const importLibraryDir = path.join(repoRoot, "assets", "import-library");
export const libraryWatchDirs = [importLibraryDir];
const toSlug = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const getOrientation = (width: number, height: number): PhotoAsset["orientation"] =>
width === height ? "square" : width > height ? "landscape" : "portrait";
const toImportedRecord = ({
id,
title,
originalKey,
thumbKey,
previewKey,
renderKey,
mimeType,
width,
height,
dominantColor
}: {
id: string;
title: string;
originalKey: string;
thumbKey: string;
previewKey: string;
renderKey: string;
mimeType: string;
width: number;
height: number;
dominantColor: string;
}) => {
const createdAt = new Date().toISOString();
const submissionId = `library-submission-${id}`;
const consentId = `library-consent-${id}`;
const submission: Submission = {
id: submissionId,
source: "library_import",
submittedAt: createdAt,
status: "approved_all",
consentId,
displayName: "Curated Library",
caption: title
};
const consent: ContributorConsent = {
id: consentId,
submissionId,
hasRights: true,
allowProjection: true,
acknowledgePublicPerformance: true,
allowArchive: true,
agreedAt: createdAt
};
const asset: PhotoAsset = {
id,
submissionId,
originalKey,
thumbKey,
previewKey,
renderKey,
mimeType,
width,
height,
orientation: getOrientation(width, height),
processingStatus: "ready",
moderationStatus: "approved",
createdAt,
approvedAt: createdAt,
dominantColor
};
return { asset, submission, consent };
};
const getDominantColor = async (source: sharp.Sharp) => {
const stats = await source
.clone()
.resize({ width: 1, height: 1, fit: "cover" })
.raw()
.toBuffer({ resolveWithObject: true });
const [red = 120, green = 120, blue = 120] = Array.from(stats.data);
return `#${[red, green, blue].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
};
const readImportFiles = async () => {
await mkdir(importLibraryDir, { recursive: true });
const discovered = new Map<string, { absolutePath: string; relativeName: string }>();
const entries = await readdir(importLibraryDir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const extension = path.extname(entry.name).toLowerCase();
if (!supportedExtensions.has(extension)) {
continue;
}
discovered.set(entry.name, {
absolutePath: path.join(importLibraryDir, entry.name),
relativeName: entry.name
});
}
return Array.from(discovered.values()).sort((left, right) => left.relativeName.localeCompare(right.relativeName));
};
export const createLibraryAssets = async () => {
const files = await readImportFiles();
if (files.length === 0) {
return [];
}
const libraryDir = path.join(config.storageDir, "runtime", "library");
await mkdir(libraryDir, { recursive: true });
const imported: ImportedAssetRecord[] = [];
for (const file of files) {
const baseName = path.parse(file.relativeName).name;
const baseId = `library-photo-${toSlug(baseName)}`;
const displayTitle = baseName.replace(/[-_]+/g, " ");
const originalRelativePath = path.join("runtime", "library", `${baseId}-original.jpg`);
const originalAbsolutePath = path.join(config.storageDir, originalRelativePath);
const sourceImage = sharp(file.absolutePath).rotate();
const metadata = await sourceImage.metadata();
const width = metadata.width ?? 1600;
const height = metadata.height ?? 900;
await sourceImage.clone().jpeg({ quality: 92 }).toFile(originalAbsolutePath);
const dominantColor = await getDominantColor(sourceImage);
const thumbRelativePath = path.join("runtime", "library", `${baseId}-thumb.jpg`);
const previewRelativePath = path.join("runtime", "library", `${baseId}-preview.jpg`);
const renderRelativePath = path.join("runtime", "library", `${baseId}-render.jpg`);
await sourceImage
.clone()
.resize({ width: 320, height: 320, fit: "cover", position: "attention" })
.jpeg({ quality: 84 })
.toFile(path.join(config.storageDir, thumbRelativePath));
await sourceImage
.clone()
.resize({
width: 1280,
height: 1280,
fit: "inside",
withoutEnlargement: true
})
.jpeg({ quality: 86 })
.toFile(path.join(config.storageDir, previewRelativePath));
await sourceImage
.clone()
.resize({
width: 1920,
height: 1920,
fit: "inside",
withoutEnlargement: true
})
.jpeg({ quality: 88 })
.toFile(path.join(config.storageDir, renderRelativePath));
imported.push(
toImportedRecord({
id: baseId,
title: displayTitle,
originalKey: `/uploads/${originalRelativePath}`,
thumbKey: `/uploads/${thumbRelativePath}`,
previewKey: `/uploads/${previewRelativePath}`,
renderKey: `/uploads/${renderRelativePath}`,
mimeType: "image/jpeg",
width,
height,
dominantColor
})
);
}
return imported;
};
+385
View File
@@ -0,0 +1,385 @@
import { watch } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import Fastify, { type FastifyRequest } from "fastify";
import type {
CueGeneratePayload,
CueMovePayload,
CueUpsertPayload,
ModerationActionPayload,
SubmissionPayload
} from "@goodgrief/shared-types";
import { config } from "./config.ts";
import { createLibraryAssets, libraryWatchDirs } from "./seed.ts";
import { StateStore } from "./state-store.ts";
const allowedMimeTypes = new Set(["image/jpeg", "image/png", "image/heic", "image/heif"]);
const moderationDecisions = new Set(["approved", "hold", "rejected", "archive_only"]);
const coerceBoolean = (value: string | undefined) => value === "true";
const normalizeBody = <Body>(body: unknown): Body => {
if (typeof body === "string") {
return JSON.parse(body) as Body;
}
return (body ?? {}) as Body;
};
const fileExtensionFor = (mimeType: string, filename?: string) => {
const extensionMap: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/heic": ".heic",
"image/heif": ".heif"
};
const byMime = extensionMap[mimeType];
if (byMime) {
return byMime;
}
const fallback = path.extname(filename ?? "");
return fallback || ".bin";
};
interface ParsedMultipartFile {
buffer: Buffer;
filename?: string;
mimetype: string;
}
const parseMultipartSubmission = async (request: FastifyRequest) => {
const parts = request.parts();
let filePart: ParsedMultipartFile | null = null;
const fields: Record<string, string> = {};
for await (const part of parts) {
if (part.type === "file") {
filePart = {
buffer: await part.toBuffer(),
filename: part.filename,
mimetype: part.mimetype
};
continue;
}
fields[part.fieldname] = String(part.value);
}
return { filePart, fields };
};
type MultipartSubmissionError = {
error: {
statusCode: number;
payload: { message: string };
};
};
type MultipartSubmissionSuccess = {
submission?: { id: string };
assetId: string;
};
const storeUploadedFile = async (filePart: ParsedMultipartFile, assetId: string) => {
const extension = fileExtensionFor(filePart.mimetype, filePart.filename);
const relativePath = path.join("runtime", "originals", `${assetId}${extension}`);
const absolutePath = path.join(config.storageDir, relativePath);
await writeFile(absolutePath, filePart.buffer);
return `/uploads/${relativePath}`;
};
const createSubmissionFromMultipart = async (
store: StateStore,
request: FastifyRequest,
defaults: Partial<SubmissionPayload> = {}
): Promise<MultipartSubmissionError | MultipartSubmissionSuccess> => {
const { filePart, fields } = await parseMultipartSubmission(request);
if (!filePart) {
return {
error: {
statusCode: 400,
payload: { message: "A single image file is required." }
}
};
}
if (!allowedMimeTypes.has(filePart.mimetype)) {
return {
error: {
statusCode: 400,
payload: { message: "Unsupported file type. Please upload a JPEG, PNG, or HEIC image." }
}
};
}
const storedAssetId = crypto.randomUUID();
const originalKey = await storeUploadedFile(filePart, storedAssetId);
const payload: SubmissionPayload = {
displayName: fields.displayName || defaults.displayName || undefined,
caption: fields.caption || defaults.caption || undefined,
promptAnswer: fields.promptAnswer || defaults.promptAnswer || undefined,
allowArchive: defaults.allowArchive ?? coerceBoolean(fields.allowArchive),
hasRights: defaults.hasRights ?? coerceBoolean(fields.hasRights),
allowProjection: defaults.allowProjection ?? coerceBoolean(fields.allowProjection),
acknowledgePublicPerformance:
defaults.acknowledgePublicPerformance ?? coerceBoolean(fields.acknowledgePublicPerformance),
source: defaults.source ?? (fields.source as SubmissionPayload["source"]) ?? "live"
};
if (!payload.hasRights || !payload.allowProjection || !payload.acknowledgePublicPerformance) {
return {
error: {
statusCode: 400,
payload: {
message: "Required consent items must be accepted before submission."
}
}
};
}
const nextState = await store.createSubmission({
...payload,
originalKey,
mimeType: filePart.mimetype
});
const submission = nextState.submissions[0];
const createdAsset = nextState.photoAssets.find((asset) => asset.originalKey === originalKey);
return {
submission,
assetId: createdAsset?.id ?? storedAssetId
};
};
export const buildServer = async () => {
const app = Fastify({
logger: true,
bodyLimit: 25 * 1024 * 1024
});
const store = new StateStore(config.stateFile);
const syncLibrary = async () => {
await store.ensure();
const importedAssets = await createLibraryAssets();
await store.syncImportedAssets(importedAssets);
return store.read();
};
await syncLibrary();
await mkdir(path.join(config.storageDir, "runtime", "originals"), { recursive: true });
await mkdir(path.join(config.storageDir, "runtime", "thumbs"), { recursive: true });
await mkdir(path.join(config.storageDir, "runtime", "previews"), { recursive: true });
await mkdir(path.join(config.storageDir, "runtime", "renders"), { recursive: true });
let rescanTimeout: NodeJS.Timeout | null = null;
let rescanInFlight = false;
const watchers = await Promise.all(
libraryWatchDirs.map(async (directory) => {
await mkdir(directory, { recursive: true });
return watch(directory, { persistent: false }, () => {
if (rescanTimeout) {
clearTimeout(rescanTimeout);
}
rescanTimeout = setTimeout(() => {
if (rescanInFlight) {
return;
}
rescanInFlight = true;
void syncLibrary()
.catch((error) => {
app.log.error(error, "Library rescan failed.");
})
.finally(() => {
rescanInFlight = false;
});
}, 350);
});
})
);
await app.register(cors, {
origin: true
});
await app.register(multipart, {
limits: {
files: 1,
fileSize: 25 * 1024 * 1024
}
});
await app.register(fastifyStatic, {
root: config.storageDir,
prefix: "/uploads/"
});
app.get("/health", async () => ({
status: "ok",
service: "api"
}));
app.get("/api/state", async () => store.read());
app.get("/api/scenes", async () => (await store.read()).scenes);
app.get("/api/cues", async () => (await store.read()).cues);
app.get("/api/effects", async () => (await store.read()).effectPresets);
app.get("/api/assets", async () => (await store.read()).photoAssets);
app.get("/api/submissions", async () => (await store.read()).submissions);
app.get("/api/show-config", async () => (await store.read()).showConfig);
app.post("/api/library/rescan", async () => syncLibrary());
app.post("/api/submissions", async (request, reply) => {
const result = await createSubmissionFromMultipart(store, request);
if ("error" in result) {
return reply.status(result.error.statusCode).send(result.error.payload);
}
return reply.status(201).send(result);
});
app.post("/api/admin/uploads", async (request, reply) => {
const result = await createSubmissionFromMultipart(store, request, {
source: "admin_upload",
allowArchive: true,
hasRights: true,
allowProjection: true,
acknowledgePublicPerformance: true
});
if ("error" in result) {
return reply.status(result.error.statusCode).send(result.error.payload);
}
return reply.status(201).send(result);
});
app.post<{
Params: { assetId: string };
Body: {
thumbKey: string;
previewKey: string;
renderKey: string;
width: number;
height: number;
orientation: "portrait" | "landscape" | "square";
sha256: string;
dominantColor: string;
qualityFlags?: {
tooSmall?: boolean;
blurry?: boolean;
lowContrast?: boolean;
unusualAspectRatio?: boolean;
};
};
}>("/api/assets/:assetId/processed", async (request, reply) => {
await store.markProcessed(
request.params.assetId,
normalizeBody<{
thumbKey: string;
previewKey: string;
renderKey: string;
width: number;
height: number;
orientation: "portrait" | "landscape" | "square";
sha256: string;
dominantColor: string;
qualityFlags?: {
tooSmall?: boolean;
blurry?: boolean;
lowContrast?: boolean;
unusualAspectRatio?: boolean;
};
}>(request.body)
);
return reply.status(204).send();
});
app.post<{ Params: { assetId: string } }>("/api/assets/:assetId/failed", async (request, reply) => {
const body = normalizeBody<{ message?: string }>(request.body);
await store.markFailed(request.params.assetId, body.message ?? "Processing failed.");
return reply.status(204).send();
});
app.post<{ Params: { assetId: string } }>("/api/assets/:assetId/moderation", async (request, reply) => {
const body = normalizeBody<ModerationActionPayload>(request.body);
if (!body.decision || !moderationDecisions.has(body.decision)) {
return reply.status(400).send({ message: "A valid moderation decision is required." });
}
await store.moderateAsset(request.params.assetId, body);
return reply.status(204).send();
});
app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/fire", async (request, reply) => {
await store.logCueEvent("cue_fired", { cueId: request.params.cueId });
return reply.status(204).send();
});
app.post<{ Body: CueGeneratePayload }>("/api/cues/generate", async (request, reply) => {
const payload = normalizeBody<CueGeneratePayload>(request.body);
try {
const draft = await store.generateCueDraft(payload);
return reply.send(draft);
} catch (error) {
const message = error instanceof Error ? error.message : "Could not generate a cue.";
return reply.status(400).send({ message });
}
});
app.post<{ Body: CueUpsertPayload }>("/api/cues", async (request, reply) => {
const payload = normalizeBody<CueUpsertPayload>(request.body);
if (!payload.sceneDefinitionId) {
return reply.status(400).send({ message: "sceneDefinitionId is required." });
}
const nextState = await store.upsertCue(payload);
const createdCue = nextState.cues.find((cue) => cue.id === (payload.id ?? nextState.cues.at(-1)?.id));
return reply.status(201).send(createdCue ?? nextState.cues.at(-1));
});
app.put<{ Params: { cueId: string }; Body: CueUpsertPayload }>("/api/cues/:cueId", async (request, reply) => {
const payload = normalizeBody<CueUpsertPayload>(request.body);
if (!payload.sceneDefinitionId) {
return reply.status(400).send({ message: "sceneDefinitionId is required." });
}
const nextState = await store.upsertCue({
...payload,
id: request.params.cueId
});
return reply.send(nextState.cues.find((cue) => cue.id === request.params.cueId) ?? null);
});
app.post<{ Params: { cueId: string }; Body: CueMovePayload }>("/api/cues/:cueId/move", async (request, reply) => {
const payload = normalizeBody<CueMovePayload>(request.body);
if (payload.direction !== "up" && payload.direction !== "down") {
return reply.status(400).send({ message: "direction must be up or down." });
}
const nextState = await store.moveCue(request.params.cueId, payload);
return reply.send(nextState.cues);
});
app.delete<{ Params: { cueId: string } }>("/api/cues/:cueId", async (request, reply) => {
await store.deleteCue(request.params.cueId);
return reply.status(204).send();
});
app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/safe", async (request, reply) => {
await store.logCueEvent("safe_scene", { cueId: request.params.cueId });
return reply.status(204).send();
});
app.addHook("onClose", async () => {
if (rescanTimeout) {
clearTimeout(rescanTimeout);
}
watchers.forEach((entry) => entry.close());
});
return app;
};
+648
View File
@@ -0,0 +1,648 @@
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;
});
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
],
"compilerOptions": {
"lib": [
"ES2022"
],
"allowImportingTsExtensions": true
}
}