Initial commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@goodgrief/worker",
|
||||
"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": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -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 ?? 4301),
|
||||
host: process.env.HOST ?? "0.0.0.0",
|
||||
apiBaseUrl: process.env.API_BASE_URL ?? "http://localhost:4300",
|
||||
storageDir: path.join(rootDir, "storage"),
|
||||
pollIntervalMs: Number(process.env.POLL_INTERVAL_MS ?? 2500)
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import Fastify from "fastify";
|
||||
import { config } from "./config.ts";
|
||||
import { runWorkerOnce } from "./processor.ts";
|
||||
|
||||
const app = Fastify({
|
||||
logger: true
|
||||
});
|
||||
|
||||
let lastRun: { processed: boolean; assetId?: string; error?: string } | null = null;
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
service: "worker",
|
||||
lastRun
|
||||
}));
|
||||
|
||||
app.post("/run-once", async () => {
|
||||
lastRun = await runWorkerOnce();
|
||||
return lastRun;
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void runWorkerOnce()
|
||||
.then((result) => {
|
||||
lastRun = result;
|
||||
if (result.processed) {
|
||||
app.log.info({ assetId: result.assetId }, "Processed queued asset.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
app.log.error(error);
|
||||
lastRun = {
|
||||
processed: false,
|
||||
error: error instanceof Error ? error.message : "Unknown worker error."
|
||||
};
|
||||
});
|
||||
}, config.pollIntervalMs);
|
||||
|
||||
process.on("SIGINT", () => clearInterval(interval));
|
||||
process.on("SIGTERM", () => clearInterval(interval));
|
||||
|
||||
try {
|
||||
await app.listen({
|
||||
port: config.port,
|
||||
host: config.host
|
||||
});
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
clearInterval(interval);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import type { PhotoAsset, RepositoryState } from "@goodgrief/shared-types";
|
||||
import { config } from "./config.ts";
|
||||
|
||||
const toStoragePath = (publicUrl: string) => path.join(config.storageDir, publicUrl.replace(/^\/uploads\//, ""));
|
||||
|
||||
const computeOrientation = (width: number, height: number) => {
|
||||
if (width === height) {
|
||||
return "square";
|
||||
}
|
||||
return width > height ? "landscape" : "portrait";
|
||||
};
|
||||
|
||||
const createDerivativePath = (assetId: string, kind: "thumbs" | "previews" | "renders") =>
|
||||
path.join(config.storageDir, "runtime", kind, `${assetId}.jpg`);
|
||||
|
||||
const publicKeyFor = (assetId: string, kind: "thumbs" | "previews" | "renders") =>
|
||||
`/uploads/runtime/${kind}/${assetId}.jpg`;
|
||||
|
||||
const fetchState = async (): Promise<RepositoryState> => {
|
||||
const response = await fetch(`${config.apiBaseUrl}/api/state`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not fetch API state.");
|
||||
}
|
||||
return (await response.json()) as RepositoryState;
|
||||
};
|
||||
|
||||
const notifyProcessed = async (assetId: string, payload: Record<string, unknown>) => {
|
||||
await fetch(`${config.apiBaseUrl}/api/assets/${assetId}/processed`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
};
|
||||
|
||||
const notifyFailure = async (assetId: string, message: string) => {
|
||||
await fetch(`${config.apiBaseUrl}/api/assets/${assetId}/failed`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ message })
|
||||
});
|
||||
};
|
||||
|
||||
export const processAsset = async (asset: PhotoAsset) => {
|
||||
const sourcePath = toStoragePath(asset.originalKey);
|
||||
const inputBuffer = await readFile(sourcePath);
|
||||
const sha256 = createHash("sha256").update(inputBuffer).digest("hex");
|
||||
|
||||
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 });
|
||||
|
||||
const image = sharp(inputBuffer, { failOn: "none" }).rotate();
|
||||
const metadata = await image.metadata();
|
||||
const stats = await image.stats();
|
||||
const width = metadata.width ?? 0;
|
||||
const height = metadata.height ?? 0;
|
||||
|
||||
await image.clone().resize({ width: 320, height: 320, fit: "inside" }).jpeg({ quality: 78 }).toFile(
|
||||
createDerivativePath(asset.id, "thumbs")
|
||||
);
|
||||
await image.clone().resize({ width: 960, height: 960, fit: "inside" }).jpeg({ quality: 84 }).toFile(
|
||||
createDerivativePath(asset.id, "previews")
|
||||
);
|
||||
await image.clone().resize({ width: 1920, height: 1920, fit: "inside" }).jpeg({ quality: 88 }).toFile(
|
||||
createDerivativePath(asset.id, "renders")
|
||||
);
|
||||
|
||||
const dominant = stats.dominant;
|
||||
const dominantColor = `#${[dominant.r, dominant.g, dominant.b]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
return {
|
||||
thumbKey: publicKeyFor(asset.id, "thumbs"),
|
||||
previewKey: publicKeyFor(asset.id, "previews"),
|
||||
renderKey: publicKeyFor(asset.id, "renders"),
|
||||
width,
|
||||
height,
|
||||
orientation: computeOrientation(width, height),
|
||||
sha256,
|
||||
dominantColor,
|
||||
qualityFlags: {
|
||||
tooSmall: width < 800 || height < 800,
|
||||
lowContrast: stats.channels[0]?.stdev ? stats.channels[0].stdev < 12 : false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const runWorkerOnce = async () => {
|
||||
const state = await fetchState();
|
||||
const queued = state.photoAssets.find((asset) => asset.processingStatus === "queued");
|
||||
if (!queued) {
|
||||
return { processed: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await processAsset(queued);
|
||||
await notifyProcessed(queued.id, payload);
|
||||
return { processed: true, assetId: queued.id };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown processing error.";
|
||||
await notifyFailure(queued.id, message);
|
||||
return { processed: false, assetId: queued.id, error: message };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user