Revamp scenic playback and operator workflow

This commit is contained in:
2026-04-09 22:30:32 -07:00
parent 3e88449fcb
commit 7150c67e33
13 changed files with 4157 additions and 4743 deletions
+62 -5
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) {
+88 -22
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);