Initial commit

This commit is contained in:
vance 2026-04-08 10:01:19 -07:00
commit 6657125a1e
68 changed files with 15886 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.git
.gitignore
.codex
node_modules
**/node_modules
**/dist
npm-debug.log*
data
storage

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.codex
node_modules
dist
.DS_Store
coverage
.vite
*.log
.smoke
data/runtime
storage/runtime

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM docker.io/library/node:22-bookworm-slim AS workspace-base
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/admin/package.json apps/admin/package.json
COPY apps/submission/package.json apps/submission/package.json
COPY packages/cue-engine/package.json packages/cue-engine/package.json
COPY packages/effects/package.json packages/effects/package.json
COPY packages/render-engine/package.json packages/render-engine/package.json
COPY packages/shared-types/package.json packages/shared-types/package.json
COPY services/api/package.json services/api/package.json
COPY services/worker/package.json services/worker/package.json
RUN npm ci
FROM workspace-base AS workspace-source
WORKDIR /app
COPY . .
FROM workspace-source AS node-runtime
WORKDIR /app
FROM workspace-source AS admin-build
WORKDIR /app
RUN npm run build --workspace @goodgrief/admin
FROM workspace-source AS submission-build
WORKDIR /app
RUN npm run build --workspace @goodgrief/submission
FROM docker.io/library/nginx:1.27-alpine AS admin-web
COPY docker/nginx/spa.conf /etc/nginx/conf.d/default.conf
COPY --from=admin-build /app/apps/admin/dist /usr/share/nginx/html
FROM docker.io/library/nginx:1.27-alpine AS submission-web
COPY docker/nginx/spa.conf /etc/nginx/conf.d/default.conf
COPY --from=submission-build /app/apps/submission/dist /usr/share/nginx/html

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# Good Grief
TypeScript-first monorepo for a live theatrical photo-submission and media-control system.
## Workspace
- `apps/submission`: mobile-first audience submission flow.
- `apps/admin`: operator and moderation console.
- `packages/shared-types`: shared domain entities and scene/cue data.
- `packages/effects`: reusable effect vocabulary and presets.
- `packages/cue-engine`: deterministic cue and transition helpers.
- `packages/render-engine`: scene registry and render host contracts.
- `services/api`: local-first Fastify API for submissions, moderation, and cueing.
- `services/worker`: derivative generation and retention worker.
## Current status
This repository implements the MVP foundation from the planning package:
- shared TypeScript domain model
- a live-intake submission UI
- an operator-facing moderation and cue-control UI
- a local-first API/storage architecture scaffold
- scene, effect, and cue contracts for the rendering layer
## Bootstrapping
1. Install dependencies with `npm install`.
2. Start everything together:
- `npm run dev:all`
3. Or reset local runtime state first and then start everything:
- `npm run dev:all:reset`
This starts:
- submission UI at `http://localhost:4100`
- admin UI at `http://localhost:4200`
- API at `http://localhost:4300`
- worker health at `http://localhost:4301/health`
## Individual services
- `npm run dev:api`
- `npm run dev:worker`
- `npm run dev:submission`
- `npm run dev:admin`
- `npm run reset:runtime`
Both apps expect the API on `http://localhost:4300` by default. Vite dev servers proxy `/api` and `/uploads` there.
## Local testing notes
- Place curated images in `assets/import-library/` and use the admin `Rescan library folder` action, or restart the API, to import them.
- The submission flow writes new uploads into `storage/runtime/`.
- The API state lives in `data/runtime/state.json`.
- Use the admin UI to approve or reject new uploads and take cues live to program.
## Containers
Base compose file contains the shared backend services. Add the prod override for the built frontends:
- Podman: `podman compose -f docker-compose.yml -f docker-compose.prod.yml up --build`
- Docker: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
That starts:
- submission UI at `http://localhost:4100`
- admin UI at `http://localhost:4200`
- API at `http://localhost:4300`
- worker health at `http://localhost:4301/health`
For local frontend hot reload, add the dev override:
- Podman: `podman compose -f docker-compose.yml -f docker-compose.dev.yml up --build`
- Docker: `docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build`
To reset runtime state inside the container stack:
- Podman: `podman compose -f docker-compose.yml run --rm api npm run reset:runtime`
- Docker: `docker compose -f docker-compose.yml run --rm api npm run reset:runtime`

12
apps/admin/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Good Grief Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
apps/admin/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "@goodgrief/admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "tsc --noEmit"
},
"dependencies": {
"@goodgrief/cue-engine": "file:../../packages/cue-engine",
"@goodgrief/effects": "file:../../packages/effects",
"@goodgrief/render-engine": "file:../../packages/render-engine",
"@goodgrief/shared-types": "file:../../packages/shared-types",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}

2005
apps/admin/src/app/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CueTransition } from "@goodgrief/shared-types";
import { SceneViewport } from "../features/live/SceneViewport";
import { readProgramOutputState, subscribeProgramOutput, type ProgramOutputState } from "../features/live/output-sync";
import "./output.css";
const enterFullscreen = async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
await document.documentElement.requestFullscreen();
};
export const ProgramOutputApp = () => {
const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState());
const [overlayVisible, setOverlayVisible] = useState(true);
const [overlayDismissed, setOverlayDismissed] = useState(false);
const hideTimeoutRef = useRef<number | null>(null);
const clearHideTimer = useCallback(() => {
if (hideTimeoutRef.current !== null) {
window.clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
const scheduleHide = useCallback(
(delayMs: number) => {
clearHideTimer();
hideTimeoutRef.current = window.setTimeout(() => {
setOverlayVisible(false);
}, delayMs);
},
[clearHideTimer]
);
const showOverlay = useCallback(
(delayMs = 1800) => {
setOverlayVisible(true);
scheduleHide(delayMs);
},
[scheduleHide]
);
useEffect(() => {
document.documentElement.classList.add("mode-output");
document.body.classList.add("mode-output");
return () => {
clearHideTimer();
document.documentElement.classList.remove("mode-output");
document.body.classList.remove("mode-output");
};
}, [clearHideTimer]);
useEffect(
() =>
subscribeProgramOutput((payload) =>
setOutputState((current) => {
if (!current || payload.outputRevision > current.outputRevision) {
return payload;
}
return current;
})
),
[]
);
useEffect(() => {
setOverlayDismissed(false);
showOverlay(2800);
}, [outputState?.updatedAt, showOverlay]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "f") {
void enterFullscreen();
}
if (event.key.toLowerCase() === "i") {
setOverlayDismissed((current) => {
const next = !current;
if (next) {
clearHideTimer();
setOverlayVisible(false);
} else {
showOverlay(2800);
}
return next;
});
}
};
const handleWindowBlur = () => {
clearHideTimer();
setOverlayVisible(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleWindowBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleWindowBlur);
};
}, [clearHideTimer, showOverlay]);
const handlePointerMove = () => {
if (overlayDismissed) {
return;
}
showOverlay();
};
const handlePointerLeave = () => {
clearHideTimer();
setOverlayVisible(false);
};
const transition: CueTransition | null = outputState?.transition ?? null;
return (
<main className="output-shell" onMouseMove={handlePointerMove} onMouseLeave={handlePointerLeave}>
<SceneViewport
presentation={outputState?.presentation ?? null}
blackout={outputState?.blackout ?? false}
transition={transition}
activationKey={`${outputState?.presentationHash ?? "program-empty"}:${outputState?.outputRevision ?? 0}:${outputState?.blackout ? "blackout" : "live"}`}
/>
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
<div>
<p>Program Output</p>
<strong>{outputState?.presentation?.label ?? "Awaiting cue"}</strong>
<span>{outputState?.blackout ? "Blackout active" : "Move this window to the projector display."}</span>
</div>
<div className="output-overlay__actions">
<button onClick={() => void enterFullscreen()}>{document.fullscreenElement ? "Exit fullscreen" : "Fullscreen"}</button>
<button
onClick={() => {
setOverlayDismissed(true);
clearHideTimer();
setOverlayVisible(false);
}}
>
Hide info
</button>
</div>
</div>
</main>
);
};

