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`
|
||||
- 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,
|
||||
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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -10,8 +10,7 @@ export const SubmissionRoute = () => {
|
||||
<p className="submission-kicker">Good Grief</p>
|
||||
<h1 className="submission-title">Offer a photo to tonight'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'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
|
||||
|
||||
@ -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
@ -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
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,
|
||||
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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user