diff --git a/apps/admin/src/app/ProgramOutputApp.tsx b/apps/admin/src/app/ProgramOutputApp.tsx
index 3b79f48..82a7e29 100644
--- a/apps/admin/src/app/ProgramOutputApp.tsx
+++ b/apps/admin/src/app/ProgramOutputApp.tsx
@@ -1,7 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CueTransition } from "@goodgrief/shared-types";
import { SceneViewport } from "../features/live/SceneViewport";
-import { readProgramOutputState, subscribeProgramOutput, type ProgramOutputState } from "../features/live/output-sync";
+import {
+ createPresentationStructureHash,
+ readProgramOutputState,
+ subscribeProgramOutput,
+ type ProgramOutputState
+} from "../features/live/output-sync";
import "./output.css";
const enterFullscreen = async () => {
@@ -126,7 +131,7 @@ export const ProgramOutputApp = () => {
presentation={outputState?.presentation ?? null}
blackout={outputState?.blackout ?? false}
transition={transition}
- activationKey={`${outputState?.presentationHash ?? "program-empty"}:${outputState?.outputRevision ?? 0}:${outputState?.blackout ? "blackout" : "live"}`}
+ activationKey={createPresentationStructureHash(outputState?.presentation ?? null)}
/>
diff --git a/apps/admin/src/features/live/SceneViewport.tsx b/apps/admin/src/features/live/SceneViewport.tsx
index 5cc22fd..364db31 100644
--- a/apps/admin/src/features/live/SceneViewport.tsx
+++ b/apps/admin/src/features/live/SceneViewport.tsx
@@ -65,7 +65,7 @@ export const SceneViewport = ({
surface.registerMany(defaultScenePlugins);
surface.setQualityProfile(qualityProfileRef.current);
surface.setBusy(busyRef.current);
- surface.setBlackout(blackoutRef.current);
+ surface.setBlackout(blackoutRef.current, null, true);
surfaceRef.current = surface;
const resize = () => {
@@ -92,8 +92,8 @@ export const SceneViewport = ({
}, []);
useEffect(() => {
- surfaceRef.current?.setBlackout(blackout);
- }, [blackout]);
+ surfaceRef.current?.setBlackout(blackout, transition ?? defaultTransition);
+ }, [blackout, transition]);
useEffect(() => {
surfaceRef.current?.setQualityProfile(qualityProfile);
diff --git a/packages/render-engine/src/index.ts b/packages/render-engine/src/index.ts
index 08fd9ef..4babd37 100644
--- a/packages/render-engine/src/index.ts
+++ b/packages/render-engine/src/index.ts
@@ -909,30 +909,6 @@ const createTextStrip = (
return { mesh, texture };
};
-const fallbackLoadedAssets = (definition: SceneDefinition, count: number): LoadedPhotoAsset[] => {
- const palette = ["#98a8c0", "#d8b28f", "#b5c8d8", "#bea5d6"];
- return Array.from({ length: count }, (_, index) => ({
- asset: {
- id: `${definition.sceneKey}-fallback-${index}`,
- submissionId: definition.id,
- originalKey: "",
- mimeType: "placeholder",
- processingStatus: "ready",
- moderationStatus: "approved",
- createdAt: new Date(0).toISOString(),
- width: 1200,
- height: 900
- },
- texture: null,
- aspect: 4 / 3,
- dominantColor: palette[index % palette.length] ?? "#8ea0b4",
- sourceUrl: null
- }));
-};
-
-const withAssets = (input: SceneActivationInput, minCount: number) =>
- input.loadedAssets.length > 0 ? input.loadedAssets : fallbackLoadedAssets(input.definition, minCount);
-
const combineInstances = (...instances: Array): SceneInstance => {
const active = instances.filter((instance): instance is SceneInstance => Boolean(instance));
if (active.length === 1) {
@@ -1034,7 +1010,7 @@ const buildTextOverlay = (input: SceneActivationInput): SceneInstance | null =>
return null;
}
- const palette = paletteFromAssets(withAssets(input, 1), input.params.scenicTreatment);
+ const palette = paletteFromAssets(input.loadedAssets, input.params.scenicTreatment);
const root = new THREE.Group();
const textures: THREE.Texture[] = [];
const animated: Array<{
@@ -1179,7 +1155,7 @@ const buildWitnessFloat = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "near_witness";
const count = clamp(1 + Math.round(composition.supportCount), 1, 3);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1240,7 +1216,7 @@ const buildPortalFrame = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "soft_gate";
const count = clamp(1 + Math.round(composition.supportCount), 1, 2);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1313,7 +1289,7 @@ const buildOrbitGallery = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "halo_arc";
const count = clamp(1 + Math.round(composition.supportCount), 1, 3);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1374,7 +1350,7 @@ const buildSuspensionField = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "hover_shelf";
const count = clamp(1 + Math.round(composition.supportCount), 2, 4);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1440,7 +1416,7 @@ const buildChorusArray = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "grid_choir";
const count = clamp(1 + Math.round(composition.supportCount), 3, 4);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1501,7 +1477,7 @@ const buildEqualCollage = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "quadrant";
const count = clamp(1 + Math.round(composition.supportCount), 2, 4);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1560,7 +1536,7 @@ const buildArrivalRelay = (input: SceneActivationInput): SceneInstance => {
const { composition, photoTreatment } = input.params;
const mode = input.modeKey ?? "edge_queue";
const count = clamp(1 + Math.round(composition.supportCount), 1, 4);
- const assets = withAssets(input, count).slice(0, count);
+ const assets = input.loadedAssets.slice(0, count);
const palette = paletteFromAssets(assets, input.params.scenicTreatment);
const backdrop = buildBackdropSystem(input, palette);
const root = new THREE.Group();
@@ -1759,6 +1735,14 @@ interface TransitionRuntime {
startedAtMs: number;
}
+interface BlackoutRuntime {
+ fromLevel: number;
+ toLevel: number;
+ style: CueTransition["style"];
+ durationMs: number;
+ startedAtMs: number;
+}
+
const transitionStyleToValue = (style: CueTransition["style"]) => {
switch (style) {
case "dissolve":
@@ -1879,6 +1863,18 @@ export class RenderSurface {
});
private readonly compositeFromQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeFromMaterial);
private readonly compositeToQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.compositeToMaterial);
+ private readonly blackoutQuad = new THREE.Mesh(
+ new THREE.PlaneGeometry(2, 2),
+ new THREE.MeshBasicMaterial({
+ color: "#000000",
+ transparent: true,
+ opacity: 0,
+ depthWrite: false,
+ depthTest: false,
+ side: THREE.DoubleSide,
+ toneMapped: false
+ })
+ );
private readonly shutterBars = Array.from({ length: 8 }, () =>
new THREE.Mesh(
new THREE.PlaneGeometry(2.4, 0.18),
@@ -1922,8 +1918,9 @@ export class RenderSurface {
};
private currentRuntime: SceneRuntime | null = null;
private transitionRuntime: TransitionRuntime | null = null;
+ private blackoutRuntime: BlackoutRuntime | null = null;
private lastFrameMs = 0;
- private blackout = false;
+ private blackoutLevel = 0;
private activationToken = 0;
private activePresentationKey: string | undefined;
private qualityProfile: SurfaceQualityProfile = "program";
@@ -1950,8 +1947,9 @@ export class RenderSurface {
this.compositeFromQuad.position.z = 0;
this.compositeToQuad.position.z = 0.01;
+ this.blackoutQuad.position.z = 0.019;
this.veilOverlay.position.z = 0.02;
- this.compositeScene.add(this.compositeFromQuad, this.compositeToQuad, this.veilOverlay);
+ this.compositeScene.add(this.compositeFromQuad, this.compositeToQuad, this.blackoutQuad, this.veilOverlay);
this.shutterBars.forEach((bar, index) => {
bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
this.compositeScene.add(bar);
@@ -1968,13 +1966,6 @@ export class RenderSurface {
const deltaMs = this.lastFrameMs === 0 ? 16.6 : timestamp - this.lastFrameMs;
this.lastFrameMs = timestamp;
- if (this.blackout) {
- this.renderer.setClearColor("#000000", 1);
- this.renderer.setRenderTarget(null);
- this.renderer.clear();
- return;
- }
-
this.renderer.setClearColor("#040508", 1);
const context = {
elapsedMs: timestamp,
@@ -1989,6 +1980,7 @@ export class RenderSurface {
this.renderRuntimeToTarget(this.transitionRuntime.from, this.fromTarget);
this.renderRuntimeToTarget(this.transitionRuntime.to, this.toTarget);
this.renderCompositeTransition(progress, this.transitionRuntime.style);
+ this.renderBlackoutOverlay(timestamp);
if (progress >= 1) {
this.finishTransition();
}
@@ -1998,12 +1990,14 @@ export class RenderSurface {
if (!this.currentRuntime) {
this.renderer.setRenderTarget(null);
this.renderer.clear();
+ this.renderBlackoutOverlay(timestamp);
return;
}
updateSceneRuntime(this.currentRuntime, context);
this.renderRuntimeToTarget(this.currentRuntime, this.toTarget);
this.renderTargetToScreen(this.toTarget);
+ this.renderBlackoutOverlay(timestamp);
});
}
@@ -2061,8 +2055,33 @@ export class RenderSurface {
}
}
- setBlackout(blackout: boolean) {
- this.blackout = blackout;
+ setBlackout(blackout: boolean, transition?: CueTransition | null, immediate = false) {
+ const nextLevel = blackout ? 1 : 0;
+ const now = this.lastFrameMs || (typeof performance !== "undefined" ? performance.now() : 0);
+
+ if (this.blackoutRuntime) {
+ const progress = clamp((now - this.blackoutRuntime.startedAtMs) / this.blackoutRuntime.durationMs, 0, 1);
+ const eased = THREE.MathUtils.smoothstep(progress, 0, 1);
+ this.blackoutLevel = THREE.MathUtils.lerp(this.blackoutRuntime.fromLevel, this.blackoutRuntime.toLevel, eased);
+ this.blackoutRuntime = null;
+ }
+
+ if (Math.abs(nextLevel - this.blackoutLevel) < 0.001) {
+ return;
+ }
+
+ if (immediate || !transition || transition.style === "cut" || transition.durationMs <= 0) {
+ this.blackoutLevel = nextLevel;
+ return;
+ }
+
+ this.blackoutRuntime = {
+ fromLevel: this.blackoutLevel,
+ toLevel: nextLevel,
+ style: transition.style,
+ durationMs: transition.durationMs,
+ startedAtMs: now
+ };
}
updatePresentation(presentation: SurfacePresentation | null, activationKey?: string) {
@@ -2131,6 +2150,7 @@ export class RenderSurface {
this.toTarget.dispose();
this.compositeFromMaterial.dispose();
this.compositeToMaterial.dispose();
+ (this.blackoutQuad.material as THREE.Material).dispose();
(this.veilOverlay.material as THREE.Material).dispose();
this.shutterBars.forEach((bar) => (bar.material as THREE.Material).dispose());
this.renderer.dispose();
@@ -2220,6 +2240,7 @@ export class RenderSurface {
this.compositeToQuad.scale.set(1, 1, 1);
this.compositeFromQuad.rotation.z = 0;
this.compositeToQuad.rotation.z = 0;
+ (this.blackoutQuad.material as THREE.MeshBasicMaterial).opacity = 0;
const veilMaterial = this.veilOverlay.material as THREE.MeshBasicMaterial;
veilMaterial.opacity = 0;
@@ -2266,6 +2287,7 @@ export class RenderSurface {
this.compositeToQuad.scale.set(1, 1, 1);
this.compositeFromQuad.rotation.z = 0;
this.compositeToQuad.rotation.z = 0;
+ (this.blackoutQuad.material as THREE.MeshBasicMaterial).opacity = 0;
(this.veilOverlay.material as THREE.MeshBasicMaterial).opacity = 0;
this.shutterBars.forEach((bar, index) => {
const material = bar.material as THREE.MeshBasicMaterial;
@@ -2277,6 +2299,77 @@ export class RenderSurface {
this.renderer.render(this.compositeScene, this.compositeCamera);
}
+ private renderBlackoutOverlay(timestamp: number) {
+ let style: CueTransition["style"] = "dissolve";
+ let progress = this.blackoutLevel > 0 ? 1 : 0;
+ let toBlack = this.blackoutLevel >= 1;
+
+ if (this.blackoutRuntime) {
+ const rawProgress = clamp((timestamp - this.blackoutRuntime.startedAtMs) / this.blackoutRuntime.durationMs, 0, 1);
+ const eased = THREE.MathUtils.smoothstep(rawProgress, 0, 1);
+ this.blackoutLevel = THREE.MathUtils.lerp(this.blackoutRuntime.fromLevel, this.blackoutRuntime.toLevel, eased);
+ style = this.blackoutRuntime.style;
+ progress = eased;
+ toBlack = this.blackoutRuntime.toLevel > this.blackoutRuntime.fromLevel;
+ if (rawProgress >= 1) {
+ this.blackoutLevel = this.blackoutRuntime.toLevel;
+ this.blackoutRuntime = null;
+ }
+ }
+
+ if (this.blackoutLevel <= 0.001) {
+ return;
+ }
+
+ this.compositeFromMaterial.opacity = 0;
+ this.compositeToMaterial.opacity = 0;
+ this.compositeFromQuad.position.set(0, 0, 0);
+ this.compositeToQuad.position.set(0, 0, 0.01);
+ this.compositeFromQuad.scale.set(1, 1, 1);
+ this.compositeToQuad.scale.set(1, 1, 1);
+ this.compositeFromQuad.rotation.z = 0;
+ this.compositeToQuad.rotation.z = 0;
+
+ const blackoutMaterial = this.blackoutQuad.material as THREE.MeshBasicMaterial;
+ blackoutMaterial.opacity = clamp(this.blackoutLevel, 0, 1);
+ this.blackoutQuad.position.set(0, 0, 0.019);
+ this.blackoutQuad.scale.set(1, 1, 1);
+ this.blackoutQuad.rotation.z = 0;
+
+ const veilMaterial = this.veilOverlay.material as THREE.MeshBasicMaterial;
+ veilMaterial.opacity = 0;
+
+ this.shutterBars.forEach((bar, index) => {
+ const material = bar.material as THREE.MeshBasicMaterial;
+ material.opacity = 0;
+ bar.position.set(0, 0.86 - index * 0.24, 0.03 + index * 0.001);
+ });
+
+ if (style === "mist_reveal" && this.blackoutRuntime) {
+ veilMaterial.opacity = (0.02 + this.blackoutLevel * 0.08) * Math.sin(progress * Math.PI);
+ blackoutMaterial.opacity = clamp(this.blackoutLevel * 1.02, 0, 1);
+ } else if (style === "depth_drift" && this.blackoutRuntime) {
+ const offset = (toBlack ? 1 - progress : progress - 1) * 0.06;
+ this.blackoutQuad.position.x = offset;
+ this.blackoutQuad.scale.set(1.03, 1, 1);
+ veilMaterial.opacity = Math.sin(progress * Math.PI) * 0.05;
+ } else if (style === "shutter_reveal" && this.blackoutRuntime) {
+ blackoutMaterial.opacity = clamp(Math.max(this.blackoutLevel, toBlack ? progress * 0.82 : this.blackoutLevel), 0, 1);
+ this.shutterBars.forEach((bar, index) => {
+ const material = bar.material as THREE.MeshBasicMaterial;
+ const bandProgress = THREE.MathUtils.clamp((progress - index * 0.05) / 0.45, 0, 1);
+ material.opacity = (1 - bandProgress) * 0.3;
+ bar.position.x = toBlack ? -1.2 + bandProgress * 2.4 : 1.2 - bandProgress * 2.4;
+ });
+ }
+
+ const previousAutoClear = this.renderer.autoClear;
+ this.renderer.autoClear = false;
+ this.renderer.setRenderTarget(null);
+ this.renderer.render(this.compositeScene, this.compositeCamera);
+ this.renderer.autoClear = previousAutoClear;
+ }
+
private finishTransition() {
if (!this.transitionRuntime) {
return;
@@ -2303,6 +2396,7 @@ export class RenderSurface {
disposeSceneRuntime(this.transitionRuntime.to);
this.transitionRuntime = null;
}
+ this.blackoutRuntime = null;
this.activePresentationKey = undefined;
}
diff --git a/packages/shared-types/src/mock.ts b/packages/shared-types/src/mock.ts
index 26fb931..71fb8bc 100644
--- a/packages/shared-types/src/mock.ts
+++ b/packages/shared-types/src/mock.ts
@@ -28,16 +28,6 @@ export const defaultCollections: Collection[] = [
assetIds: [],
tagIds: ["tag-archive", "tag-portrait"]
},
- {
- id: "collection-favorites",
- name: "Favorites",
- kind: "favorites",
- createdAt: new Date().toISOString(),
- description: "Operator-trusted images for flexible live use.",
- locked: false,
- assetIds: [],
- tagIds: ["tag-portrait", "tag-quiet"]
- },
{
id: "collection-choir-swell",
name: "Choir Swell",
diff --git a/packages/shared-types/src/scenes.ts b/packages/shared-types/src/scenes.ts
index 4ac9f9f..56f1d61 100644
--- a/packages/shared-types/src/scenes.ts
+++ b/packages/shared-types/src/scenes.ts
@@ -119,7 +119,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "medium",
performanceRisk: "low",
inputRules: {
- minAssets: 1,
+ minAssets: 0,
maxAssets: 3,
recommendedTags: ["portrait", "hands", "still life", "quiet"]
},
@@ -153,7 +153,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "medium",
performanceRisk: "low",
inputRules: {
- minAssets: 1,
+ minAssets: 0,
maxAssets: 2,
recommendedTags: ["portrait", "window", "still life"]
},
@@ -187,7 +187,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "medium",
performanceRisk: "medium",
inputRules: {
- minAssets: 1,
+ minAssets: 0,
maxAssets: 3,
recommendedTags: ["portrait", "flowers", "still life", "archive"]
},
@@ -221,7 +221,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "medium",
performanceRisk: "low",
inputRules: {
- minAssets: 2,
+ minAssets: 0,
maxAssets: 4,
recommendedTags: ["portrait", "family", "still life", "room"]
},
@@ -255,7 +255,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "medium",
performanceRisk: "low",
inputRules: {
- minAssets: 3,
+ minAssets: 0,
maxAssets: 4,
recommendedTags: ["family", "portrait", "group", "archive"]
},
@@ -289,7 +289,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "medium",
performanceRisk: "medium",
inputRules: {
- minAssets: 2,
+ minAssets: 0,
maxAssets: 4,
recommendedTags: ["portrait", "still life", "room", "window"]
},
@@ -323,7 +323,7 @@ export const defaultSceneDefinitions: SceneDefinition[] = [
complexity: "low",
performanceRisk: "low",
inputRules: {
- minAssets: 1,
+ minAssets: 0,
maxAssets: 4,
recommendedTags: ["live", "portrait", "recent"]
},
diff --git a/services/api/src/state-store.ts b/services/api/src/state-store.ts
index e767b59..eb96491 100644
--- a/services/api/src/state-store.ts
+++ b/services/api/src/state-store.ts
@@ -198,7 +198,8 @@ const ensureSafeCue = (cues: Cue[]) => {
const mergeCollections = (state: RepositoryState, importedAssetIds: string[]) => {
const defaultCollectionIds = new Set(defaultCollections.map((collection) => collection.id));
- const existingCollectionMap = new Map(state.collections.map((collection) => [collection.id, collection] as const));
+ const existingCollections = state.collections.filter((collection) => collection.kind !== "favorites");
+ const existingCollectionMap = new Map(existingCollections.map((collection) => [collection.id, collection] as const));
const mergedDefaults = defaultCollections.map((collection) => {
const existing = existingCollectionMap.get(collection.id);
@@ -218,7 +219,7 @@ const mergeCollections = (state: RepositoryState, importedAssetIds: string[]) =>
};
});
- const customCollections = state.collections
+ const customCollections = existingCollections
.filter((collection) => !defaultCollectionIds.has(collection.id))
.map((collection) => ({
...collection,
@@ -296,7 +297,7 @@ const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayl
const usablePool =
assetPool.length >= scene.inputRules.minAssets ? assetPool : approvedAssets;
const maxAssets = Math.min(scene.inputRules.maxAssets ?? usablePool.length, usablePool.length);
- const minAssets = Math.min(scene.inputRules.minAssets, maxAssets);
+ const minAssets = Math.min(Math.max(scene.inputRules.minAssets, usablePool.length > 0 ? 1 : 0), maxAssets);
const assetCount = Math.max(minAssets, Math.round(random(minAssets, maxAssets + 0.49)));
const selectedAssets = shuffle(usablePool).slice(0, assetCount);
@@ -543,10 +544,6 @@ export class StateStore {
submission.status = "approved_all";
asset.moderationStatus = "approved";
asset.approvedAt = new Date().toISOString();
- const favorites = state.collections.find((collection) => collection.kind === "favorites");
- if (favorites && !favorites.assetIds.includes(assetId)) {
- favorites.assetIds.unshift(assetId);
- }
} else {
submission.status = "pending_moderation";
}
@@ -619,13 +616,6 @@ export class StateStore {
})
);
- if (payload.decision === "approved") {
- const favorites = state.collections.find((collection) => collection.kind === "favorites");
- if (favorites && !favorites.assetIds.includes(assetId)) {
- favorites.assetIds.unshift(assetId);
- }
- }
-
if (payload.collectionIds?.length) {
const collections = state.collections.filter((collection) => payload.collectionIds?.includes(collection.id));
for (const collection of collections) {