1731
apps/admin/src/app/app.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,111 @@
html.mode-output {
--type-3xs: 0.58rem;
--type-2xs: 0.62rem;
--type-xs: 0.68rem;
--type-sm: 0.74rem;
--type-md: 0.8rem;
--type-base: 0.84rem;
--type-lg: 0.92rem;
--type-xl: 1.1rem;
}
html.mode-output,
body.mode-output,
body.mode-output #root {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
body.mode-output .output-shell {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
overflow: hidden;
background: #000;
}
body.mode-output .output-shell .surface-viewport {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0;
background: #000;
}
body.mode-output .output-shell .surface-viewport__canvas {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
body.mode-output .output-overlay {
position: fixed;
left: 24px;
right: 24px;
bottom: 24px;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
padding: 16px 18px;
border-radius: 18px;
background: rgba(8, 12, 16, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #f5f2ea;
backdrop-filter: blur(18px);
opacity: 0;
transform: translateY(12px);
pointer-events: none;
transition: opacity 220ms ease, transform 220ms ease;
}
body.mode-output .output-overlay--visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
body.mode-output .output-overlay p,
body.mode-output .output-overlay strong,
body.mode-output .output-overlay span {
display: block;
}
body.mode-output .output-overlay p {
margin: 0 0 6px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: var(--type-sm);
color: #aeb7c0;
}
body.mode-output .output-overlay strong {
font-size: var(--type-xl);
margin-bottom: 4px;
}
body.mode-output .output-overlay span {
color: #aeb7c0;
font-size: var(--type-md);
}
body.mode-output .output-overlay__actions {
display: flex;
gap: 10px;
}
body.mode-output .output-overlay__actions button {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 10px 14px;
background: rgba(22, 28, 34, 0.92);
color: inherit;
cursor: pointer;
font-size: var(--type-sm);
}

View File

@ -0,0 +1,93 @@
import { useEffect, useRef } from "react";
import type { RenderSurface as RenderSurfaceType, SurfacePresentation } from "@goodgrief/render-engine";
import type { CueTransition } from "@goodgrief/shared-types";
import "./viewport.css";
interface SceneViewportProps {
presentation: SurfacePresentation | null;
blackout?: boolean;
transition?: CueTransition | null;
activationKey?: string;
}
const defaultTransition: CueTransition = {
style: "cut",
durationMs: 0
};
export const SceneViewport = ({ presentation, blackout = false, transition, activationKey }: SceneViewportProps) => {
const frameRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const surfaceRef = useRef<RenderSurfaceType | null>(null);
const presentationRef = useRef<SurfacePresentation | null>(presentation);
const activationRef = useRef<string | undefined>(activationKey);
const transitionRef = useRef<CueTransition | null | undefined>(transition);
const blackoutRef = useRef(blackout);
presentationRef.current = presentation;
activationRef.current = activationKey;
transitionRef.current = transition;
blackoutRef.current = blackout;
useEffect(() => {
const frame = frameRef.current;
const canvas = canvasRef.current;
if (!frame || !canvas) {
return;
}
let cancelled = false;
let observer: ResizeObserver | null = null;
void import("@goodgrief/render-engine").then(({ RenderSurface, defaultScenePlugins }) => {
if (cancelled) {
return;
}
const surface = new RenderSurface(canvas);
surface.registerMany(defaultScenePlugins);
surface.setBlackout(blackoutRef.current);
surfaceRef.current = surface;
const resize = () => {
const { width, height } = frame.getBoundingClientRect();
surface.setSize(Math.max(1, width), Math.max(1, height));
};
resize();
observer = new ResizeObserver(() => resize());
observer.observe(frame);
const initialPresentation = presentationRef.current;
if (initialPresentation) {
void surface.activate(initialPresentation, defaultTransition);
}
});
return () => {
cancelled = true;
observer?.disconnect();
surfaceRef.current?.dispose();
surfaceRef.current = null;
};
}, []);
useEffect(() => {
surfaceRef.current?.setBlackout(blackout);
}, [blackout]);
useEffect(() => {
const surface = surfaceRef.current;
if (!surface) {
return;
}
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition);
}, [activationKey]);
return (
<div ref={frameRef} className="surface-viewport">
<canvas ref={canvasRef} className="surface-viewport__canvas" />
</div>
);
};

View File

@ -0,0 +1,113 @@
import type {
Cue,
CueGeneratePayload,
CueMovePayload,
CueUpsertPayload,
ModerationActionPayload,
RepositoryState
} from "@goodgrief/shared-types";
const postVoid = async (url: string, body?: unknown) => {
const response = await fetch(url, {
method: "POST",
headers: body
? {
"Content-Type": "application/json"
}
: undefined,
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
throw new Error(`Request failed for ${url}.`);
}
};
const requestJson = async <T>(url: string, init?: RequestInit) => {
const response = await fetch(url, init);
if (!response.ok) {
let message = `Request failed for ${url}.`;
try {
const payload = (await response.json()) as { message?: string };
if (payload.message) {
message = payload.message;
}
} catch {
// ignore non-json errors
}
throw new Error(message);
}
return (await response.json()) as T;
};
export const loadState = async (): Promise<RepositoryState> => {
const response = await fetch("/api/state");
if (!response.ok) {
throw new Error("Could not load admin state.");
}
return (await response.json()) as RepositoryState;
};
export const rescanLibrary = async (): Promise<RepositoryState> =>
requestJson<RepositoryState>("/api/library/rescan", {
method: "POST"
});
export const moderateAsset = async (assetId: string, payload: ModerationActionPayload) => {
await postVoid(`/api/assets/${assetId}/moderation`, payload);
};
export const fireCue = async (cueId: string) => {
await postVoid(`/api/cues/${cueId}/fire`);
};
export const activateSafeCue = async (cueId: string) => {
await postVoid(`/api/cues/${cueId}/safe`);
};
export const createCue = async (payload: CueUpsertPayload) =>
requestJson<Cue>("/api/cues", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const updateCue = async (cueId: string, payload: CueUpsertPayload) =>
requestJson<Cue>(`/api/cues/${cueId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const moveCue = async (cueId: string, payload: CueMovePayload) => {
await postVoid(`/api/cues/${cueId}/move`, payload);
};
export const generateCue = async (payload: CueGeneratePayload) =>
requestJson<CueUpsertPayload>("/api/cues/generate", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const createAdminUpload = async (payload: FormData) =>
requestJson<{ submission?: { id: string }; assetId: string }>("/api/admin/uploads", {
method: "POST",
body: payload
});
export const deleteCue = async (cueId: string) => {
const response = await fetch(`/api/cues/${cueId}`, {
method: "DELETE"
});
if (!response.ok) {
throw new Error(`Request failed for /api/cues/${cueId}.`);
}
};

View File

@ -0,0 +1,126 @@
import type { SurfacePresentation } from "@goodgrief/render-engine";
import { flattenSceneParams, type CueTransition } from "@goodgrief/shared-types";
export interface ProgramOutputState {
presentation: SurfacePresentation | null;
blackout: boolean;
transition: CueTransition | null;
presentationHash: string;
outputRevision: number;
updatedAt: string;
takenAt: string;
}
const storageKey = "goodgrief:program-output";
const channelName = "goodgrief-program-output";
const canUseWindow = () => typeof window !== "undefined";
const createChannel = () =>
canUseWindow() && "BroadcastChannel" in window ? new BroadcastChannel(channelName) : null;
const hashString = (input: string) => {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
};
export const createPresentationHash = (
presentation: SurfacePresentation | null,
blackout: boolean,
transition: CueTransition | null
) =>
hashString(
JSON.stringify({
blackout,
transition: transition
? {
style: transition.style,
durationMs: transition.durationMs
}
: null,
presentation: presentation
? {
cueId: presentation.cue?.id ?? null,
definitionId: presentation.definition.id,
effectPresetId: presentation.effectPresetId ?? null,
modeKey: presentation.modeKey ?? null,
assetIds: presentation.assets.map((asset) => asset.id),
textFragments: presentation.textFragments ?? [],
anchorCaption: presentation.anchorCaption ?? null,
params: presentation.params ? flattenSceneParams(presentation.params) : null,
label: presentation.label ?? null
}
: null
})
);
export const readProgramOutputState = (): ProgramOutputState | null => {
if (!canUseWindow()) {
return null;
}
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<ProgramOutputState>;
return {
presentation: parsed.presentation ?? null,
blackout: parsed.blackout ?? false,
transition: parsed.transition ?? null,
presentationHash:
parsed.presentationHash ?? createPresentationHash(parsed.presentation ?? null, parsed.blackout ?? false, parsed.transition ?? null),
outputRevision: parsed.outputRevision ?? 0,
updatedAt: parsed.updatedAt ?? new Date(0).toISOString(),
takenAt: parsed.takenAt ?? parsed.updatedAt ?? new Date(0).toISOString()
};
} catch {
return null;
}
};
export const writeProgramOutputState = (payload: ProgramOutputState) => {
if (!canUseWindow()) {
return;
}
window.localStorage.setItem(storageKey, JSON.stringify(payload));
const channel = createChannel();
channel?.postMessage(payload);
channel?.close();
};
export const subscribeProgramOutput = (onMessage: (payload: ProgramOutputState) => void) => {
if (!canUseWindow()) {
return () => undefined;
}
const channel = createChannel();
const storageHandler = (event: StorageEvent) => {
if (event.key !== storageKey || !event.newValue) {
return;
}
try {
onMessage(JSON.parse(event.newValue) as ProgramOutputState);
} catch {
// ignore malformed storage state
}
};
channel?.addEventListener("message", (event) => {
onMessage(event.data as ProgramOutputState);
});
window.addEventListener("storage", storageHandler);
return () => {
channel?.close();
window.removeEventListener("storage", storageHandler);
};
};

View File

@ -0,0 +1,28 @@
.surface-viewport {
position: relative;
min-height: 0;
aspect-ratio: 16 / 9;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(244, 225, 208, 0.08);
background:
radial-gradient(circle at center, rgba(143, 170, 190, 0.08), transparent 52%),
linear-gradient(180deg, rgba(9, 11, 14, 0.94), rgba(4, 5, 8, 0.98));
isolation: isolate;
}
.surface-viewport::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 22%, transparent 78%, rgba(255, 255, 255, 0.02)),
radial-gradient(circle at center, transparent 62%, rgba(0, 0, 0, 0.14));
}
.surface-viewport__canvas {
width: 100%;
height: 100%;
display: block;
}

12
apps/admin/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { ProgramOutputApp } from "./app/ProgramOutputApp";
const mode = new URLSearchParams(window.location.search).get("mode");
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
{mode === "output" ? <ProgramOutputApp /> : <App />}
</React.StrictMode>
);

7
apps/admin/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src",
"vite.config.ts"
]
}

15
apps/admin/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? "http://localhost:4300";
export default defineConfig({
plugins: [react()],
server: {
port: 4200,
proxy: {
"/api": apiProxyTarget,
"/uploads": apiProxyTarget
}
}
});

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Good Grief Submission</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
{
"name": "@goodgrief/submission",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "tsc --noEmit"
},
"dependencies": {
"@goodgrief/shared-types": "file:../../packages/shared-types",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}

View File

@ -0,0 +1,9 @@
import { Routes, Route } from "react-router-dom";
import { SubmissionRoute } from "../routes/SubmissionRoute";
import "./app.css";
export const App = () => (
<Routes>
<Route path="*" element={<SubmissionRoute />} />
</Routes>
);

View File

@ -0,0 +1,201 @@
:root {
color-scheme: only light;
font-family: "Georgia", "Times New Roman", serif;
background:
radial-gradient(circle at top, rgba(245, 224, 210, 0.65), transparent 40%),
linear-gradient(160deg, #f5efe9 0%, #e8ddd2 42%, #d7cabe 100%);
color: #1f1815;
--card: rgba(255, 250, 246, 0.78);
--border: rgba(73, 54, 42, 0.15);
--accent: #8a5037;
--accent-strong: #6a3422;
--soft: #5d524b;
--success: #2a6e52;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input,
textarea,
select {
font: inherit;
}
.submission-shell {
min-height: 100vh;
padding: 24px 18px 48px;
}
.submission-stage {
width: min(100%, 720px);
margin: 0 auto;
display: grid;
gap: 20px;
}
.submission-hero {
padding: 24px;
border: 1px solid var(--border);
border-radius: 28px;
background: linear-gradient(180deg, rgba(255, 247, 240, 0.92), rgba(255, 251, 248, 0.72));
box-shadow: 0 18px 60px rgba(70, 47, 29, 0.08);
}
.submission-kicker {
margin: 0 0 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.75rem;
color: var(--soft);
}
.submission-title {
margin: 0;
font-size: clamp(2rem, 6vw, 4rem);
line-height: 0.95;
}
.submission-copy {
margin: 16px 0 0;
max-width: 52ch;
line-height: 1.55;
color: var(--soft);
}
.submission-card {
padding: 22px;
background: var(--card);
backdrop-filter: blur(18px);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: 0 18px 40px rgba(70, 47, 29, 0.08);
}
.submission-grid {
display: grid;
gap: 16px;
}
.submission-field {
display: grid;
gap: 8px;
}
.submission-field label,
.submission-label {
font-size: 0.95rem;
font-weight: 600;
}
.submission-field input[type="text"],
.submission-field textarea {
width: 100%;
border-radius: 16px;
border: 1px solid rgba(73, 54, 42, 0.18);
padding: 14px 16px;
background: rgba(255, 255, 255, 0.7);
}
.submission-file {
border: 1px dashed rgba(73, 54, 42, 0.3);
border-radius: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.55);
}
.submission-checkboxes {
display: grid;
gap: 12px;
padding: 16px;
border-radius: 18px;
background: rgba(251, 246, 240, 0.8);
}
.submission-checkbox {
display: grid;
grid-template-columns: 20px 1fr;
gap: 12px;
align-items: start;
font-size: 0.95rem;
line-height: 1.45;
}
.submission-actions {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.submission-button {
border: 0;
border-radius: 999px;
padding: 14px 24px;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff7f0;
cursor: pointer;
min-width: 160px;
transition: transform 120ms ease, box-shadow 120ms ease;
box-shadow: 0 12px 28px rgba(106, 52, 34, 0.24);
}
.submission-button:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
.submission-button:not(:disabled):hover {
transform: translateY(-1px);
}
.submission-status {
font-size: 0.92rem;
color: var(--soft);
}
.submission-status[data-tone="error"] {
color: #9f3a2f;
}
.submission-status[data-tone="success"] {
color: var(--success);
}
.submission-progress {
height: 8px;
border-radius: 999px;
overflow: hidden;
background: rgba(73, 54, 42, 0.08);
}
.submission-progress > span {
display: block;
height: 100%;
background: linear-gradient(90deg, #cd8a67 0%, #8a5037 100%);
}
@media (max-width: 640px) {
.submission-shell {
padding: 16px 14px 36px;
}
.submission-card,
.submission-hero {
padding: 18px;
border-radius: 22px;
}
.submission-actions {
flex-direction: column;
align-items: stretch;
}
}

View File

@ -0,0 +1,59 @@
import type { Submission } from "@goodgrief/shared-types";
export interface CreateSubmissionResponse {
submission: Submission;
assetId: string;
}
export interface CreateSubmissionInput {
displayName?: string;
caption?: string;
promptAnswer?: string;
allowArchive: boolean;
hasRights: boolean;
allowProjection: boolean;
acknowledgePublicPerformance: boolean;
file: File;
}
export const createSubmission = async (
input: CreateSubmissionInput,
onProgress?: (progress: number) => void
): Promise<CreateSubmissionResponse> =>
new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", input.file);
formData.append("displayName", input.displayName ?? "");
formData.append("caption", input.caption ?? "");
formData.append("promptAnswer", input.promptAnswer ?? "");
formData.append("allowArchive", String(input.allowArchive));
formData.append("hasRights", String(input.hasRights));
formData.append("allowProjection", String(input.allowProjection));
formData.append("acknowledgePublicPerformance", String(input.acknowledgePublicPerformance));
formData.append("source", "live");
const request = new XMLHttpRequest();
request.open("POST", "/api/submissions");
request.responseType = "json";
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
onProgress?.(Math.round((event.loaded / event.total) * 100));
}
});
request.addEventListener("load", () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.response as CreateSubmissionResponse);
return;
}
reject(new Error((request.response as { message?: string } | null)?.message ?? "Upload failed."));
});
request.addEventListener("error", () => {
reject(new Error("The upload could not be completed. Please try again."));
});
request.send(formData);
});

View File

@ -0,0 +1,91 @@
import { useState } from "react";
import { createSubmission } from "./api";
export interface SubmissionFormState {
displayName: string;
caption: string;
promptAnswer: string;
allowArchive: boolean;
hasRights: boolean;
allowProjection: boolean;
acknowledgePublicPerformance: boolean;
file: File | null;
}
const initialState: SubmissionFormState = {
displayName: "",
caption: "",
promptAnswer: "",
allowArchive: false,
hasRights: false,
allowProjection: false,
acknowledgePublicPerformance: false,
file: null
};
export const useSubmissionForm = () => {
const [state, setState] = useState<SubmissionFormState>(initialState);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<string | null>(null);
const [statusTone, setStatusTone] = useState<"neutral" | "error" | "success">("neutral");
const [submitting, setSubmitting] = useState(false);
const updateField = <Key extends keyof SubmissionFormState>(key: Key, value: SubmissionFormState[Key]) => {
setState((current) => ({
...current,
[key]: value
}));
};
const submit = async () => {
const { file } = state;
if (!file) {
setStatusTone("error");
setStatus("Please choose a photo before uploading.");
return;
}
if (!state.hasRights || !state.allowProjection || !state.acknowledgePublicPerformance) {
setStatusTone("error");
setStatus("Please review and accept the required consent items.");
return;
}
setSubmitting(true);
setProgress(0);
setStatusTone("neutral");
setStatus("Uploading for review...");
try {
await createSubmission(
{
...state,
file
},
(nextProgress) => setProgress(nextProgress)
);
setStatusTone("success");
setStatus(
"Thank you. Your image has been received and will be reviewed before it can appear during the show."
);
setState(initialState);
setProgress(100);
} catch (error) {
setStatusTone("error");
setStatus(error instanceof Error ? error.message : "Upload failed.");
} finally {
setSubmitting(false);
}
};
return {
state,
progress,
status,
statusTone,
submitting,
updateField,
submit
};
};

View File

@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./app/App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,126 @@
import { useSubmissionForm } from "../features/submission/useSubmissionForm";
export const SubmissionRoute = () => {
const { state, progress, status, statusTone, submitting, updateField, submit } = useSubmissionForm();
return (
<main className="submission-shell">
<div className="submission-stage">
<section className="submission-hero">
<p className="submission-kicker">Good Grief</p>
<h1 className="submission-title">Offer a photo to tonight&apos;s memory field.</h1>
<p className="submission-copy">
Share one image that carries memory, witness, humor, or tenderness for you. The creative team will
review each submission. Not every image will appear, and none will be shown without moderation.
</p>
</section>
<section className="submission-card">
<div className="submission-grid">
<div className="submission-field">
<label htmlFor="file">Photo</label>
<div className="submission-file">
<input
id="file"
type="file"
accept="image/jpeg,image/png,image/heic,image/heif"
onChange={(event) => updateField("file", event.target.files?.[0] ?? null)}
/>
<p className="submission-status">
One image only. Common phone photos work best. Unsupported files will be declined.
</p>
</div>
</div>
<div className="submission-field">
<label htmlFor="displayName">Name or initials (optional)</label>
<input
id="displayName"
type="text"
value={state.displayName}
maxLength={80}
onChange={(event) => updateField("displayName", event.target.value)}
/>
</div>
<div className="submission-field">
<label htmlFor="caption">Caption or note (optional)</label>
<textarea
id="caption"
rows={3}
maxLength={180}
placeholder="A short caption, dedication, or line of context."
value={state.caption}
onChange={(event) => updateField("caption", event.target.value)}
/>
</div>
<div className="submission-field">
<label htmlFor="promptAnswer">Optional prompt</label>
<textarea
id="promptAnswer"
rows={4}
maxLength={240}
placeholder="What would you want this image to carry tonight?"
value={state.promptAnswer}
onChange={(event) => updateField("promptAnswer", event.target.value)}
/>
</div>
<div className="submission-checkboxes">
<p className="submission-label">Consent</p>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.hasRights}
onChange={(event) => updateField("hasRights", event.target.checked)}
/>
<span>I have the right to share this photo, and I understand it may be declined.</span>
</label>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.allowProjection}
onChange={(event) => updateField("allowProjection", event.target.checked)}
/>
<span>I consent to this image being used in a live theatrical performance.</span>
</label>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.acknowledgePublicPerformance}
onChange={(event) => updateField("acknowledgePublicPerformance", event.target.checked)}
/>
<span>I understand this is a public performance setting and projection is not guaranteed.</span>
</label>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.allowArchive}
onChange={(event) => updateField("allowArchive", event.target.checked)}
/>
<span>Optional: you may retain this image briefly after the show for archive review.</span>
</label>
</div>
{submitting ? (
<div className="submission-progress" aria-hidden="true">
<span style={{ width: `${progress}%` }} />
</div>
) : null}
<div className="submission-actions">
<p className="submission-status" data-tone={statusTone}>
{status ??
"This flow is intentionally simple: one image, clear consent, moderated review, no public gallery."}
</p>
<button className="submission-button" type="button" disabled={submitting} onClick={() => void submit()}>
{submitting ? "Uploading..." : "Submit Photo"}
</button>
</div>
</div>
</section>
</div>
</main>
);
};

View File

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src",
"vite.config.ts"
]
}

View File

@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? "http://localhost:4300";
export default defineConfig({
plugins: [react()],
server: {
port: 4100,
proxy: {
"/api": apiProxyTarget,
"/uploads": apiProxyTarget
}
}
});

View File

@ -0,0 +1 @@

1
data/.gitkeep Normal file
View File

@ -0,0 +1 @@

44
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,44 @@
services:
api:
command: ["npm", "run", "dev:api:watch"]
volumes:
- .:/app
- workspace_node_modules:/app/node_modules
worker:
command: ["npm", "run", "dev:worker:watch"]
volumes:
- .:/app
- workspace_node_modules:/app/node_modules
admin:
build:
context: .
dockerfile: Dockerfile
target: node-runtime
command: ["npm", "run", "dev:admin", "--", "--host", "0.0.0.0", "--port", "4200"]
working_dir: /app
environment:
VITE_API_PROXY_TARGET: http://api:4300
CHOKIDAR_USEPOLLING: "true"
ports:
- "4200:4200"
volumes:
- .:/app
- workspace_node_modules:/app/node_modules
submission:
build:
context: .
dockerfile: Dockerfile
target: node-runtime
command: ["npm", "run", "dev:submission", "--", "--host", "0.0.0.0", "--port", "4100"]
working_dir: /app
environment:
VITE_API_PROXY_TARGET: http://api:4300
CHOKIDAR_USEPOLLING: "true"
ports:
- "4100:4100"
volumes:
- .:/app
- workspace_node_modules:/app/node_modules

22
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,22 @@
services:
admin:
build:
context: .
dockerfile: Dockerfile
target: admin-web
depends_on:
- api
ports:
- "4200:80"
restart: unless-stopped
submission:
build:
context: .
dockerfile: Dockerfile
target: submission-web
depends_on:
- api
ports:
- "4100:80"
restart: unless-stopped

65
docker-compose.yml Normal file
View File

@ -0,0 +1,65 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
target: node-runtime
command: ["npm", "run", "dev:api"]
working_dir: /app
environment:
HOST: 0.0.0.0
PORT: 4300
ports:
- "4300:4300"
volumes:
- ./data:/app/data
- ./storage:/app/storage
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:4300/health').then((response)=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))"
]
interval: 10s
timeout: 5s
retries: 6
start_period: 5s
restart: unless-stopped
worker:
build:
context: .
dockerfile: Dockerfile
target: node-runtime
command: ["npm", "run", "dev:worker"]
working_dir: /app
depends_on:
- api
environment:
HOST: 0.0.0.0
PORT: 4301
API_BASE_URL: http://api:4300
POLL_INTERVAL_MS: 2500
ports:
- "4301:4301"
volumes:
- ./data:/app/data
- ./storage:/app/storage
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:4301/health').then((response)=>process.exit(response.ok?0:1)).catch(()=>process.exit(1))"
]
interval: 10s
timeout: 5s
retries: 6
start_period: 5s
restart: unless-stopped
volumes:
workspace_node_modules:

29
docker/nginx/spa.conf Normal file
View File

@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:4300/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /uploads/ {
proxy_pass http://api:4300/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

4267
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "goodgrief",
"version": "0.1.0",
"private": true,
"packageManager": "npm@11.12.1",
"workspaces": [
"apps/*",
"packages/*",
"services/*"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"check": "npm run check --workspaces --if-present",
"reset:runtime": "node scripts/reset-runtime.mjs",
"dev:all": "node scripts/run-local.mjs",
"dev:all:reset": "node scripts/run-local.mjs --reset",
"dev:submission": "npm run dev --workspace @goodgrief/submission",
"dev:admin": "npm run dev --workspace @goodgrief/admin",
"dev:api": "npm run dev --workspace @goodgrief/api",
"dev:api:watch": "npm run dev:watch --workspace @goodgrief/api",
"dev:worker": "npm run dev --workspace @goodgrief/worker",
"dev:worker:watch": "npm run dev:watch --workspace @goodgrief/worker"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/three": "^0.183.1",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@goodgrief/cue-engine",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@goodgrief/shared-types": "file:../shared-types"
}
}

View File

@ -0,0 +1,62 @@
import type { Cue } from "@goodgrief/shared-types";
export interface CueRuntimeState {
cueStack: Cue[];
currentCueId: string | null;
armedCueId: string | null;
previewCueId: string | null;
blackout: boolean;
safeSceneActive: boolean;
}
export const createCueRuntimeState = (cueStack: Cue[]): CueRuntimeState => ({
cueStack: [...cueStack].sort((left, right) => left.orderIndex - right.orderIndex),
currentCueId: null,
armedCueId: cueStack[0]?.id ?? null,
previewCueId: cueStack[0]?.id ?? null,
blackout: false,
safeSceneActive: false
});
export const armCue = (state: CueRuntimeState, cueId: string): CueRuntimeState => ({
...state,
armedCueId: cueId,
previewCueId: cueId
});
export const takeCue = (state: CueRuntimeState): CueRuntimeState => {
if (!state.armedCueId) {
return state;
}
const currentIndex = state.cueStack.findIndex((cue) => cue.id === state.armedCueId);
const nextCue = state.cueStack[currentIndex + 1] ?? null;
return {
...state,
currentCueId: state.armedCueId,
armedCueId: nextCue?.id ?? null,
previewCueId: nextCue?.id ?? null,
blackout: false,
safeSceneActive: false
};
};
export const triggerBlackout = (state: CueRuntimeState): CueRuntimeState => ({
...state,
blackout: true
});
export const triggerSafeScene = (state: CueRuntimeState, cueId: string): CueRuntimeState => ({
...state,
currentCueId: cueId,
previewCueId: cueId,
safeSceneActive: true,
blackout: false
});
export const skipToCue = (state: CueRuntimeState, cueId: string): CueRuntimeState => ({
...state,
armedCueId: cueId,
previewCueId: cueId
});

View File

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
]
}

View File

@ -0,0 +1,12 @@
{
"name": "@goodgrief/effects",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@goodgrief/shared-types": "file:../shared-types"
}
}

View File

@ -0,0 +1,98 @@
import type { EffectPreset } from "@goodgrief/shared-types";
import { defaultEffectPresets } from "@goodgrief/shared-types";
export interface EffectCategorySpec {
id:
| "compositing"
| "temporal"
| "spatial"
| "color"
| "depth"
| "particles"
| "reveal"
| "audio"
| "performer"
| "projection";
title: string;
artisticPurpose: string;
recommendedImplementation: "css" | "canvas" | "webgl" | "custom_shader";
performanceNote: string;
}
export const effectCategories: EffectCategorySpec[] = [
{
id: "compositing",
title: "Compositing",
artisticPurpose: "Layer images as memories held in relation rather than simply stacked.",
recommendedImplementation: "webgl",
performanceNote: "Keep pass count low and prefer pre-sized textures."
},
{
id: "temporal",
title: "Temporal",
artisticPurpose: "Create residue, afterimage, freeze, and unstable recall.",
recommendedImplementation: "custom_shader",
performanceNote: "Requires explicit history buffer management."
},
{
id: "spatial",
title: "Spatial",
artisticPurpose: "Turn still photos into playable, live-composed space.",
recommendedImplementation: "webgl",
performanceNote: "Cheap until object counts and shadow complexity grow."
},
{
id: "color",
title: "Color and Tone",
artisticPurpose: "Shift between warmth, institutional coldness, camp, and grief hush.",
recommendedImplementation: "custom_shader",
performanceNote: "Bound operator ranges to prevent unreadable projections."
},
{
id: "depth",
title: "Depth and Parallax",
artisticPurpose: "Give still imagery weight, procession, and breath.",
recommendedImplementation: "webgl",
performanceNote: "Batch materials and cap simultaneous textures."
},
{
id: "particles",
title: "Particles",
artisticPurpose: "Dust, ash, petals, paper, stars, and bodily debris.",
recommendedImplementation: "custom_shader",
performanceNote: "Use one reusable GPU-instanced system."
},
{
id: "reveal",
title: "Reveal and Conceal",
artisticPurpose: "Let imagery emerge through masks, curtains, water, and tears.",
recommendedImplementation: "custom_shader",
performanceNote: "Ordering and alpha complexity need strict discipline."
},
{
id: "audio",
title: "Audio Reactive",
artisticPurpose: "Subtle breathing and swell tied to score rather than spectacle.",
recommendedImplementation: "webgl",
performanceNote: "Should always be operator-disableable."
},
{
id: "performer",
title: "Performer Reactive",
artisticPurpose: "Rare moments where the body reveals or occludes memory.",
recommendedImplementation: "custom_shader",
performanceNote: "Calibration-heavy, high risk, not for core playback."
},
{
id: "projection",
title: "Projection Safety",
artisticPurpose: "Keep output readable in dark rooms and across mismatched surfaces.",
recommendedImplementation: "webgl",
performanceNote: "Apply as the final output calibration layer."
}
];
export const effectPresetLibrary: EffectPreset[] = defaultEffectPresets;
export const findEffectPreset = (id: string) =>
effectPresetLibrary.find((preset) => preset.id === id) ?? null;

View File

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
]
}

View File

@ -0,0 +1,13 @@
{
"name": "@goodgrief/render-engine",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@goodgrief/shared-types": "file:../shared-types",
"three": "^0.176.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
]
}

View File

@ -0,0 +1,12 @@
{
"name": "@goodgrief/shared-types",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"zod": "^3.24.4"
}
}

View File

@ -0,0 +1,374 @@
export type SubmissionSource = "live" | "pre_show" | "invite" | "admin_upload" | "library_import";
export type SubmissionStatus =
| "uploaded"
| "processing"
| "pending_moderation"
| "approved_partial"
| "approved_all"
| "rejected"
| "archived";
export type ProcessingStatus = "queued" | "ready" | "failed";
export type ModerationStatus = "pending" | "approved" | "hold" | "rejected" | "archived";
export type ModerationDecisionType = "approved" | "hold" | "rejected" | "archive_only";
export type CollectionKind = "bank" | "playlist" | "moment" | "favorites" | "archive_set";
export type SceneRenderMode = "2d" | "3d" | "hybrid" | "shader_overlay";
export type CueTriggerMode = "manual" | "follow" | "hold" | "armed";
export type OperatorSessionMode = "rehearsal" | "tech" | "show" | "archive_review";
export type OutputSurfaceRole = "program" | "preview" | "aux";
export type SceneTier = "mvp" | "v1" | "stretch";
export type SceneFamily = "hero" | "chorus" | "floor_paint" | "arrival" | "rupture" | "safe";
export type TextTreatmentMode = "off" | "edge_whispers" | "relay_ticker" | "anchor_caption";
export type SceneCategory =
| "memory_elegy"
| "humor_rupture"
| "choir_swell"
| "abstract_grief"
| "photo_collage"
| "immersive_3d"
| "transition"
| "audience_reactive";
export interface ContributorConsent {
id: string;
submissionId: string;
hasRights: boolean;
allowProjection: boolean;
acknowledgePublicPerformance: boolean;
agreedAt: string;
allowArchive?: boolean;
contactEmail?: string;
guardianConfirmed?: boolean;
}
export interface Submission {
id: string;
source: SubmissionSource;
submittedAt: string;
status: SubmissionStatus;
consentId: string;
displayName?: string;
caption?: string;
promptAnswer?: string;
sessionToken?: string;
notes?: string;
}
export interface QualityFlags {
tooSmall?: boolean;
blurry?: boolean;
lowContrast?: boolean;
unusualAspectRatio?: boolean;
}
export interface PhotoAsset {
id: string;
submissionId: string;
originalKey: string;
thumbKey?: string;
previewKey?: string;
renderKey?: string;
mimeType: string;
width?: number;
height?: number;
orientation?: "portrait" | "landscape" | "square";
sha256?: string;
pHash?: string;
dominantColor?: string;
processingStatus: ProcessingStatus;
moderationStatus: ModerationStatus;
createdAt: string;
qualityFlags?: QualityFlags;
approvedAt?: string;
rejectionReason?: string;
}
export interface Tag {
id: string;
label: string;
category: "tone" | "era" | "subject" | "palette" | "scene_fit" | "quality" | "show_moment";
color?: string;
description?: string;
}
export interface Collection {
id: string;
name: string;
kind: CollectionKind;
createdAt: string;
description?: string;
coverAssetId?: string;
locked?: boolean;
assetIds: string[];
tagIds: string[];
}
export interface PhotoTreatmentParams {
contrast: number;
saturation: number;
blackPoint: number;
whitePoint: number;
paletteMix: number;
clarity: number;
edgeLight: number;
}
export interface ScenicTreatmentParams {
washIntensity: number;
spill: number;
floorMix: number;
paletteBias: number;
vignette: number;
fillHue: number;
fillSaturation: number;
fillLightness: number;
}
export interface CompositionParams {
motion: number;
density: number;
depth: number;
focus: number;
crop: number;
emphasis: number;
bands?: number;
columns?: number;
shutters?: number;
tiles?: number;
lanes?: number;
edge?: "left" | "right";
}
export interface TextTreatmentParams {
mode: TextTreatmentMode;
opacity: number;
density: number;
scale: number;
}
export interface SceneParamGroups {
photoTreatment: PhotoTreatmentParams;
scenicTreatment: ScenicTreatmentParams;
composition: CompositionParams;
textTreatment: TextTreatmentParams;
}
export interface SceneParamPatch {
photoTreatment?: Partial<PhotoTreatmentParams>;
scenicTreatment?: Partial<ScenicTreatmentParams>;
composition?: Partial<CompositionParams>;
textTreatment?: Partial<TextTreatmentParams>;
}
export interface PhotoTreatmentPreset {
id: string;
name: string;
params: Partial<PhotoTreatmentParams>;
}
export interface ScenicTreatmentPreset {
id: string;
name: string;
params: Partial<ScenicTreatmentParams>;
}
export interface SceneDefinition {
id: string;
sceneKey: string;
sceneFamily: SceneFamily;
name: string;
category: SceneCategory;
tier: SceneTier;
visualDescription: string;
emotionalUseCase: string;
renderMode: SceneRenderMode;
complexity: "low" | "medium" | "high";
performanceRisk: "low" | "medium" | "high";
inputRules: {
minAssets: number;
maxAssets?: number;
recommendedTags: string[];
};
defaultParams: SceneParamGroups;
defaultPresetId: string;
supportedPresetIds: string[];
operatorControls: string[];
metadataHints: string[];
}
export interface CueTransition {
style: "cut" | "dissolve" | "veil_wipe" | "luma_hold" | "rupture_offset";
durationMs: number;
}
export interface Cue {
id: string;
showConfigId: string;
orderIndex: number;
sceneDefinitionId: string;
triggerMode: CueTriggerMode;
transitionIn: CueTransition;
transitionOut: CueTransition;
collectionId?: string;
assetIds?: string[];
durationMs?: number;
effectPresetId?: string;
parameterOverrides?: SceneParamPatch;
notes?: string;
nextCueId?: string;
}
export interface CueUpsertPayload {
id?: string;
showConfigId?: string;
orderIndex?: number;
sceneDefinitionId: string;
triggerMode: CueTriggerMode;
transitionIn: CueTransition;
transitionOut: CueTransition;
collectionId?: string;
assetIds?: string[];
durationMs?: number;
effectPresetId?: string;
parameterOverrides?: SceneParamPatch;
notes?: string;
nextCueId?: string;
}
export interface CueMovePayload {
direction: "up" | "down";
}
export interface CueGeneratePayload {
sceneDefinitionId?: string;
preferredAssetIds?: string[];
includeRupture?: boolean;
}
export interface EffectPreset {
id: string;
modeKey: string;
compatibleSceneFamilies: SceneFamily[];
name: string;
category:
| "compositing"
| "temporal"
| "spatial"
| "color"
| "depth"
| "particles"
| "reveal"
| "audio"
| "performer"
| "projection";
artisticPurpose: string;
operatorControls: string[];
implementationLevel: "css" | "canvas" | "webgl" | "custom_shader";
performanceNotes: string;
paramDefaults: SceneParamPatch;
safeRanges: Record<string, { min: number; max: number }>;
}
export interface OperatorSession {
id: string;
startedAt: string;
mode: OperatorSessionMode;
operatorName: string;
showConfigId: string;
endedAt?: string;
venueName?: string;
incidentNotes?: string;
}
export interface ModerationDecision {
id: string;
assetId: string;
operatorSessionId: string;
decision: ModerationDecisionType;
decidedAt: string;
reasonCode?: string;
note?: string;
}
export interface OutputSurface {
id: string;
name: string;
role: OutputSurfaceRole;
width: number;
height: number;
aspectRatio: string;
screenIndex?: number;
maskShape?: "full_frame" | "letterbox" | "pillarbox" | "custom";
safeMargin?: number;
colorProfile?: string;
fullscreenBounds?: {
x: number;
y: number;
width: number;
height: number;
};
}
export interface ShowConfig {
id: string;
showName: string;
venueName: string;
defaultOutputSurfaceId: string;
safeSceneCueId: string;
retentionDays: number;
ingestPolicy: "fully_live" | "pre_show_plus_live" | "pre_show_only";
theme?: string;
projectionNotes?: string;
operatorShortcuts?: Record<string, string>;
}
export interface SessionEvent {
id: string;
sessionId: string;
timestamp: string;
type:
| "cue_fired"
| "cue_skipped"
| "blackout"
| "safe_scene"
| "submission_received"
| "asset_approved"
| "asset_rejected";
payload: Record<string, string | number | boolean | null>;
}
export interface SubmissionPayload {
displayName?: string;
caption?: string;
promptAnswer?: string;
allowArchive?: boolean;
hasRights: boolean;
allowProjection: boolean;
acknowledgePublicPerformance: boolean;
source?: SubmissionSource;
}
export interface ModerationActionPayload {
decision: ModerationDecisionType;
reasonCode?: string;
note?: string;
tagIds?: string[];
collectionIds?: string[];
}
export interface RepositoryState {
submissions: Submission[];
consents: ContributorConsent[];
photoAssets: PhotoAsset[];
tags: Tag[];
collections: Collection[];
scenes: SceneDefinition[];
cues: Cue[];
effectPresets: EffectPreset[];
operatorSessions: OperatorSession[];
moderationDecisions: ModerationDecision[];
outputSurfaces: OutputSurface[];
showConfig: ShowConfig;
sessionEvents: SessionEvent[];
}

View File

@ -0,0 +1,21 @@
import type { ModerationDecisionType } from "./entities";
export type ApiEvent =
| {
type: "submission.received";
submissionId: string;
assetId: string;
}
| {
type: "asset.moderated";
assetId: string;
decision: ModerationDecisionType;
}
| {
type: "cue.fired";
cueId: string;
}
| {
type: "cue.safe";
cueId: string;
};

View File

@ -0,0 +1,5 @@
export * from "./entities";
export * from "./events";
export * from "./mock";
export * from "./scene-params";
export * from "./scenes";

View File

@ -0,0 +1,113 @@
import type {
Collection,
OperatorSession,
OutputSurface,
RepositoryState,
ShowConfig,
Tag
} from "./entities";
import { defaultCueStack, defaultEffectPresets, defaultSceneDefinitions } from "./scenes";
export const defaultTags: Tag[] = [
{ id: "tag-quiet", label: "quiet", category: "tone", color: "#9ea29f" },
{ id: "tag-family", label: "family", category: "subject", color: "#d09d74" },
{ id: "tag-portrait", label: "portrait", category: "subject", color: "#6f8579" },
{ id: "tag-live", label: "live", category: "show_moment", color: "#e27f66" },
{ id: "tag-choir", label: "choir", category: "show_moment", color: "#7d7098" },
{ id: "tag-archive", label: "archive", category: "scene_fit", color: "#c8c0b3" }
];
export const defaultCollections: Collection[] = [
{
id: "collection-curated-library",
name: "Curated Library",
kind: "bank",
createdAt: new Date().toISOString(),
description: "Imported operator-managed seed and rehearsal media.",
locked: true,
assetIds: [],
tagIds: ["tag-archive", "tag-portrait"]
},
{
id: "collection-favorites",
name: "Favorites",
kind: "favorites",
createdAt: new Date().toISOString(),
description: "Operator-trusted images for flexible live use.",
locked: false,
assetIds: [],
tagIds: ["tag-portrait", "tag-quiet"]
},
{
id: "collection-choir-swell",
name: "Choir Swell",
kind: "moment",
createdAt: new Date().toISOString(),
description: "Assets suitable for collective or musical lift.",
locked: false,
assetIds: [],
tagIds: ["tag-choir", "tag-family"]
}
];
export const defaultOutputSurfaces: OutputSurface[] = [
{
id: "surface-program",
name: "Program",
role: "program",
width: 1920,
height: 1080,
aspectRatio: "16:9",
safeMargin: 0.06
},
{
id: "surface-preview",
name: "Preview",
role: "preview",
width: 1280,
height: 720,
aspectRatio: "16:9"
}
];
export const defaultShowConfig: ShowConfig = {
id: "show-good-grief",
showName: "Good Grief",
venueName: "Studio Black Box",
defaultOutputSurfaceId: "surface-program",
safeSceneCueId: "cue-safe-hold",
retentionDays: 21,
ingestPolicy: "fully_live",
theme: "Tender collage for live projection.",
projectionNotes: "Maintain center-safe composition and low white clip.",
operatorShortcuts: {
Space: "take cue",
KeyB: "blackout",
KeyS: "safe scene",
ArrowDown: "next cue"
}
};
export const defaultOperatorSession: OperatorSession = {
id: "session-default",
startedAt: new Date().toISOString(),
mode: "rehearsal",
operatorName: "Operator",
showConfigId: "show-good-grief"
};
export const createEmptyRepositoryState = (): RepositoryState => ({
submissions: [],
consents: [],
photoAssets: [],
tags: defaultTags,
collections: defaultCollections,
scenes: defaultSceneDefinitions,
cues: defaultCueStack,
effectPresets: defaultEffectPresets,
operatorSessions: [defaultOperatorSession],
moderationDecisions: [],
outputSurfaces: defaultOutputSurfaces,
showConfig: defaultShowConfig,
sessionEvents: []
});

View File

@ -0,0 +1,108 @@
import type {
CompositionParams,
PhotoTreatmentParams,
ScenicTreatmentParams,
SceneParamGroups,
SceneParamPatch,
TextTreatmentParams
} from "./entities";
const defaultTextTreatment = (): TextTreatmentParams => ({
mode: "off",
opacity: 0.2,
density: 0.35,
scale: 0.8
});
export type SceneParamScalar = number | string | boolean;
export const createSceneParams = (input: {
photoTreatment: PhotoTreatmentParams;
scenicTreatment: ScenicTreatmentParams;
composition: CompositionParams;
textTreatment?: TextTreatmentParams;
}): SceneParamGroups => ({
photoTreatment: { ...input.photoTreatment },
scenicTreatment: { ...input.scenicTreatment },
composition: { ...input.composition },
textTreatment: {
...defaultTextTreatment(),
...input.textTreatment
}
});
export const mergeSceneParams = (base: SceneParamGroups, ...patches: Array<SceneParamPatch | undefined>): SceneParamGroups => {
const merged = createSceneParams(base);
for (const patch of patches) {
if (!patch) {
continue;
}
if (patch.photoTreatment) {
merged.photoTreatment = {
...merged.photoTreatment,
...patch.photoTreatment
};
}
if (patch.scenicTreatment) {
merged.scenicTreatment = {
...merged.scenicTreatment,
...patch.scenicTreatment
};
}
if (patch.composition) {
merged.composition = {
...merged.composition,
...patch.composition
};
}
if (patch.textTreatment) {
merged.textTreatment = {
...merged.textTreatment,
...patch.textTreatment
};
}
}
return merged;
};
export const getSceneParamValue = (params: SceneParamGroups | SceneParamPatch, path: string): SceneParamScalar | undefined => {
const [group, key] = path.split(".");
if (!group || !key) {
return undefined;
}
const container = params[group as keyof SceneParamGroups] as Record<string, SceneParamScalar> | undefined;
return container?.[key];
};
export const setSceneParamValue = (params: SceneParamGroups, path: string, value: SceneParamScalar): SceneParamGroups => {
const [group, key] = path.split(".");
if (!group || !key) {
return params;
}
if (!(group in params)) {
return params;
}
return {
...params,
[group]: {
...((params[group as keyof SceneParamGroups] as unknown as Record<string, SceneParamScalar>) ?? {}),
[key]: value
}
} as SceneParamGroups;
};
export const flattenSceneParams = (params: SceneParamGroups | SceneParamPatch) =>
Object.fromEntries(
Object.entries(params).flatMap(([group, values]) =>
Object.entries(values ?? {}).map(([key, value]) => [`${group}.${key}`, value] as const)
)
) as Record<string, SceneParamScalar>;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
]
}

30
scripts/reset-runtime.mjs Normal file
View File

@ -0,0 +1,30 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const resetTargets = [
path.join(repoRoot, "data", "runtime"),
path.join(repoRoot, "storage", "runtime", "seed"),
path.join(repoRoot, "storage", "runtime", "originals"),
path.join(repoRoot, "storage", "runtime", "thumbs"),
path.join(repoRoot, "storage", "runtime", "previews"),
path.join(repoRoot, "storage", "runtime", "renders")
];
await Promise.all(
resetTargets.map(async (targetPath) => {
await fs.rm(targetPath, { recursive: true, force: true });
})
);
await Promise.all(
[
path.join(repoRoot, "data"),
path.join(repoRoot, "storage")
].map((targetPath) => fs.mkdir(targetPath, { recursive: true }))
);
console.log("Runtime state cleared.");

187
scripts/run-local.mjs Normal file
View File

@ -0,0 +1,187 @@
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const shouldReset = process.argv.includes("--reset");
const colors = {
api: "\x1b[38;5;180m",
worker: "\x1b[38;5;110m",
admin: "\x1b[38;5;117m",
submission: "\x1b[38;5;216m",
system: "\x1b[38;5;246m",
reset: "\x1b[0m"
};
const services = [
{
id: "api",
title: "API",
command: npmCommand,
args: ["run", "dev:api"],
url: "http://localhost:4300/health",
waitForReady: true
},
{
id: "worker",
title: "Worker",
command: npmCommand,
args: ["run", "dev:worker"],
url: "http://localhost:4301/health",
waitForReady: true
},
{
id: "admin",
title: "Admin",
command: npmCommand,
args: ["run", "dev:admin"],
url: "http://localhost:4200"
},
{
id: "submission",
title: "Submission",
command: npmCommand,
args: ["run", "dev:submission"],
url: "http://localhost:4100"
}
];
const prefixLine = (serviceId, title, line) => {
const color = colors[serviceId] ?? colors.system;
return `${color}[${title}]${colors.reset} ${line}`;
};
const attachStream = (child, streamName, serviceId, title) => {
const stream = child[streamName];
if (!stream) {
return;
}
let buffer = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.length > 0) {
console.log(prefixLine(serviceId, title, line));
}
}
});
stream.on("end", () => {
if (buffer.length > 0) {
console.log(prefixLine(serviceId, title, buffer));
}
});
};
const childProcesses = [];
let shuttingDown = false;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForService = async (service, timeoutMs = 20_000) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(service.url);
if (response.ok) {
return true;
}
} catch {
// keep waiting
}
await delay(400);
}
return false;
};
const shutdown = (reason = "shutdown") => {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(prefixLine("system", "SYSTEM", `Stopping local stack (${reason})...`));
for (const child of childProcesses) {
if (!child.killed) {
child.kill("SIGTERM");
}
}
setTimeout(() => {
for (const child of childProcesses) {
if (!child.killed) {
child.kill("SIGKILL");
}
}
}, 1200).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
if (shouldReset) {
console.log(prefixLine("system", "SYSTEM", "Resetting runtime state before startup..."));
const reset = spawn(npmCommand, ["run", "reset:runtime"], {
cwd: repoRoot,
stdio: ["ignore", "pipe", "pipe"]
});
attachStream(reset, "stdout", "system", "RESET");
attachStream(reset, "stderr", "system", "RESET");
const resetExitCode = await new Promise((resolve) => {
reset.on("exit", (code) => resolve(code ?? 1));
});
if (resetExitCode !== 0) {
process.exit(resetExitCode);
}
}
console.log(prefixLine("system", "SYSTEM", "Starting local Good Grief stack..."));
for (const service of services) {
console.log(prefixLine("system", "SYSTEM", `${service.title}: ${service.url}`));
}
for (const service of services) {
const child = spawn(service.command, service.args, {
cwd: repoRoot,
env: {
...process.env
},
stdio: ["ignore", "pipe", "pipe"]
});
childProcesses.push(child);
attachStream(child, "stdout", service.id, service.title);
attachStream(child, "stderr", service.id, service.title);
child.on("exit", (code, signal) => {
if (!shuttingDown) {
const reason = signal ? `${service.title} exited from ${signal}` : `${service.title} exited with code ${code ?? 1}`;
console.error(prefixLine(service.id, service.title, reason));
shutdown(reason);
process.exitCode = code ?? 1;
}
});
if (service.waitForReady) {
const ready = await waitForService(service);
if (ready) {
console.log(prefixLine("system", "SYSTEM", `${service.title} ready.`));
} else {
console.log(prefixLine("system", "SYSTEM", `${service.title} did not report ready before timeout.`));
}
}
}
await new Promise(() => {});

25
services/api/package.json Normal file
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"
}
}

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
services/api/src/index.ts Normal file
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
services/api/src/seed.ts Normal file
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
services/api/src/server.ts Normal file
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;
};

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;
});
}
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
],
"compilerOptions": {
"lib": [
"ES2022"
],
"allowImportingTsExtensions": true
}
}

View File

@ -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"
}
}

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 ?? 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)
};

View File

@ -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);
}

View File

@ -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 };
}
};

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src"
],
"compilerOptions": {
"lib": [
"ES2022"
],
"allowImportingTsExtensions": true
}
}

1
storage/.gitkeep Normal file
View File

@ -0,0 +1 @@

22
tsconfig.base.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true,
"jsx": "react-jsx",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
]
}
}