Revamp scenic playback and operator workflow
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user