Revamp scenic playback and operator workflow
This commit is contained in:
parent
3e88449fcb
commit
7150c67e33
@ -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
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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's memory field.</h1>
|
<h1 className="submission-title">Offer a photo to tonight'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'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
|
||||||
|
|||||||
@ -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
@ -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
116
scripts/reset-production.mjs
Normal file
116
scripts/reset-production.mjs
Normal 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.");
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user