Revamp scenic playback and operator workflow

This commit is contained in:
vance 2026-04-09 22:30:32 -07:00
parent 3e88449fcb
commit 7150c67e33
13 changed files with 4157 additions and 4743 deletions

View File

@ -78,3 +78,8 @@ 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`
Or use the bundled production-reset script, which detects Docker or Podman Compose, stops the prod stack if it is running, clears runtime data, and starts it again:
- `npm run reset:prod`
- `npm run reset:prod -- --keep-down`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,9 @@ import type {
CueMovePayload,
CueUpsertPayload,
ModerationActionPayload,
RepositoryState
RepositoryState,
Submission,
SubmissionUpdatePayload
} from "@goodgrief/shared-types";
const postVoid = async (url: string, body?: unknown) => {
@ -102,6 +104,15 @@ export const createAdminUpload = async (payload: FormData) =>
body: payload
});
export const updateSubmissionMetadata = async (submissionId: string, payload: SubmissionUpdatePayload) =>
requestJson<Submission>(`/api/submissions/${submissionId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const deleteCue = async (cueId: string) => {
const response = await fetch(`/api/cues/${cueId}`, {
method: "DELETE"

View File

@ -6,9 +6,7 @@ export interface SubmissionFormState {
caption: string;
promptAnswer: string;
allowArchive: boolean;
hasRights: boolean;
allowProjection: boolean;
acknowledgePublicPerformance: boolean;
consentAccepted: boolean;
file: File | null;
}
@ -17,9 +15,7 @@ const initialState: SubmissionFormState = {
caption: "",
promptAnswer: "",
allowArchive: false,
hasRights: false,
allowProjection: false,
acknowledgePublicPerformance: false,
consentAccepted: false,
file: null
};
@ -46,9 +42,9 @@ export const useSubmissionForm = () => {
return;
}
if (!state.hasRights || !state.allowProjection || !state.acknowledgePublicPerformance) {
if (!state.consentAccepted) {
setStatusTone("error");
setStatus("Please review and accept the required consent items.");
setStatus("Please review and accept the consent note.");
return;
}
@ -60,7 +56,13 @@ export const useSubmissionForm = () => {
try {
await createSubmission(
{
...state,
displayName: state.displayName,
caption: state.caption,
promptAnswer: state.promptAnswer,
allowArchive: state.allowArchive,
hasRights: true,
allowProjection: true,
acknowledgePublicPerformance: true,
file
},
(nextProgress) => setProgress(nextProgress)

View File

@ -10,8 +10,7 @@ export const SubmissionRoute = () => {
<p className="submission-kicker">Good Grief</p>
<h1 className="submission-title">Offer a photo to tonight&apos;s memory field.</h1>
<p className="submission-copy">
Share one image that carries memory, witness, humor, or tenderness for you. The creative team will
review each submission. Not every image will appear, and none will be shown without moderation.
Share one image that carries memory, witness, humor, or tenderness for a loved one that has passed.
</p>
</section>
@ -23,11 +22,11 @@ export const SubmissionRoute = () => {
<input
id="file"
type="file"
accept="image/jpeg,image/png,image/heic,image/heif"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.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.
One image only. Common phone photos work best. Unsupported files don&apos;t work in the system.
</p>
</div>
</div>
@ -72,26 +71,13 @@ export const SubmissionRoute = () => {
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.hasRights}
onChange={(event) => updateField("hasRights", event.target.checked)}
checked={state.consentAccepted}
onChange={(event) => updateField("consentAccepted", 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>
<span>
I have permission to share this photo and understand it may be used in a live public performance,
though projection is not guaranteed.
</span>
</label>
<label className="submission-checkbox">
<input

View File

@ -12,6 +12,7 @@
"build": "npm run build --workspaces --if-present",
"check": "npm run check --workspaces --if-present",
"reset:runtime": "node scripts/reset-runtime.mjs",
"reset:prod": "node scripts/reset-production.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",

File diff suppressed because it is too large Load Diff

View File

@ -18,8 +18,17 @@ 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 SceneFamily = "hero" | "chorus" | "arrival" | "safe";
export type TextTreatmentMode = "off" | "glyph_dust" | "constellation_trace" | "crystal_runes";
export type ScenicFieldType =
| "stardust_drift"
| "nebula_veil"
| "crystal_caustic"
| "geode_bloom"
| "aurora_mesh"
| "void_shimmer"
| "quiet_ether";
export type CompositionFormation = "stack" | "line" | "arc" | "cluster" | "grid" | "ribbon" | "queue";
export type SceneCategory =
| "memory_elegy"
| "humor_rupture"
@ -105,39 +114,35 @@ export interface Collection {
}
export interface PhotoTreatmentParams {
exposure: number;
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;
fieldType: ScenicFieldType;
fieldIntensity: number;
fieldScale: number;
fieldSpeed: number;
hue: number;
saturation: number;
lightness: number;
accentIntensity: number;
depthFog: 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";
spread: number;
supportCount: number;
cameraTravel: number;
orbitAmount: number;
stagger: number;
formation?: CompositionFormation;
}
export interface TextTreatmentParams {
@ -198,7 +203,7 @@ export interface SceneDefinition {
}
export interface CueTransition {
style: "cut" | "dissolve" | "veil_wipe" | "luma_hold" | "rupture_offset";
style: "cut" | "dissolve" | "mist_reveal" | "depth_drift" | "shutter_reveal";
durationMs: number;
}
@ -349,6 +354,13 @@ export interface SubmissionPayload {
source?: SubmissionSource;
}
export interface SubmissionUpdatePayload {
displayName?: string;
caption?: string;
promptAnswer?: string;
notes?: string;
}
export interface ModerationActionPayload {
decision: ModerationDecisionType;
reasonCode?: string;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,116 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const args = new Set(process.argv.slice(2));
if (args.has("--help") || args.has("-h")) {
console.log(`Reset production runtime data for the container stack.
Usage:
npm run reset:prod
npm run reset:prod -- --keep-down
npm run reset:prod -- --tool=docker
npm run reset:prod -- --tool=podman
Options:
--keep-down Do not restart the stack after reset.
--tool=<name> Force compose tool: docker or podman.
--help, -h Show this help output.
`);
process.exit(0);
}
const forcedToolArg = [...args].find((arg) => arg.startsWith("--tool="));
const forcedTool = forcedToolArg ? forcedToolArg.slice("--tool=".length) : null;
const keepDown = args.has("--keep-down");
function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, {
cwd: repoRoot,
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
encoding: "utf8"
});
if (options.allowFailure) {
return result;
}
if (result.status !== 0) {
const stderr = result.stderr?.trim();
throw new Error(
stderr
? `${command} ${commandArgs.join(" ")} failed: ${stderr}`
: `${command} ${commandArgs.join(" ")} failed with exit code ${result.status ?? "unknown"}`
);
}
return result;
}
function composeCommandFor(tool) {
if (tool === "docker") {
return ["docker", ["compose"]];
}
if (tool === "podman") {
return ["podman", ["compose"]];
}
throw new Error(`Unsupported compose tool: ${tool}`);
}
function detectComposeTool() {
const candidates = forcedTool ? [forcedTool] : ["docker", "podman"];
for (const candidate of candidates) {
const [command, baseArgs] = composeCommandFor(candidate);
const probe = run(command, [...baseArgs, "version"], { allowFailure: true, capture: true });
if (probe.status === 0) {
return { tool: candidate, command, baseArgs };
}
}
throw new Error("Could not find a working compose tool. Install Docker Compose or Podman Compose, or pass --tool=docker|podman.");
}
const composeTool = detectComposeTool();
const composeArgs = [
...composeTool.baseArgs,
"-f",
"docker-compose.yml",
"-f",
"docker-compose.prod.yml"
];
const services = ["api", "worker", "admin", "submission"];
function compose(commandArgs, options) {
return run(composeTool.command, [...composeArgs, ...commandArgs], options);
}
console.log(`Using ${composeTool.tool} compose.`);
const psResult = compose(["ps", "-q"], { capture: true, allowFailure: true });
const wasRunning = psResult.status === 0 && psResult.stdout.trim().length > 0;
if (wasRunning) {
console.log("Stopping production stack...");
compose(["down"]);
}
console.log("Clearing production runtime data...");
compose(["run", "--rm", "api", "npm", "run", "reset:runtime"]);
if (wasRunning && !keepDown) {
console.log("Restarting production stack...");
compose(["up", "-d", ...services]);
}
if (!wasRunning) {
console.log("Production stack was not running. Data is reset and the stack remains down.");
} else if (keepDown) {
console.log("Production stack was stopped for reset and left down.");
} else {
console.log("Production stack reset and restarted.");
}

View File

@ -10,13 +10,24 @@ import type {
CueMovePayload,
CueUpsertPayload,
ModerationActionPayload,
SubmissionPayload
SubmissionPayload,
SubmissionUpdatePayload
} 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 allowedMimeTypes = new Set([
"image/jpeg",
"image/jpg",
"image/pjpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
"image/heic-sequence",
"image/heif-sequence"
]);
const moderationDecisions = new Set(["approved", "hold", "rejected", "archive_only"]);
const coerceBoolean = (value: string | undefined) => value === "true";
@ -32,9 +43,14 @@ const normalizeBody = <Body>(body: unknown): Body => {
const fileExtensionFor = (mimeType: string, filename?: string) => {
const extensionMap: Record<string, string> = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/pjpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/heic": ".heic",
"image/heif": ".heif"
"image/heif": ".heif",
"image/heic-sequence": ".heic",
"image/heif-sequence": ".heif"
};
const byMime = extensionMap[mimeType];
@ -46,6 +62,30 @@ const fileExtensionFor = (mimeType: string, filename?: string) => {
return fallback || ".bin";
};
const normalizeMimeType = (mimeType: string | undefined, filename?: string) => {
const normalized = mimeType?.toLowerCase().trim() ?? "";
if (allowedMimeTypes.has(normalized)) {
return normalized;
}
const extension = path.extname(filename ?? "").toLowerCase();
switch (extension) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".heic":
return "image/heic";
case ".heif":
return "image/heif";
default:
return normalized;
}
};
interface ParsedMultipartFile {
buffer: Buffer;
filename?: string;
@ -62,7 +102,7 @@ const parseMultipartSubmission = async (request: FastifyRequest) => {
filePart = {
buffer: await part.toBuffer(),
filename: part.filename,
mimetype: part.mimetype
mimetype: normalizeMimeType(part.mimetype, part.filename)
};
continue;
}
@ -113,7 +153,7 @@ const createSubmissionFromMultipart = async (
return {
error: {
statusCode: 400,
payload: { message: "Unsupported file type. Please upload a JPEG, PNG, or HEIC image." }
payload: { message: "This file type doesn't work in the system yet. Please try a JPEG, PNG, WebP, or HEIC image." }
}
};
}
@ -233,6 +273,23 @@ export const buildServer = async () => {
app.get("/api/show-config", async () => (await store.read()).showConfig);
app.post("/api/library/rescan", async () => syncLibrary());
app.put<{ Params: { submissionId: string }; Body: SubmissionUpdatePayload }>(
"/api/submissions/:submissionId",
async (request, reply) => {
try {
const nextState = await store.updateSubmission(
request.params.submissionId,
normalizeBody<SubmissionUpdatePayload>(request.body)
);
const submission = nextState.submissions.find((entry) => entry.id === request.params.submissionId);
return reply.send(submission ?? null);
} catch (error) {
const message = error instanceof Error ? error.message : "Could not update submission.";
return reply.status(message === "Submission not found." ? 404 : 400).send({ message });
}
}
);
app.post("/api/submissions", async (request, reply) => {
const result = await createSubmissionFromMultipart(store, request);
if ("error" in result) {

View File

@ -23,7 +23,8 @@ import {
type RepositoryState,
type SessionEvent,
type Submission,
type SubmissionPayload
type SubmissionPayload,
type SubmissionUpdatePayload
} from "@goodgrief/shared-types";
interface SeedAssetInput {
@ -50,6 +51,10 @@ const randomizeParameterValue = (
value: string | number | boolean,
safeRanges: Record<string, { min: number; max: number }>
) => {
if (path.startsWith("photoTreatment.")) {
return value;
}
if (typeof value === "number") {
const range = safeRanges[path];
if (range) {
@ -67,12 +72,8 @@ const randomizeParameterValue = (
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;
const modes = ["off", "glyph_dust", "constellation_trace", "crystal_runes"] as const;
return sample([...modes]);
}
@ -163,6 +164,23 @@ const upsertById = <T extends { id: string }>(items: T[], nextItem: T) => {
}
};
const normalizeEditableText = (value: string | undefined) => value?.trim() ?? "";
const mergeImportedSubmission = (existing: Submission | undefined, imported: Submission): Submission => {
if (!existing) {
return imported;
}
return {
...imported,
...existing,
displayName: existing.displayName ?? imported.displayName,
caption: existing.caption ?? imported.caption,
promptAnswer: existing.promptAnswer ?? imported.promptAnswer,
notes: existing.notes ?? imported.notes
};
};
const normalizeCueOrder = (cues: Cue[]) =>
[...cues]
.sort((left, right) => left.orderIndex - right.orderIndex)
@ -270,9 +288,6 @@ const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayl
if (scene.sceneFamily === "safe") {
return false;
}
if (scene.sceneFamily === "rupture" && !payload.includeRupture) {
return false;
}
return scene.inputRules.minAssets <= approvedAssets.length;
});
@ -298,20 +313,34 @@ const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayl
);
}
if (scene.sceneFamily !== "arrival") {
randomizedParams = setSceneParamValue(randomizedParams, "textTreatment.mode", "off");
}
if (scene.sceneFamily === "arrival") {
randomizedParams = setSceneParamValue(
randomizedParams,
"composition.motion",
Number(clamp(randomizedParams.composition.motion, 0.08, 0.22).toFixed(2))
);
randomizedParams = setSceneParamValue(
randomizedParams,
"scenicTreatment.fieldSpeed",
Number(clamp(randomizedParams.scenicTreatment.fieldSpeed, 0.06, 0.18).toFixed(2))
);
}
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);
const opacityFloor = 0.18 + random(0.04, 0.1);
randomizedParams = setSceneParamValue(
randomizedParams,
"textTreatment.opacity",
Number(clamp(Math.max(randomizedParams.textTreatment.opacity, opacityFloor), 0.4, 0.96).toFixed(2))
Number(clamp(Math.max(randomizedParams.textTreatment.opacity, opacityFloor), 0.14, 0.52).toFixed(2))
);
randomizedParams = setSceneParamValue(
randomizedParams,
"textTreatment.scale",
Number(clamp(Math.max(randomizedParams.textTreatment.scale, 0.82), 0.55, 1.2).toFixed(2))
Number(clamp(Math.max(randomizedParams.textTreatment.scale, 0.64), 0.56, 0.96).toFixed(2))
);
}
@ -319,20 +348,32 @@ const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayl
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);
scene.sceneFamily === "arrival"
? (["shutter_reveal", "mist_reveal", "dissolve"] as const)
: scene.sceneFamily === "safe"
? (["dissolve"] as const)
: (["dissolve", "mist_reveal", "depth_drift", "shutter_reveal"] as const);
return {
sceneDefinitionId: scene.id,
triggerMode: "manual",
transitionIn: {
style: sample([...transitionOptions]),
durationMs: Math.round(random(750, 1200) / 50) * 50
durationMs:
scene.sceneFamily === "safe"
? Math.round(random(4400, 5200) / 50) * 50
: scene.sceneFamily === "arrival"
? Math.round(random(3500, 4200) / 50) * 50
: Math.round(random(3800, 4600) / 50) * 50
},
transitionOut: {
style: scene.sceneFamily === "arrival" ? "veil_wipe" : "dissolve",
durationMs: Math.round(random(700, 1000) / 50) * 50
style: scene.sceneFamily === "arrival" ? "mist_reveal" : "dissolve",
durationMs:
scene.sceneFamily === "safe"
? Math.round(random(4400, 5200) / 50) * 50
: scene.sceneFamily === "arrival"
? Math.round(random(3600, 4300) / 50) * 50
: Math.round(random(3800, 4500) / 50) * 50
},
assetIds: selectedAssets.map((asset) => asset.id),
effectPresetId: effectPreset.id,
@ -379,7 +420,8 @@ export class StateStore {
return this.update((state) => {
const next = pruneLegacyLibraryVariants(reconcileState(state));
for (const imported of importedAssets) {
upsertById(next.submissions, imported.submission);
const existingSubmission = next.submissions.find((submission) => submission.id === imported.submission.id);
upsertById(next.submissions, mergeImportedSubmission(existingSubmission, imported.submission));
upsertById(next.consents, imported.consent);
upsertById(next.photoAssets, imported.asset);
}
@ -440,6 +482,30 @@ export class StateStore {
});
}
async updateSubmission(submissionId: string, payload: SubmissionUpdatePayload) {
return this.update((state) => {
const submission = state.submissions.find((entry) => entry.id === submissionId);
if (!submission) {
throw new Error("Submission not found.");
}
if (Object.prototype.hasOwnProperty.call(payload, "displayName")) {
submission.displayName = normalizeEditableText(payload.displayName);
}
if (Object.prototype.hasOwnProperty.call(payload, "caption")) {
submission.caption = normalizeEditableText(payload.caption);
}
if (Object.prototype.hasOwnProperty.call(payload, "promptAnswer")) {
submission.promptAnswer = normalizeEditableText(payload.promptAnswer);
}
if (Object.prototype.hasOwnProperty.call(payload, "notes")) {
submission.notes = normalizeEditableText(payload.notes);
}
return state;
});
}
async markProcessed(assetId: string, payload: ProcessedAssetPayload) {
return this.update((state) => {
const asset = state.photoAssets.find((entry) => entry.id === assetId);