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` - 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` - 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, CueMovePayload,
CueUpsertPayload, CueUpsertPayload,
ModerationActionPayload, ModerationActionPayload,
RepositoryState RepositoryState,
Submission,
SubmissionUpdatePayload
} from "@goodgrief/shared-types"; } from "@goodgrief/shared-types";
const postVoid = async (url: string, body?: unknown) => { const postVoid = async (url: string, body?: unknown) => {
@ -102,6 +104,15 @@ export const createAdminUpload = async (payload: FormData) =>
body: payload 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) => { export const deleteCue = async (cueId: string) => {
const response = await fetch(`/api/cues/${cueId}`, { const response = await fetch(`/api/cues/${cueId}`, {
method: "DELETE" method: "DELETE"

View File

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

View File

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

View File

@ -12,6 +12,7 @@
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"check": "npm run check --workspaces --if-present", "check": "npm run check --workspaces --if-present",
"reset:runtime": "node scripts/reset-runtime.mjs", "reset:runtime": "node scripts/reset-runtime.mjs",
"reset:prod": "node scripts/reset-production.mjs",
"dev:all": "node scripts/run-local.mjs", "dev:all": "node scripts/run-local.mjs",
"dev:all:reset": "node scripts/run-local.mjs --reset", "dev:all:reset": "node scripts/run-local.mjs --reset",
"dev:submission": "npm run dev --workspace @goodgrief/submission", "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 OperatorSessionMode = "rehearsal" | "tech" | "show" | "archive_review";
export type OutputSurfaceRole = "program" | "preview" | "aux"; export type OutputSurfaceRole = "program" | "preview" | "aux";
export type SceneTier = "mvp" | "v1" | "stretch"; export type SceneTier = "mvp" | "v1" | "stretch";
export type SceneFamily = "hero" | "chorus" | "floor_paint" | "arrival" | "rupture" | "safe"; export type SceneFamily = "hero" | "chorus" | "arrival" | "safe";
export type TextTreatmentMode = "off" | "edge_whispers" | "relay_ticker" | "anchor_caption"; 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 = export type SceneCategory =
| "memory_elegy" | "memory_elegy"
| "humor_rupture" | "humor_rupture"
@ -105,39 +114,35 @@ export interface Collection {
} }
export interface PhotoTreatmentParams { export interface PhotoTreatmentParams {
exposure: number;
contrast: number; contrast: number;
saturation: number; saturation: number;
blackPoint: number; blackPoint: number;
whitePoint: number; whitePoint: number;
paletteMix: number;
clarity: number;
edgeLight: number;
} }
export interface ScenicTreatmentParams { export interface ScenicTreatmentParams {
washIntensity: number; fieldType: ScenicFieldType;
spill: number; fieldIntensity: number;
floorMix: number; fieldScale: number;
paletteBias: number; fieldSpeed: number;
vignette: number; hue: number;
fillHue: number; saturation: number;
fillSaturation: number; lightness: number;
fillLightness: number; accentIntensity: number;
depthFog: number;
} }
export interface CompositionParams { export interface CompositionParams {
motion: number; motion: number;
density: number;
depth: number; depth: number;
focus: number; focus: number;
crop: number; spread: number;
emphasis: number; supportCount: number;
bands?: number; cameraTravel: number;
columns?: number; orbitAmount: number;
shutters?: number; stagger: number;
tiles?: number; formation?: CompositionFormation;
lanes?: number;
edge?: "left" | "right";
} }
export interface TextTreatmentParams { export interface TextTreatmentParams {
@ -198,7 +203,7 @@ export interface SceneDefinition {
} }
export interface CueTransition { export interface CueTransition {
style: "cut" | "dissolve" | "veil_wipe" | "luma_hold" | "rupture_offset"; style: "cut" | "dissolve" | "mist_reveal" | "depth_drift" | "shutter_reveal";
durationMs: number; durationMs: number;
} }
@ -349,6 +354,13 @@ export interface SubmissionPayload {
source?: SubmissionSource; source?: SubmissionSource;
} }
export interface SubmissionUpdatePayload {
displayName?: string;
caption?: string;
promptAnswer?: string;
notes?: string;
}
export interface ModerationActionPayload { export interface ModerationActionPayload {
decision: ModerationDecisionType; decision: ModerationDecisionType;
reasonCode?: string; 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, CueMovePayload,
CueUpsertPayload, CueUpsertPayload,
ModerationActionPayload, ModerationActionPayload,
SubmissionPayload SubmissionPayload,
SubmissionUpdatePayload
} from "@goodgrief/shared-types"; } from "@goodgrief/shared-types";
import { config } from "./config.ts"; import { config } from "./config.ts";
import { createLibraryAssets, libraryWatchDirs } from "./seed.ts"; import { createLibraryAssets, libraryWatchDirs } from "./seed.ts";
import { StateStore } from "./state-store.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 moderationDecisions = new Set(["approved", "hold", "rejected", "archive_only"]);
const coerceBoolean = (value: string | undefined) => value === "true"; const coerceBoolean = (value: string | undefined) => value === "true";
@ -32,9 +43,14 @@ const normalizeBody = <Body>(body: unknown): Body => {
const fileExtensionFor = (mimeType: string, filename?: string) => { const fileExtensionFor = (mimeType: string, filename?: string) => {
const extensionMap: Record<string, string> = { const extensionMap: Record<string, string> = {
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/pjpeg": ".jpg",
"image/png": ".png", "image/png": ".png",
"image/webp": ".webp",
"image/heic": ".heic", "image/heic": ".heic",
"image/heif": ".heif" "image/heif": ".heif",
"image/heic-sequence": ".heic",
"image/heif-sequence": ".heif"
}; };
const byMime = extensionMap[mimeType]; const byMime = extensionMap[mimeType];
@ -46,6 +62,30 @@ const fileExtensionFor = (mimeType: string, filename?: string) => {
return fallback || ".bin"; 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 { interface ParsedMultipartFile {
buffer: Buffer; buffer: Buffer;
filename?: string; filename?: string;
@ -62,7 +102,7 @@ const parseMultipartSubmission = async (request: FastifyRequest) => {
filePart = { filePart = {
buffer: await part.toBuffer(), buffer: await part.toBuffer(),
filename: part.filename, filename: part.filename,
mimetype: part.mimetype mimetype: normalizeMimeType(part.mimetype, part.filename)
}; };
continue; continue;
} }
@ -113,7 +153,7 @@ const createSubmissionFromMultipart = async (
return { return {
error: { error: {
statusCode: 400, 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.get("/api/show-config", async () => (await store.read()).showConfig);
app.post("/api/library/rescan", async () => syncLibrary()); 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) => { app.post("/api/submissions", async (request, reply) => {
const result = await createSubmissionFromMultipart(store, request); const result = await createSubmissionFromMultipart(store, request);
if ("error" in result) { if ("error" in result) {

View File

@ -23,7 +23,8 @@ import {
type RepositoryState, type RepositoryState,
type SessionEvent, type SessionEvent,
type Submission, type Submission,
type SubmissionPayload type SubmissionPayload,
type SubmissionUpdatePayload
} from "@goodgrief/shared-types"; } from "@goodgrief/shared-types";
interface SeedAssetInput { interface SeedAssetInput {
@ -50,6 +51,10 @@ const randomizeParameterValue = (
value: string | number | boolean, value: string | number | boolean,
safeRanges: Record<string, { min: number; max: number }> safeRanges: Record<string, { min: number; max: number }>
) => { ) => {
if (path.startsWith("photoTreatment.")) {
return value;
}
if (typeof value === "number") { if (typeof value === "number") {
const range = safeRanges[path]; const range = safeRanges[path];
if (range) { if (range) {
@ -67,12 +72,8 @@ const randomizeParameterValue = (
return Math.random() > 0.5; return Math.random() > 0.5;
} }
if (path === "composition.edge") {
return Math.random() > 0.5 ? "left" : "right";
}
if (path === "textTreatment.mode") { 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]); 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[]) => const normalizeCueOrder = (cues: Cue[]) =>
[...cues] [...cues]
.sort((left, right) => left.orderIndex - right.orderIndex) .sort((left, right) => left.orderIndex - right.orderIndex)
@ -270,9 +288,6 @@ const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayl
if (scene.sceneFamily === "safe") { if (scene.sceneFamily === "safe") {
return false; return false;
} }
if (scene.sceneFamily === "rupture" && !payload.includeRupture) {
return false;
}
return scene.inputRules.minAssets <= approvedAssets.length; 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") { if (randomizedParams.textTreatment.mode !== "off") {
const opacityFloor = const opacityFloor = 0.18 + random(0.04, 0.1);
randomizedParams.textTreatment.mode === "anchor_caption"
? 0.64 + random(0.08, 0.18)
: 0.5 + random(0.08, 0.2);
randomizedParams = setSceneParamValue( randomizedParams = setSceneParamValue(
randomizedParams, randomizedParams,
"textTreatment.opacity", "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 = setSceneParamValue(
randomizedParams, randomizedParams,
"textTreatment.scale", "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 = const anchorLabel =
anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || anchorSubmission?.displayName?.trim(); anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || anchorSubmission?.displayName?.trim();
const transitionOptions = const transitionOptions =
scene.sceneFamily === "rupture" scene.sceneFamily === "arrival"
? (["rupture_offset", "dissolve"] as const) ? (["shutter_reveal", "mist_reveal", "dissolve"] as const)
: (["dissolve", "veil_wipe", "luma_hold"] as const); : scene.sceneFamily === "safe"
? (["dissolve"] as const)
: (["dissolve", "mist_reveal", "depth_drift", "shutter_reveal"] as const);
return { return {
sceneDefinitionId: scene.id, sceneDefinitionId: scene.id,
triggerMode: "manual", triggerMode: "manual",
transitionIn: { transitionIn: {
style: sample([...transitionOptions]), 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: { transitionOut: {
style: scene.sceneFamily === "arrival" ? "veil_wipe" : "dissolve", style: scene.sceneFamily === "arrival" ? "mist_reveal" : "dissolve",
durationMs: Math.round(random(700, 1000) / 50) * 50 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), assetIds: selectedAssets.map((asset) => asset.id),
effectPresetId: effectPreset.id, effectPresetId: effectPreset.id,
@ -379,7 +420,8 @@ export class StateStore {
return this.update((state) => { return this.update((state) => {
const next = pruneLegacyLibraryVariants(reconcileState(state)); const next = pruneLegacyLibraryVariants(reconcileState(state));
for (const imported of importedAssets) { 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.consents, imported.consent);
upsertById(next.photoAssets, imported.asset); 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) { async markProcessed(assetId: string, payload: ProcessedAssetPayload) {
return this.update((state) => { return this.update((state) => {
const asset = state.photoAssets.find((entry) => entry.id === assetId); const asset = state.photoAssets.find((entry) => entry.id === assetId);