Initial commit
This commit is contained in:
commit
6657125a1e
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
.codex
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
npm-debug.log*
|
||||
data
|
||||
storage
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.codex
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
coverage
|
||||
.vite
|
||||
*.log
|
||||
.smoke
|
||||
data/runtime
|
||||
storage/runtime
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal 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
80
README.md
Normal 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
12
apps/admin/index.html
Normal 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
26
apps/admin/package.json
Normal 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
2005
apps/admin/src/app/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
152
apps/admin/src/app/ProgramOutputApp.tsx
Normal file
152
apps/admin/src/app/ProgramOutputApp.tsx
Normal 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
1731
apps/admin/src/app/app.css
Normal file
File diff suppressed because it is too large
Load Diff
111
apps/admin/src/app/output.css
Normal file
111
apps/admin/src/app/output.css
Normal 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);
|
||||
}
|
||||
93
apps/admin/src/features/live/SceneViewport.tsx
Normal file
93
apps/admin/src/features/live/SceneViewport.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
apps/admin/src/features/live/api.ts
Normal file
113
apps/admin/src/features/live/api.ts
Normal 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}.`);
|
||||
}
|
||||
};
|
||||
126
apps/admin/src/features/live/output-sync.ts
Normal file
126
apps/admin/src/features/live/output-sync.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
28
apps/admin/src/features/live/viewport.css
Normal file
28
apps/admin/src/features/live/viewport.css
Normal 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
12
apps/admin/src/main.tsx
Normal 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
7
apps/admin/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
15
apps/admin/vite.config.ts
Normal file
15
apps/admin/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
12
apps/submission/index.html
Normal file
12
apps/submission/index.html
Normal 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>
|
||||
24
apps/submission/package.json
Normal file
24
apps/submission/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
apps/submission/src/app/App.tsx
Normal file
9
apps/submission/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
201
apps/submission/src/app/app.css
Normal file
201
apps/submission/src/app/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
59
apps/submission/src/features/submission/api.ts
Normal file
59
apps/submission/src/features/submission/api.ts
Normal 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);
|
||||
});
|
||||
91
apps/submission/src/features/submission/useSubmissionForm.ts
Normal file
91
apps/submission/src/features/submission/useSubmissionForm.ts
Normal 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
|
||||
};
|
||||
};
|
||||
12
apps/submission/src/main.tsx
Normal file
12
apps/submission/src/main.tsx
Normal 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>
|
||||
);
|
||||
126
apps/submission/src/routes/SubmissionRoute.tsx
Normal file
126
apps/submission/src/routes/SubmissionRoute.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
||||
7
apps/submission/tsconfig.json
Normal file
7
apps/submission/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
15
apps/submission/vite.config.ts
Normal file
15
apps/submission/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
1
assets/import-library/.gitkeep
Normal file
1
assets/import-library/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
data/.gitkeep
Normal file
1
data/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
44
docker-compose.dev.yml
Normal file
44
docker-compose.dev.yml
Normal 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
22
docker-compose.prod.yml
Normal 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
65
docker-compose.yml
Normal 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
29
docker/nginx/spa.conf
Normal 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
4267
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
packages/cue-engine/package.json
Normal file
12
packages/cue-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
62
packages/cue-engine/src/index.ts
Normal file
62
packages/cue-engine/src/index.ts
Normal 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
|
||||
});
|
||||
6
packages/cue-engine/tsconfig.json
Normal file
6
packages/cue-engine/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
12
packages/effects/package.json
Normal file
12
packages/effects/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
98
packages/effects/src/index.ts
Normal file
98
packages/effects/src/index.ts
Normal 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;
|
||||
6
packages/effects/tsconfig.json
Normal file
6
packages/effects/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
13
packages/render-engine/package.json
Normal file
13
packages/render-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2485
packages/render-engine/src/index.ts
Normal file
2485
packages/render-engine/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
6
packages/render-engine/tsconfig.json
Normal file
6
packages/render-engine/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
12
packages/shared-types/package.json
Normal file
12
packages/shared-types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
374
packages/shared-types/src/entities.ts
Normal file
374
packages/shared-types/src/entities.ts
Normal 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[];
|
||||
}
|
||||
21
packages/shared-types/src/events.ts
Normal file
21
packages/shared-types/src/events.ts
Normal 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;
|
||||
};
|
||||
5
packages/shared-types/src/index.ts
Normal file
5
packages/shared-types/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./entities";
|
||||
export * from "./events";
|
||||
export * from "./mock";
|
||||
export * from "./scene-params";
|
||||
export * from "./scenes";
|
||||
113
packages/shared-types/src/mock.ts
Normal file
113
packages/shared-types/src/mock.ts
Normal 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: []
|
||||
});
|
||||
108
packages/shared-types/src/scene-params.ts
Normal file
108
packages/shared-types/src/scene-params.ts
Normal 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>;
|
||||
1168
packages/shared-types/src/scenes.ts
Normal file
1168
packages/shared-types/src/scenes.ts
Normal file
File diff suppressed because it is too large
Load Diff
6
packages/shared-types/tsconfig.json
Normal file
6
packages/shared-types/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
30
scripts/reset-runtime.mjs
Normal file
30
scripts/reset-runtime.mjs
Normal 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
187
scripts/run-local.mjs
Normal 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
25
services/api/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@goodgrief/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:watch": "tsx watch src/index.ts",
|
||||
"build": "tsc --noEmit",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.1.0",
|
||||
"@goodgrief/shared-types": "file:../../packages/shared-types",
|
||||
"fastify": "^5.2.1",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
39
services/api/src/config.ts
Normal file
39
services/api/src/config.ts
Normal 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
14
services/api/src/index.ts
Normal 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
198
services/api/src/seed.ts
Normal 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
385
services/api/src/server.ts
Normal file
@ -0,0 +1,385 @@
|
||||
import { watch } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import Fastify, { type FastifyRequest } from "fastify";
|
||||
import type {
|
||||
CueGeneratePayload,
|
||||
CueMovePayload,
|
||||
CueUpsertPayload,
|
||||
ModerationActionPayload,
|
||||
SubmissionPayload
|
||||
} from "@goodgrief/shared-types";
|
||||
import { config } from "./config.ts";
|
||||
import { createLibraryAssets, libraryWatchDirs } from "./seed.ts";
|
||||
import { StateStore } from "./state-store.ts";
|
||||
|
||||
const allowedMimeTypes = new Set(["image/jpeg", "image/png", "image/heic", "image/heif"]);
|
||||
const moderationDecisions = new Set(["approved", "hold", "rejected", "archive_only"]);
|
||||
|
||||
const coerceBoolean = (value: string | undefined) => value === "true";
|
||||
|
||||
const normalizeBody = <Body>(body: unknown): Body => {
|
||||
if (typeof body === "string") {
|
||||
return JSON.parse(body) as Body;
|
||||
}
|
||||
|
||||
return (body ?? {}) as Body;
|
||||
};
|
||||
|
||||
const fileExtensionFor = (mimeType: string, filename?: string) => {
|
||||
const extensionMap: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/heic": ".heic",
|
||||
"image/heif": ".heif"
|
||||
};
|
||||
|
||||
const byMime = extensionMap[mimeType];
|
||||
if (byMime) {
|
||||
return byMime;
|
||||
}
|
||||
|
||||
const fallback = path.extname(filename ?? "");
|
||||
return fallback || ".bin";
|
||||
};
|
||||
|
||||
interface ParsedMultipartFile {
|
||||
buffer: Buffer;
|
||||
filename?: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
const parseMultipartSubmission = async (request: FastifyRequest) => {
|
||||
const parts = request.parts();
|
||||
let filePart: ParsedMultipartFile | null = null;
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
filePart = {
|
||||
buffer: await part.toBuffer(),
|
||||
filename: part.filename,
|
||||
mimetype: part.mimetype
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
fields[part.fieldname] = String(part.value);
|
||||
}
|
||||
|
||||
return { filePart, fields };
|
||||
};
|
||||
|
||||
type MultipartSubmissionError = {
|
||||
error: {
|
||||
statusCode: number;
|
||||
payload: { message: string };
|
||||
};
|
||||
};
|
||||
|
||||
type MultipartSubmissionSuccess = {
|
||||
submission?: { id: string };
|
||||
assetId: string;
|
||||
};
|
||||
|
||||
const storeUploadedFile = async (filePart: ParsedMultipartFile, assetId: string) => {
|
||||
const extension = fileExtensionFor(filePart.mimetype, filePart.filename);
|
||||
const relativePath = path.join("runtime", "originals", `${assetId}${extension}`);
|
||||
const absolutePath = path.join(config.storageDir, relativePath);
|
||||
await writeFile(absolutePath, filePart.buffer);
|
||||
return `/uploads/${relativePath}`;
|
||||
};
|
||||
|
||||
const createSubmissionFromMultipart = async (
|
||||
store: StateStore,
|
||||
request: FastifyRequest,
|
||||
defaults: Partial<SubmissionPayload> = {}
|
||||
): Promise<MultipartSubmissionError | MultipartSubmissionSuccess> => {
|
||||
const { filePart, fields } = await parseMultipartSubmission(request);
|
||||
|
||||
if (!filePart) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: { message: "A single image file is required." }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!allowedMimeTypes.has(filePart.mimetype)) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: { message: "Unsupported file type. Please upload a JPEG, PNG, or HEIC image." }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const storedAssetId = crypto.randomUUID();
|
||||
const originalKey = await storeUploadedFile(filePart, storedAssetId);
|
||||
const payload: SubmissionPayload = {
|
||||
displayName: fields.displayName || defaults.displayName || undefined,
|
||||
caption: fields.caption || defaults.caption || undefined,
|
||||
promptAnswer: fields.promptAnswer || defaults.promptAnswer || undefined,
|
||||
allowArchive: defaults.allowArchive ?? coerceBoolean(fields.allowArchive),
|
||||
hasRights: defaults.hasRights ?? coerceBoolean(fields.hasRights),
|
||||
allowProjection: defaults.allowProjection ?? coerceBoolean(fields.allowProjection),
|
||||
acknowledgePublicPerformance:
|
||||
defaults.acknowledgePublicPerformance ?? coerceBoolean(fields.acknowledgePublicPerformance),
|
||||
source: defaults.source ?? (fields.source as SubmissionPayload["source"]) ?? "live"
|
||||
};
|
||||
|
||||
if (!payload.hasRights || !payload.allowProjection || !payload.acknowledgePublicPerformance) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
message: "Required consent items must be accepted before submission."
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = await store.createSubmission({
|
||||
...payload,
|
||||
originalKey,
|
||||
mimeType: filePart.mimetype
|
||||
});
|
||||
const submission = nextState.submissions[0];
|
||||
const createdAsset = nextState.photoAssets.find((asset) => asset.originalKey === originalKey);
|
||||
|
||||
return {
|
||||
submission,
|
||||
assetId: createdAsset?.id ?? storedAssetId
|
||||
};
|
||||
};
|
||||
|
||||
export const buildServer = async () => {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
bodyLimit: 25 * 1024 * 1024
|
||||
});
|
||||
|
||||
const store = new StateStore(config.stateFile);
|
||||
const syncLibrary = async () => {
|
||||
await store.ensure();
|
||||
const importedAssets = await createLibraryAssets();
|
||||
await store.syncImportedAssets(importedAssets);
|
||||
return store.read();
|
||||
};
|
||||
|
||||
await syncLibrary();
|
||||
await mkdir(path.join(config.storageDir, "runtime", "originals"), { recursive: true });
|
||||
await mkdir(path.join(config.storageDir, "runtime", "thumbs"), { recursive: true });
|
||||
await mkdir(path.join(config.storageDir, "runtime", "previews"), { recursive: true });
|
||||
await mkdir(path.join(config.storageDir, "runtime", "renders"), { recursive: true });
|
||||
|
||||
let rescanTimeout: NodeJS.Timeout | null = null;
|
||||
let rescanInFlight = false;
|
||||
const watchers = await Promise.all(
|
||||
libraryWatchDirs.map(async (directory) => {
|
||||
await mkdir(directory, { recursive: true });
|
||||
return watch(directory, { persistent: false }, () => {
|
||||
if (rescanTimeout) {
|
||||
clearTimeout(rescanTimeout);
|
||||
}
|
||||
|
||||
rescanTimeout = setTimeout(() => {
|
||||
if (rescanInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
rescanInFlight = true;
|
||||
void syncLibrary()
|
||||
.catch((error) => {
|
||||
app.log.error(error, "Library rescan failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
rescanInFlight = false;
|
||||
});
|
||||
}, 350);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true
|
||||
});
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
files: 1,
|
||||
fileSize: 25 * 1024 * 1024
|
||||
}
|
||||
});
|
||||
await app.register(fastifyStatic, {
|
||||
root: config.storageDir,
|
||||
prefix: "/uploads/"
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
service: "api"
|
||||
}));
|
||||
|
||||
app.get("/api/state", async () => store.read());
|
||||
app.get("/api/scenes", async () => (await store.read()).scenes);
|
||||
app.get("/api/cues", async () => (await store.read()).cues);
|
||||
app.get("/api/effects", async () => (await store.read()).effectPresets);
|
||||
app.get("/api/assets", async () => (await store.read()).photoAssets);
|
||||
app.get("/api/submissions", async () => (await store.read()).submissions);
|
||||
app.get("/api/show-config", async () => (await store.read()).showConfig);
|
||||
app.post("/api/library/rescan", async () => syncLibrary());
|
||||
|
||||
app.post("/api/submissions", async (request, reply) => {
|
||||
const result = await createSubmissionFromMultipart(store, request);
|
||||
if ("error" in result) {
|
||||
return reply.status(result.error.statusCode).send(result.error.payload);
|
||||
}
|
||||
|
||||
return reply.status(201).send(result);
|
||||
});
|
||||
|
||||
app.post("/api/admin/uploads", async (request, reply) => {
|
||||
const result = await createSubmissionFromMultipart(store, request, {
|
||||
source: "admin_upload",
|
||||
allowArchive: true,
|
||||
hasRights: true,
|
||||
allowProjection: true,
|
||||
acknowledgePublicPerformance: true
|
||||
});
|
||||
if ("error" in result) {
|
||||
return reply.status(result.error.statusCode).send(result.error.payload);
|
||||
}
|
||||
|
||||
return reply.status(201).send(result);
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { assetId: string };
|
||||
Body: {
|
||||
thumbKey: string;
|
||||
previewKey: string;
|
||||
renderKey: string;
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: "portrait" | "landscape" | "square";
|
||||
sha256: string;
|
||||
dominantColor: string;
|
||||
qualityFlags?: {
|
||||
tooSmall?: boolean;
|
||||
blurry?: boolean;
|
||||
lowContrast?: boolean;
|
||||
unusualAspectRatio?: boolean;
|
||||
};
|
||||
};
|
||||
}>("/api/assets/:assetId/processed", async (request, reply) => {
|
||||
await store.markProcessed(
|
||||
request.params.assetId,
|
||||
normalizeBody<{
|
||||
thumbKey: string;
|
||||
previewKey: string;
|
||||
renderKey: string;
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: "portrait" | "landscape" | "square";
|
||||
sha256: string;
|
||||
dominantColor: string;
|
||||
qualityFlags?: {
|
||||
tooSmall?: boolean;
|
||||
blurry?: boolean;
|
||||
lowContrast?: boolean;
|
||||
unusualAspectRatio?: boolean;
|
||||
};
|
||||
}>(request.body)
|
||||
);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.post<{ Params: { assetId: string } }>("/api/assets/:assetId/failed", async (request, reply) => {
|
||||
const body = normalizeBody<{ message?: string }>(request.body);
|
||||
await store.markFailed(request.params.assetId, body.message ?? "Processing failed.");
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.post<{ Params: { assetId: string } }>("/api/assets/:assetId/moderation", async (request, reply) => {
|
||||
const body = normalizeBody<ModerationActionPayload>(request.body);
|
||||
if (!body.decision || !moderationDecisions.has(body.decision)) {
|
||||
return reply.status(400).send({ message: "A valid moderation decision is required." });
|
||||
}
|
||||
|
||||
await store.moderateAsset(request.params.assetId, body);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/fire", async (request, reply) => {
|
||||
await store.logCueEvent("cue_fired", { cueId: request.params.cueId });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.post<{ Body: CueGeneratePayload }>("/api/cues/generate", async (request, reply) => {
|
||||
const payload = normalizeBody<CueGeneratePayload>(request.body);
|
||||
try {
|
||||
const draft = await store.generateCueDraft(payload);
|
||||
return reply.send(draft);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not generate a cue.";
|
||||
return reply.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: CueUpsertPayload }>("/api/cues", async (request, reply) => {
|
||||
const payload = normalizeBody<CueUpsertPayload>(request.body);
|
||||
if (!payload.sceneDefinitionId) {
|
||||
return reply.status(400).send({ message: "sceneDefinitionId is required." });
|
||||
}
|
||||
|
||||
const nextState = await store.upsertCue(payload);
|
||||
const createdCue = nextState.cues.find((cue) => cue.id === (payload.id ?? nextState.cues.at(-1)?.id));
|
||||
return reply.status(201).send(createdCue ?? nextState.cues.at(-1));
|
||||
});
|
||||
|
||||
app.put<{ Params: { cueId: string }; Body: CueUpsertPayload }>("/api/cues/:cueId", async (request, reply) => {
|
||||
const payload = normalizeBody<CueUpsertPayload>(request.body);
|
||||
if (!payload.sceneDefinitionId) {
|
||||
return reply.status(400).send({ message: "sceneDefinitionId is required." });
|
||||
}
|
||||
|
||||
const nextState = await store.upsertCue({
|
||||
...payload,
|
||||
id: request.params.cueId
|
||||
});
|
||||
return reply.send(nextState.cues.find((cue) => cue.id === request.params.cueId) ?? null);
|
||||
});
|
||||
|
||||
app.post<{ Params: { cueId: string }; Body: CueMovePayload }>("/api/cues/:cueId/move", async (request, reply) => {
|
||||
const payload = normalizeBody<CueMovePayload>(request.body);
|
||||
if (payload.direction !== "up" && payload.direction !== "down") {
|
||||
return reply.status(400).send({ message: "direction must be up or down." });
|
||||
}
|
||||
|
||||
const nextState = await store.moveCue(request.params.cueId, payload);
|
||||
return reply.send(nextState.cues);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { cueId: string } }>("/api/cues/:cueId", async (request, reply) => {
|
||||
await store.deleteCue(request.params.cueId);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.post<{ Params: { cueId: string } }>("/api/cues/:cueId/safe", async (request, reply) => {
|
||||
await store.logCueEvent("safe_scene", { cueId: request.params.cueId });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
if (rescanTimeout) {
|
||||
clearTimeout(rescanTimeout);
|
||||
}
|
||||
watchers.forEach((entry) => entry.close());
|
||||
});
|
||||
|
||||
return app;
|
||||
};
|
||||
648
services/api/src/state-store.ts
Normal file
648
services/api/src/state-store.ts
Normal file
@ -0,0 +1,648 @@
|
||||
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createEmptyRepositoryState,
|
||||
defaultCollections,
|
||||
defaultCueStack,
|
||||
defaultEffectPresets,
|
||||
defaultOutputSurfaces,
|
||||
defaultSceneDefinitions,
|
||||
defaultShowConfig,
|
||||
defaultTags,
|
||||
flattenSceneParams,
|
||||
mergeSceneParams,
|
||||
setSceneParamValue,
|
||||
type ContributorConsent,
|
||||
type Cue,
|
||||
type CueGeneratePayload,
|
||||
type CueMovePayload,
|
||||
type CueUpsertPayload,
|
||||
type ModerationActionPayload,
|
||||
type ModerationDecision,
|
||||
type PhotoAsset,
|
||||
type RepositoryState,
|
||||
type SessionEvent,
|
||||
type Submission,
|
||||
type SubmissionPayload
|
||||
} from "@goodgrief/shared-types";
|
||||
|
||||
interface SeedAssetInput {
|
||||
asset: PhotoAsset;
|
||||
submission: Submission;
|
||||
consent: ContributorConsent;
|
||||
}
|
||||
|
||||
const curatedLibraryCollectionId = "collection-curated-library";
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
const random = (min = 0, max = 1) => min + Math.random() * (max - min);
|
||||
const sample = <T>(items: T[]) => items[Math.floor(Math.random() * items.length)]!;
|
||||
const shuffle = <T>(items: T[]) => {
|
||||
const next = [...items];
|
||||
for (let index = next.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(Math.random() * (index + 1));
|
||||
[next[index], next[swapIndex]] = [next[swapIndex]!, next[index]!];
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const randomizeParameterValue = (
|
||||
path: string,
|
||||
value: string | number | boolean,
|
||||
safeRanges: Record<string, { min: number; max: number }>
|
||||
) => {
|
||||
if (typeof value === "number") {
|
||||
const range = safeRanges[path];
|
||||
if (range) {
|
||||
const spread = range.max - range.min;
|
||||
const centered = clamp(value + random(-0.5, 0.5) * spread * 0.95, range.min, range.max);
|
||||
const isIntegerRange =
|
||||
Number.isInteger(range.min) && Number.isInteger(range.max) && Number.isInteger(Math.round(value));
|
||||
return isIntegerRange ? Math.round(centered) : Number(centered.toFixed(2));
|
||||
}
|
||||
|
||||
return Number((value + random(-0.25, 0.25)).toFixed(2));
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return Math.random() > 0.5;
|
||||
}
|
||||
|
||||
if (path === "composition.edge") {
|
||||
return Math.random() > 0.5 ? "left" : "right";
|
||||
}
|
||||
|
||||
if (path === "textTreatment.mode") {
|
||||
const modes = ["off", "edge_whispers", "relay_ticker", "anchor_caption"] as const;
|
||||
return sample([...modes]);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const pruneLegacyLibraryVariants = (state: RepositoryState) => {
|
||||
const librarySubmissionIds = new Set(
|
||||
state.submissions.filter((submission) => submission.source === "library_import").map((submission) => submission.id)
|
||||
);
|
||||
const removedAssetIds = new Set(
|
||||
state.photoAssets
|
||||
.filter((asset) => librarySubmissionIds.has(asset.submissionId) && asset.id.endsWith("-detail"))
|
||||
.map((asset) => asset.id)
|
||||
);
|
||||
if (removedAssetIds.size === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const removedSubmissionIds = new Set(
|
||||
state.photoAssets
|
||||
.filter((asset) => removedAssetIds.has(asset.id))
|
||||
.map((asset) => asset.submissionId)
|
||||
);
|
||||
const removedConsentIds = new Set(
|
||||
state.submissions
|
||||
.filter((submission) => removedSubmissionIds.has(submission.id))
|
||||
.map((submission) => submission.consentId)
|
||||
);
|
||||
|
||||
state.photoAssets = state.photoAssets.filter((asset) => !removedAssetIds.has(asset.id));
|
||||
state.submissions = state.submissions.filter((submission) => !removedSubmissionIds.has(submission.id));
|
||||
state.consents = state.consents.filter((consent) => !removedConsentIds.has(consent.id));
|
||||
state.collections = state.collections.map((collection) => ({
|
||||
...collection,
|
||||
assetIds: collection.assetIds.filter((assetId) => !removedAssetIds.has(assetId))
|
||||
}));
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export interface CreateSubmissionInput extends SubmissionPayload {
|
||||
originalKey: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface ProcessedAssetPayload {
|
||||
thumbKey: string;
|
||||
previewKey: string;
|
||||
renderKey: string;
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: "portrait" | "landscape" | "square";
|
||||
sha256: string;
|
||||
dominantColor: string;
|
||||
qualityFlags?: PhotoAsset["qualityFlags"];
|
||||
}
|
||||
|
||||
const ensureDirectory = async (dirPath: string) => {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
};
|
||||
|
||||
const writeJsonAtomic = async (filePath: string, data: RepositoryState) => {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
await writeFile(tempPath, JSON.stringify(data, null, 2), "utf8");
|
||||
await rename(tempPath, filePath);
|
||||
};
|
||||
|
||||
const createSessionEvent = (
|
||||
sessionId: string,
|
||||
type: SessionEvent["type"],
|
||||
payload: SessionEvent["payload"]
|
||||
): SessionEvent => ({
|
||||
id: crypto.randomUUID(),
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
type,
|
||||
payload
|
||||
});
|
||||
|
||||
const dedupe = <T>(items: T[]) => Array.from(new Set(items));
|
||||
const upsertById = <T extends { id: string }>(items: T[], nextItem: T) => {
|
||||
const index = items.findIndex((item) => item.id === nextItem.id);
|
||||
if (index >= 0) {
|
||||
items[index] = nextItem;
|
||||
} else {
|
||||
items.unshift(nextItem);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeCueOrder = (cues: Cue[]) =>
|
||||
[...cues]
|
||||
.sort((left, right) => left.orderIndex - right.orderIndex)
|
||||
.map((cue, index) => ({
|
||||
...cue,
|
||||
orderIndex: index
|
||||
}));
|
||||
|
||||
const ensureSafeCue = (cues: Cue[]) => {
|
||||
const safeCue = defaultCueStack.find((cue) => cue.id === defaultShowConfig.safeSceneCueId);
|
||||
const next = normalizeCueOrder(cues);
|
||||
if (safeCue && !next.some((cue) => cue.id === safeCue.id)) {
|
||||
return normalizeCueOrder([safeCue, ...next]);
|
||||
}
|
||||
return next.length > 0 ? next : defaultCueStack;
|
||||
};
|
||||
|
||||
const mergeCollections = (state: RepositoryState, importedAssetIds: string[]) => {
|
||||
const defaultCollectionIds = new Set(defaultCollections.map((collection) => collection.id));
|
||||
const existingCollectionMap = new Map(state.collections.map((collection) => [collection.id, collection] as const));
|
||||
|
||||
const mergedDefaults = defaultCollections.map((collection) => {
|
||||
const existing = existingCollectionMap.get(collection.id);
|
||||
return existing
|
||||
? {
|
||||
...collection,
|
||||
createdAt: existing.createdAt ?? collection.createdAt,
|
||||
description: existing.description ?? collection.description,
|
||||
locked: existing.locked ?? collection.locked,
|
||||
assetIds: [...existing.assetIds],
|
||||
tagIds: existing.tagIds.length > 0 ? [...existing.tagIds] : [...collection.tagIds]
|
||||
}
|
||||
: {
|
||||
...collection,
|
||||
assetIds: [...collection.assetIds],
|
||||
tagIds: [...collection.tagIds]
|
||||
};
|
||||
});
|
||||
|
||||
const customCollections = state.collections
|
||||
.filter((collection) => !defaultCollectionIds.has(collection.id))
|
||||
.map((collection) => ({
|
||||
...collection,
|
||||
assetIds: [...collection.assetIds],
|
||||
tagIds: [...collection.tagIds]
|
||||
}));
|
||||
|
||||
const collections = [...mergedDefaults, ...customCollections];
|
||||
const curatedLibrary = collections.find((collection) => collection.id === curatedLibraryCollectionId);
|
||||
if (curatedLibrary) {
|
||||
curatedLibrary.assetIds = dedupe([...importedAssetIds, ...curatedLibrary.assetIds]);
|
||||
}
|
||||
|
||||
return collections;
|
||||
};
|
||||
|
||||
const reconcileState = (state: RepositoryState) => {
|
||||
const base = createEmptyRepositoryState();
|
||||
base.submissions = [...state.submissions];
|
||||
base.consents = [...state.consents];
|
||||
base.photoAssets = [...state.photoAssets];
|
||||
|
||||
const defaultTagIds = new Set(defaultTags.map((tag) => tag.id));
|
||||
base.tags = [
|
||||
...defaultTags,
|
||||
...state.tags.filter((tag) => !defaultTagIds.has(tag.id))
|
||||
];
|
||||
base.collections = mergeCollections(state, []);
|
||||
|
||||
base.scenes = defaultSceneDefinitions;
|
||||
base.cues = ensureSafeCue(state.cues);
|
||||
base.effectPresets = defaultEffectPresets;
|
||||
base.outputSurfaces = defaultOutputSurfaces;
|
||||
base.showConfig = {
|
||||
...defaultShowConfig,
|
||||
venueName: state.showConfig?.venueName ?? defaultShowConfig.venueName,
|
||||
retentionDays: state.showConfig?.retentionDays ?? defaultShowConfig.retentionDays,
|
||||
ingestPolicy: state.showConfig?.ingestPolicy ?? defaultShowConfig.ingestPolicy,
|
||||
theme: state.showConfig?.theme ?? defaultShowConfig.theme,
|
||||
projectionNotes: state.showConfig?.projectionNotes ?? defaultShowConfig.projectionNotes
|
||||
};
|
||||
|
||||
base.operatorSessions = state.operatorSessions.length > 0 ? state.operatorSessions : base.operatorSessions;
|
||||
base.moderationDecisions = state.moderationDecisions;
|
||||
base.sessionEvents = state.sessionEvents;
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayload = {}): CueUpsertPayload => {
|
||||
const approvedAssets = state.photoAssets.filter(
|
||||
(asset) => asset.moderationStatus === "approved" && asset.processingStatus === "ready"
|
||||
);
|
||||
|
||||
if (approvedAssets.length === 0) {
|
||||
throw new Error("No approved assets are available for cue generation.");
|
||||
}
|
||||
|
||||
const requestedScene = payload.sceneDefinitionId
|
||||
? state.scenes.find((scene) => scene.id === payload.sceneDefinitionId)
|
||||
: undefined;
|
||||
const scenePool = requestedScene
|
||||
? [requestedScene]
|
||||
: state.scenes.filter((scene) => {
|
||||
if (scene.sceneFamily === "safe") {
|
||||
return false;
|
||||
}
|
||||
if (scene.sceneFamily === "rupture" && !payload.includeRupture) {
|
||||
return false;
|
||||
}
|
||||
return scene.inputRules.minAssets <= approvedAssets.length;
|
||||
});
|
||||
|
||||
const scene = sample(scenePool.length > 0 ? scenePool : state.scenes.filter((candidate) => candidate.sceneFamily !== "safe"));
|
||||
const assetPool = payload.preferredAssetIds?.length
|
||||
? approvedAssets.filter((asset) => payload.preferredAssetIds?.includes(asset.id))
|
||||
: approvedAssets;
|
||||
const usablePool =
|
||||
assetPool.length >= scene.inputRules.minAssets ? assetPool : approvedAssets;
|
||||
const maxAssets = Math.min(scene.inputRules.maxAssets ?? usablePool.length, usablePool.length);
|
||||
const minAssets = Math.min(scene.inputRules.minAssets, maxAssets);
|
||||
const assetCount = Math.max(minAssets, Math.round(random(minAssets, maxAssets + 0.49)));
|
||||
const selectedAssets = shuffle(usablePool).slice(0, assetCount);
|
||||
|
||||
const presetPool = state.effectPresets.filter((preset) => scene.supportedPresetIds.includes(preset.id));
|
||||
const effectPreset = sample(presetPool.length > 0 ? presetPool : state.effectPresets);
|
||||
let randomizedParams = mergeSceneParams(scene.defaultParams, effectPreset.paramDefaults);
|
||||
for (const [path, currentValue] of Object.entries(flattenSceneParams(randomizedParams))) {
|
||||
randomizedParams = setSceneParamValue(
|
||||
randomizedParams,
|
||||
path,
|
||||
randomizeParameterValue(path, currentValue, effectPreset.safeRanges)
|
||||
);
|
||||
}
|
||||
|
||||
if (randomizedParams.textTreatment.mode !== "off") {
|
||||
const opacityFloor =
|
||||
randomizedParams.textTreatment.mode === "anchor_caption"
|
||||
? 0.64 + random(0.08, 0.18)
|
||||
: 0.5 + random(0.08, 0.2);
|
||||
randomizedParams = setSceneParamValue(
|
||||
randomizedParams,
|
||||
"textTreatment.opacity",
|
||||
Number(clamp(Math.max(randomizedParams.textTreatment.opacity, opacityFloor), 0.4, 0.96).toFixed(2))
|
||||
);
|
||||
randomizedParams = setSceneParamValue(
|
||||
randomizedParams,
|
||||
"textTreatment.scale",
|
||||
Number(clamp(Math.max(randomizedParams.textTreatment.scale, 0.82), 0.55, 1.2).toFixed(2))
|
||||
);
|
||||
}
|
||||
|
||||
const anchorSubmission = state.submissions.find((submission) => submission.id === selectedAssets[0]?.submissionId);
|
||||
const anchorLabel =
|
||||
anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || anchorSubmission?.displayName?.trim();
|
||||
const transitionOptions =
|
||||
scene.sceneFamily === "rupture"
|
||||
? (["rupture_offset", "dissolve"] as const)
|
||||
: (["dissolve", "veil_wipe", "luma_hold"] as const);
|
||||
|
||||
return {
|
||||
sceneDefinitionId: scene.id,
|
||||
triggerMode: "manual",
|
||||
transitionIn: {
|
||||
style: sample([...transitionOptions]),
|
||||
durationMs: Math.round(random(750, 1200) / 50) * 50
|
||||
},
|
||||
transitionOut: {
|
||||
style: scene.sceneFamily === "arrival" ? "veil_wipe" : "dissolve",
|
||||
durationMs: Math.round(random(700, 1000) / 50) * 50
|
||||
},
|
||||
assetIds: selectedAssets.map((asset) => asset.id),
|
||||
effectPresetId: effectPreset.id,
|
||||
parameterOverrides: randomizedParams,
|
||||
notes: anchorLabel ? `${scene.name} / ${effectPreset.name} / ${anchorLabel}` : `${scene.name} / ${effectPreset.name}`
|
||||
};
|
||||
};
|
||||
|
||||
export class StateStore {
|
||||
constructor(private readonly stateFile: string) {}
|
||||
|
||||
async ensure() {
|
||||
await ensureDirectory(path.dirname(this.stateFile));
|
||||
|
||||
let state: RepositoryState;
|
||||
try {
|
||||
await stat(this.stateFile);
|
||||
state = await this.read();
|
||||
} catch {
|
||||
state = createEmptyRepositoryState();
|
||||
}
|
||||
|
||||
const reconciled = reconcileState(state);
|
||||
await writeJsonAtomic(this.stateFile, reconciled);
|
||||
}
|
||||
|
||||
async read(): Promise<RepositoryState> {
|
||||
const raw = await readFile(this.stateFile, "utf8");
|
||||
return JSON.parse(raw) as RepositoryState;
|
||||
}
|
||||
|
||||
async write(state: RepositoryState) {
|
||||
await writeJsonAtomic(this.stateFile, state);
|
||||
}
|
||||
|
||||
async update(mutator: (state: RepositoryState) => RepositoryState | void): Promise<RepositoryState> {
|
||||
const state = await this.read();
|
||||
const updated = reconcileState(mutator(state) ?? state);
|
||||
await this.write(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async syncImportedAssets(importedAssets: SeedAssetInput[]) {
|
||||
return this.update((state) => {
|
||||
const next = pruneLegacyLibraryVariants(reconcileState(state));
|
||||
for (const imported of importedAssets) {
|
||||
upsertById(next.submissions, imported.submission);
|
||||
upsertById(next.consents, imported.consent);
|
||||
upsertById(next.photoAssets, imported.asset);
|
||||
}
|
||||
|
||||
next.collections = mergeCollections(next, importedAssets.map((entry) => entry.asset.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async createSubmission(input: CreateSubmissionInput) {
|
||||
return this.update((state) => {
|
||||
const submissionId = crypto.randomUUID();
|
||||
const assetId = crypto.randomUUID();
|
||||
const consentId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const submission: Submission = {
|
||||
id: submissionId,
|
||||
source: input.source ?? "live",
|
||||
submittedAt: now,
|
||||
status: "processing",
|
||||
consentId,
|
||||
displayName: input.displayName,
|
||||
caption: input.caption,
|
||||
promptAnswer: input.promptAnswer
|
||||
};
|
||||
|
||||
const consent: ContributorConsent = {
|
||||
id: consentId,
|
||||
submissionId,
|
||||
hasRights: input.hasRights,
|
||||
allowProjection: input.allowProjection,
|
||||
acknowledgePublicPerformance: input.acknowledgePublicPerformance,
|
||||
allowArchive: input.allowArchive,
|
||||
agreedAt: now
|
||||
};
|
||||
|
||||
const asset: PhotoAsset = {
|
||||
id: assetId,
|
||||
submissionId,
|
||||
originalKey: input.originalKey,
|
||||
mimeType: input.mimeType,
|
||||
processingStatus: "queued",
|
||||
moderationStatus: "pending",
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
state.submissions.unshift(submission);
|
||||
state.consents.unshift(consent);
|
||||
state.photoAssets.unshift(asset);
|
||||
state.sessionEvents.unshift(
|
||||
createSessionEvent(state.operatorSessions[0]?.id ?? "session-default", "submission_received", {
|
||||
submissionId,
|
||||
assetId
|
||||
})
|
||||
);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async markProcessed(assetId: string, payload: ProcessedAssetPayload) {
|
||||
return this.update((state) => {
|
||||
const asset = state.photoAssets.find((entry) => entry.id === assetId);
|
||||
if (!asset) {
|
||||
throw new Error("Asset not found.");
|
||||
}
|
||||
|
||||
asset.thumbKey = payload.thumbKey;
|
||||
asset.previewKey = payload.previewKey;
|
||||
asset.renderKey = payload.renderKey;
|
||||
asset.width = payload.width;
|
||||
asset.height = payload.height;
|
||||
asset.orientation = payload.orientation;
|
||||
asset.sha256 = payload.sha256;
|
||||
asset.dominantColor = payload.dominantColor;
|
||||
asset.qualityFlags = payload.qualityFlags;
|
||||
asset.processingStatus = "ready";
|
||||
|
||||
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
|
||||
if (submission) {
|
||||
if (submission.source === "admin_upload") {
|
||||
submission.status = "approved_all";
|
||||
asset.moderationStatus = "approved";
|
||||
asset.approvedAt = new Date().toISOString();
|
||||
const favorites = state.collections.find((collection) => collection.kind === "favorites");
|
||||
if (favorites && !favorites.assetIds.includes(assetId)) {
|
||||
favorites.assetIds.unshift(assetId);
|
||||
}
|
||||
} else {
|
||||
submission.status = "pending_moderation";
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async markFailed(assetId: string, message: string) {
|
||||
return this.update((state) => {
|
||||
const asset = state.photoAssets.find((entry) => entry.id === assetId);
|
||||
if (!asset) {
|
||||
throw new Error("Asset not found.");
|
||||
}
|
||||
|
||||
asset.processingStatus = "failed";
|
||||
asset.rejectionReason = message;
|
||||
|
||||
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
|
||||
if (submission) {
|
||||
submission.status = "pending_moderation";
|
||||
submission.notes = message;
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async moderateAsset(assetId: string, payload: ModerationActionPayload) {
|
||||
return this.update((state) => {
|
||||
const asset = state.photoAssets.find((entry) => entry.id === assetId);
|
||||
if (!asset) {
|
||||
throw new Error("Asset not found.");
|
||||
}
|
||||
|
||||
asset.moderationStatus =
|
||||
payload.decision === "archive_only" ? "archived" : payload.decision === "approved" ? "approved" : payload.decision;
|
||||
asset.approvedAt = payload.decision === "approved" ? new Date().toISOString() : asset.approvedAt;
|
||||
asset.rejectionReason = payload.reasonCode;
|
||||
|
||||
const submission = state.submissions.find((entry) => entry.id === asset.submissionId);
|
||||
if (submission) {
|
||||
submission.status =
|
||||
payload.decision === "approved"
|
||||
? "approved_all"
|
||||
: payload.decision === "rejected"
|
||||
? "rejected"
|
||||
: "pending_moderation";
|
||||
}
|
||||
|
||||
const sessionId = state.operatorSessions[0]?.id ?? "session-default";
|
||||
const decision: ModerationDecision = {
|
||||
id: crypto.randomUUID(),
|
||||
assetId,
|
||||
operatorSessionId: sessionId,
|
||||
decision: payload.decision,
|
||||
decidedAt: new Date().toISOString(),
|
||||
reasonCode: payload.reasonCode,
|
||||
note: payload.note
|
||||
};
|
||||
|
||||
state.moderationDecisions.unshift(decision);
|
||||
state.sessionEvents.unshift(
|
||||
createSessionEvent(sessionId, payload.decision === "approved" ? "asset_approved" : "asset_rejected", {
|
||||
assetId,
|
||||
decision: payload.decision
|
||||
})
|
||||
);
|
||||
|
||||
if (payload.decision === "approved") {
|
||||
const favorites = state.collections.find((collection) => collection.kind === "favorites");
|
||||
if (favorites && !favorites.assetIds.includes(assetId)) {
|
||||
favorites.assetIds.unshift(assetId);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.collectionIds?.length) {
|
||||
const collections = state.collections.filter((collection) => payload.collectionIds?.includes(collection.id));
|
||||
for (const collection of collections) {
|
||||
if (!collection.assetIds.includes(assetId)) {
|
||||
collection.assetIds.unshift(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async logCueEvent(type: SessionEvent["type"], payload: SessionEvent["payload"]) {
|
||||
return this.update((state) => {
|
||||
state.sessionEvents.unshift(
|
||||
createSessionEvent(state.operatorSessions[0]?.id ?? "session-default", type, payload)
|
||||
);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async upsertCue(payload: CueUpsertPayload) {
|
||||
return this.update((state) => {
|
||||
const cueId = payload.id ?? `cue-${crypto.randomUUID()}`;
|
||||
const baseCue: Cue = {
|
||||
id: cueId,
|
||||
showConfigId: payload.showConfigId ?? state.showConfig.id,
|
||||
orderIndex: payload.orderIndex ?? state.cues.length,
|
||||
sceneDefinitionId: payload.sceneDefinitionId,
|
||||
triggerMode: payload.triggerMode,
|
||||
transitionIn: payload.transitionIn,
|
||||
transitionOut: payload.transitionOut,
|
||||
collectionId: payload.collectionId,
|
||||
assetIds: payload.assetIds ?? [],
|
||||
durationMs: payload.durationMs,
|
||||
effectPresetId: payload.effectPresetId,
|
||||
parameterOverrides: payload.parameterOverrides,
|
||||
notes: payload.notes,
|
||||
nextCueId: payload.nextCueId
|
||||
};
|
||||
|
||||
const sorted = normalizeCueOrder(state.cues);
|
||||
const existingIndex = sorted.findIndex((cue) => cue.id === cueId);
|
||||
const targetIndex = Math.max(0, Math.min(payload.orderIndex ?? sorted.length, sorted.length));
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = sorted[existingIndex]!;
|
||||
sorted.splice(existingIndex, 1);
|
||||
sorted.splice(Math.min(targetIndex, sorted.length), 0, {
|
||||
...existing,
|
||||
...baseCue,
|
||||
id: existing.id
|
||||
});
|
||||
} else {
|
||||
sorted.splice(targetIndex, 0, baseCue);
|
||||
}
|
||||
|
||||
state.cues = normalizeCueOrder(sorted);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async generateCueDraft(payload: CueGeneratePayload = {}) {
|
||||
const state = await this.read();
|
||||
return buildGeneratedCueDraft(reconcileState(state), payload);
|
||||
}
|
||||
|
||||
async moveCue(cueId: string, payload: CueMovePayload) {
|
||||
return this.update((state) => {
|
||||
const sorted = normalizeCueOrder(state.cues);
|
||||
const currentIndex = sorted.findIndex((cue) => cue.id === cueId);
|
||||
if (currentIndex < 0) {
|
||||
throw new Error("Cue not found.");
|
||||
}
|
||||
|
||||
const swapIndex = payload.direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= sorted.length) {
|
||||
state.cues = sorted;
|
||||
return state;
|
||||
}
|
||||
|
||||
const current = sorted[currentIndex]!;
|
||||
sorted[currentIndex] = sorted[swapIndex]!;
|
||||
sorted[swapIndex] = current;
|
||||
state.cues = normalizeCueOrder(sorted);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCue(cueId: string) {
|
||||
return this.update((state) => {
|
||||
if (cueId === state.showConfig.safeSceneCueId) {
|
||||
throw new Error("Cannot delete the configured safe cue.");
|
||||
}
|
||||
|
||||
state.cues = normalizeCueOrder(state.cues.filter((cue) => cue.id !== cueId));
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}
|
||||
12
services/api/tsconfig.json
Normal file
12
services/api/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
22
services/worker/package.json
Normal file
22
services/worker/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
39
services/worker/src/config.ts
Normal file
39
services/worker/src/config.ts
Normal 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)
|
||||
};
|
||||
51
services/worker/src/index.ts
Normal file
51
services/worker/src/index.ts
Normal 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);
|
||||
}
|
||||
113
services/worker/src/processor.ts
Normal file
113
services/worker/src/processor.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
12
services/worker/tsconfig.json
Normal file
12
services/worker/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
}
|
||||
1
storage/.gitkeep
Normal file
1
storage/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
22
tsconfig.base.json
Normal file
22
tsconfig.base.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user